chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
import { db } from '@sim/db'
import { user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import type Stripe from 'stripe'
import { getEmailSubject, renderAbandonedCheckoutEmail } from '@/components/emails'
import { isProPlan } from '@/lib/billing/core/subscription'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getHelpEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
const logger = createLogger('CheckoutWebhooks')
/**
* Handles checkout.session.expired — fires when a user starts an upgrade but doesn't complete it.
* Sends a plain personal email to check in and offer help.
* Only fires for subscription-mode sessions to avoid misfires on credit purchase or setup sessions.
* Skips users who have already completed a subscription (session may expire after a successful upgrade).
*/
export async function handleAbandonedCheckout(event: Stripe.Event): Promise<void> {
const session = event.data.object as Stripe.Checkout.Session
if (session.mode !== 'subscription') return
const customerId = typeof session.customer === 'string' ? session.customer : session.customer?.id
if (!customerId) {
logger.warn('No customer ID on expired session', { sessionId: session.id })
return
}
const [userData] = await db
.select({ id: user.id, email: user.email, name: user.name })
.from(user)
.where(eq(user.stripeCustomerId, customerId))
.limit(1)
if (!userData?.email) {
logger.warn('No user found for Stripe customer', { customerId, sessionId: session.id })
return
}
// Skip if the user already has a paid plan (direct or via org) — covers session expiring after a successful upgrade
const alreadySubscribed = await isProPlan(userData.id)
if (alreadySubscribed) return
const { from } = getPersonalEmailFrom()
const replyTo = getHelpEmailAddress()
const html = await renderAbandonedCheckoutEmail(userData.name || undefined)
await sendEmail({
to: userData.email,
subject: getEmailSubject('abandoned-checkout'),
html,
from,
replyTo,
emailType: 'notifications',
})
logger.info('Sent abandoned checkout email', { userId: userData.id, sessionId: session.id })
}
+226
View File
@@ -0,0 +1,226 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { member, subscription, user, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import type Stripe from 'stripe'
import { blockOrgMembers, unblockOrgMembers } from '@/lib/billing'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { stripeWebhookIdempotency } from '@/lib/billing/webhooks/idempotency'
import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('DisputeWebhooks')
async function getCustomerIdFromDispute(dispute: Stripe.Dispute): Promise<string | null> {
const chargeId = typeof dispute.charge === 'string' ? dispute.charge : dispute.charge?.id
if (!chargeId) return null
const stripe = requireStripeClient()
const charge = await stripe.charges.retrieve(chargeId)
return typeof charge.customer === 'string' ? charge.customer : (charge.customer?.id ?? null)
}
async function getOrganizationOwnerId(organizationId: string): Promise<string | null> {
try {
const rows = await db
.select({ userId: member.userId })
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner')))
.limit(1)
return rows[0]?.userId ?? null
} catch (error) {
logger.warn('Failed to resolve organization owner for dispute audit', { organizationId, error })
return null
}
}
/**
* Record audit + PostHog instrumentation for a charge dispute money event.
* `actorId` must be the responsible user (org owner for org-scoped charges).
*/
function recordDisputeInstrumentation(
status: 'opened' | 'closed',
dispute: Stripe.Dispute,
customerId: string,
actorId: string,
entity: { type: 'user' | 'organization'; id: string }
): void {
const amount = dispute.amount / 100
recordAudit({
actorId,
action:
status === 'opened' ? AuditAction.CHARGE_DISPUTE_OPENED : AuditAction.CHARGE_DISPUTE_CLOSED,
resourceType: AuditResourceType.BILLING,
resourceId: dispute.id,
description: `Charge dispute ${status} for $${amount.toFixed(2)} (${dispute.reason})`,
metadata: {
entityType: entity.type,
entityId: entity.id,
...(entity.type === 'organization' ? { organizationId: entity.id } : {}),
customerId,
amount,
currency: dispute.currency,
reason: dispute.reason,
status: dispute.status,
},
})
captureServerEvent(actorId, 'charge_disputed', {
amount,
currency: dispute.currency,
reason: dispute.reason,
status,
entity_type: entity.type,
reference_id: entity.id,
})
}
/**
* Handles charge.dispute.created - blocks the responsible user
*/
export async function handleChargeDispute(event: Stripe.Event): Promise<void> {
const dispute = event.data.object as Stripe.Dispute
await stripeWebhookIdempotency.executeWithIdempotency(
'charge-dispute-opened',
event.id,
async () => {
const customerId = await getCustomerIdFromDispute(dispute)
if (!customerId) {
logger.warn('No customer ID found in dispute', { disputeId: dispute.id })
return
}
// Find user by stripeCustomerId (Pro plans)
const users = await db
.select({ id: user.id })
.from(user)
.where(eq(user.stripeCustomerId, customerId))
.limit(1)
if (users.length > 0) {
await db
.update(userStats)
.set({ billingBlocked: true, billingBlockedReason: 'dispute' })
.where(eq(userStats.userId, users[0].id))
logger.warn('Blocked user due to dispute', {
disputeId: dispute.id,
userId: users[0].id,
})
recordDisputeInstrumentation('opened', dispute, customerId, users[0].id, {
type: 'user',
id: users[0].id,
})
return
}
// Find subscription by stripeCustomerId (Team/Enterprise)
const subs = await db
.select({ referenceId: subscription.referenceId })
.from(subscription)
.where(eq(subscription.stripeCustomerId, customerId))
.limit(1)
if (subs.length > 0) {
const orgId = subs[0].referenceId
const memberCount = await blockOrgMembers(orgId, 'dispute')
if (memberCount > 0) {
logger.warn('Blocked all org members due to dispute', {
disputeId: dispute.id,
organizationId: orgId,
memberCount,
})
}
const actorId = (await getOrganizationOwnerId(orgId)) ?? orgId
recordDisputeInstrumentation('opened', dispute, customerId, actorId, {
type: 'organization',
id: orgId,
})
}
}
)
}
/**
* Handles charge.dispute.closed - unblocks user if dispute was won or warning closed
*
* Status meanings:
* - 'won': Merchant won, customer's chargeback denied → unblock
* - 'lost': Customer won, money refunded → stay blocked (they owe us)
* - 'warning_closed': Pre-dispute inquiry closed without chargeback → unblock (false alarm)
*/
export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
const dispute = event.data.object as Stripe.Dispute
await stripeWebhookIdempotency.executeWithIdempotency(
'charge-dispute-closed',
event.id,
async () => {
const customerId = await getCustomerIdFromDispute(dispute)
if (!customerId) {
return
}
// Unblock only on won/warning_closed; a 'lost' dispute stays blocked. The close
// is audited in every case (dispute.status in metadata distinguishes the outcome).
const shouldUnblock = dispute.status === 'won' || dispute.status === 'warning_closed'
const users = await db
.select({ id: user.id })
.from(user)
.where(eq(user.stripeCustomerId, customerId))
.limit(1)
if (users.length > 0) {
if (shouldUnblock) {
await db
.update(userStats)
.set({ billingBlocked: false, billingBlockedReason: null })
.where(
and(eq(userStats.userId, users[0].id), eq(userStats.billingBlockedReason, 'dispute'))
)
}
logger.info('Dispute closed for user', {
disputeId: dispute.id,
userId: users[0].id,
status: dispute.status,
unblocked: shouldUnblock,
})
recordDisputeInstrumentation('closed', dispute, customerId, users[0].id, {
type: 'user',
id: users[0].id,
})
return
}
const subs = await db
.select({ referenceId: subscription.referenceId })
.from(subscription)
.where(eq(subscription.stripeCustomerId, customerId))
.limit(1)
if (subs.length > 0) {
const orgId = subs[0].referenceId
if (shouldUnblock) {
await unblockOrgMembers(orgId, 'dispute')
}
logger.info('Dispute closed for organization', {
disputeId: dispute.id,
organizationId: orgId,
status: dispute.status,
unblocked: shouldUnblock,
})
const actorId = (await getOrganizationOwnerId(orgId)) ?? orgId
recordDisputeInstrumentation('closed', dispute, customerId, actorId, {
type: 'organization',
id: orgId,
})
}
}
)
}
+263
View File
@@ -0,0 +1,263 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { organization, subscription, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { eq } from 'drizzle-orm'
import type Stripe from 'stripe'
import { getEmailSubject, renderEnterpriseSubscriptionEmail } from '@/components/emails'
import { stripeWebhookIdempotency } from '@/lib/billing/webhooks/idempotency'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
import { captureServerEvent } from '@/lib/posthog/server'
import { parseEnterpriseSubscriptionMetadata } from '../types'
const logger = createLogger('BillingEnterprise')
export async function handleManualEnterpriseSubscription(event: Stripe.Event) {
const stripeSubscription = event.data.object as Stripe.Subscription
const metaPlan = (stripeSubscription.metadata?.plan as string | undefined)?.toLowerCase() || ''
if (metaPlan !== 'enterprise') {
logger.info('[subscription.created] Skipping non-enterprise subscription', {
subscriptionId: stripeSubscription.id,
plan: metaPlan || 'unknown',
})
return
}
const stripeCustomerId = stripeSubscription.customer as string
if (!stripeCustomerId) {
logger.error('[subscription.created] Missing Stripe customer ID', {
subscriptionId: stripeSubscription.id,
})
throw new Error('Missing Stripe customer ID on subscription')
}
const metadata = stripeSubscription.metadata || {}
const referenceId =
typeof metadata.referenceId === 'string' && metadata.referenceId.length > 0
? metadata.referenceId
: null
if (!referenceId) {
logger.error('[subscription.created] Unable to resolve referenceId', {
subscriptionId: stripeSubscription.id,
stripeCustomerId,
})
throw new Error('Unable to resolve referenceId for subscription')
}
const enterpriseMetadata = parseEnterpriseSubscriptionMetadata(metadata)
if (!enterpriseMetadata) {
logger.error('[subscription.created] Invalid enterprise metadata shape', {
subscriptionId: stripeSubscription.id,
metadata,
})
throw new Error('Invalid enterprise metadata for subscription')
}
const { seats, monthlyPrice } = enterpriseMetadata
// Get the first subscription item which contains the period information
const referenceItem = stripeSubscription.items?.data?.[0]
const subscriptionRow = {
id: generateId(),
plan: 'enterprise',
referenceId,
stripeCustomerId,
stripeSubscriptionId: stripeSubscription.id,
status: stripeSubscription.status || null,
periodStart: referenceItem?.current_period_start
? new Date(referenceItem.current_period_start * 1000)
: null,
periodEnd: referenceItem?.current_period_end
? new Date(referenceItem.current_period_end * 1000)
: null,
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end ?? null,
seats: 1, // Enterprise uses metadata.seats for actual seat count, column is always 1
trialStart: stripeSubscription.trial_start
? new Date(stripeSubscription.trial_start * 1000)
: null,
trialEnd: stripeSubscription.trial_end ? new Date(stripeSubscription.trial_end * 1000) : null,
metadata: metadata as Record<string, unknown>,
}
const existing = await db
.select({ id: subscription.id })
.from(subscription)
.where(eq(subscription.stripeSubscriptionId, stripeSubscription.id))
.limit(1)
if (existing.length > 0) {
await db
.update(subscription)
.set({
plan: subscriptionRow.plan,
referenceId: subscriptionRow.referenceId,
stripeCustomerId: subscriptionRow.stripeCustomerId,
status: subscriptionRow.status,
periodStart: subscriptionRow.periodStart,
periodEnd: subscriptionRow.periodEnd,
cancelAtPeriodEnd: subscriptionRow.cancelAtPeriodEnd,
seats: 1, // Enterprise uses metadata.seats for actual seat count, column is always 1
trialStart: subscriptionRow.trialStart,
trialEnd: subscriptionRow.trialEnd,
metadata: subscriptionRow.metadata,
})
.where(eq(subscription.stripeSubscriptionId, stripeSubscription.id))
} else {
await db.insert(subscription).values(subscriptionRow)
}
// Update the organization's usage limit to match the monthly price
// The referenceId for enterprise plans is the organization ID
try {
await db
.update(organization)
.set({
orgUsageLimit: monthlyPrice.toFixed(2),
updatedAt: new Date(),
})
.where(eq(organization.id, referenceId))
logger.info('[subscription.created] Updated organization usage limit', {
organizationId: referenceId,
usageLimit: monthlyPrice,
})
} catch (error) {
logger.error('[subscription.created] Failed to update organization usage limit', {
organizationId: referenceId,
usageLimit: monthlyPrice,
error,
})
// Don't throw - the subscription was created successfully, just log the error
}
logger.info('[subscription.created] Upserted enterprise subscription', {
subscriptionId: subscriptionRow.id,
referenceId: subscriptionRow.referenceId,
plan: subscriptionRow.plan,
status: subscriptionRow.status,
monthlyPrice,
seats,
note: 'Seats from metadata, Stripe quantity set to 1',
})
let actorId = referenceId
try {
const [provisioningUser] = await db
.select({ id: user.id })
.from(user)
.where(eq(user.stripeCustomerId, stripeCustomerId))
.limit(1)
actorId = provisioningUser?.id ?? referenceId
} catch (error) {
logger.warn('Failed to resolve enterprise provisioning actor; falling back to reference id', {
referenceId,
error,
})
}
// Dedupe against Stripe redelivery — the upsert above is idempotent, but
// recordAudit/captureServerEvent are pure appends and would double-record.
await stripeWebhookIdempotency.executeWithIdempotency(
'enterprise-subscription-provisioned',
event.id,
async () => {
recordAudit({
actorId,
action: AuditAction.ENTERPRISE_SUBSCRIPTION_PROVISIONED,
resourceType: AuditResourceType.SUBSCRIPTION,
resourceId: subscriptionRow.id,
description: `Enterprise subscription provisioned for organization ${referenceId} (${seats} seats)`,
metadata: {
organizationId: referenceId,
stripeCustomerId,
stripeSubscriptionId: stripeSubscription.id,
seats,
monthlyPrice,
currency: 'usd',
},
})
captureServerEvent(actorId, 'enterprise_subscription_created', {
reference_id: referenceId,
seats,
monthly_price: monthlyPrice,
currency: 'usd',
})
}
)
try {
const userDetails = await db
.select({
id: user.id,
name: user.name,
email: user.email,
})
.from(user)
.where(eq(user.stripeCustomerId, stripeCustomerId))
.limit(1)
const orgDetails = await db
.select({
id: organization.id,
name: organization.name,
})
.from(organization)
.where(eq(organization.id, referenceId))
.limit(1)
if (userDetails.length > 0 && orgDetails.length > 0) {
const user = userDetails[0]
const org = orgDetails[0]
const html = await renderEnterpriseSubscriptionEmail(user.name || user.email)
const emailResult = await sendEmail({
to: user.email,
subject: getEmailSubject('enterprise-subscription'),
html,
from: getFromEmailAddress(),
emailType: 'transactional',
})
if (emailResult.success) {
logger.info('[subscription.created] Enterprise subscription email sent successfully', {
userId: user.id,
email: user.email,
organizationId: org.id,
subscriptionId: subscriptionRow.id,
})
} else {
logger.warn('[subscription.created] Failed to send enterprise subscription email', {
userId: user.id,
email: user.email,
error: emailResult.message,
})
}
} else {
logger.warn(
'[subscription.created] Could not find user or organization for email notification',
{
userFound: userDetails.length > 0,
orgFound: orgDetails.length > 0,
stripeCustomerId,
referenceId,
}
)
}
} catch (emailError) {
logger.error('[subscription.created] Error sending enterprise subscription email', {
error: emailError,
stripeCustomerId,
referenceId,
subscriptionId: subscriptionRow.id,
})
}
}
@@ -0,0 +1,39 @@
import { IdempotencyService } from '@/lib/core/idempotency/service'
/**
* Idempotency service for Stripe webhook handlers.
*
* Stripe delivers webhook events at-least-once and retries failed
* deliveries for up to 3 days. Handlers that perform non-idempotent work
* (crediting accounts, removing credits, resetting usage trackers, etc.)
* must be wrapped in a claim so duplicate deliveries are collapsed to a
* single execution.
*
* Storage is **forced to Postgres** regardless of whether Redis is
* configured. Billing handlers mutate `user_stats` / `organization` /
* `subscription` rows via DB transactions — keeping the idempotency
* record in the same Postgres closes the narrow window where the
* operation commits but a Redis `storeResult` fails, which would cause
* Stripe's next retry to re-run the money-affecting work. The latency
* cost (15 ms per claim/store) is invisible on webhook responses, and
* volume is low enough (roughly one event per customer per billing
* cycle) that DB storage scales comfortably.
*
* `retryFailures: true` means a thrown handler releases the claim so
* Stripe's next retry runs from scratch — without it, one transient
* failure would poison the key for the whole TTL window.
*
* TTL of 7 days is slightly longer than Stripe's 3-day retry horizon so
* late retries still dedupe against completed work. Rows past their TTL
* are handled two ways: `atomicallyClaimDb` reclaims stale rows inline
* via `ON CONFLICT DO UPDATE WHERE created_at < expired_before` (so
* correctness does not depend on cleanup running), and the external
* cleanup cron (scheduled from the infra repo) hits
* `/api/webhooks/cleanup/idempotency` to bound table size.
*/
export const stripeWebhookIdempotency = new IdempotencyService({
namespace: 'stripe-webhook',
ttlSeconds: 60 * 60 * 24 * 7,
retryFailures: true,
forceStorage: 'database',
})
@@ -0,0 +1,323 @@
/**
* @vitest-environment node
*/
import {
createMockStripeEvent,
dbChainMock,
dbChainMockFns,
drizzleOrmMock,
resetDbChainMock,
stripeClientMock,
stripePaymentMethodMock,
urlsMock,
urlsMockFns,
} from '@sim/testing'
import type Stripe from 'stripe'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockBlockOrgMembers, mockUnblockOrgMembers } = vi.hoisted(() => ({
mockBlockOrgMembers: vi.fn(),
mockUnblockOrgMembers: vi.fn(),
}))
vi.mock('@sim/db', () => dbChainMock)
vi.mock('drizzle-orm', () => drizzleOrmMock)
vi.mock('@/components/emails', () => ({
PaymentFailedEmail: vi.fn(),
getEmailSubject: vi.fn(),
renderCreditPurchaseEmail: vi.fn(),
}))
vi.mock('@/lib/billing/core/billing', () => ({
calculateSubscriptionOverage: vi.fn(),
isSubscriptionOrgScoped: vi.fn().mockResolvedValue(true),
}))
vi.mock('@/lib/billing/core/usage-log', () => ({
getBillingPeriodUsageCostByUser: vi.fn().mockResolvedValue(new Map()),
}))
vi.mock('@/lib/billing/credits/balance', () => ({
addCredits: vi.fn(),
getCreditBalance: vi.fn(),
removeCredits: vi.fn(),
}))
vi.mock('@/lib/billing/credits/purchase', () => ({
setUsageLimitForCredits: vi.fn(),
}))
vi.mock('@/lib/billing/organizations/membership', () => ({
blockOrgMembers: mockBlockOrgMembers,
unblockOrgMembers: mockUnblockOrgMembers,
}))
vi.mock('@/lib/billing/plan-helpers', () => ({
isEnterprise: vi.fn(() => false),
isOrgPlan: vi.fn((plan: string | null | undefined) => Boolean(plan?.startsWith('team'))),
isTeam: vi.fn((plan: string | null | undefined) => Boolean(plan?.startsWith('team'))),
}))
vi.mock('@/lib/billing/stripe-client', () => stripeClientMock)
vi.mock('@/lib/billing/stripe-payment-method', () => stripePaymentMethodMock)
vi.mock('@/lib/billing/subscriptions/utils', () => ({
ENTITLED_SUBSCRIPTION_STATUSES: ['active', 'trialing', 'past_due'],
}))
vi.mock('@/lib/billing/utils/decimal', () => ({
toDecimal: vi.fn((v: string | number | null | undefined) => {
if (v === null || v === undefined || v === '') return { toNumber: () => 0 }
return { toNumber: () => Number(v) }
}),
toNumber: vi.fn((d: { toNumber: () => number }) => d.toNumber()),
}))
vi.mock('@/lib/billing/webhooks/idempotency', () => ({
stripeWebhookIdempotency: {
executeWithIdempotency: vi.fn(
async (_provider: string, _identifier: string, operation: () => Promise<unknown>) =>
operation()
),
},
}))
vi.mock('@/lib/core/utils/urls', () => urlsMock)
vi.mock('@/lib/messaging/email/mailer', () => ({
sendEmail: vi.fn(),
}))
vi.mock('@/lib/messaging/email/utils', () => ({
getPersonalEmailFrom: vi.fn(() => ({
from: 'billing@sim.test',
replyTo: 'support@sim.test',
})),
getHelpEmailAddress: vi.fn(() => 'help@sim.test'),
}))
vi.mock('@/lib/messaging/email/validation', () => ({
quickValidateEmail: vi.fn(() => ({ isValid: true })),
}))
vi.mock('@react-email/render', () => ({
render: vi.fn(),
}))
import {
handleInvoicePaymentFailed,
handleInvoicePaymentSucceeded,
resetUsageForSubscription,
} from '@/lib/billing/webhooks/invoices'
import { sendEmail } from '@/lib/messaging/email/mailer'
interface SelectResponse {
limitResult?: unknown
whereResult?: unknown
}
const selectResponses: SelectResponse[] = []
function queueSelectResponse(response: SelectResponse) {
selectResponses.push(response)
}
/**
* Override `where` so that each select-then-where chain pops the next queued
* response. Supports both `.limit(1)` terminals and directly-awaited `where()`.
*/
function installSelectResponseQueue() {
dbChainMockFns.where.mockImplementation(() => {
const next = selectResponses.shift()
if (!next) {
throw new Error('No queued db.select response')
}
const builder = {
for: vi.fn(() => builder),
limit: vi.fn(async () => next.limitResult ?? next.whereResult ?? []),
orderBy: vi.fn(async () => next.limitResult ?? next.whereResult ?? []),
returning: vi.fn(async () => next.limitResult ?? next.whereResult ?? []),
groupBy: vi.fn(async () => next.limitResult ?? next.whereResult ?? []),
then: (resolve: (value: unknown) => unknown, reject?: (reason: unknown) => unknown) =>
Promise.resolve(next.whereResult ?? next.limitResult ?? []).then(resolve, reject),
}
return builder as unknown as ReturnType<typeof dbChainMockFns.where>
})
}
function createInvoiceEvent(
type: 'invoice.payment_failed' | 'invoice.payment_succeeded',
invoice: Partial<Stripe.Invoice>
): Stripe.Event {
return createMockStripeEvent(type, invoice)
}
describe('invoice billing recovery', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
selectResponses.length = 0
installSelectResponseQueue()
urlsMockFns.mockGetBaseUrl.mockReturnValue('https://sim.test')
mockBlockOrgMembers.mockResolvedValue(2)
mockUnblockOrgMembers.mockResolvedValue(2)
})
it('blocks org members when a metadata-backed invoice payment fails', async () => {
queueSelectResponse({
limitResult: [
{
id: 'sub-db-1',
plan: 'team_8000',
referenceId: 'org-1',
stripeSubscriptionId: 'sub_stripe_1',
},
],
})
await handleInvoicePaymentFailed(
createInvoiceEvent('invoice.payment_failed', {
amount_due: 3582,
attempt_count: 2,
customer: 'cus_123',
customer_email: 'owner@sim.test',
hosted_invoice_url: 'https://stripe.test/invoices/in_123',
id: 'in_123',
metadata: {
billingPeriod: '2026-04',
subscriptionId: 'sub_stripe_1',
type: 'overage_threshold_billing_org',
},
})
)
expect(mockBlockOrgMembers).toHaveBeenCalledWith('org-1', 'payment_failed')
expect(mockUnblockOrgMembers).not.toHaveBeenCalled()
})
it('sends the payment-failure email with the shared help inbox as reply-to', async () => {
queueSelectResponse({
limitResult: [
{
id: 'sub-db-1',
plan: 'team_8000',
referenceId: 'org-1',
stripeSubscriptionId: 'sub_stripe_1',
},
],
})
queueSelectResponse({ limitResult: [{ userId: 'owner-1' }] }) // resolveBillingActorId owner lookup (payment_failed instrumentation)
queueSelectResponse({
whereResult: [{ userId: 'owner-1', role: 'owner' }],
})
queueSelectResponse({
whereResult: [{ email: 'owner@sim.test', name: 'Owner' }],
})
await handleInvoicePaymentFailed(
createInvoiceEvent('invoice.payment_failed', {
amount_due: 3582,
attempt_count: 1,
customer: 'cus_123',
customer_email: 'owner@sim.test',
hosted_invoice_url: 'https://stripe.test/invoices/in_123',
id: 'in_123',
metadata: {
billingPeriod: '2026-04',
subscriptionId: 'sub_stripe_1',
type: 'overage_threshold_billing_org',
},
})
)
expect(sendEmail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'owner@sim.test',
from: 'billing@sim.test',
replyTo: 'help@sim.test',
})
)
})
it('unblocks org members when the matching metadata-backed invoice payment succeeds', async () => {
queueSelectResponse({
limitResult: [
{
id: 'sub-db-1',
plan: 'team_8000',
referenceId: 'org-1',
stripeSubscriptionId: 'sub_stripe_1',
},
],
})
queueSelectResponse({
whereResult: [{ userId: 'owner-1' }, { userId: 'member-1' }],
})
queueSelectResponse({
whereResult: [{ blocked: false }, { blocked: false }],
})
await handleInvoicePaymentSucceeded(
createInvoiceEvent('invoice.payment_succeeded', {
amount_paid: 3582,
billing_reason: 'manual',
customer: 'cus_123',
id: 'in_123',
metadata: {
billingPeriod: '2026-04',
subscriptionId: 'sub_stripe_1',
type: 'overage_threshold_billing_org',
},
})
)
expect(mockUnblockOrgMembers).toHaveBeenCalledWith('org-1', 'payment_failed')
expect(mockBlockOrgMembers).not.toHaveBeenCalled()
})
it('locks member userStats before the organization row during usage reset', async () => {
queueSelectResponse({ limitResult: [{ userId: 'owner-1' }] }) // owner member row
queueSelectResponse({ limitResult: [{ userId: 'owner-1' }] }) // owner userStats
queueSelectResponse({ whereResult: [{ userId: 'owner-1' }, { userId: 'member-1' }] }) // member ids
queueSelectResponse({ whereResult: [] }) // all-member userStats FOR UPDATE (pre-org lock)
queueSelectResponse({ limitResult: [{ id: 'org-1' }] }) // organization
queueSelectResponse({
whereResult: [
{ userId: 'owner-1', current: '125', currentCopilot: '10' },
{ userId: 'member-1', current: '75', currentCopilot: '5' },
],
})
queueSelectResponse({ whereResult: [] })
queueSelectResponse({ whereResult: [] })
await resetUsageForSubscription({ plan: 'team', referenceId: 'org-1' })
expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1)
expect(dbChainMockFns.update).toHaveBeenCalledTimes(2)
const whereArgs = dbChainMockFns.where.mock.calls.map(
(call) => call[0] as { type?: string; column?: string; left?: string }
)
const allMemberStatsLockIndex = whereArgs.findIndex(
(arg) => arg?.type === 'inArray' && arg?.column === 'userId'
)
const orgLockIndex = whereArgs.findIndex((arg) => arg?.type === 'eq' && arg?.left === 'id')
expect(allMemberStatsLockIndex).toBeGreaterThanOrEqual(0)
expect(orgLockIndex).toBeGreaterThanOrEqual(0)
expect(allMemberStatsLockIndex).toBeLessThan(orgLockIndex)
const statsReset = dbChainMockFns.set.mock.calls[0][0] as Record<string, unknown>
expect(statsReset.currentPeriodCost).not.toBe('0')
expect(statsReset.currentPeriodCopilotCost).not.toBe('0')
expect(statsReset.lastPeriodCost).toMatchObject({
toSQL: expect.any(Function),
})
expect((statsReset.lastPeriodCost as { toSQL: () => { sql: string } }).toSQL().sql).toContain(
'CASE'
)
expect(
(statsReset.currentPeriodCost as { toSQL: () => { sql: string } }).toSQL().sql
).toContain('GREATEST')
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,184 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetPlanByName, mockResolveDefaultPaymentMethod, queryQueue, stripeMock } = vi.hoisted(
() => {
const stripeMock = {
subscriptions: {
retrieve: vi.fn(),
update: vi.fn(),
},
}
return {
mockGetPlanByName: vi.fn(),
mockResolveDefaultPaymentMethod: vi.fn(),
queryQueue: { value: [] as unknown[][] },
stripeMock,
}
}
)
vi.mock('@sim/db', () => {
const makeChain = () => {
const chain: Record<string, unknown> = {}
chain.from = () => chain
chain.where = () => chain
chain.limit = () => Promise.resolve(queryQueue.value.shift() ?? [])
return chain
}
return { db: { select: () => makeChain() } }
})
vi.mock('@/lib/billing/stripe-client', () => ({
requireStripeClient: () => stripeMock,
}))
vi.mock('@/lib/billing/plans', () => ({
getPlanByName: mockGetPlanByName,
}))
vi.mock('@/lib/billing/stripe-payment-method', () => ({
resolveDefaultPaymentMethod: mockResolveDefaultPaymentMethod,
}))
import { billingOutboxHandlers, OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers'
const seatSyncHandler = billingOutboxHandlers[OUTBOX_EVENT_TYPES.STRIPE_SYNC_SUBSCRIPTION_SEATS]
const ctx = {
eventId: 'evt-1',
eventType: OUTBOX_EVENT_TYPES.STRIPE_SYNC_SUBSCRIPTION_SEATS,
attempts: 0,
}
function stripeItem(overrides: {
quantity?: number
priceId?: string
interval?: 'month' | 'year'
}) {
return {
status: 'active',
items: {
data: [
{
id: 'si_1',
quantity: overrides.quantity ?? 1,
price: {
id: overrides.priceId ?? 'price_pro_month',
recurring: { interval: overrides.interval ?? 'month' },
},
},
],
},
}
}
describe('stripeSyncSubscriptionSeats outbox handler', () => {
beforeEach(() => {
vi.clearAllMocks()
queryQueue.value = []
mockGetPlanByName.mockReturnValue({
priceId: 'price_team_month',
annualDiscountPriceId: 'price_team_year',
})
stripeMock.subscriptions.update.mockResolvedValue({})
})
it('reconciles both price and quantity for a Pro→Team conversion', async () => {
const row = {
plan: 'team_6000',
seats: 2,
status: 'active',
stripeSubscriptionId: 'stripe_sub',
}
queryQueue.value = [[row], [row]]
stripeMock.subscriptions.retrieve.mockResolvedValue(
stripeItem({ quantity: 1, priceId: 'price_pro_month' })
)
await seatSyncHandler({ subscriptionId: 'sub-1' }, ctx)
expect(stripeMock.subscriptions.update).toHaveBeenCalledWith(
'stripe_sub',
expect.objectContaining({
items: [{ id: 'si_1', quantity: 2, price: 'price_team_month' }],
proration_behavior: 'always_invoice',
}),
expect.any(Object)
)
})
it('uses the annual price when the subscription bills yearly', async () => {
const row = {
plan: 'team_6000',
seats: 2,
status: 'active',
stripeSubscriptionId: 'stripe_sub',
}
queryQueue.value = [[row], [row]]
stripeMock.subscriptions.retrieve.mockResolvedValue(
stripeItem({ quantity: 1, priceId: 'price_pro_year', interval: 'year' })
)
await seatSyncHandler({ subscriptionId: 'sub-1' }, ctx)
expect(stripeMock.subscriptions.update).toHaveBeenCalledWith(
'stripe_sub',
expect.objectContaining({
items: [{ id: 'si_1', quantity: 2, price: 'price_team_year' }],
}),
expect.any(Object)
)
})
it('adjusts quantity only when the price already matches', async () => {
const row = {
plan: 'team_6000',
seats: 3,
status: 'active',
stripeSubscriptionId: 'stripe_sub',
}
queryQueue.value = [[row], [row]]
stripeMock.subscriptions.retrieve.mockResolvedValue(
stripeItem({ quantity: 2, priceId: 'price_team_month' })
)
await seatSyncHandler({ subscriptionId: 'sub-1' }, ctx)
const updateArg = stripeMock.subscriptions.update.mock.calls[0][1] as {
items: Array<{ price?: string; quantity: number }>
}
expect(updateArg.items[0].quantity).toBe(3)
expect(updateArg.items[0].price).toBeUndefined()
})
it('does nothing when price and quantity are already in sync', async () => {
const row = {
plan: 'team_6000',
seats: 2,
status: 'active',
stripeSubscriptionId: 'stripe_sub',
}
queryQueue.value = [[row], [row]]
stripeMock.subscriptions.retrieve.mockResolvedValue(
stripeItem({ quantity: 2, priceId: 'price_team_month' })
)
await seatSyncHandler({ subscriptionId: 'sub-1' }, ctx)
expect(stripeMock.subscriptions.update).not.toHaveBeenCalled()
})
it('skips non-Team subscriptions', async () => {
queryQueue.value = [
[{ plan: 'pro_6000', seats: 1, status: 'active', stripeSubscriptionId: 's' }],
]
await seatSyncHandler({ subscriptionId: 'sub-1' }, ctx)
expect(stripeMock.subscriptions.retrieve).not.toHaveBeenCalled()
expect(stripeMock.subscriptions.update).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,405 @@
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
@@ -0,0 +1,560 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { member, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, ne } from 'drizzle-orm'
import { calculateSubscriptionOverage, isSubscriptionOrgScoped } from '@/lib/billing/core/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { restoreUserProSubscription } from '@/lib/billing/organizations/membership'
import { isEnterprise, isPaid, isPro, isTeam } from '@/lib/billing/plan-helpers'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { stripeWebhookIdempotency } from '@/lib/billing/webhooks/idempotency'
import {
getBilledOverageForSubscription,
resetUsageForSubscription,
} from '@/lib/billing/webhooks/invoices'
import { captureServerEvent } from '@/lib/posthog/server'
import { detachOrganizationWorkspaces } from '@/lib/workspaces/organization-workspaces'
const logger = createLogger('StripeSubscriptionWebhooks')
/**
* Resolve a real `user.id` to use as the audit actor for a subscription
* event. Org-scoped subscriptions resolve to the org owner; personally
* scoped subscriptions already reference a user.
*/
async function resolveSubscriptionActorId(referenceId: string): Promise<string> {
try {
const rows = await db
.select({ userId: member.userId })
.from(member)
.where(and(eq(member.organizationId, referenceId), eq(member.role, 'owner')))
.limit(1)
return rows[0]?.userId ?? referenceId
} catch (error) {
logger.warn('Failed to resolve subscription actor; falling back to reference id', {
referenceId,
error,
})
return referenceId
}
}
/**
* Restore personal Pro subscriptions for every member of an organization
* when the team/enterprise subscription ends. Errors propagate so the
* enclosing webhook handler fails and Stripe retries the delivery.
*
* `restoreUserProSubscription` is idempotent: already-restored members
* are no-ops on retry, so a partial first attempt is safe to re-run.
*/
async function restoreMemberProSubscriptions(organizationId: string): Promise<number> {
const members = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, organizationId))
let restoredCount = 0
for (const m of members) {
const result = await restoreUserProSubscription(m.userId)
if (result.restored) {
restoredCount++
}
}
if (restoredCount > 0) {
logger.info('Restored Pro subscriptions for team members', {
organizationId,
restoredCount,
totalMembers: members.length,
})
}
return restoredCount
}
export interface OrganizationDormantTransitionResult {
restoredProCount: number
membersSynced: number
workspacesDetached: number
organizationRetainsTeamOrEnterprise: boolean
}
/**
* Returns true when the organization is still covered by an **active
* Team or Enterprise** subscription other than `excludeSubscriptionId`.
* The org keeps its team-owned workspaces only while such a sub exists;
* a Pro sub on the org does not count.
*/
async function hasOtherActiveTeamOrEnterpriseSubscription(
organizationId: string,
excludeSubscriptionId: string | null
): Promise<boolean> {
const filters = [
eq(subscription.referenceId, organizationId),
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES),
]
if (excludeSubscriptionId) {
filters.push(ne(subscription.id, excludeSubscriptionId))
}
const rows = await db
.select({ plan: subscription.plan })
.from(subscription)
.where(and(...filters))
return rows.some((row) => isTeam(row.plan) || isEnterprise(row.plan))
}
async function transitionOrganizationToDormantState(
organizationId: string,
triggeringSubscriptionId: string | null
): Promise<OrganizationDormantTransitionResult> {
const memberUserIds = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, organizationId))
if (await hasOtherActiveTeamOrEnterpriseSubscription(organizationId, triggeringSubscriptionId)) {
logger.info(
'Skipping dormant transition - another Team/Enterprise subscription still covers this organization',
{ organizationId, triggeringSubscriptionId }
)
for (const m of memberUserIds) {
await syncUsageLimitsFromSubscription(m.userId)
}
return {
restoredProCount: 0,
membersSynced: memberUserIds.length,
workspacesDetached: 0,
organizationRetainsTeamOrEnterprise: true,
}
}
const { detachedWorkspaceIds } = await detachOrganizationWorkspaces(organizationId)
const restoredProCount = await restoreMemberProSubscriptions(organizationId)
for (const m of memberUserIds) {
await syncUsageLimitsFromSubscription(m.userId)
}
return {
restoredProCount,
membersSynced: memberUserIds.length,
workspacesDetached: detachedWorkspaceIds.length,
organizationRetainsTeamOrEnterprise: false,
}
}
export async function handleOrganizationPlanDowngrade(
subscriptionData: {
id: string
plan: string | null
referenceId: string
status: string | null
},
stripeEventId?: string
): Promise<void> {
if (!(await isSubscriptionOrgScoped({ referenceId: subscriptionData.referenceId }))) {
return
}
const stillTeamOrEnterprise = isTeam(subscriptionData.plan) || isEnterprise(subscriptionData.plan)
if (stillTeamOrEnterprise) {
return
}
const [currentRow] = await db
.select({ plan: subscription.plan })
.from(subscription)
.where(eq(subscription.id, subscriptionData.id))
.limit(1)
if (currentRow && (isTeam(currentRow.plan) || isEnterprise(currentRow.plan))) {
logger.info('Skipping plan downgrade transition - subscription is currently Team/Enterprise', {
subscriptionId: subscriptionData.id,
organizationId: subscriptionData.referenceId,
eventPlan: subscriptionData.plan,
currentPlan: currentRow.plan,
})
return
}
const idempotencyIdentifier = stripeEventId ?? `plan-downgrade:${subscriptionData.id}`
try {
await stripeWebhookIdempotency.executeWithIdempotency(
'organization-plan-downgrade',
idempotencyIdentifier,
async () => {
const result = await transitionOrganizationToDormantState(
subscriptionData.referenceId,
subscriptionData.id
)
if (result.workspacesDetached > 0 || result.restoredProCount > 0) {
logger.info('Transitioned organization to dormant state after plan downgrade', {
organizationId: subscriptionData.referenceId,
subscriptionId: subscriptionData.id,
plan: subscriptionData.plan,
...result,
})
}
return result
}
)
} catch (error) {
logger.error('Failed to transition organization to dormant state on plan downgrade', {
organizationId: subscriptionData.referenceId,
subscriptionId: subscriptionData.id,
plan: subscriptionData.plan,
error,
})
throw error
}
}
/**
* Handle new subscription creation - reset usage if transitioning from free to paid
*/
export async function handleSubscriptionCreated(
subscriptionData: {
id: string
referenceId: string
plan: string | null
status: string
periodStart?: Date | null
periodEnd?: Date | null
},
stripeEventId?: string
) {
const idempotencyIdentifier = stripeEventId ?? `sub-created:${subscriptionData.id}`
try {
await stripeWebhookIdempotency.executeWithIdempotency(
'subscription-created',
idempotencyIdentifier,
async () => {
const otherActiveSubscriptions = await db
.select()
.from(subscription)
.where(
and(
eq(subscription.referenceId, subscriptionData.referenceId),
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES),
ne(subscription.id, subscriptionData.id) // Exclude current subscription
)
)
const wasFreePreviously = otherActiveSubscriptions.length === 0
const isPaidPlan = isPaid(subscriptionData.plan)
if (wasFreePreviously && isPaidPlan) {
logger.info('Detected free -> paid transition, resetting usage', {
subscriptionId: subscriptionData.id,
referenceId: subscriptionData.referenceId,
plan: subscriptionData.plan,
})
await resetUsageForSubscription({
plan: subscriptionData.plan,
referenceId: subscriptionData.referenceId,
periodStart: subscriptionData.periodStart ?? null,
periodEnd: subscriptionData.periodEnd ?? null,
})
logger.info('Successfully reset usage for free -> paid transition', {
subscriptionId: subscriptionData.id,
referenceId: subscriptionData.referenceId,
plan: subscriptionData.plan,
})
} else {
logger.info('No usage reset needed', {
subscriptionId: subscriptionData.id,
referenceId: subscriptionData.referenceId,
plan: subscriptionData.plan,
wasFreePreviously,
isPaidPlan,
otherActiveSubscriptionsCount: otherActiveSubscriptions.length,
})
}
if (wasFreePreviously && isPaidPlan) {
// Best-effort instrumentation; a transient DB error here must never abort
// the (already-committed) free -> paid usage reset above, so it's guarded.
try {
const actorId = await resolveSubscriptionActorId(subscriptionData.referenceId)
const isOrgScoped = await isSubscriptionOrgScoped({
referenceId: subscriptionData.referenceId,
})
recordAudit({
actorId,
action: AuditAction.SUBSCRIPTION_CREATED,
resourceType: AuditResourceType.SUBSCRIPTION,
resourceId: subscriptionData.id,
description: `Subscription created on ${subscriptionData.plan ?? 'unknown'} plan for ${subscriptionData.referenceId}`,
metadata: {
plan: subscriptionData.plan,
status: subscriptionData.status,
referenceId: subscriptionData.referenceId,
...(isOrgScoped ? { organizationId: subscriptionData.referenceId } : {}),
},
})
captureServerEvent(subscriptionData.referenceId, 'subscription_created', {
plan: subscriptionData.plan ?? 'unknown',
status: subscriptionData.status,
reference_id: subscriptionData.referenceId,
})
} catch (instrumentationError) {
logger.warn('Failed to record subscription-created instrumentation', {
subscriptionId: subscriptionData.id,
referenceId: subscriptionData.referenceId,
error: instrumentationError,
})
}
}
}
)
} catch (error) {
logger.error('Failed to handle subscription creation usage reset', {
subscriptionId: subscriptionData.id,
referenceId: subscriptionData.referenceId,
error,
})
throw error
}
}
/**
* Handles a subscription deletion (cancel) event. Bills any final-period
* overages, resets usage, and transitions the organization to a dormant
* state via `transitionOrganizationToDormantState` — the same path used
* by plan downgrades. Wrapped in `stripeWebhookIdempotency` so duplicate
* event deliveries collapse to one execution; if any step throws, the
* webhook retries from scratch.
*/
export async function handleSubscriptionDeleted(
subscription: {
id: string
plan: string | null
referenceId: string
stripeSubscriptionId: string | null
seats?: number | null
periodStart?: Date | null
periodEnd?: Date | null
},
stripeEventId?: string
) {
const stripeSubscriptionId = subscription.stripeSubscriptionId || ''
logger.info('Processing subscription deletion', {
stripeEventId,
stripeSubscriptionId,
subscriptionId: subscription.id,
})
// Fall back to the subscription DB id when we don't have an event id
// (e.g. called outside the Stripe webhook context). Still dedupes a
// single subscription's deletion, just not event-granular.
const idempotencyIdentifier = stripeEventId ?? `sub:${subscription.id}`
try {
await stripeWebhookIdempotency.executeWithIdempotency(
'subscription-deleted',
idempotencyIdentifier,
async () => {
const totalOverage = await calculateSubscriptionOverage(subscription)
const stripe = requireStripeClient()
if (isEnterprise(subscription.plan)) {
await resetUsageForSubscription({
plan: subscription.plan,
referenceId: subscription.referenceId,
periodStart: subscription.periodStart ?? null,
periodEnd: subscription.periodEnd ?? null,
})
const dormantResult = await transitionOrganizationToDormantState(
subscription.referenceId,
subscription.id
)
logger.info('Successfully processed enterprise subscription cancellation', {
subscriptionId: subscription.id,
stripeSubscriptionId,
...dormantResult,
})
const enterpriseActorId = await resolveSubscriptionActorId(subscription.referenceId)
recordAudit({
actorId: enterpriseActorId,
action: AuditAction.SUBSCRIPTION_CANCELLED,
resourceType: AuditResourceType.SUBSCRIPTION,
resourceId: subscription.id,
description: `Enterprise subscription cancelled for ${subscription.referenceId}`,
metadata: {
plan: subscription.plan,
referenceId: subscription.referenceId,
organizationId: subscription.referenceId,
kind: 'enterprise',
},
})
captureServerEvent(subscription.referenceId, 'subscription_cancelled', {
plan: subscription.plan ?? 'unknown',
reference_id: subscription.referenceId,
})
return { totalOverage: 0, kind: 'enterprise' as const }
}
const billedOverage = await getBilledOverageForSubscription(subscription)
const remainingOverage = Math.max(0, totalOverage - billedOverage)
logger.info('Subscription deleted overage calculation', {
subscriptionId: subscription.id,
totalOverage,
billedOverage,
remainingOverage,
})
if (remainingOverage > 0 && stripeSubscriptionId) {
const stripeSubscription = await stripe.subscriptions.retrieve(stripeSubscriptionId)
const customerId = stripeSubscription.customer as string
const cents = Math.round(remainingOverage * 100)
const endedAt = stripeSubscription.ended_at || Math.floor(Date.now() / 1000)
const billingPeriod = new Date(endedAt * 1000).toISOString().slice(0, 7)
const itemIdemKey = `final-overage-item:${customerId}:${stripeSubscriptionId}:${billingPeriod}`
const invoiceIdemKey = `final-overage-invoice:${customerId}:${stripeSubscriptionId}:${billingPeriod}`
const finalizeIdemKey = `final-overage-finalize:${customerId}:${stripeSubscriptionId}:${billingPeriod}`
const overageInvoice = await stripe.invoices.create(
{
customer: customerId,
collection_method: 'charge_automatically',
auto_advance: true,
description: `Final overage charges for ${subscription.plan} subscription (${billingPeriod})`,
metadata: {
type: 'final_overage_billing',
billingPeriod,
subscriptionId: stripeSubscriptionId,
cancelledAt: stripeSubscription.canceled_at?.toString() || '',
},
},
{ idempotencyKey: invoiceIdemKey }
)
await stripe.invoiceItems.create(
{
customer: customerId,
invoice: overageInvoice.id,
amount: cents,
currency: 'usd',
description: `Usage overage for ${subscription.plan} plan (Final billing period)`,
metadata: {
type: 'final_usage_overage',
usage: remainingOverage.toFixed(2),
totalOverage: totalOverage.toFixed(2),
billedOverage: billedOverage.toFixed(2),
billingPeriod,
},
},
{ idempotencyKey: itemIdemKey }
)
if (overageInvoice.id) {
await stripe.invoices.finalizeInvoice(
overageInvoice.id,
{},
{ idempotencyKey: finalizeIdemKey }
)
}
logger.info('Created final overage invoice for cancelled subscription', {
subscriptionId: subscription.id,
stripeSubscriptionId,
invoiceId: overageInvoice.id,
totalOverage,
billedOverage,
remainingOverage,
cents,
billingPeriod,
})
} else {
logger.info('No overage to bill for cancelled subscription', {
subscriptionId: subscription.id,
plan: subscription.plan,
})
}
await resetUsageForSubscription({
plan: subscription.plan,
referenceId: subscription.referenceId,
periodStart: subscription.periodStart ?? null,
periodEnd: subscription.periodEnd ?? null,
})
let restoredProCount = 0
let membersSynced = 0
let workspacesDetached = 0
const isOrgScoped = await isSubscriptionOrgScoped(subscription)
if (isOrgScoped) {
const dormantResult = await transitionOrganizationToDormantState(
subscription.referenceId,
subscription.id
)
restoredProCount = dormantResult.restoredProCount
membersSynced = dormantResult.membersSynced
workspacesDetached = dormantResult.workspacesDetached
} else if (isPro(subscription.plan)) {
await syncUsageLimitsFromSubscription(subscription.referenceId)
membersSynced = 1
}
logger.info('Successfully processed subscription cancellation', {
subscriptionId: subscription.id,
stripeSubscriptionId,
plan: subscription.plan,
totalOverage,
restoredProCount,
membersSynced,
workspacesDetached,
})
const cancelActorId = await resolveSubscriptionActorId(subscription.referenceId)
recordAudit({
actorId: cancelActorId,
action: AuditAction.SUBSCRIPTION_CANCELLED,
resourceType: AuditResourceType.SUBSCRIPTION,
resourceId: subscription.id,
description: `Subscription cancelled on ${subscription.plan ?? 'unknown'} plan for ${subscription.referenceId}`,
metadata: {
plan: subscription.plan,
referenceId: subscription.referenceId,
totalOverage,
...(isOrgScoped ? { organizationId: subscription.referenceId } : {}),
},
})
captureServerEvent(subscription.referenceId, 'subscription_cancelled', {
plan: subscription.plan ?? 'unknown',
reference_id: subscription.referenceId,
})
return { totalOverage, remainingOverage, restoredProCount, workspacesDetached }
}
)
} catch (error) {
logger.error('Failed to handle subscription deletion', {
subscriptionId: subscription.id,
stripeSubscriptionId,
error,
})
throw error
}
}