Skip to content

Commit 95194c8

Browse files
committed
fix(billing): close remaining stale-intent paths (enterprise retry, legacy echoes, customer restore)
1 parent 9f7c170 commit 95194c8

7 files changed

Lines changed: 208 additions & 12 deletions

File tree

‎apps/sim/lib/auth/auth.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,10 @@ import {
103103
handleSubscriptionCreated,
104104
handleSubscriptionDeleted,
105105
} from '@/lib/billing/webhooks/subscription'
106-
import { reconcileSubscriptionSyncFromStripe } from '@/lib/billing/webhooks/subscription-sync'
106+
import {
107+
commitCustomerRestoredSubscription,
108+
reconcileSubscriptionSyncFromStripe,
109+
} from '@/lib/billing/webhooks/subscription-sync'
107110
import { handleSubscriptionUsageUpdate } from '@/lib/billing/webhooks/subscription-usage'
108111
import { env } from '@/lib/core/config/env'
109112
import {
@@ -1101,6 +1104,16 @@ export const auth = betterAuth({
11011104
return
11021105
}),
11031106
after: createAuthMiddleware(async (ctx) => {
1107+
if (isBillingEnabled && ctx.path === '/subscription/restore') {
1108+
try {
1109+
await commitCustomerRestoredSubscription(ctx.context.returned)
1110+
} catch (error) {
1111+
logger.error('Failed to record a restored subscription as the committed value', {
1112+
error,
1113+
})
1114+
}
1115+
}
1116+
11041117
if (isBillingEnabled && ctx.path === '/subscription/upgrade') {
11051118
const checkoutContext = ctx as typeof ctx & {
11061119
billingCheckoutAdmissionClaim?: CheckoutAdmissionClaim

‎apps/sim/lib/billing/enterprise-provisioning.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ import { TERMINAL_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/util
7777
import { countPendingSeatInvitations } from '@/lib/billing/validation/seat-management'
7878
import { withEnterpriseReconciliationLease } from '@/lib/billing/webhooks/enterprise-reconciliation-lease'
7979
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-events'
80+
import { recommitSubscriptionSync } from '@/lib/billing/webhooks/subscription-sync'
8081
import { env } from '@/lib/core/config/env'
8182
import {
8283
continueOutboxHandler,
@@ -2135,6 +2136,13 @@ export async function retryEnterpriseFollowUpJob(
21352136
processedAt: null,
21362137
})
21372138
.where(eq(outboxEvent.id, jobEventId))
2139+
if (detail.kind === 'personal_subscription_cancellation') {
2140+
await recommitSubscriptionSync(
2141+
tx,
2142+
OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END,
2143+
detail.subjectId
2144+
)
2145+
}
21382146
return true
21392147
})
21402148

‎apps/sim/lib/billing/webhooks/stripe-sync-convergence.integration.ts‎

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,10 @@ import { isTeam } from '@/lib/billing/plan-helpers'
5353
import { syncSeatsFromStripeQuantity } from '@/lib/billing/validation/seat-management'
5454
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-events'
5555
import { billingOutboxHandlers } from '@/lib/billing/webhooks/outbox-handlers'
56-
import { reconcileSubscriptionSyncFromStripe } from '@/lib/billing/webhooks/subscription-sync'
56+
import {
57+
commitCustomerRestoredSubscription,
58+
reconcileSubscriptionSyncFromStripe,
59+
} from '@/lib/billing/webhooks/subscription-sync'
5760
import { enqueueOutboxEvent, processOutboxEventById } from '@/lib/core/outbox/service'
5861
import { POST as requeueOutboxEvent } from '@/app/api/v1/admin/outbox/[id]/requeue/route'
5962

@@ -519,6 +522,89 @@ describe('cancel_at_period_end sync', () => {
519522

520523
expect((await storedSubscription(pro.subscriptionId)).cancelAtPeriodEnd).toBe(true)
521524
})
525+
526+
it('does not revive an older value when a retry path resets its sync without re-committing', async () => {
527+
const pro = await createProUserInPaidOrganization()
528+
await pauseProSubscriptionForOrgCoverage(pro.userId)
529+
const pauseSync = await latestOutboxEventId(
530+
OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END,
531+
pro.subscriptionId
532+
)
533+
await deadLetter(pauseSync)
534+
await leaveOrganization(pro.userId, pro.paidOrganization.organizationId)
535+
await restoreUserProSubscription(pro.userId)
536+
const restoreSync = await latestOutboxEventId(
537+
OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END,
538+
pro.subscriptionId
539+
)
540+
await expect(processEvent(restoreSync)).resolves.toBe('completed')
541+
542+
await testDatabase
543+
.update(outboxEvent)
544+
.set({
545+
status: 'pending',
546+
attempts: 0,
547+
lastError: null,
548+
availableAt: new Date(),
549+
lockedAt: null,
550+
processedAt: null,
551+
})
552+
.where(eq(outboxEvent.id, pauseSync))
553+
await deliverUnrelatedUpdate(pro.stripeSubscriptionId)
554+
555+
expect((await storedSubscription(pro.subscriptionId)).cancelAtPeriodEnd).toBe(false)
556+
await expect(processEvent(pauseSync)).resolves.toBe('completed')
557+
expect(stripe.subscription(pro.stripeSubscriptionId).cancel_at_period_end).toBe(false)
558+
})
559+
560+
it("treats a write from an older deploy's sync handler as Sim's own echo", async () => {
561+
const pro = await createProUserInPaidOrganization()
562+
await pauseProSubscriptionForOrgCoverage(pro.userId)
563+
const pauseSync = await latestOutboxEventId(
564+
OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END,
565+
pro.subscriptionId
566+
)
567+
await leaveOrganization(pro.userId, pro.paidOrganization.organizationId)
568+
await restoreUserProSubscription(pro.userId)
569+
570+
await stripe.client.subscriptions.update(
571+
pro.stripeSubscriptionId,
572+
{ cancel_at_period_end: true },
573+
{ idempotencyKey: `outbox:${pauseSync}` }
574+
)
575+
await deliver(stripe.events.at(-1) as Stripe.Event)
576+
577+
expect((await storedSubscription(pro.subscriptionId)).cancelAtPeriodEnd).toBe(false)
578+
})
579+
580+
it("keeps a customer's restore made while an earlier sync is retrying", async () => {
581+
const pro = await createProUserInPaidOrganization()
582+
await pauseProSubscriptionForOrgCoverage(pro.userId)
583+
const pauseSync = await latestOutboxEventId(
584+
OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END,
585+
pro.subscriptionId
586+
)
587+
stripe.failNextUpdateAfterApplying('subscriptions')
588+
await expect(processEvent(pauseSync)).resolves.toBe('pending')
589+
590+
const restored = await stripe.client.subscriptions.update(pro.stripeSubscriptionId, {
591+
cancel_at_period_end: false,
592+
})
593+
const restoreEvent = stripe.events.at(-1) as Stripe.Event
594+
await testDatabase
595+
.update(subscription)
596+
.set({ cancelAtPeriodEnd: false, cancelAt: null, canceledAt: null })
597+
.where(eq(subscription.id, pro.subscriptionId))
598+
await commitCustomerRestoredSubscription(restored)
599+
600+
await deliverUnrelatedUpdate(pro.stripeSubscriptionId)
601+
await makeDue(pauseSync)
602+
await expect(processEvent(pauseSync)).resolves.toBe('completed')
603+
await deliver(restoreEvent)
604+
605+
expect((await storedSubscription(pro.subscriptionId)).cancelAtPeriodEnd).toBe(false)
606+
expect(stripe.subscription(pro.stripeSubscriptionId).cancel_at_period_end).toBe(false)
607+
})
522608
})
523609

524610
describe('customer contact sync', () => {

‎apps/sim/lib/billing/webhooks/subscription-sync.ts‎

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-events'
1010
import {
1111
enqueueOutboxEvent,
1212
listInflightOutboxEvents,
13+
maxSettledOutboxPayloadNumber,
1314
patchInflightOutboxEvents,
1415
} from '@/lib/core/outbox/service'
1516
import type { DbOrTx } from '@/lib/db/types'
@@ -22,6 +23,8 @@ const logger = createLogger('BillingSubscriptionSync')
2223
* webhook is recognized as the echo of Sim's own sync rather than a change made in Stripe.
2324
*/
2425
const CANCEL_AT_PERIOD_END_SYNC_KEY_PREFIX = 'outbox-sync-cancel-at-period-end:'
26+
/** The key prefix the cancel-sync handler used before the one above; old pods still send it mid-rollout. */
27+
const LEGACY_SYNC_KEY_PREFIX = 'outbox:'
2528

2629
const CANCEL_SYNC = OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END
2730
const SEATS_SYNC = OUTBOX_EVENT_TYPES.STRIPE_SYNC_SUBSCRIPTION_SEATS
@@ -161,20 +164,62 @@ export async function recommitSubscriptionSync(
161164
)
162165
}
163166

167+
/**
168+
* Records a customer's restore through Better Auth's `/subscription/restore` as Sim's latest
169+
* committed `cancelAtPeriodEnd`. That endpoint updates Stripe and then writes the row directly,
170+
* so a cancel sync still in flight would otherwise carry the cancellation the customer just
171+
* undid, and a webhook processed before the restore's own could restore it for that sync to
172+
* push. Enqueuing re-pushes `false` even if such a sync already ran. Called from the endpoint's
173+
* `after` hook with the Stripe subscription it returned.
174+
*/
175+
export async function commitCustomerRestoredSubscription(restored: unknown): Promise<void> {
176+
const stripeSubscription = toRecord(restored)
177+
const stripeSubscriptionId = stripeSubscription.id
178+
if (
179+
typeof stripeSubscriptionId !== 'string' ||
180+
stripeSubscription.cancel_at_period_end !== false
181+
) {
182+
return
183+
}
184+
185+
await db.transaction(async (tx) => {
186+
const [row] = await tx
187+
.select({ id: subscription.id })
188+
.from(subscription)
189+
.where(eq(subscription.stripeSubscriptionId, stripeSubscriptionId))
190+
.for('update')
191+
.limit(1)
192+
if (!row) return
193+
194+
await tx
195+
.update(subscription)
196+
.set({ cancelAtPeriodEnd: false })
197+
.where(eq(subscription.id, row.id))
198+
await enqueueCancelAtPeriodEndSync(tx, {
199+
stripeSubscriptionId,
200+
subscriptionId: row.id,
201+
cancelAtPeriodEnd: false,
202+
reason: 'customer-restored',
203+
})
204+
})
205+
}
206+
164207
/** A fresh key per Stripe write: the SDK reuses it across its own network retries of that call. */
165208
export function cancelAtPeriodEndSyncIdempotencyKey(eventId: string): string {
166209
return `${CANCEL_AT_PERIOD_END_SYNC_KEY_PREFIX}${eventId}:${generateShortId()}`
167210
}
168211

169212
/**
170-
* What a sync type's in-flight events say about its field: nothing in flight, the latest
213+
* What a sync type's in-flight events say about its field: no applicable value, the latest
171214
* committed value, or `legacy` when an event predates recorded values (enqueued by an older
172-
* deploy) so the committed value is unknown.
215+
* deploy) so the committed value is unknown. An in-flight value older than one a settled event
216+
* already carried was revived by a retry path that did not re-commit, and does not apply.
173217
*/
174218
type InflightIntent<T> = { status: 'none' } | { status: 'legacy' } | { status: 'value'; value: T }
175219

176220
function latestIntent<T>(
177221
events: { eventType: string; payload: unknown }[],
222+
settledCommittedAt: Map<string, number>,
178223
eventType: SubscriptionSyncEventType,
179224
readValue: (payload: Record<string, unknown>) => T | undefined
180225
): InflightIntent<T> {
@@ -188,20 +233,27 @@ function latestIntent<T>(
188233
latest = { committedAt: payload.committedAt, value }
189234
}
190235
}
191-
return latest ? { status: 'value', value: latest.value } : { status: 'none' }
236+
if (!latest || latest.committedAt < (settledCommittedAt.get(eventType) ?? 0)) {
237+
return { status: 'none' }
238+
}
239+
return { status: 'value', value: latest.value }
192240
}
193241

194242
async function readInflightIntents(executor: DbOrTx, subscriptionId: string) {
195-
const events = await listInflightOutboxEvents(
243+
const eventTypes = [CANCEL_SYNC, SEATS_SYNC]
244+
const subject = subscriptionSubject(subscriptionId)
245+
const events = await listInflightOutboxEvents(executor, eventTypes, subject)
246+
const settledCommittedAt = await maxSettledOutboxPayloadNumber(
196247
executor,
197-
[CANCEL_SYNC, SEATS_SYNC],
198-
subscriptionSubject(subscriptionId)
248+
eventTypes,
249+
subject,
250+
'committedAt'
199251
)
200252
return {
201-
cancelAtPeriodEnd: latestIntent(events, CANCEL_SYNC, (payload) =>
253+
cancelAtPeriodEnd: latestIntent(events, settledCommittedAt, CANCEL_SYNC, (payload) =>
202254
typeof payload.cancelAtPeriodEnd === 'boolean' ? payload.cancelAtPeriodEnd : undefined
203255
),
204-
seats: latestIntent(events, SEATS_SYNC, (payload) =>
256+
seats: latestIntent(events, settledCommittedAt, SEATS_SYNC, (payload) =>
205257
typeof payload.seats === 'number' ? payload.seats : undefined
206258
),
207259
}
@@ -212,7 +264,10 @@ function isCancellationChangedInStripe(event: Stripe.Event): boolean {
212264
const previousAttributes = toRecord(event.data.previous_attributes)
213265
if (!('cancel_at_period_end' in previousAttributes)) return false
214266
const idempotencyKey = event.request?.idempotency_key
215-
return !idempotencyKey?.startsWith(CANCEL_AT_PERIOD_END_SYNC_KEY_PREFIX)
267+
const issuedBySimSync =
268+
idempotencyKey?.startsWith(CANCEL_AT_PERIOD_END_SYNC_KEY_PREFIX) ||
269+
idempotencyKey?.startsWith(LEGACY_SYNC_KEY_PREFIX)
270+
return !issuedBySimSync
216271
}
217272

218273
type CancelAtPeriodEndSource =

‎apps/sim/lib/core/outbox/service.ts‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { createLogger } from '@sim/logger'
44
import { describeError, toError } from '@sim/utils/errors'
55
import { generateId } from '@sim/utils/id'
66
import { truncate } from '@sim/utils/string'
7-
import { and, asc, desc, eq, inArray, lte, sql } from 'drizzle-orm'
7+
import { and, asc, desc, eq, inArray, lte, notInArray, sql } from 'drizzle-orm'
88
import {
99
dueOutboxWorkQuery,
1010
isStuckProcessing,
@@ -440,6 +440,35 @@ export async function listInflightOutboxEvents(
440440
return limit === undefined ? query : query.limit(limit)
441441
}
442442

443+
/**
444+
* For each event type, the largest numeric `payloadKey` among the subject's settled
445+
* (`completed` or `dead_letter`) events. Types with no such value are absent from the map.
446+
*/
447+
export async function maxSettledOutboxPayloadNumber(
448+
executor: Pick<typeof db, 'select'>,
449+
eventTypes: readonly string[],
450+
subject: OutboxPayloadSubject,
451+
payloadKey: string
452+
): Promise<Map<string, number>> {
453+
const rows = await executor
454+
.select({
455+
eventType: outboxEvent.eventType,
456+
value: sql<string | null>`max((${outboxEvent.payload} ->> ${payloadKey})::numeric)::text`,
457+
})
458+
.from(outboxEvent)
459+
.where(
460+
and(
461+
inArray(outboxEvent.eventType, [...eventTypes]),
462+
notInArray(outboxEvent.status, [...INFLIGHT_OUTBOX_STATUSES]),
463+
sql`${outboxEvent.payload} ->> ${subject.payloadKey} = ${subject.payloadValue}`
464+
)
465+
)
466+
.groupBy(outboxEvent.eventType)
467+
return new Map(
468+
rows.flatMap((row) => (row.value === null ? [] : [[row.eventType, Number(row.value)]]))
469+
)
470+
}
471+
443472
/**
444473
* Shallow-merges `patch` into the payload of every `pending` or `processing` event of the type
445474
* for one subject. Callers serialize writers for the subject with their domain lock.

‎packages/testing/src/mocks/billing-subscription-sync.mock.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export const billingSubscriptionSyncMockFns = {
2828
(eventId: string) => `outbox-sync-cancel-at-period-end:${eventId}:key`
2929
),
3030
mockReconcileSubscriptionSyncFromStripe: vi.fn(async () => undefined),
31+
mockCommitCustomerRestoredSubscription: vi.fn(async () => undefined),
3132
}
3233

3334
/**
@@ -47,4 +48,6 @@ export const billingSubscriptionSyncMock = {
4748
billingSubscriptionSyncMockFns.mockCancelAtPeriodEndSyncIdempotencyKey,
4849
reconcileSubscriptionSyncFromStripe:
4950
billingSubscriptionSyncMockFns.mockReconcileSubscriptionSyncFromStripe,
51+
commitCustomerRestoredSubscription:
52+
billingSubscriptionSyncMockFns.mockCommitCustomerRestoredSubscription,
5053
}

‎packages/testing/src/mocks/outbox-service.mock.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export const outboxServiceMockFns = {
7070
mockFindDeadLetteredEvents: vi.fn(),
7171
mockListInflightOutboxEvents: vi.fn(),
7272
mockPatchInflightOutboxEvents: vi.fn(),
73+
mockMaxSettledOutboxPayloadNumber: vi.fn(),
7374
mockHasInflightOutboxEvent: vi.fn(),
7475
mockHasDueOutboxWork: vi.fn(),
7576
mockProcessOutboxEvents: vi.fn(),
@@ -101,6 +102,7 @@ export const outboxServiceMock = {
101102
findDeadLetteredEvents: outboxServiceMockFns.mockFindDeadLetteredEvents,
102103
listInflightOutboxEvents: outboxServiceMockFns.mockListInflightOutboxEvents,
103104
patchInflightOutboxEvents: outboxServiceMockFns.mockPatchInflightOutboxEvents,
105+
maxSettledOutboxPayloadNumber: outboxServiceMockFns.mockMaxSettledOutboxPayloadNumber,
104106
hasInflightOutboxEvent: outboxServiceMockFns.mockHasInflightOutboxEvent,
105107
hasDueOutboxWork: outboxServiceMockFns.mockHasDueOutboxWork,
106108
processOutboxEvents: outboxServiceMockFns.mockProcessOutboxEvents,

0 commit comments

Comments
 (0)