Skip to content

Commit 9f7c170

Browse files
committed
fix(billing): record each committed sync value on every in-flight event so no stale intent can resurface
1 parent f2a6052 commit 9f7c170

12 files changed

Lines changed: 564 additions & 196 deletions

File tree

‎apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts‎

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { db } from '@sim/db'
22
import { outboxEvent } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { toError } from '@sim/utils/errors'
5+
import { toRecord } from '@sim/utils/object'
56
import { and, eq, sql } from 'drizzle-orm'
67
import { NextResponse } from 'next/server'
78
import { adminV1RequeueOutboxEventContract } from '@/lib/api/contracts/v1/admin'
@@ -11,6 +12,10 @@ import {
1112
ENTERPRISE_METADATA_SYNC_EVENT_TYPE,
1213
ENTERPRISE_PROVISION_EVENT_TYPE,
1314
} from '@/lib/billing/enterprise-outbox-events'
15+
import {
16+
isSubscriptionSyncEventType,
17+
recommitSubscriptionSync,
18+
} from '@/lib/billing/webhooks/subscription-sync'
1419
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1520
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
1621

@@ -28,7 +33,9 @@ const invalidOutboxEventResponse = (message: string) =>
2833
* will retry it. Resets `attempts`, `lastError`, and `availableAt` so
2934
* the next poll picks it up. Only dead-lettered events can be
3035
* requeued — completed/pending/processing rows are rejected to avoid
31-
* operator errors.
36+
* operator errors. A Stripe subscription sync is re-committed with the
37+
* subscription's current value, so the retry never revives the value it
38+
* failed with.
3239
*/
3340
export const POST = withRouteHandler(
3441
withAdminAuthParams<{ id: string }>(async (request, context) => {
@@ -61,23 +68,34 @@ export const POST = withRouteHandler(
6168
const deliveryRevision = metadataIntent?.success
6269
? metadataIntent.data.deliveryRevision + 1
6370
: null
64-
const result = await db
65-
.update(outboxEvent)
66-
.set({
67-
status: 'pending',
68-
attempts: 0,
69-
lastError: null,
70-
availableAt: new Date(),
71-
lockedAt: null,
72-
processedAt: null,
73-
...(deliveryRevision === null
74-
? {}
75-
: {
76-
payload: sql`(${outboxEvent.payload}::jsonb || ${JSON.stringify({ deliveryRevision })}::jsonb)::json`,
77-
}),
78-
})
79-
.where(and(eq(outboxEvent.id, id), eq(outboxEvent.status, 'dead_letter')))
80-
.returning({ id: outboxEvent.id, eventType: outboxEvent.eventType })
71+
const result = await db.transaction(async (tx) => {
72+
const requeued = await tx
73+
.update(outboxEvent)
74+
.set({
75+
status: 'pending',
76+
attempts: 0,
77+
lastError: null,
78+
availableAt: new Date(),
79+
lockedAt: null,
80+
processedAt: null,
81+
...(deliveryRevision === null
82+
? {}
83+
: {
84+
payload: sql`(${outboxEvent.payload}::jsonb || ${JSON.stringify({ deliveryRevision })}::jsonb)::json`,
85+
}),
86+
})
87+
.where(and(eq(outboxEvent.id, id), eq(outboxEvent.status, 'dead_letter')))
88+
.returning({ id: outboxEvent.id, eventType: outboxEvent.eventType })
89+
const subscriptionId = toRecord(existing?.payload).subscriptionId
90+
if (
91+
requeued.length > 0 &&
92+
isSubscriptionSyncEventType(requeued[0].eventType) &&
93+
typeof subscriptionId === 'string'
94+
) {
95+
await recommitSubscriptionSync(tx, requeued[0].eventType, subscriptionId)
96+
}
97+
return requeued
98+
})
8199

82100
if (result.length === 0) {
83101
return NextResponse.json(

‎apps/sim/lib/admin/subscription-lifecycle.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,10 @@ describe('admin subscription cancellation', () => {
124124
expect.objectContaining({ status: 'pending', attempts: 0, lastError: null })
125125
)
126126
expect(dbChainMockFns.set).toHaveBeenCalledWith({ cancelAtPeriodEnd: true })
127-
expect(billingSubscriptionSyncMockFns.mockRecommitCancelAtPeriodEndSync).toHaveBeenCalledWith(
127+
expect(billingSubscriptionSyncMockFns.mockRecommitSubscriptionSync).toHaveBeenCalledWith(
128128
expect.anything(),
129-
'outbox-1',
130-
true
129+
'stripe.sync-cancel-at-period-end',
130+
'sub-row-1'
131131
)
132132
expect(result).toMatchObject({
133133
operationId: '67e55044-10b1-426f-9247-bb680e5fe0c8',

‎apps/sim/lib/admin/subscription-lifecycle.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/util
1010
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-events'
1111
import {
1212
enqueueCancelAtPeriodEndSync,
13-
recommitCancelAtPeriodEndSync,
13+
recommitSubscriptionSync,
1414
} from '@/lib/billing/webhooks/subscription-sync'
1515
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
1616

@@ -305,7 +305,6 @@ export async function requestDashboardSubscriptionCancellation({
305305
if (!restoredSubscription) {
306306
throw new Error('Cancellation subscription no longer exists')
307307
}
308-
await recommitCancelAtPeriodEndSync(tx, existingOperation.id, true)
309308
}
310309
await tx
311310
.update(outboxEvent)
@@ -320,6 +319,13 @@ export async function requestDashboardSubscriptionCancellation({
320319
.where(
321320
and(eq(outboxEvent.id, existingOperation.id), eq(outboxEvent.status, 'dead_letter'))
322321
)
322+
if (existingOperation.eventType === OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END) {
323+
await recommitSubscriptionSync(
324+
tx,
325+
OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END,
326+
existingOperation.subscriptionId
327+
)
328+
}
323329
return {
324330
operationId,
325331
outboxEventId: existingOperation.id,

‎apps/sim/lib/billing/webhooks/outbox-events.ts‎

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,5 @@
11
export const OUTBOX_EVENT_TYPES = {
2-
/**
3-
* Sync a subscription's `cancel_at_period_end` flag from our DB to
4-
* Stripe. Enqueue through `enqueueCancelAtPeriodEndSync` in the same
5-
* transaction as every DB change to `cancelAtPeriodEnd`.
6-
*
7-
* Guarantee: once every in-flight event for a subscription completes,
8-
* Stripe holds the last value committed to the DB. Each handler pushes
9-
* the row's current value (not its payload's) and re-reads the row
10-
* after its Stripe write, retrying while the value moved, so racing
11-
* events converge even when an earlier request lands in Stripe last.
12-
* While an event is in flight, `reconcileSubscriptionSyncFromStripe`
13-
* keeps webhook echoes and stale snapshots from overwriting the
14-
* committed value; a change made in Stripe itself wins over it.
15-
*/
2+
/** Sync `cancelAtPeriodEnd` from our DB to Stripe; enqueue via `enqueueCancelAtPeriodEndSync`. */
163
STRIPE_SYNC_CANCEL_AT_PERIOD_END: 'stripe.sync-cancel-at-period-end',
174
/** Cancel in Stripe; the verified deletion webhook remains the only DB entitlement authority. */
185
STRIPE_CANCEL_SUBSCRIPTION_IMMEDIATELY: 'stripe.cancel-subscription-immediately',

‎apps/sim/lib/billing/webhooks/outbox-handlers.ts‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,17 @@ import type { OutboxHandler } from '@/lib/core/outbox/service'
2020

2121
const logger = createLogger('BillingOutboxHandlers')
2222

23+
/**
24+
* Passes a DB→Stripe sync handler makes before throwing for an outbox retry: each pass re-reads
25+
* the row after its Stripe write and goes again when the value moved in the meantime.
26+
*/
27+
const MAX_SYNC_ATTEMPTS = 2
28+
29+
/** A fresh key per Stripe write: the SDK reuses it across its own network retries of that call. */
30+
function customerContactSyncIdempotencyKey(eventId: string): string {
31+
return `outbox:${eventId}:${generateShortId()}`
32+
}
33+
2334
interface StripeCancelSubscriptionImmediatelyPayload {
2435
stripeSubscriptionId: string
2536
subscriptionId: string
@@ -110,9 +121,8 @@ const stripeSyncCancelAtPeriodEnd: OutboxHandler<CancelAtPeriodEndSyncPayload> =
110121
) => {
111122
await recordAdminCancellationAudit({ ...payload, timing: 'period_end' })
112123
const stripe = requireStripeClient()
113-
const maxSyncAttempts = 2
114124

115-
for (let attempt = 1; attempt <= maxSyncAttempts; attempt++) {
125+
for (let attempt = 1; attempt <= MAX_SYNC_ATTEMPTS; attempt++) {
116126
const desiredValue = await readCancelAtPeriodEnd(payload.subscriptionId)
117127
if (desiredValue === null) {
118128
logger.warn('Subscription not found when syncing cancel_at_period_end', {
@@ -184,9 +194,8 @@ const stripeSyncSubscriptionSeats: OutboxHandler<SubscriptionSeatsSyncPayload> =
184194
ctx
185195
) => {
186196
const stripe = requireStripeClient()
187-
const maxSyncAttempts = 2
188197

189-
for (let attempt = 1; attempt <= maxSyncAttempts; attempt++) {
198+
for (let attempt = 1; attempt <= MAX_SYNC_ATTEMPTS; attempt++) {
190199
const row = await getSubscriptionSeatSyncState(payload.subscriptionId)
191200
if (!row) {
192201
logger.warn('Subscription not found when syncing seats', {
@@ -450,9 +459,8 @@ const stripeSyncCustomerContact: OutboxHandler<StripeSyncCustomerContactPayload>
450459
ctx
451460
) => {
452461
const stripe = requireStripeClient()
453-
const maxSyncAttempts = 2
454462

455-
for (let attempt = 1; attempt <= maxSyncAttempts; attempt++) {
463+
for (let attempt = 1; attempt <= MAX_SYNC_ATTEMPTS; attempt++) {
456464
const contact = await readCustomerContact(payload.subscriptionId)
457465
if (contact.status === 'skipped') {
458466
logger.warn(contact.reason, {
@@ -476,7 +484,7 @@ const stripeSyncCustomerContact: OutboxHandler<StripeSyncCustomerContactPayload>
476484
email: contact.email,
477485
...(contact.name ? { name: contact.name } : {}),
478486
},
479-
{ idempotencyKey: `outbox:${ctx.eventId}:${generateShortId()}` }
487+
{ idempotencyKey: customerContactSyncIdempotencyKey(ctx.eventId) }
480488
)
481489
}
482490

0 commit comments

Comments
 (0)