Skip to content

Commit 26f7bc6

Browse files
committed
fix(billing): order Stripe-accepted reconcile values by when Stripe was read
1 parent c3a3364 commit 26f7bc6

2 files changed

Lines changed: 43 additions & 3 deletions

File tree

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,38 @@ describe('cancel_at_period_end sync', () => {
618618
expect((await storedSubscription(pro.subscriptionId)).cancelAtPeriodEnd).toBe(true)
619619
})
620620

621+
it('orders concurrent reconciles of Stripe-side changes by when each read Stripe', async () => {
622+
const pro = await createProUserInPaidOrganization()
623+
await pauseProSubscriptionForOrgCoverage(pro.userId)
624+
const pauseSync = await latestOutboxEventId(
625+
OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END,
626+
pro.subscriptionId
627+
)
628+
stripe.failNextUpdateAfterApplying('subscriptions')
629+
await expect(processEvent(pauseSync)).resolves.toBe('pending')
630+
631+
stripe.updateOutsideSim(pro.stripeSubscriptionId, { cancel_at_period_end: false })
632+
const renewal = stripe.events.at(-1) as Stripe.Event
633+
const firstRead = stripe.holdNextRequest('subscriptions.retrieve')
634+
const secondRead = stripe.holdNextRequest('subscriptions.retrieve')
635+
const reconcilingRenewal = deliver(renewal)
636+
await firstRead.reached
637+
638+
stripe.updateOutsideSim(pro.stripeSubscriptionId, { cancel_at_period_end: true })
639+
const reconcilingCancel = deliver(stripe.events.at(-1) as Stripe.Event)
640+
await secondRead.reached
641+
642+
firstRead.release()
643+
await reconcilingRenewal
644+
secondRead.release()
645+
await reconcilingCancel
646+
647+
expect((await storedSubscription(pro.subscriptionId)).cancelAtPeriodEnd).toBe(true)
648+
await makeDue(pauseSync)
649+
await expect(processEvent(pauseSync)).resolves.toBe('completed')
650+
expect(stripe.subscription(pro.stripeSubscriptionId).cancel_at_period_end).toBe(true)
651+
})
652+
621653
it('keeps a value Sim committed after the reconcile read Stripe', async () => {
622654
const pro = await createProUserInPaidOrganization()
623655
await pauseProSubscriptionForOrgCoverage(pro.userId)

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,14 +99,22 @@ async function readDatabaseClock(executor: DbOrTx): Promise<number> {
9999
* an older value for the webhook reconcile to restore. The caller must hold the subscription row
100100
* lock (`FOR UPDATE`, or the `UPDATE` itself), per the lock order on
101101
* {@link lockSubscriptionForSyncRetry}.
102+
*
103+
* A Sim commit is stamped with the clock under that lock. A value taken from Stripe passes
104+
* `observedAt`, the clock read just before Stripe was read, so it orders by when it was observed:
105+
* a slower reconcile of an earlier Stripe read cannot outrank a later one.
102106
*/
103107
async function commitIntent<T extends SyncIntentFields>(
104108
tx: DbOrTx,
105109
eventType: SubscriptionSyncEventType,
106110
subscriptionId: string,
107-
fields: T
111+
fields: T,
112+
observedAt?: number
108113
): Promise<T & { committedAt: number }> {
109-
const committed = await withCommittedAt(tx, fields)
114+
const committed =
115+
observedAt === undefined
116+
? await withCommittedAt(tx, fields)
117+
: { ...fields, committedAt: observedAt }
110118
await patchRetryableOutboxEvents(tx, eventType, subscriptionSubject(subscriptionId), committed)
111119
return committed
112120
}
@@ -446,7 +454,7 @@ export async function reconcileSubscriptionSyncFromStripe(event: Stripe.Event):
446454
if (liveCancelAtPeriodEnd === undefined) return false
447455
cancelAtPeriodEnd = liveCancelAtPeriodEnd
448456
if (intents.cancelSyncCarriesOtherThan(cancelAtPeriodEnd)) {
449-
await commitIntent(tx, CANCEL_SYNC, row.id, { cancelAtPeriodEnd })
457+
await commitIntent(tx, CANCEL_SYNC, row.id, { cancelAtPeriodEnd }, liveReadAt)
450458
}
451459
}
452460
const seats = intents.seats.status === 'value' ? intents.seats.value : current.seats

0 commit comments

Comments
 (0)