Files
simstudioai--sim/apps/sim/lib/billing/webhooks/outbox-handlers.ts
T
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

406 lines
14 KiB
TypeScript

import { db } from '@sim/db'
import { member, subscription as subscriptionTable, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { and, eq } from 'drizzle-orm'
import { isTeam } from '@/lib/billing/plan-helpers'
import { getPlanByName } from '@/lib/billing/plans'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { resolveDefaultPaymentMethod } from '@/lib/billing/stripe-payment-method'
import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils'
import type { OutboxHandler } from '@/lib/core/outbox/service'
const logger = createLogger('BillingOutboxHandlers')
export const OUTBOX_EVENT_TYPES = {
/**
* Sync a subscription's `cancel_at_period_end` flag from our DB to
* Stripe. The handler reads the current DB value at processing time
* — so rapid cancel→uncancel→cancel sequences always converge on
* the last-committed DB state regardless of outbox ordering. Callers
* enqueue this event after every DB change to `cancelAtPeriodEnd`.
*/
STRIPE_SYNC_CANCEL_AT_PERIOD_END: 'stripe.sync-cancel-at-period-end',
/**
* Sync a Team subscription's price and seat quantity from our DB to
* Stripe. The handler reads the current DB plan + seats at processing
* time and reconciles the Stripe item's price (e.g. after a Pro→Team
* conversion) and quantity, charging the proration via `always_invoice`.
* A failed charge surfaces through Stripe dunning and the existing
* billing-blocked system, never under the synchronous accept path.
*/
STRIPE_SYNC_SUBSCRIPTION_SEATS: 'stripe.sync-subscription-seats',
STRIPE_THRESHOLD_OVERAGE_INVOICE: 'stripe.threshold-overage-invoice',
STRIPE_SYNC_CUSTOMER_CONTACT: 'stripe.sync-customer-contact',
} as const
interface StripeSyncCancelAtPeriodEndPayload {
stripeSubscriptionId: string
/** The DB subscription row id — also our source-of-truth pointer. */
subscriptionId: string
/** Optional: reason this was enqueued — e.g. 'member-joined-paid-org'. */
reason?: string
}
interface StripeSyncSubscriptionSeatsPayload {
/** The DB subscription row id — the handler reads current seats from this row. */
subscriptionId: string
reason?: string
}
interface StripeSyncCustomerContactPayload {
/** The DB subscription row id — handler resolves current owner/contact at processing time. */
subscriptionId: string
reason?: string
}
interface StripeThresholdOverageInvoicePayload {
customerId: string
stripeSubscriptionId: string
amountCents: number
description: string
itemDescription: string
billingPeriod: string
/** Stripe idempotency key stem — we append the outbox event id for per-retry safety. */
invoiceIdemKeyStem: string
itemIdemKeyStem: string
metadata?: Record<string, string>
}
async function getSubscriptionSeatSyncState(subscriptionId: string) {
const [row] = await db
.select({
plan: subscriptionTable.plan,
seats: subscriptionTable.seats,
status: subscriptionTable.status,
stripeSubscriptionId: subscriptionTable.stripeSubscriptionId,
})
.from(subscriptionTable)
.where(eq(subscriptionTable.id, subscriptionId))
.limit(1)
return row ?? null
}
const stripeSyncCancelAtPeriodEnd: OutboxHandler<StripeSyncCancelAtPeriodEndPayload> = async (
payload,
ctx
) => {
// Read the DB value at processing time (not at enqueue time). This
// makes the handler idempotent across racing enqueues: multiple
// events for the same subscription all push whatever the DB
// currently says, converging on the last committed value.
const rows = await db
.select({ cancelAtPeriodEnd: subscriptionTable.cancelAtPeriodEnd })
.from(subscriptionTable)
.where(eq(subscriptionTable.id, payload.subscriptionId))
.limit(1)
if (rows.length === 0) {
logger.warn('Subscription not found when syncing cancel_at_period_end', {
subscriptionId: payload.subscriptionId,
})
return
}
const desiredValue = Boolean(rows[0].cancelAtPeriodEnd)
const stripe = requireStripeClient()
await stripe.subscriptions.update(
payload.stripeSubscriptionId,
{ cancel_at_period_end: desiredValue },
{ idempotencyKey: `outbox:${ctx.eventId}` }
)
logger.info('Synced cancel_at_period_end from DB to Stripe', {
eventId: ctx.eventId,
stripeSubscriptionId: payload.stripeSubscriptionId,
subscriptionId: payload.subscriptionId,
desiredValue,
reason: payload.reason,
})
}
const stripeSyncSubscriptionSeats: OutboxHandler<StripeSyncSubscriptionSeatsPayload> = async (
payload,
ctx
) => {
const stripe = requireStripeClient()
const maxSyncAttempts = 2
for (let attempt = 1; attempt <= maxSyncAttempts; attempt++) {
const row = await getSubscriptionSeatSyncState(payload.subscriptionId)
if (!row) {
logger.warn('Subscription not found when syncing seats', {
eventId: ctx.eventId,
subscriptionId: payload.subscriptionId,
})
return
}
if (!isTeam(row.plan)) {
logger.info('Skipping seat sync for non-Team subscription', {
eventId: ctx.eventId,
subscriptionId: payload.subscriptionId,
plan: row.plan,
})
return
}
if (!row.stripeSubscriptionId) {
logger.warn('Subscription has no Stripe id when syncing seats', {
eventId: ctx.eventId,
subscriptionId: payload.subscriptionId,
})
return
}
if (!hasUsableSubscriptionStatus(row.status)) {
logger.warn('Skipping seat sync for unusable DB subscription status', {
eventId: ctx.eventId,
subscriptionId: payload.subscriptionId,
status: row.status,
})
return
}
const desiredSeats = row.seats || 1
const stripeSubscription = await stripe.subscriptions.retrieve(row.stripeSubscriptionId)
if (!hasUsableSubscriptionStatus(stripeSubscription.status)) {
logger.warn('Skipping seat sync for unusable Stripe subscription', {
eventId: ctx.eventId,
subscriptionId: payload.subscriptionId,
stripeSubscriptionId: row.stripeSubscriptionId,
stripeStatus: stripeSubscription.status,
})
return
}
const subscriptionItem = stripeSubscription.items.data[0]
if (!subscriptionItem) {
throw new Error(
`No subscription item found for Stripe subscription ${row.stripeSubscriptionId}`
)
}
// Reconcile the price too: a Pro→Team conversion moves the DB plan to a
// Team tier while Stripe is still on the Pro price. Resolve the target
// price for the DB plan at the item's current interval. If the target
// price is unconfigured we leave the price untouched and sync quantity
// only — `mapToTeamPlanName` guards conversions against missing tiers.
const targetPlan = getPlanByName(row.plan)
const interval = subscriptionItem.price?.recurring?.interval
const targetPriceId =
interval === 'year' ? targetPlan?.annualDiscountPriceId : targetPlan?.priceId
const priceNeedsChange = Boolean(targetPriceId) && subscriptionItem.price?.id !== targetPriceId
const quantityNeedsChange = subscriptionItem.quantity !== desiredSeats
if (priceNeedsChange || quantityNeedsChange) {
await stripe.subscriptions.update(
row.stripeSubscriptionId,
{
items: [
{
id: subscriptionItem.id,
quantity: desiredSeats,
...(priceNeedsChange ? { price: targetPriceId } : {}),
},
],
proration_behavior: 'always_invoice',
},
{ idempotencyKey: `outbox:${ctx.eventId}:${row.plan}:${desiredSeats}` }
)
}
const latest = await getSubscriptionSeatSyncState(payload.subscriptionId)
const latestSeats = latest?.seats || 1
if (latestSeats !== desiredSeats) {
logger.info('Subscription seats changed during Stripe sync; retrying latest value', {
eventId: ctx.eventId,
subscriptionId: payload.subscriptionId,
stripeSubscriptionId: row.stripeSubscriptionId,
attemptedSeats: desiredSeats,
latestSeats,
attempt,
})
continue
}
logger.info('Synced subscription price and seats from DB to Stripe', {
eventId: ctx.eventId,
subscriptionId: payload.subscriptionId,
stripeSubscriptionId: row.stripeSubscriptionId,
plan: row.plan,
seats: desiredSeats,
alreadySynced: !priceNeedsChange && !quantityNeedsChange,
reason: payload.reason,
})
return
}
throw new Error(`Subscription seats changed while syncing ${payload.subscriptionId}`)
}
const stripeThresholdOverageInvoice: OutboxHandler<StripeThresholdOverageInvoicePayload> = async (
payload,
ctx
) => {
const stripe = requireStripeClient()
// Resolve default PM from (subscription → customer) so Stripe can
// auto-collect when the invoice finalizes. Without this, an ad-hoc
// invoice (no subscription link) falls back to customer-level PM
// only, which may not be set for customers onboarded via Checkout
// Subscription flows.
const { paymentMethodId: defaultPaymentMethod } = await resolveDefaultPaymentMethod(
stripe,
payload.stripeSubscriptionId,
payload.customerId
)
// Compose Stripe idempotency keys from caller-provided stem + outbox
// event id so retries of the SAME outbox event collapse on Stripe's
// side.
const invoiceIdemKey = `${payload.invoiceIdemKeyStem}:${ctx.eventId}`
const itemIdemKey = `${payload.itemIdemKeyStem}:${ctx.eventId}`
const finalizeIdemKey = `${payload.invoiceIdemKeyStem}:finalize:${ctx.eventId}`
const payIdemKey = `${payload.invoiceIdemKeyStem}:pay:${ctx.eventId}`
// `auto_advance: false` + explicit finalize mirrors pre-refactor
// behavior: we control exactly when the invoice finalizes, so it
// doesn't silently convert to paid/open on Stripe's schedule while
// our retry state is still in flight.
const invoice = await stripe.invoices.create(
{
customer: payload.customerId,
collection_method: 'charge_automatically',
auto_advance: false,
description: payload.description,
metadata: payload.metadata,
...(defaultPaymentMethod ? { default_payment_method: defaultPaymentMethod } : {}),
},
{ idempotencyKey: invoiceIdemKey }
)
if (!invoice.id) {
throw new Error('Stripe returned invoice without id')
}
await stripe.invoiceItems.create(
{
customer: payload.customerId,
invoice: invoice.id,
amount: payload.amountCents,
currency: 'usd',
description: payload.itemDescription,
metadata: payload.metadata,
},
{ idempotencyKey: itemIdemKey }
)
const finalized = await stripe.invoices.finalizeInvoice(
invoice.id,
{},
{ idempotencyKey: finalizeIdemKey }
)
if (finalized.status === 'open' && finalized.id && defaultPaymentMethod) {
try {
await stripe.invoices.pay(
finalized.id,
{ payment_method: defaultPaymentMethod },
{ idempotencyKey: payIdemKey }
)
} catch (payError) {
logger.warn('Auto-pay failed for threshold overage invoice — Stripe dunning will retry', {
invoiceId: finalized.id,
error: getErrorMessage(payError),
})
}
}
logger.info('Created threshold overage invoice via outbox', {
eventId: ctx.eventId,
invoiceId: invoice.id,
customerId: payload.customerId,
amountCents: payload.amountCents,
billingPeriod: payload.billingPeriod,
defaultPaymentMethod: defaultPaymentMethod ? 'resolved' : 'none',
})
}
const stripeSyncCustomerContact: OutboxHandler<StripeSyncCustomerContactPayload> = async (
payload,
ctx
) => {
const [subscriptionRow] = await db
.select({
referenceId: subscriptionTable.referenceId,
stripeCustomerId: subscriptionTable.stripeCustomerId,
})
.from(subscriptionTable)
.where(eq(subscriptionTable.id, payload.subscriptionId))
.limit(1)
if (!subscriptionRow) {
logger.warn('Subscription not found when syncing Stripe customer contact', {
eventId: ctx.eventId,
subscriptionId: payload.subscriptionId,
})
return
}
if (!subscriptionRow.stripeCustomerId) {
logger.warn('Subscription has no Stripe customer id when syncing contact', {
eventId: ctx.eventId,
subscriptionId: payload.subscriptionId,
})
return
}
const [owner] = await db
.select({
email: user.email,
name: user.name,
})
.from(member)
.innerJoin(user, eq(member.userId, user.id))
.where(and(eq(member.organizationId, subscriptionRow.referenceId), eq(member.role, 'owner')))
.limit(1)
if (!owner) {
logger.warn('Organization owner not found when syncing Stripe customer contact', {
eventId: ctx.eventId,
subscriptionId: payload.subscriptionId,
organizationId: subscriptionRow.referenceId,
})
return
}
const stripe = requireStripeClient()
await stripe.customers.update(
subscriptionRow.stripeCustomerId,
{
email: owner.email,
...(owner.name ? { name: owner.name } : {}),
},
{ idempotencyKey: `outbox:${ctx.eventId}` }
)
logger.info('Synced Stripe customer contact', {
eventId: ctx.eventId,
stripeCustomerId: subscriptionRow.stripeCustomerId,
subscriptionId: payload.subscriptionId,
reason: payload.reason,
})
}
export const billingOutboxHandlers = {
[OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END]:
stripeSyncCancelAtPeriodEnd as OutboxHandler<unknown>,
[OUTBOX_EVENT_TYPES.STRIPE_SYNC_SUBSCRIPTION_SEATS]:
stripeSyncSubscriptionSeats as OutboxHandler<unknown>,
[OUTBOX_EVENT_TYPES.STRIPE_THRESHOLD_OVERAGE_INVOICE]:
stripeThresholdOverageInvoice as OutboxHandler<unknown>,
[OUTBOX_EVENT_TYPES.STRIPE_SYNC_CUSTOMER_CONTACT]:
stripeSyncCustomerContact as OutboxHandler<unknown>,
} as const