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
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:
@@ -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 })
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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 (1–5 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user