项目文件夹

文件
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

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
}
}