simstudioai--sim
d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
1360 行
48 KiB
TypeScript
1360 行
48 KiB
TypeScript
import { render } from '@react-email/render'
|
|
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
|
import { db } from '@sim/db'
|
|
import {
|
|
member,
|
|
organization,
|
|
subscription as subscriptionTable,
|
|
user,
|
|
userStats,
|
|
} from '@sim/db/schema'
|
|
import { createLogger } from '@sim/logger'
|
|
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
|
|
import { and, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm'
|
|
import type Stripe from 'stripe'
|
|
import { getEmailSubject, PaymentFailedEmail, renderCreditPurchaseEmail } from '@/components/emails'
|
|
import { BILLING_LOCK_TIMEOUT_MS } from '@/lib/billing/constants'
|
|
import { calculateSubscriptionOverage, isSubscriptionOrgScoped } from '@/lib/billing/core/billing'
|
|
import {
|
|
COPILOT_USAGE_SOURCES,
|
|
getBillingPeriodUsageCostByUser,
|
|
} from '@/lib/billing/core/usage-log'
|
|
import { addCredits, getCreditBalanceForEntity } from '@/lib/billing/credits/balance'
|
|
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
|
|
import { blockOrgMembers, unblockOrgMembers } from '@/lib/billing/organizations/membership'
|
|
import { isEnterprise } from '@/lib/billing/plan-helpers'
|
|
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
|
import { resolveDefaultPaymentMethod } from '@/lib/billing/stripe-payment-method'
|
|
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
|
|
import { toDecimal, toNumber } from '@/lib/billing/utils/decimal'
|
|
import { stripeWebhookIdempotency } from '@/lib/billing/webhooks/idempotency'
|
|
import { getBaseUrl } from '@/lib/core/utils/urls'
|
|
import { sendEmail } from '@/lib/messaging/email/mailer'
|
|
import { getHelpEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
|
|
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
|
import { captureServerEvent } from '@/lib/posthog/server'
|
|
|
|
const logger = createLogger('StripeInvoiceWebhooks')
|
|
|
|
/**
|
|
* Resolve the audit actor for a billing event. For org-scoped subscriptions the
|
|
* actor is the org owner; for personal subscriptions it is the reference (user)
|
|
* id. The owner lookup is best-effort — a failure must never break webhook
|
|
* processing, so it falls back to the reference id (which the audit layer nulls
|
|
* to a system actor if it is not a real user id).
|
|
*/
|
|
async function resolveBillingActorId(isOrgScoped: boolean, referenceId: string): Promise<string> {
|
|
if (!isOrgScoped) return referenceId
|
|
try {
|
|
const ownerRows = await db
|
|
.select({ userId: member.userId })
|
|
.from(member)
|
|
.where(and(eq(member.organizationId, referenceId), eq(member.role, 'owner')))
|
|
.limit(1)
|
|
return ownerRows[0]?.userId ?? referenceId
|
|
} catch (error) {
|
|
logger.warn('Failed to resolve billing actor; falling back to reference id', {
|
|
referenceId,
|
|
error,
|
|
})
|
|
return referenceId
|
|
}
|
|
}
|
|
|
|
function getSubscriptionLinePeriod(
|
|
invoice: Stripe.Invoice,
|
|
stripeSubscriptionId: string
|
|
): { periodStart: Date; periodEnd: Date } | null {
|
|
const subscriptionLine = invoice.lines?.data?.find(
|
|
(line) =>
|
|
line.parent?.type === 'subscription_item_details' &&
|
|
line.parent.subscription_item_details?.subscription === stripeSubscriptionId
|
|
)
|
|
|
|
if (!subscriptionLine?.period?.start || !subscriptionLine.period.end) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
periodStart: new Date(subscriptionLine.period.start * 1000),
|
|
periodEnd: new Date(subscriptionLine.period.end * 1000),
|
|
}
|
|
}
|
|
|
|
const METADATA_SUBSCRIPTION_INVOICE_TYPES = new Set<string>([
|
|
'overage_billing',
|
|
'overage_threshold_billing',
|
|
'overage_threshold_billing_org',
|
|
])
|
|
|
|
type InvoiceSubscriptionResolutionSource =
|
|
| 'parent.subscription_details.subscription'
|
|
| 'metadata.subscriptionId'
|
|
| 'none'
|
|
|
|
interface InvoiceSubscriptionContext {
|
|
invoiceType: string | null
|
|
resolutionSource: InvoiceSubscriptionResolutionSource
|
|
stripeSubscriptionId: string | null
|
|
}
|
|
|
|
type BillingSubscription = typeof subscriptionTable.$inferSelect
|
|
|
|
interface ResolvedInvoiceSubscription extends InvoiceSubscriptionContext {
|
|
sub: BillingSubscription
|
|
stripeSubscriptionId: string
|
|
}
|
|
|
|
function resolveInvoiceSubscriptionContext(invoice: Stripe.Invoice): InvoiceSubscriptionContext {
|
|
const invoiceType = invoice.metadata?.type ?? null
|
|
const canResolveFromMetadata = !!(
|
|
invoiceType && METADATA_SUBSCRIPTION_INVOICE_TYPES.has(invoiceType)
|
|
)
|
|
const metadataSubscriptionId =
|
|
canResolveFromMetadata &&
|
|
typeof invoice.metadata?.subscriptionId === 'string' &&
|
|
invoice.metadata.subscriptionId.length > 0
|
|
? invoice.metadata.subscriptionId
|
|
: null
|
|
|
|
const parentSubscription = invoice.parent?.subscription_details?.subscription
|
|
const parentSubscriptionId =
|
|
typeof parentSubscription === 'string' ? parentSubscription : (parentSubscription?.id ?? null)
|
|
|
|
if (
|
|
parentSubscriptionId &&
|
|
metadataSubscriptionId &&
|
|
parentSubscriptionId !== metadataSubscriptionId
|
|
) {
|
|
logger.warn('Invoice has conflicting subscription identifiers', {
|
|
invoiceId: invoice.id,
|
|
invoiceType,
|
|
metadataSubscriptionId,
|
|
parentSubscriptionId,
|
|
})
|
|
}
|
|
|
|
if (parentSubscriptionId) {
|
|
return {
|
|
invoiceType,
|
|
resolutionSource: 'parent.subscription_details.subscription',
|
|
stripeSubscriptionId: parentSubscriptionId,
|
|
}
|
|
}
|
|
|
|
if (metadataSubscriptionId) {
|
|
return {
|
|
invoiceType,
|
|
resolutionSource: 'metadata.subscriptionId',
|
|
stripeSubscriptionId: metadataSubscriptionId,
|
|
}
|
|
}
|
|
|
|
return {
|
|
invoiceType,
|
|
resolutionSource: 'none',
|
|
stripeSubscriptionId: null,
|
|
}
|
|
}
|
|
|
|
async function resolveInvoiceSubscription(
|
|
invoice: Stripe.Invoice,
|
|
handlerName: string
|
|
): Promise<ResolvedInvoiceSubscription | null> {
|
|
const subscriptionContext = resolveInvoiceSubscriptionContext(invoice)
|
|
|
|
if (!subscriptionContext.stripeSubscriptionId) {
|
|
logger.info('No subscription found on invoice; skipping handler', {
|
|
handlerName,
|
|
invoiceId: invoice.id,
|
|
invoiceType: subscriptionContext.invoiceType,
|
|
resolutionSource: subscriptionContext.resolutionSource,
|
|
})
|
|
return null
|
|
}
|
|
|
|
const records = await db
|
|
.select()
|
|
.from(subscriptionTable)
|
|
.where(eq(subscriptionTable.stripeSubscriptionId, subscriptionContext.stripeSubscriptionId))
|
|
.limit(1)
|
|
|
|
if (records.length === 0) {
|
|
logger.warn('Subscription not found in database for invoice', {
|
|
handlerName,
|
|
invoiceId: invoice.id,
|
|
invoiceType: subscriptionContext.invoiceType,
|
|
resolutionSource: subscriptionContext.resolutionSource,
|
|
stripeSubscriptionId: subscriptionContext.stripeSubscriptionId,
|
|
})
|
|
return null
|
|
}
|
|
|
|
return {
|
|
...subscriptionContext,
|
|
stripeSubscriptionId: subscriptionContext.stripeSubscriptionId,
|
|
sub: records[0],
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a billing portal URL for a Stripe customer
|
|
*/
|
|
async function createBillingPortalUrl(stripeCustomerId: string): Promise<string> {
|
|
try {
|
|
const stripe = requireStripeClient()
|
|
const baseUrl = getBaseUrl()
|
|
const portal = await stripe.billingPortal.sessions.create({
|
|
customer: stripeCustomerId,
|
|
return_url: `${baseUrl}/workspace?billing=updated`,
|
|
})
|
|
return portal.url
|
|
} catch (error) {
|
|
logger.error('Failed to create billing portal URL', { error, stripeCustomerId })
|
|
// Fallback to generic billing page
|
|
return `${getBaseUrl()}/workspace?tab=subscription`
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get payment method details from Stripe invoice
|
|
*/
|
|
async function getPaymentMethodDetails(
|
|
invoice: Stripe.Invoice
|
|
): Promise<{ lastFourDigits?: string; failureReason?: string }> {
|
|
let lastFourDigits: string | undefined
|
|
let failureReason: string | undefined
|
|
|
|
// Try to get last 4 digits from payment method
|
|
try {
|
|
const stripe = requireStripeClient()
|
|
|
|
// Try to get from default payment method
|
|
if (invoice.default_payment_method && typeof invoice.default_payment_method === 'string') {
|
|
const paymentMethod = await stripe.paymentMethods.retrieve(invoice.default_payment_method)
|
|
if (paymentMethod.card?.last4) {
|
|
lastFourDigits = paymentMethod.card.last4
|
|
}
|
|
}
|
|
|
|
// If no default payment method, try getting from customer's default
|
|
if (!lastFourDigits && invoice.customer && typeof invoice.customer === 'string') {
|
|
const customer = await stripe.customers.retrieve(invoice.customer)
|
|
if (customer && !('deleted' in customer)) {
|
|
const defaultPm = customer.invoice_settings?.default_payment_method
|
|
if (defaultPm && typeof defaultPm === 'string') {
|
|
const paymentMethod = await stripe.paymentMethods.retrieve(defaultPm)
|
|
if (paymentMethod.card?.last4) {
|
|
lastFourDigits = paymentMethod.card.last4
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.warn('Failed to retrieve payment method details', { error, invoiceId: invoice.id })
|
|
}
|
|
|
|
// Get failure message - check multiple sources
|
|
if (invoice.last_finalization_error?.message) {
|
|
failureReason = invoice.last_finalization_error.message
|
|
}
|
|
|
|
// If not found, check the payments array (requires expand: ['payments'])
|
|
if (!failureReason && invoice.payments?.data) {
|
|
const defaultPayment = invoice.payments.data.find((p) => p.is_default)
|
|
const payment = defaultPayment || invoice.payments.data[0]
|
|
|
|
if (payment?.payment) {
|
|
try {
|
|
const stripe = requireStripeClient()
|
|
|
|
if (payment.payment.type === 'payment_intent' && payment.payment.payment_intent) {
|
|
const piId =
|
|
typeof payment.payment.payment_intent === 'string'
|
|
? payment.payment.payment_intent
|
|
: payment.payment.payment_intent.id
|
|
|
|
const paymentIntent = await stripe.paymentIntents.retrieve(piId)
|
|
if (paymentIntent.last_payment_error?.message) {
|
|
failureReason = paymentIntent.last_payment_error.message
|
|
}
|
|
} else if (payment.payment.type === 'charge' && payment.payment.charge) {
|
|
const chargeId =
|
|
typeof payment.payment.charge === 'string'
|
|
? payment.payment.charge
|
|
: payment.payment.charge.id
|
|
|
|
const charge = await stripe.charges.retrieve(chargeId)
|
|
if (charge.failure_message) {
|
|
failureReason = charge.failure_message
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.warn('Failed to retrieve payment details for failure reason', {
|
|
error,
|
|
invoiceId: invoice.id,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return { lastFourDigits, failureReason }
|
|
}
|
|
|
|
/**
|
|
* Send payment failure notification emails to affected users
|
|
* Note: This is only called when billing is enabled (Stripe plugin loaded)
|
|
*/
|
|
async function sendPaymentFailureEmails(
|
|
sub: { plan: string | null; referenceId: string },
|
|
invoice: Stripe.Invoice,
|
|
stripeCustomerId: string
|
|
): Promise<void> {
|
|
try {
|
|
const billingPortalUrl = await createBillingPortalUrl(stripeCustomerId)
|
|
const amountDue = invoice.amount_due / 100 // Convert cents to dollars
|
|
const { lastFourDigits, failureReason } = await getPaymentMethodDetails(invoice)
|
|
|
|
// Notify based on subscription scope — org-scoped subs alert owners/admins.
|
|
let usersToNotify: Array<{ email: string; name: string | null }> = []
|
|
const orgScoped = await isSubscriptionOrgScoped(sub)
|
|
|
|
if (orgScoped) {
|
|
const members = await db
|
|
.select({
|
|
userId: member.userId,
|
|
role: member.role,
|
|
})
|
|
.from(member)
|
|
.where(eq(member.organizationId, sub.referenceId))
|
|
|
|
const ownerAdminIds = members.filter((m) => isOrgAdminRole(m.role)).map((m) => m.userId)
|
|
|
|
if (ownerAdminIds.length > 0) {
|
|
const users = await db
|
|
.select({ email: user.email, name: user.name })
|
|
.from(user)
|
|
.where(inArray(user.id, ownerAdminIds))
|
|
|
|
usersToNotify = users.filter((u) => u.email && quickValidateEmail(u.email).isValid)
|
|
}
|
|
} else {
|
|
const users = await db
|
|
.select({ email: user.email, name: user.name })
|
|
.from(user)
|
|
.where(eq(user.id, sub.referenceId))
|
|
.limit(1)
|
|
|
|
if (users.length > 0) {
|
|
usersToNotify = users.filter((u) => u.email && quickValidateEmail(u.email).isValid)
|
|
}
|
|
}
|
|
|
|
// Send emails to all affected users
|
|
for (const userToNotify of usersToNotify) {
|
|
try {
|
|
const emailHtml = await render(
|
|
PaymentFailedEmail({
|
|
userName: userToNotify.name || undefined,
|
|
amountDue,
|
|
lastFourDigits,
|
|
billingPortalUrl,
|
|
failureReason,
|
|
sentDate: new Date(),
|
|
})
|
|
)
|
|
|
|
const { from } = getPersonalEmailFrom()
|
|
const replyTo = getHelpEmailAddress()
|
|
await sendEmail({
|
|
to: userToNotify.email,
|
|
subject: 'Payment Failed - Action Required',
|
|
html: emailHtml,
|
|
from,
|
|
replyTo,
|
|
emailType: 'transactional',
|
|
})
|
|
|
|
logger.info('Payment failure email sent', {
|
|
email: userToNotify.email,
|
|
invoiceId: invoice.id,
|
|
})
|
|
} catch (emailError) {
|
|
logger.error('Failed to send payment failure email', {
|
|
error: emailError,
|
|
email: userToNotify.email,
|
|
})
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to send payment failure emails', { error })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get total billed overage for a subscription, handling org-scoped vs
|
|
* personally-scoped plans.
|
|
* - Org-scoped (team, enterprise, or `pro_*` attached to an org):
|
|
* stored on the org owner's `userStats.billedOverageThisPeriod`.
|
|
* - Personally-scoped: the user's own `billedOverageThisPeriod`.
|
|
*/
|
|
export async function getBilledOverageForSubscription(sub: {
|
|
plan: string | null
|
|
referenceId: string
|
|
}): Promise<number> {
|
|
if (await isSubscriptionOrgScoped(sub)) {
|
|
const ownerRows = await db
|
|
.select({ userId: member.userId })
|
|
.from(member)
|
|
.where(and(eq(member.organizationId, sub.referenceId), eq(member.role, 'owner')))
|
|
.limit(1)
|
|
|
|
const ownerId = ownerRows[0]?.userId
|
|
|
|
if (!ownerId) {
|
|
logger.warn('Organization has no owner when fetching billed overage', {
|
|
organizationId: sub.referenceId,
|
|
})
|
|
return 0
|
|
}
|
|
|
|
const ownerStats = await db
|
|
.select({ billedOverageThisPeriod: userStats.billedOverageThisPeriod })
|
|
.from(userStats)
|
|
.where(eq(userStats.userId, ownerId))
|
|
.limit(1)
|
|
|
|
return ownerStats.length > 0 ? toNumber(toDecimal(ownerStats[0].billedOverageThisPeriod)) : 0
|
|
}
|
|
|
|
const userStatsRecords = await db
|
|
.select({ billedOverageThisPeriod: userStats.billedOverageThisPeriod })
|
|
.from(userStats)
|
|
.where(eq(userStats.userId, sub.referenceId))
|
|
.limit(1)
|
|
|
|
return userStatsRecords.length > 0
|
|
? toNumber(toDecimal(userStatsRecords[0].billedOverageThisPeriod))
|
|
: 0
|
|
}
|
|
|
|
export async function resetUsageForSubscription(sub: {
|
|
plan: string | null
|
|
referenceId: string
|
|
periodStart?: Date | null
|
|
periodEnd?: Date | null
|
|
}) {
|
|
const billingPeriod =
|
|
sub.periodStart && sub.periodEnd ? { start: sub.periodStart, end: sub.periodEnd } : null
|
|
|
|
if (await isSubscriptionOrgScoped(sub)) {
|
|
const ledgerUsageByUser = billingPeriod
|
|
? await getBillingPeriodUsageCostByUser(
|
|
{ type: 'organization', id: sub.referenceId },
|
|
billingPeriod
|
|
)
|
|
: new Map<string, number>()
|
|
// Copilot-family ledger per user, so last-period copilot mirrors last-period
|
|
// cost (baseline + usage_log) instead of capturing the baseline alone.
|
|
const copilotLedgerByUser = billingPeriod
|
|
? await getBillingPeriodUsageCostByUser(
|
|
{ type: 'organization', id: sub.referenceId },
|
|
billingPeriod,
|
|
COPILOT_USAGE_SOURCES
|
|
)
|
|
: new Map<string, number>()
|
|
|
|
await db.transaction(async (tx) => {
|
|
await tx.execute(sql.raw(`SET LOCAL lock_timeout = '${BILLING_LOCK_TIMEOUT_MS}ms'`))
|
|
|
|
const ownerRows = await tx
|
|
.select({ userId: member.userId })
|
|
.from(member)
|
|
.where(and(eq(member.organizationId, sub.referenceId), eq(member.role, 'owner')))
|
|
.for('update')
|
|
.limit(1)
|
|
|
|
const ownerId = ownerRows[0]?.userId
|
|
if (ownerId) {
|
|
await tx
|
|
.select({ userId: userStats.userId })
|
|
.from(userStats)
|
|
.where(eq(userStats.userId, ownerId))
|
|
.for('update')
|
|
.limit(1)
|
|
}
|
|
|
|
const membersRows = await tx
|
|
.select({ userId: member.userId })
|
|
.from(member)
|
|
.where(eq(member.organizationId, sub.referenceId))
|
|
|
|
const memberIds = membersRows.map((row) => row.userId)
|
|
|
|
// Lock every member's userStats before the organization row so this path
|
|
// follows the canonical userStats → organization order shared by the
|
|
// join, remove, threshold-billing, and storage-transfer paths. Locking
|
|
// organization first would invert against them and risk an AB-BA
|
|
// deadlock. The per-member UPDATE below re-locks these rows (no-op).
|
|
if (memberIds.length > 0) {
|
|
await tx
|
|
.select({ userId: userStats.userId })
|
|
.from(userStats)
|
|
.where(inArray(userStats.userId, memberIds))
|
|
.for('update')
|
|
}
|
|
|
|
await tx
|
|
.select({ id: organization.id })
|
|
.from(organization)
|
|
.where(eq(organization.id, sub.referenceId))
|
|
.for('update')
|
|
.limit(1)
|
|
if (memberIds.length > 0) {
|
|
const memberStatsRows = await tx
|
|
.select({
|
|
userId: userStats.userId,
|
|
current: userStats.currentPeriodCost,
|
|
currentCopilot: userStats.currentPeriodCopilotCost,
|
|
})
|
|
.from(userStats)
|
|
.where(inArray(userStats.userId, memberIds))
|
|
|
|
const statsUserIds = memberStatsRows.map((row) => row.userId)
|
|
if (statsUserIds.length === 0) {
|
|
await tx
|
|
.update(organization)
|
|
.set({ departedMemberUsage: '0' })
|
|
.where(eq(organization.id, sub.referenceId))
|
|
return
|
|
}
|
|
|
|
const lastCostByUser = sql.join(
|
|
memberStatsRows.map((row) => {
|
|
const baseline = toNumber(toDecimal(row.current))
|
|
const ledgerUsage = ledgerUsageByUser.get(row.userId) ?? 0
|
|
const lastPeriodCost = (baseline + ledgerUsage).toString()
|
|
return sql`WHEN ${row.userId} THEN ${lastPeriodCost}`
|
|
}),
|
|
sql` `
|
|
)
|
|
const currentCostByUser = sql.join(
|
|
memberStatsRows.map((row) => sql`WHEN ${row.userId} THEN ${row.current ?? '0'}`),
|
|
sql` `
|
|
)
|
|
const currentCopilotCostByUser = sql.join(
|
|
memberStatsRows.map((row) => sql`WHEN ${row.userId} THEN ${row.currentCopilot ?? '0'}`),
|
|
sql` `
|
|
)
|
|
// Last-period copilot = baseline copilot + copilot-family ledger, mirroring
|
|
// lastPeriodCost. (The reset below still subtracts only the baseline, since
|
|
// the ledger is period-scoped and rolls over on its own.)
|
|
const lastCopilotCostByUser = sql.join(
|
|
memberStatsRows.map((row) => {
|
|
const baselineCopilot = toNumber(toDecimal(row.currentCopilot))
|
|
const copilotLedger = copilotLedgerByUser.get(row.userId) ?? 0
|
|
return sql`WHEN ${row.userId} THEN ${(baselineCopilot + copilotLedger).toString()}`
|
|
}),
|
|
sql` `
|
|
)
|
|
const capturedLastCost = sql`CASE ${userStats.userId} ${lastCostByUser} ELSE '0' END`
|
|
const capturedCurrentCost = sql`CASE ${userStats.userId} ${currentCostByUser} ELSE '0' END`
|
|
const capturedCurrentCopilotCost = sql`CASE ${userStats.userId} ${currentCopilotCostByUser} ELSE '0' END`
|
|
const capturedLastCopilotCost = sql`CASE ${userStats.userId} ${lastCopilotCostByUser} ELSE '0' END`
|
|
|
|
await tx
|
|
.update(userStats)
|
|
.set({
|
|
lastPeriodCost: capturedLastCost,
|
|
lastPeriodCopilotCost: capturedLastCopilotCost,
|
|
currentPeriodCost: sql`GREATEST(0, ${userStats.currentPeriodCost} - (${capturedCurrentCost})::decimal)`,
|
|
currentPeriodCopilotCost: sql`GREATEST(0, ${userStats.currentPeriodCopilotCost} - (${capturedCurrentCopilotCost})::decimal)`,
|
|
billedOverageThisPeriod: '0',
|
|
})
|
|
.where(inArray(userStats.userId, statsUserIds))
|
|
}
|
|
|
|
await tx
|
|
.update(organization)
|
|
.set({ departedMemberUsage: '0' })
|
|
.where(eq(organization.id, sub.referenceId))
|
|
})
|
|
} else {
|
|
const currentStats = await db
|
|
.select({
|
|
current: userStats.currentPeriodCost,
|
|
snapshot: userStats.proPeriodCostSnapshot,
|
|
currentCopilot: userStats.currentPeriodCopilotCost,
|
|
})
|
|
.from(userStats)
|
|
.where(eq(userStats.userId, sub.referenceId))
|
|
.limit(1)
|
|
if (currentStats.length > 0) {
|
|
const current = currentStats[0].current || '0'
|
|
const snapshot = toNumber(toDecimal(currentStats[0].snapshot))
|
|
const currentCopilot = currentStats[0].currentCopilot || '0'
|
|
const ledgerUsage = billingPeriod
|
|
? await getBillingPeriodUsageCostByUser(
|
|
{ type: 'user', id: sub.referenceId },
|
|
billingPeriod
|
|
)
|
|
: new Map<string, number>()
|
|
const userLedgerUsage = ledgerUsage.get(sub.referenceId) ?? 0
|
|
const copilotLedgerUsage = billingPeriod
|
|
? ((
|
|
await getBillingPeriodUsageCostByUser(
|
|
{ type: 'user', id: sub.referenceId },
|
|
billingPeriod,
|
|
COPILOT_USAGE_SOURCES
|
|
)
|
|
).get(sub.referenceId) ?? 0)
|
|
: 0
|
|
|
|
// Snapshot > 0: user joined a paid org mid-cycle. The pre-join
|
|
// portion was billed on this invoice (snapshot); `currentPeriodCost`
|
|
// is post-join usage the org will bill next cycle-close, so keep
|
|
// it. Only retire the personal-billing trackers here.
|
|
if (snapshot > 0) {
|
|
await db
|
|
.update(userStats)
|
|
.set({
|
|
lastPeriodCost: (snapshot + userLedgerUsage).toString(),
|
|
// Pre-join personal copilot = the user-scoped copilot ledger only
|
|
// (post-join copilot usage is org-attributed, so this captures the
|
|
// pre-join portion). The copilot baseline stays with the org via the
|
|
// retained currentPeriodCopilotCost, so don't add it here (avoids a
|
|
// double count at the org's cycle-close).
|
|
lastPeriodCopilotCost: copilotLedgerUsage.toString(),
|
|
proPeriodCostSnapshot: '0',
|
|
proPeriodCostSnapshotAt: null,
|
|
billedOverageThisPeriod: '0',
|
|
})
|
|
.where(eq(userStats.userId, sub.referenceId))
|
|
} else {
|
|
const totalLastPeriod = (
|
|
toNumber(toDecimal(current)) +
|
|
snapshot +
|
|
userLedgerUsage
|
|
).toString()
|
|
// Delta-reset for the same reason as the org branch above.
|
|
await db
|
|
.update(userStats)
|
|
.set({
|
|
lastPeriodCost: totalLastPeriod,
|
|
lastPeriodCopilotCost: (
|
|
toNumber(toDecimal(currentCopilot)) + copilotLedgerUsage
|
|
).toString(),
|
|
currentPeriodCost: sql`GREATEST(0, ${userStats.currentPeriodCost} - ${current}::decimal)`,
|
|
currentPeriodCopilotCost: sql`GREATEST(0, ${userStats.currentPeriodCopilotCost} - ${currentCopilot}::decimal)`,
|
|
proPeriodCostSnapshot: '0',
|
|
proPeriodCostSnapshotAt: null,
|
|
billedOverageThisPeriod: '0',
|
|
})
|
|
.where(eq(userStats.userId, sub.referenceId))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle credit purchase invoice payment succeeded.
|
|
*/
|
|
async function handleCreditPurchaseSuccess(invoice: Stripe.Invoice): Promise<void> {
|
|
const { entityType, entityId, amountDollars, purchasedBy } = invoice.metadata || {}
|
|
if (!entityType || !entityId || !amountDollars) {
|
|
logger.error('Missing metadata in credit purchase invoice', {
|
|
invoiceId: invoice.id,
|
|
metadata: invoice.metadata,
|
|
})
|
|
return
|
|
}
|
|
|
|
if (entityType !== 'user' && entityType !== 'organization') {
|
|
logger.error('Invalid entityType in credit purchase', { invoiceId: invoice.id, entityType })
|
|
return
|
|
}
|
|
|
|
const amount = Number.parseFloat(amountDollars)
|
|
if (!Number.isFinite(amount) || amount <= 0) {
|
|
logger.error('Invalid amount in credit purchase', { invoiceId: invoice.id, amountDollars })
|
|
return
|
|
}
|
|
|
|
if (!invoice.id) {
|
|
logger.error('Credit purchase invoice missing id, cannot dedupe', {
|
|
metadata: invoice.metadata,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Idempotent apply: duplicate Stripe deliveries collapse to a single
|
|
// execution. On exception the key is released (retryFailures: true)
|
|
// so the next Stripe retry runs from scratch. On success, subsequent
|
|
// deliveries short-circuit with the cached result.
|
|
//
|
|
// CRITICAL: everything after `addCredits` must be either idempotent or
|
|
// wrapped in try/catch that does not rethrow. Otherwise a failure
|
|
// after credits commit would release the key and the retry would
|
|
// double-credit. `setUsageLimitForCredits` and the email are both
|
|
// best-effort and wrapped; the subscription lookup before them is a
|
|
// read, safe to rerun.
|
|
await stripeWebhookIdempotency.executeWithIdempotency('credit-purchase', invoice.id, async () => {
|
|
await addCredits(entityType, entityId, amount)
|
|
|
|
try {
|
|
const subscription = await db
|
|
.select()
|
|
.from(subscriptionTable)
|
|
.where(
|
|
and(
|
|
eq(subscriptionTable.referenceId, entityId),
|
|
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES)
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
if (subscription.length > 0) {
|
|
const sub = subscription[0]
|
|
const newCreditBalance = await getCreditBalanceForEntity(entityType, entityId)
|
|
await setUsageLimitForCredits(entityType, entityId, sub.plan, sub.seats, newCreditBalance)
|
|
}
|
|
} catch (limitError) {
|
|
// Limit bump is best-effort. Customer already got credits; if the
|
|
// cap doesn't auto-raise they can edit it themselves or another
|
|
// credit purchase will rebase it. Do NOT rethrow — that would
|
|
// release the idempotency claim and double-credit on retry.
|
|
logger.error('Failed to update usage limit after credit purchase', {
|
|
invoiceId: invoice.id,
|
|
entityType,
|
|
entityId,
|
|
error: limitError,
|
|
})
|
|
}
|
|
|
|
logger.info('Credit purchase completed via webhook', {
|
|
invoiceId: invoice.id,
|
|
entityType,
|
|
entityId,
|
|
amount,
|
|
purchasedBy,
|
|
})
|
|
|
|
const actorId =
|
|
purchasedBy ?? (await resolveBillingActorId(entityType === 'organization', entityId))
|
|
recordAudit({
|
|
actorId,
|
|
action: AuditAction.CREDIT_PURCHASED,
|
|
resourceType: AuditResourceType.BILLING,
|
|
resourceId: invoice.id,
|
|
description: `Credit purchase of $${amount.toFixed(2)} fulfilled for ${entityType} ${entityId}`,
|
|
metadata: {
|
|
entityType,
|
|
entityId,
|
|
...(entityType === 'organization' ? { organizationId: entityId } : {}),
|
|
amount,
|
|
currency: 'usd',
|
|
purchasedBy: purchasedBy ?? null,
|
|
invoiceId: invoice.id,
|
|
},
|
|
})
|
|
captureServerEvent(actorId, 'credits_purchased', {
|
|
amount,
|
|
currency: 'usd',
|
|
entity_type: entityType,
|
|
reference_id: entityId,
|
|
})
|
|
|
|
try {
|
|
const newBalance = await getCreditBalanceForEntity(entityType, entityId)
|
|
let recipients: Array<{ email: string; name: string | null }> = []
|
|
|
|
if (entityType === 'organization') {
|
|
const members = await db
|
|
.select({ userId: member.userId, role: member.role })
|
|
.from(member)
|
|
.where(eq(member.organizationId, entityId))
|
|
|
|
const ownerAdminIds = members.filter((m) => isOrgAdminRole(m.role)).map((m) => m.userId)
|
|
|
|
if (ownerAdminIds.length > 0) {
|
|
recipients = await db
|
|
.select({ email: user.email, name: user.name })
|
|
.from(user)
|
|
.where(inArray(user.id, ownerAdminIds))
|
|
}
|
|
} else if (purchasedBy) {
|
|
const users = await db
|
|
.select({ email: user.email, name: user.name })
|
|
.from(user)
|
|
.where(eq(user.id, purchasedBy))
|
|
.limit(1)
|
|
|
|
recipients = users
|
|
}
|
|
|
|
for (const recipient of recipients) {
|
|
if (!recipient.email) continue
|
|
|
|
const emailHtml = await renderCreditPurchaseEmail({
|
|
userName: recipient.name || undefined,
|
|
amount,
|
|
newBalance,
|
|
})
|
|
|
|
await sendEmail({
|
|
to: recipient.email,
|
|
subject: getEmailSubject('credit-purchase'),
|
|
html: emailHtml,
|
|
emailType: 'transactional',
|
|
})
|
|
|
|
logger.info('Sent credit purchase confirmation email', {
|
|
email: recipient.email,
|
|
invoiceId: invoice.id,
|
|
})
|
|
}
|
|
} catch (emailError) {
|
|
// Emails are best-effort — a failure here should NOT release the
|
|
// claim (otherwise Stripe retries would re-credit the user).
|
|
logger.error('Failed to send credit purchase emails', {
|
|
emailError,
|
|
invoiceId: invoice.id,
|
|
})
|
|
}
|
|
|
|
return { ok: true }
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Handle invoice payment succeeded webhook.
|
|
* Handles both credit purchases and subscription payments.
|
|
*/
|
|
export async function handleInvoicePaymentSucceeded(event: Stripe.Event) {
|
|
try {
|
|
const invoice = event.data.object as Stripe.Invoice
|
|
|
|
if (invoice.metadata?.type === 'credit_purchase') {
|
|
await handleCreditPurchaseSuccess(invoice)
|
|
return
|
|
}
|
|
|
|
await stripeWebhookIdempotency.executeWithIdempotency(
|
|
'invoice-payment-succeeded',
|
|
event.id,
|
|
async () => {
|
|
const resolvedInvoice = await resolveInvoiceSubscription(
|
|
invoice,
|
|
'invoice.payment_succeeded'
|
|
)
|
|
if (!resolvedInvoice) {
|
|
return
|
|
}
|
|
|
|
const { sub } = resolvedInvoice
|
|
const subIsOrgScoped = await isSubscriptionOrgScoped(sub)
|
|
|
|
let wasBlocked = false
|
|
if (subIsOrgScoped) {
|
|
const membersRows = await db
|
|
.select({ userId: member.userId })
|
|
.from(member)
|
|
.where(eq(member.organizationId, sub.referenceId))
|
|
const memberIds = membersRows.map((m) => m.userId)
|
|
if (memberIds.length > 0) {
|
|
const blockedRows = await db
|
|
.select({ blocked: userStats.billingBlocked })
|
|
.from(userStats)
|
|
.where(inArray(userStats.userId, memberIds))
|
|
|
|
wasBlocked = blockedRows.some((row) => !!row.blocked)
|
|
}
|
|
} else {
|
|
const row = await db
|
|
.select({ blocked: userStats.billingBlocked })
|
|
.from(userStats)
|
|
.where(eq(userStats.userId, sub.referenceId))
|
|
.limit(1)
|
|
wasBlocked = row.length > 0 ? !!row[0].blocked : false
|
|
}
|
|
|
|
const isProrationInvoice = invoice.billing_reason === 'subscription_update'
|
|
const shouldUnblock = !isProrationInvoice || (invoice.amount_paid ?? 0) > 0
|
|
|
|
if (shouldUnblock) {
|
|
if (subIsOrgScoped) {
|
|
await unblockOrgMembers(sub.referenceId, 'payment_failed')
|
|
} else {
|
|
await db
|
|
.update(userStats)
|
|
.set({ billingBlocked: false, billingBlockedReason: null })
|
|
.where(
|
|
and(
|
|
eq(userStats.userId, sub.referenceId),
|
|
eq(userStats.billingBlockedReason, 'payment_failed')
|
|
)
|
|
)
|
|
}
|
|
} else {
|
|
logger.info('Skipping unblock for zero-amount proration invoice', {
|
|
invoiceId: invoice.id,
|
|
billingReason: invoice.billing_reason,
|
|
amountPaid: invoice.amount_paid,
|
|
})
|
|
}
|
|
|
|
if (wasBlocked && !isProrationInvoice) {
|
|
const invoicePeriod = getSubscriptionLinePeriod(
|
|
invoice,
|
|
resolvedInvoice.stripeSubscriptionId
|
|
)
|
|
await resetUsageForSubscription({
|
|
plan: sub.plan,
|
|
referenceId: sub.referenceId,
|
|
periodStart: invoicePeriod?.periodStart ?? null,
|
|
periodEnd: invoicePeriod?.periodEnd ?? null,
|
|
})
|
|
}
|
|
|
|
const entityType = subIsOrgScoped ? 'organization' : 'user'
|
|
const amountPaid = (invoice.amount_paid ?? 0) / 100
|
|
const actorId = await resolveBillingActorId(subIsOrgScoped, sub.referenceId)
|
|
|
|
recordAudit({
|
|
actorId,
|
|
action: AuditAction.INVOICE_PAYMENT_SUCCEEDED,
|
|
resourceType: AuditResourceType.BILLING,
|
|
resourceId: invoice.id,
|
|
description: `Invoice payment of $${amountPaid.toFixed(2)} succeeded for ${entityType} ${sub.referenceId}`,
|
|
metadata: {
|
|
entityType,
|
|
referenceId: sub.referenceId,
|
|
...(entityType === 'organization' ? { organizationId: sub.referenceId } : {}),
|
|
plan: sub.plan,
|
|
amount: amountPaid,
|
|
currency: invoice.currency ?? 'usd',
|
|
invoiceId: invoice.id,
|
|
},
|
|
})
|
|
captureServerEvent(actorId, 'payment_succeeded', {
|
|
plan: sub.plan ?? 'unknown',
|
|
amount: amountPaid,
|
|
currency: invoice.currency ?? 'usd',
|
|
entity_type: entityType,
|
|
reference_id: sub.referenceId,
|
|
})
|
|
}
|
|
)
|
|
} catch (error) {
|
|
logger.error('Failed to handle invoice payment succeeded', { eventId: event.id, error })
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle invoice payment failed webhook
|
|
* This is triggered when a user's payment fails for any invoice (subscription or overage)
|
|
*/
|
|
export async function handleInvoicePaymentFailed(event: Stripe.Event) {
|
|
try {
|
|
const invoice = event.data.object as Stripe.Invoice
|
|
|
|
await stripeWebhookIdempotency.executeWithIdempotency(
|
|
'invoice-payment-failed',
|
|
event.id,
|
|
async () => {
|
|
const resolvedInvoice = await resolveInvoiceSubscription(invoice, 'invoice.payment_failed')
|
|
if (!resolvedInvoice) {
|
|
return
|
|
}
|
|
|
|
const { invoiceType, resolutionSource, stripeSubscriptionId, sub } = resolvedInvoice
|
|
|
|
const customerId = invoice.customer
|
|
if (!customerId || typeof customerId !== 'string') {
|
|
logger.error('Invalid customer ID on invoice', {
|
|
invoiceId: invoice.id,
|
|
customer: invoice.customer,
|
|
})
|
|
return
|
|
}
|
|
|
|
const failedAmount = invoice.amount_due / 100
|
|
const billingPeriod = invoice.metadata?.billingPeriod || 'unknown'
|
|
const attemptCount = invoice.attempt_count ?? 1
|
|
|
|
logger.warn('Invoice payment failed', {
|
|
invoiceId: invoice.id,
|
|
customerId,
|
|
failedAmount,
|
|
billingPeriod,
|
|
attemptCount,
|
|
customerEmail: invoice.customer_email,
|
|
hostedInvoiceUrl: invoice.hosted_invoice_url,
|
|
invoiceType: invoiceType ?? 'subscription',
|
|
resolutionSource,
|
|
})
|
|
|
|
// Best-effort instrumentation; its DB reads must never abort the
|
|
// user-blocking that follows, so the whole block is guarded.
|
|
try {
|
|
const failureOrgScoped = await isSubscriptionOrgScoped(sub)
|
|
const failureEntityType = failureOrgScoped ? 'organization' : 'user'
|
|
const failureActorId = await resolveBillingActorId(failureOrgScoped, sub.referenceId)
|
|
|
|
recordAudit({
|
|
actorId: failureActorId,
|
|
action: AuditAction.INVOICE_PAYMENT_FAILED,
|
|
resourceType: AuditResourceType.BILLING,
|
|
resourceId: invoice.id,
|
|
description: `Invoice payment of $${failedAmount.toFixed(2)} failed for ${failureEntityType} ${sub.referenceId} (attempt ${attemptCount})`,
|
|
metadata: {
|
|
entityType: failureEntityType,
|
|
referenceId: sub.referenceId,
|
|
...(failureEntityType === 'organization' ? { organizationId: sub.referenceId } : {}),
|
|
plan: sub.plan,
|
|
amount: failedAmount,
|
|
currency: invoice.currency ?? 'usd',
|
|
attemptCount,
|
|
invoiceType: invoiceType ?? 'subscription',
|
|
invoiceId: invoice.id,
|
|
},
|
|
})
|
|
captureServerEvent(failureActorId, 'payment_failed', {
|
|
plan: sub.plan ?? 'unknown',
|
|
amount: failedAmount,
|
|
currency: invoice.currency ?? 'usd',
|
|
entity_type: failureEntityType,
|
|
reference_id: sub.referenceId,
|
|
attempt_count: attemptCount,
|
|
})
|
|
} catch (auditError) {
|
|
logger.warn('Failed to record payment_failed instrumentation', { auditError })
|
|
}
|
|
|
|
if (attemptCount >= 1) {
|
|
logger.error('Payment failure - blocking users', {
|
|
customerId,
|
|
attemptCount,
|
|
invoiceId: invoice.id,
|
|
invoiceType: invoiceType ?? 'subscription',
|
|
resolutionSource,
|
|
stripeSubscriptionId,
|
|
})
|
|
|
|
if (await isSubscriptionOrgScoped(sub)) {
|
|
const memberCount = await blockOrgMembers(sub.referenceId, 'payment_failed')
|
|
logger.info('Blocked org members due to payment failure', {
|
|
invoiceType: invoiceType ?? 'subscription',
|
|
memberCount,
|
|
organizationId: sub.referenceId,
|
|
})
|
|
} else {
|
|
await db
|
|
.update(userStats)
|
|
.set({ billingBlocked: true, billingBlockedReason: 'payment_failed' })
|
|
.where(
|
|
and(
|
|
eq(userStats.userId, sub.referenceId),
|
|
or(
|
|
ne(userStats.billingBlockedReason, 'dispute'),
|
|
isNull(userStats.billingBlockedReason)
|
|
)
|
|
)
|
|
)
|
|
logger.info('Blocked user due to payment failure', {
|
|
invoiceType: invoiceType ?? 'subscription',
|
|
userId: sub.referenceId,
|
|
})
|
|
}
|
|
|
|
if (attemptCount === 1) {
|
|
await sendPaymentFailureEmails(sub, invoice, customerId)
|
|
logger.info('Payment failure email sent on first attempt', {
|
|
customerId,
|
|
invoiceId: invoice.id,
|
|
})
|
|
} else {
|
|
logger.info('Skipping payment failure email on retry attempt', {
|
|
attemptCount,
|
|
customerId,
|
|
invoiceId: invoice.id,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
)
|
|
} catch (error) {
|
|
logger.error('Failed to handle invoice payment failed', {
|
|
eventId: event.id,
|
|
error,
|
|
})
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle base invoice finalized → create a separate overage-only invoice
|
|
* Note: Enterprise plans no longer have overages
|
|
*/
|
|
export async function handleInvoiceFinalized(event: Stripe.Event) {
|
|
try {
|
|
const invoice = event.data.object as Stripe.Invoice
|
|
const subscription = invoice.parent?.subscription_details?.subscription
|
|
const stripeSubscriptionId = typeof subscription === 'string' ? subscription : subscription?.id
|
|
if (!stripeSubscriptionId) {
|
|
logger.info('No subscription found on invoice; skipping finalized handler', {
|
|
invoiceId: invoice.id,
|
|
})
|
|
return
|
|
}
|
|
if (invoice.billing_reason && invoice.billing_reason !== 'subscription_cycle') return
|
|
|
|
const records = await db
|
|
.select()
|
|
.from(subscriptionTable)
|
|
.where(eq(subscriptionTable.stripeSubscriptionId, stripeSubscriptionId))
|
|
.limit(1)
|
|
|
|
if (records.length === 0) return
|
|
const sub = records[0]
|
|
|
|
const invoicePeriod = getSubscriptionLinePeriod(invoice, stripeSubscriptionId)
|
|
if (!invoicePeriod) {
|
|
logger.error('Missing subscription line period on subscription cycle invoice', {
|
|
invoiceId: invoice.id,
|
|
stripeSubscriptionId,
|
|
})
|
|
if (isEnterprise(sub.plan)) {
|
|
await resetUsageForSubscription({ plan: sub.plan, referenceId: sub.referenceId })
|
|
}
|
|
return
|
|
}
|
|
|
|
if (isEnterprise(sub.plan)) {
|
|
await resetUsageForSubscription({
|
|
plan: sub.plan,
|
|
referenceId: sub.referenceId,
|
|
periodStart: invoicePeriod.periodStart,
|
|
periodEnd: invoicePeriod.periodEnd,
|
|
})
|
|
return
|
|
}
|
|
|
|
await stripeWebhookIdempotency.executeWithIdempotency(
|
|
'invoice-finalized',
|
|
event.id,
|
|
async () => {
|
|
const stripe = requireStripeClient()
|
|
const periodStart = Math.floor(invoicePeriod.periodStart.getTime() / 1000)
|
|
const periodEnd = Math.floor(invoicePeriod.periodEnd.getTime() / 1000)
|
|
const billingPeriod = new Date(periodEnd * 1000).toISOString().slice(0, 7)
|
|
|
|
const totalOverage = await calculateSubscriptionOverage({
|
|
...sub,
|
|
periodStart: new Date(periodStart * 1000),
|
|
periodEnd: new Date(periodEnd * 1000),
|
|
})
|
|
|
|
const entityType = (await isSubscriptionOrgScoped(sub)) ? 'organization' : 'user'
|
|
const entityId = sub.referenceId
|
|
|
|
// Phase 1 — atomic commit. Resolve org owners inside the transaction,
|
|
// then lock the tracker row so `billedOverageThisPeriod` is serialized
|
|
// against threshold billing, resets, owner transfers, and retries.
|
|
const phase1 = await db.transaction(async (tx) => {
|
|
await tx.execute(sql.raw(`SET LOCAL lock_timeout = '${BILLING_LOCK_TIMEOUT_MS}ms'`))
|
|
|
|
let trackerUserId = entityId
|
|
if (entityType === 'organization') {
|
|
const ownerRows = await tx
|
|
.select({ userId: member.userId })
|
|
.from(member)
|
|
.where(and(eq(member.organizationId, entityId), eq(member.role, 'owner')))
|
|
.for('update')
|
|
.limit(1)
|
|
const ownerId = ownerRows[0]?.userId
|
|
if (!ownerId) {
|
|
throw new Error(
|
|
`Organization ${entityId} has no owner member; cannot process invoice finalization`
|
|
)
|
|
}
|
|
trackerUserId = ownerId
|
|
}
|
|
|
|
const trackerRows = await tx
|
|
.select({ billed: userStats.billedOverageThisPeriod })
|
|
.from(userStats)
|
|
.where(eq(userStats.userId, trackerUserId))
|
|
.for('update')
|
|
.limit(1)
|
|
|
|
const billedInTx = trackerRows.length > 0 ? toNumber(toDecimal(trackerRows[0].billed)) : 0
|
|
const remaining = Math.max(0, totalOverage - billedInTx)
|
|
|
|
if (remaining === 0) {
|
|
return { billedInTx, applied: 0, billed: 0, remaining: 0 }
|
|
}
|
|
|
|
const lockedBalance =
|
|
entityType === 'organization'
|
|
? await tx
|
|
.select({ creditBalance: organization.creditBalance })
|
|
.from(organization)
|
|
.where(eq(organization.id, entityId))
|
|
.for('update')
|
|
.limit(1)
|
|
: await tx
|
|
.select({ creditBalance: userStats.creditBalance })
|
|
.from(userStats)
|
|
.where(eq(userStats.userId, entityId))
|
|
.for('update')
|
|
.limit(1)
|
|
|
|
const creditBalance =
|
|
lockedBalance.length > 0 ? toNumber(toDecimal(lockedBalance[0].creditBalance)) : 0
|
|
|
|
const applied = Math.min(creditBalance, remaining)
|
|
const billed = remaining - applied
|
|
|
|
if (applied > 0) {
|
|
if (entityType === 'organization') {
|
|
await tx
|
|
.update(organization)
|
|
.set({
|
|
creditBalance: sql`GREATEST(0, ${organization.creditBalance} - ${applied})`,
|
|
})
|
|
.where(eq(organization.id, entityId))
|
|
} else {
|
|
await tx
|
|
.update(userStats)
|
|
.set({
|
|
creditBalance: sql`GREATEST(0, ${userStats.creditBalance} - ${applied})`,
|
|
})
|
|
.where(eq(userStats.userId, entityId))
|
|
}
|
|
}
|
|
|
|
await tx
|
|
.update(userStats)
|
|
.set({ billedOverageThisPeriod: totalOverage.toString() })
|
|
.where(eq(userStats.userId, trackerUserId))
|
|
|
|
return { billedInTx, applied, billed, remaining }
|
|
})
|
|
|
|
const creditsApplied = phase1.applied
|
|
const amountToBillStripe = phase1.billed
|
|
|
|
logger.info('Invoice finalized overage calculation', {
|
|
subscriptionId: sub.id,
|
|
totalOverage,
|
|
billedOverageBeforeTx: phase1.billedInTx,
|
|
creditsApplied,
|
|
amountToBillStripe,
|
|
billingPeriod,
|
|
})
|
|
|
|
// Phase 2 — Stripe invoice. Runs outside any DB transaction.
|
|
// Every call uses a deterministic idempotency key so retries
|
|
// converge on the same invoice object: re-create returns the
|
|
// existing draft, re-finalize no-ops on an already-finalized
|
|
// invoice, re-pay no-ops on an already-paid invoice.
|
|
if (amountToBillStripe > 0) {
|
|
const customerId = String(invoice.customer)
|
|
const cents = Math.round(amountToBillStripe * 100)
|
|
const itemIdemKey = `overage-item:${customerId}:${stripeSubscriptionId}:${billingPeriod}`
|
|
const invoiceIdemKey = `overage-invoice:${customerId}:${stripeSubscriptionId}:${billingPeriod}`
|
|
const finalizeIdemKey = `overage-finalize:${customerId}:${stripeSubscriptionId}:${billingPeriod}`
|
|
const payIdemKey = `overage-pay:${customerId}:${stripeSubscriptionId}:${billingPeriod}`
|
|
|
|
const { paymentMethodId: defaultPaymentMethod, collectionMethod } =
|
|
await resolveDefaultPaymentMethod(stripe, stripeSubscriptionId, customerId)
|
|
|
|
const effectiveCollectionMethod = collectionMethod ?? 'charge_automatically'
|
|
|
|
const overageInvoice = await stripe.invoices.create(
|
|
{
|
|
customer: customerId,
|
|
collection_method: effectiveCollectionMethod,
|
|
auto_advance: false,
|
|
...(defaultPaymentMethod ? { default_payment_method: defaultPaymentMethod } : {}),
|
|
metadata: {
|
|
type: 'overage_billing',
|
|
billingPeriod,
|
|
subscriptionId: stripeSubscriptionId,
|
|
},
|
|
},
|
|
{ idempotencyKey: invoiceIdemKey }
|
|
)
|
|
|
|
await stripe.invoiceItems.create(
|
|
{
|
|
customer: customerId,
|
|
invoice: overageInvoice.id,
|
|
amount: cents,
|
|
currency: 'usd',
|
|
description: `Usage Based Overage – ${billingPeriod}`,
|
|
metadata: {
|
|
type: 'overage_billing',
|
|
billingPeriod,
|
|
subscriptionId: stripeSubscriptionId,
|
|
},
|
|
},
|
|
{ idempotencyKey: itemIdemKey }
|
|
)
|
|
|
|
const draftId = overageInvoice.id
|
|
if (typeof draftId !== 'string' || draftId.length === 0) {
|
|
logger.error('Stripe created overage invoice without id; aborting finalize')
|
|
} else {
|
|
const finalized = await stripe.invoices.finalizeInvoice(
|
|
draftId,
|
|
{},
|
|
{ idempotencyKey: finalizeIdemKey }
|
|
)
|
|
if (
|
|
effectiveCollectionMethod === 'charge_automatically' &&
|
|
finalized.status === 'open'
|
|
) {
|
|
try {
|
|
const payId = finalized.id
|
|
if (typeof payId !== 'string' || payId.length === 0) {
|
|
logger.error('Finalized invoice missing id')
|
|
throw new Error('Finalized invoice missing id')
|
|
}
|
|
await stripe.invoices.pay(
|
|
payId,
|
|
{ payment_method: defaultPaymentMethod },
|
|
{ idempotencyKey: payIdemKey }
|
|
)
|
|
} catch (payError) {
|
|
logger.error('Failed to auto-pay overage invoice', {
|
|
error: payError,
|
|
invoiceId: finalized.id,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Phase 3 — reset usage for the new period. Clears trackers and
|
|
// rolls `currentPeriodCost` forward by delta. Idempotent on its
|
|
// own (delta subtraction of a value that's already been
|
|
// subtracted is a no-op).
|
|
await resetUsageForSubscription({
|
|
plan: sub.plan,
|
|
referenceId: sub.referenceId,
|
|
periodStart: invoicePeriod.periodStart,
|
|
periodEnd: invoicePeriod.periodEnd,
|
|
})
|
|
|
|
return { totalOverage, creditsApplied, amountToBillStripe }
|
|
}
|
|
)
|
|
} catch (error) {
|
|
logger.error('Failed to handle invoice finalized', { error })
|
|
throw error
|
|
}
|
|
}
|