Skip to content

Commit c3a3364

Browse files
committed
fix(billing): push each sync's recorded value and reject Stripe reads older than a newer Sim commit
1 parent 9ac1b6c commit c3a3364

12 files changed

Lines changed: 320 additions & 163 deletions

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

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,7 @@ import { outboxEvent, subscription } from '@sim/db/schema'
22
import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
33
import { auditMock, auditMockFns } from '@sim/testing/mocks/audit.mock'
44
import { billingOutboxHandlersMock } from '@sim/testing/mocks/billing-outbox-handlers.mock'
5-
import {
6-
billingSubscriptionSyncMock,
7-
billingSubscriptionSyncMockFns,
8-
} from '@sim/testing/mocks/billing-subscription-sync.mock'
5+
import { billingSubscriptionSyncMock } from '@sim/testing/mocks/billing-subscription-sync.mock'
96
import { organizationMembershipMock } from '@sim/testing/mocks/organization-membership.mock'
107
import { outboxServiceMock, outboxServiceMockFns } from '@sim/testing/mocks/outbox-service.mock'
118
import { stripeClientMock } from '@sim/testing/mocks/stripe.mock'
@@ -125,15 +122,6 @@ describe('admin subscription cancellation', () => {
125122
expect.objectContaining({ status: 'pending', attempts: 0, lastError: null })
126123
)
127124
expect(dbChainMockFns.set).toHaveBeenCalledWith({ cancelAtPeriodEnd: true })
128-
expect(billingSubscriptionSyncMockFns.mockLockSubscriptionForSyncRetry).toHaveBeenCalledWith(
129-
expect.anything(),
130-
'sub-row-1'
131-
)
132-
expect(billingSubscriptionSyncMockFns.mockRecommitSubscriptionSync).toHaveBeenCalledWith(
133-
expect.anything(),
134-
'stripe.sync-cancel-at-period-end',
135-
'sub-row-1'
136-
)
137125
expect(result).toMatchObject({
138126
operationId: '67e55044-10b1-426f-9247-bb680e5fe0c8',
139127
status: 'pending',

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

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,8 @@ import {
104104
handleSubscriptionDeleted,
105105
} from '@/lib/billing/webhooks/subscription'
106106
import {
107-
commitCustomerRestoredSubscription,
108107
reconcileSubscriptionSyncFromStripe,
108+
recordCustomerRestoreAfterHook,
109109
} from '@/lib/billing/webhooks/subscription-sync'
110110
import { handleSubscriptionUsageUpdate } from '@/lib/billing/webhooks/subscription-usage'
111111
import { env } from '@/lib/core/config/env'
@@ -1104,15 +1104,7 @@ export const auth = betterAuth({
11041104
return
11051105
}),
11061106
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-
}
1107+
if (isBillingEnabled) await recordCustomerRestoreAfterHook(ctx)
11161108

11171109
if (isBillingEnabled && ctx.path === '/subscription/upgrade') {
11181110
const checkoutContext = ctx as typeof ctx & {

‎apps/sim/lib/billing/organizations/pause-pro-for-coverage.test.ts‎

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
import { dbChainMockFns, resetDbChainMock } from '@sim/testing'
2-
import {
3-
billingSubscriptionSyncMock,
4-
billingSubscriptionSyncMockFns,
5-
} from '@sim/testing/mocks/billing-subscription-sync.mock'
2+
import { billingSubscriptionSyncMock } from '@sim/testing/mocks/billing-subscription-sync.mock'
63
import { outboxServiceMock } from '@sim/testing/mocks/outbox-service.mock'
74
import { beforeEach, describe, expect, it, vi } from 'vitest'
85

@@ -16,9 +13,6 @@ vi.mock('@/lib/billing/webhooks/subscription-sync', () => billingSubscriptionSyn
1613

1714
import { pauseProSubscriptionForOrgCoverage } from '@/lib/billing/organizations/membership'
1815

19-
const mockEnqueueCancelAtPeriodEndSync =
20-
billingSubscriptionSyncMockFns.mockEnqueueCancelAtPeriodEndSync
21-
2216
const ACTIVE_PERSONAL_PRO = {
2317
id: 'sub-personal',
2418
plan: 'pro_6000',
@@ -55,7 +49,7 @@ describe('pauseProSubscriptionForOrgCoverage', () => {
5549
resetDbChainMock()
5650
})
5751

58-
it('pauses the personal Pro and queues the Stripe sync when an entitled paid org covers the user', async () => {
52+
it('pauses the personal Pro when an entitled paid org covers the user', async () => {
5953
queueWhereResponses([
6054
[{ organizationId: 'org-1' }],
6155
[{ plan: 'team_6000', referenceId: 'org-1' }],
@@ -73,12 +67,6 @@ describe('pauseProSubscriptionForOrgCoverage', () => {
7367
organizationId: 'org-1',
7468
})
7569
expect(dbChainMockFns.set).toHaveBeenCalledWith({ cancelAtPeriodEnd: true })
76-
expect(mockEnqueueCancelAtPeriodEndSync).toHaveBeenCalledWith(expect.anything(), {
77-
stripeSubscriptionId: 'stripe-sub-personal',
78-
subscriptionId: 'sub-personal',
79-
cancelAtPeriodEnd: true,
80-
reason: 'covered-by-organization',
81-
})
8270
})
8371

8472
it('reports covered even when no entitled personal Pro row exists', async () => {
@@ -96,7 +84,6 @@ describe('pauseProSubscriptionForOrgCoverage', () => {
9684
organizationId: 'org-1',
9785
})
9886
expect(dbChainMockFns.update).not.toHaveBeenCalled()
99-
expect(mockEnqueueCancelAtPeriodEndSync).not.toHaveBeenCalled()
10087
})
10188

10289
it('reports covered without pausing again when the personal Pro is already pausing', async () => {
@@ -115,6 +102,5 @@ describe('pauseProSubscriptionForOrgCoverage', () => {
115102
organizationId: 'org-1',
116103
})
117104
expect(dbChainMockFns.update).not.toHaveBeenCalled()
118-
expect(mockEnqueueCancelAtPeriodEndSync).not.toHaveBeenCalled()
119105
})
120106
})

‎apps/sim/lib/billing/organizations/provision-seat.test.ts‎

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
import { billingCoreMock, billingCoreMockFns } from '@sim/testing/mocks/billing-core.mock'
22
import { billingOutboxHandlersMock } from '@sim/testing/mocks/billing-outbox-handlers.mock'
33
import { billingPlanMock, billingPlanMockFns } from '@sim/testing/mocks/billing-plan.mock'
4-
import {
5-
billingSubscriptionSyncMock,
6-
billingSubscriptionSyncMockFns,
7-
} from '@sim/testing/mocks/billing-subscription-sync.mock'
4+
import { billingSubscriptionSyncMock } from '@sim/testing/mocks/billing-subscription-sync.mock'
85
import { dbChainMockFns, resetDbChainMock } from '@sim/testing/mocks/database.mock'
96
import {
107
organizationMembershipMock,
@@ -55,8 +52,6 @@ const { mockAcquireOrganizationMutationLock } = organizationMembershipMockFns
5552
const mockGetOrganizationSubscription = billingCoreMockFns.mockGetOrganizationSubscription
5653
const mockGetHighestPriorityPersonalSubscription =
5754
billingPlanMockFns.mockGetHighestPriorityPersonalSubscription
58-
const { mockEnqueueSubscriptionSeatsSync, mockEnqueueCancelAtPeriodEndSync } =
59-
billingSubscriptionSyncMockFns
6055

6156
/** The subscription row as the activation re-reads it under its lock. */
6257
function testExecutor(onSubscriptionLock: () => void = () => {}) {
@@ -130,11 +125,6 @@ describe('ensureTeamOrganizationForAcceptance', () => {
130125
},
131126
})
132127
expect(updateCalls.value).toContainEqual(expect.objectContaining({ plan: 'team_6000' }))
133-
// The Pro→Team price migration is durably enqueued at conversion time.
134-
expect(mockEnqueueSubscriptionSeatsSync).toHaveBeenCalledWith(
135-
executor,
136-
expect.objectContaining({ subscriptionId: 'sub-pro', seats: 1 })
137-
)
138128
expect(mockGetOrganizationSubscription).toHaveBeenCalledWith(
139129
'org-1',
140130
expect.objectContaining({ executor })
@@ -219,15 +209,8 @@ describe('ensureTeamOrganizationForAcceptance', () => {
219209
executor,
220210
expect.objectContaining({ plan: 'team_6000', referenceId: 'owner-1' })
221211
)
222-
// The plan change enqueues the price seat-sync...
223-
expect(mockEnqueueSubscriptionSeatsSync).toHaveBeenCalledWith(
224-
executor,
225-
expect.objectContaining({ subscriptionId: 'sub-pro', seats: 1 })
226-
)
227212
expect(dbChainMockFns.transaction).not.toHaveBeenCalled()
228213
expect(lockOrder).toEqual(['organization', 'subscription'])
229-
// ...but with no scheduled cancellation there is no cancel-sync event.
230-
expect(mockEnqueueCancelAtPeriodEndSync).not.toHaveBeenCalled()
231214
})
232215

233216
it('blocks personal Pro conversion when the reused organization has unresolved Enterprise', async () => {
@@ -255,8 +238,6 @@ describe('ensureTeamOrganizationForAcceptance', () => {
255238
})
256239
).rejects.toThrow('Enterprise issuance is unfinished')
257240
expect(updateCalls.value).toHaveLength(0)
258-
expect(mockEnqueueSubscriptionSeatsSync).not.toHaveBeenCalled()
259-
expect(mockEnqueueCancelAtPeriodEndSync).not.toHaveBeenCalled()
260241
})
261242

262243
it('provisions an org for a legacy personal-scoped Team subscription without a plan change', async () => {
@@ -283,9 +264,6 @@ describe('ensureTeamOrganizationForAcceptance', () => {
283264
expect.anything(),
284265
expect.objectContaining({ plan: 'team', referenceId: 'owner-1' })
285266
)
286-
// No plan change and no scheduled cancellation: nothing to push to Stripe.
287-
expect(mockEnqueueSubscriptionSeatsSync).not.toHaveBeenCalled()
288-
expect(mockEnqueueCancelAtPeriodEndSync).not.toHaveBeenCalled()
289267
})
290268

291269
it('returns upgrade-required (no downgrade) when no eligible Team tier exists', async () => {

‎apps/sim/lib/billing/organizations/seats.test.ts‎

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,7 @@ import {
88
setEnvFlags,
99
} from '@sim/testing'
1010
import { billingOutboxHandlersMock } from '@sim/testing/mocks/billing-outbox-handlers.mock'
11-
import {
12-
billingSubscriptionSyncMock,
13-
billingSubscriptionSyncMockFns,
14-
} from '@sim/testing/mocks/billing-subscription-sync.mock'
11+
import { billingSubscriptionSyncMock } from '@sim/testing/mocks/billing-subscription-sync.mock'
1512
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
1613

1714
const { mockSyncSubscriptionUsageLimits } = vi.hoisted(() => ({
@@ -30,8 +27,6 @@ vi.mock('@sim/audit', () => auditMock)
3027

3128
import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats'
3229

33-
const enqueueMock = billingSubscriptionSyncMockFns.mockEnqueueSubscriptionSeatsSync
34-
3530
const teamSub = {
3631
id: 'sub-1',
3732
plan: 'team_6000',
@@ -58,7 +53,7 @@ describe('reconcileOrganizationSeats', () => {
5853
resetDbChainMock()
5954
})
6055

61-
it('grows seats to the member count and enqueues a Stripe sync', async () => {
56+
it('grows seats to the member count', async () => {
6257
queueReconcileReads([teamSub], [{ value: 2 }])
6358

6459
const result = await reconcileOrganizationSeats({
@@ -74,11 +69,6 @@ describe('reconcileOrganizationSeats', () => {
7469
outboxEventId: 'subscription-seats-sync-event',
7570
})
7671
expect(dbChainMockFns.set).toHaveBeenCalledWith({ seats: 2 })
77-
expect(enqueueMock).toHaveBeenCalledWith(expect.anything(), {
78-
subscriptionId: 'sub-1',
79-
seats: 2,
80-
reason: 'member-accepted-invite',
81-
})
8272
expect(mockSyncSubscriptionUsageLimits).toHaveBeenCalledWith(
8373
expect.objectContaining({ id: 'sub-1', referenceId: 'org-1', seats: 2 })
8474
)
@@ -94,7 +84,6 @@ describe('reconcileOrganizationSeats', () => {
9484

9585
expect(result.changed).toBe(true)
9686
expect(dbChainMockFns.set).toHaveBeenCalledWith({ seats: 2 })
97-
expect(enqueueMock).toHaveBeenCalledOnce()
9887
})
9988

10089
it('still records the seat audit when the post-commit usage-limit sync fails', async () => {
@@ -129,7 +118,6 @@ describe('reconcileOrganizationSeats', () => {
129118
expect(result.changed).toBe(true)
130119
expect(result.seats).toBe(2)
131120
expect(dbChainMockFns.set).toHaveBeenCalledWith({ seats: 2 })
132-
expect(enqueueMock).toHaveBeenCalled()
133121
})
134122

135123
it('never drops below one seat', async () => {

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

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-events'
1414
import {
1515
type CancelAtPeriodEndSyncPayload,
1616
cancelAtPeriodEndSyncIdempotencyKey,
17+
readRecordedSyncValue,
1718
type SubscriptionSeatsSyncPayload,
1819
} from '@/lib/billing/webhooks/subscription-sync'
1920
import type { OutboxHandler } from '@/lib/core/outbox/service'
@@ -100,20 +101,32 @@ async function getSubscriptionSeatSyncState(subscriptionId: string) {
100101
return row ?? null
101102
}
102103

103-
async function readCancelAtPeriodEnd(subscriptionId: string): Promise<boolean | null> {
104+
/**
105+
* The value this sync should push: the one recorded on its own event, re-read now, which every
106+
* commit keeps current on each sync that can still run. The subscription row is not used for
107+
* this, because the Stripe plugin can overwrite it with a stale webhook payload before the
108+
* reconcile step restores it. An event enqueued before values were recorded falls back to the
109+
* row. Null when the subscription no longer exists.
110+
*/
111+
async function readDesiredCancelAtPeriodEnd(
112+
eventId: string,
113+
subscriptionId: string
114+
): Promise<boolean | null> {
104115
const [row] = await db
105116
.select({ cancelAtPeriodEnd: subscriptionTable.cancelAtPeriodEnd })
106117
.from(subscriptionTable)
107118
.where(eq(subscriptionTable.id, subscriptionId))
108119
.limit(1)
109-
return row ? Boolean(row.cancelAtPeriodEnd) : null
120+
if (!row) return null
121+
return (await readRecordedSyncValue(eventId))?.cancelAtPeriodEnd ?? Boolean(row.cancelAtPeriodEnd)
110122
}
111123

112124
/**
113-
* Pushes the row's current value, never the payload's: racing events for one subscription each
114-
* converge on the last committed value. Stripe is read first and written only when it differs,
115-
* and the row is re-read after the write so a value committed while this event's request was in
116-
* flight is pushed too, even when an earlier event's request lands in Stripe after a newer one.
125+
* Pushes the latest committed value (see `readDesiredCancelAtPeriodEnd`), never the claim-time
126+
* payload: racing events for one subscription each converge on it. Stripe is read first and
127+
* written only when it differs, and the value is re-read after the write so one committed while
128+
* this event's request was in flight is pushed too, even when an earlier event's request lands
129+
* in Stripe after a newer one.
117130
*/
118131
const stripeSyncCancelAtPeriodEnd: OutboxHandler<CancelAtPeriodEndSyncPayload> = async (
119132
payload,
@@ -123,7 +136,7 @@ const stripeSyncCancelAtPeriodEnd: OutboxHandler<CancelAtPeriodEndSyncPayload> =
123136
const stripe = requireStripeClient()
124137

125138
for (let attempt = 1; attempt <= MAX_SYNC_ATTEMPTS; attempt++) {
126-
const desiredValue = await readCancelAtPeriodEnd(payload.subscriptionId)
139+
const desiredValue = await readDesiredCancelAtPeriodEnd(ctx.eventId, payload.subscriptionId)
127140
if (desiredValue === null) {
128141
logger.warn('Subscription not found when syncing cancel_at_period_end', {
129142
eventId: ctx.eventId,
@@ -142,7 +155,7 @@ const stripeSyncCancelAtPeriodEnd: OutboxHandler<CancelAtPeriodEndSyncPayload> =
142155
)
143156
}
144157

145-
const latestValue = await readCancelAtPeriodEnd(payload.subscriptionId)
158+
const latestValue = await readDesiredCancelAtPeriodEnd(ctx.eventId, payload.subscriptionId)
146159
if (latestValue !== desiredValue) {
147160
logger.info('cancel_at_period_end changed during Stripe sync; retrying latest value', {
148161
eventId: ctx.eventId,
@@ -189,6 +202,10 @@ const stripeCancelSubscriptionImmediately: OutboxHandler<
189202
})
190203
}
191204

205+
/**
206+
* Pushes the seat count recorded on its own event, re-read each pass (falling back to the row
207+
* for an event enqueued before values were recorded), for the same reason as the cancel sync.
208+
*/
192209
const stripeSyncSubscriptionSeats: OutboxHandler<SubscriptionSeatsSyncPayload> = async (
193210
payload,
194211
ctx
@@ -231,7 +248,7 @@ const stripeSyncSubscriptionSeats: OutboxHandler<SubscriptionSeatsSyncPayload> =
231248
return
232249
}
233250

234-
const desiredSeats = row.seats || 1
251+
const desiredSeats = (await readRecordedSyncValue(ctx.eventId))?.seats ?? (row.seats || 1)
235252
const stripeSubscription = await stripe.subscriptions.retrieve(row.stripeSubscriptionId)
236253

237254
if (!hasPaidSubscriptionStatus(stripeSubscription.status)) {
@@ -281,7 +298,7 @@ const stripeSyncSubscriptionSeats: OutboxHandler<SubscriptionSeatsSyncPayload> =
281298
}
282299

283300
const latest = await getSubscriptionSeatSyncState(payload.subscriptionId)
284-
const latestSeats = latest?.seats || 1
301+
const latestSeats = (await readRecordedSyncValue(ctx.eventId))?.seats ?? (latest?.seats || 1)
285302
if (latestSeats !== desiredSeats) {
286303
logger.info('Subscription seats changed during Stripe sync; retrying latest value', {
287304
eventId: ctx.eventId,

0 commit comments

Comments
 (0)