Skip to content

Commit f2a6052

Browse files
committed
fix(billing): converge Stripe cancel/contact syncs and guard webhook echoes over unsynced DB changes
1 parent 9ab24e9 commit f2a6052

18 files changed

Lines changed: 1272 additions & 156 deletions

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,7 @@ import {
3333
} from '@/lib/api/contracts/v1/admin'
3434
import { parseRequest } from '@/lib/api/server'
3535
import { requireStripeClient } from '@/lib/billing/stripe-client'
36-
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-events'
37-
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
36+
import { enqueueCancelAtPeriodEndSync } from '@/lib/billing/webhooks/subscription-sync'
3837
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
3938
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
4039
import {
@@ -113,15 +112,17 @@ export const DELETE = withRouteHandler(
113112
}
114113

115114
if (atPeriodEnd) {
115+
const stripeSubscriptionId = existing.stripeSubscriptionId
116116
await db.transaction(async (tx) => {
117117
await tx
118118
.update(subscription)
119119
.set({ cancelAtPeriodEnd: true })
120120
.where(eq(subscription.id, subscriptionId))
121121

122-
await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END, {
123-
stripeSubscriptionId: existing.stripeSubscriptionId,
122+
await enqueueCancelAtPeriodEndSync(tx, {
123+
stripeSubscriptionId,
124124
subscriptionId: existing.id,
125+
cancelAtPeriodEnd: true,
125126
reason: reason ?? 'admin-cancel-at-period-end',
126127
})
127128
})

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ 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'
59
import { organizationMembershipMock } from '@sim/testing/mocks/organization-membership.mock'
610
import { outboxServiceMock, outboxServiceMockFns } from '@sim/testing/mocks/outbox-service.mock'
711
import { stripeClientMock } from '@sim/testing/mocks/stripe.mock'
@@ -24,6 +28,7 @@ vi.mock('@/lib/billing/organizations/membership', () => organizationMembershipMo
2428
vi.mock('@/lib/billing/stripe-client', () => stripeClientMock)
2529
vi.mock('@/lib/billing/webhooks/outbox-handlers', () => billingOutboxHandlersMock)
2630
vi.mock('@/lib/core/outbox/service', () => outboxServiceMock)
31+
vi.mock('@/lib/billing/webhooks/subscription-sync', () => billingSubscriptionSyncMock)
2732

2833
import {
2934
refundDashboardSubscriptionPayment,
@@ -119,6 +124,11 @@ describe('admin subscription cancellation', () => {
119124
expect.objectContaining({ status: 'pending', attempts: 0, lastError: null })
120125
)
121126
expect(dbChainMockFns.set).toHaveBeenCalledWith({ cancelAtPeriodEnd: true })
127+
expect(billingSubscriptionSyncMockFns.mockRecommitCancelAtPeriodEndSync).toHaveBeenCalledWith(
128+
expect.anything(),
129+
'outbox-1',
130+
true
131+
)
122132
expect(result).toMatchObject({
123133
operationId: '67e55044-10b1-426f-9247-bb680e5fe0c8',
124134
status: 'pending',

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

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/mem
88
import { requireStripeClient } from '@/lib/billing/stripe-client'
99
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
1010
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-events'
11+
import {
12+
enqueueCancelAtPeriodEndSync,
13+
recommitCancelAtPeriodEndSync,
14+
} from '@/lib/billing/webhooks/subscription-sync'
1115
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
1216

1317
const RECENT_INVOICE_LIMIT = 12
@@ -301,6 +305,7 @@ export async function requestDashboardSubscriptionCancellation({
301305
if (!restoredSubscription) {
302306
throw new Error('Cancellation subscription no longer exists')
303307
}
308+
await recommitCancelAtPeriodEndSync(tx, existingOperation.id, true)
304309
}
305310
await tx
306311
.update(outboxEvent)
@@ -384,18 +389,15 @@ export async function requestDashboardSubscriptionCancellation({
384389
.set({ cancelAtPeriodEnd: true })
385390
.where(eq(subscription.id, subscriptionRow.id))
386391
}
387-
const eventId = await enqueueOutboxEvent(
388-
tx,
389-
OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END,
390-
{
391-
operationId,
392-
organizationId,
393-
subscriptionId: subscriptionRow.id,
394-
stripeSubscriptionId: subscriptionRow.stripeSubscriptionId,
395-
reason: normalizedReason,
396-
requestedBy: actor,
397-
}
398-
)
392+
const eventId = await enqueueCancelAtPeriodEndSync(tx, {
393+
operationId,
394+
organizationId,
395+
subscriptionId: subscriptionRow.id,
396+
stripeSubscriptionId: subscriptionRow.stripeSubscriptionId,
397+
cancelAtPeriodEnd: true,
398+
reason: normalizedReason,
399+
requestedBy: actor,
400+
})
399401
return {
400402
operationId,
401403
outboxEventId: eventId,

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ import {
103103
handleSubscriptionCreated,
104104
handleSubscriptionDeleted,
105105
} from '@/lib/billing/webhooks/subscription'
106+
import { reconcileSubscriptionSyncFromStripe } from '@/lib/billing/webhooks/subscription-sync'
106107
import { handleSubscriptionUsageUpdate } from '@/lib/billing/webhooks/subscription-usage'
107108
import { env } from '@/lib/core/config/env'
108109
import {
@@ -1727,6 +1728,7 @@ export const auth = betterAuth({
17271728
case 'customer.subscription.created':
17281729
case 'customer.subscription.updated': {
17291730
await handleManualEnterpriseSubscription(event)
1731+
await reconcileSubscriptionSyncFromStripe(event)
17301732
await handleSubscriptionUsageUpdate(event)
17311733
break
17321734
}

‎apps/sim/lib/billing/organizations/lock-order.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
workspace,
1818
} from '@sim/db/schema'
1919
import { dbChainMockFns, resetDbChainMock } from '@sim/testing'
20+
import { billingSubscriptionSyncMock } from '@sim/testing/mocks/billing-subscription-sync.mock'
2021
import { outboxServiceMock } from '@sim/testing/mocks/outbox-service.mock'
2122
import { beforeEach, describe, expect, it, vi } from 'vitest'
2223

@@ -42,6 +43,7 @@ import type { DbOrTx } from '@/lib/db/types'
4243
import { attachOwnedWorkspacesToOrganizationTx } from '@/lib/workspaces/organization-workspaces'
4344

4445
vi.mock('@/lib/core/outbox/service', () => outboxServiceMock)
46+
vi.mock('@/lib/billing/webhooks/subscription-sync', () => billingSubscriptionSyncMock)
4547

4648
/**
4749
* A superset row that satisfies every read in the join path: a paid org sub, a

‎apps/sim/lib/billing/organizations/membership.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
import { toDecimal, toNumber } from '@/lib/billing/utils/decimal'
4949
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
5050
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-events'
51+
import { enqueueCancelAtPeriodEndSync } from '@/lib/billing/webhooks/subscription-sync'
5152
import { isBillingEnabled } from '@/lib/core/config/env-flags'
5253
import { OrchestrationError } from '@/lib/core/orchestration/types'
5354
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
@@ -288,9 +289,10 @@ export async function restoreUserProSubscription(userId: string): Promise<Restor
288289
.set({ cancelAtPeriodEnd: false })
289290
.where(eq(subscriptionTable.id, personalPro.id))
290291

291-
await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END, {
292+
await enqueueCancelAtPeriodEndSync(tx, {
292293
stripeSubscriptionId: personalPro.stripeSubscriptionId,
293294
subscriptionId: personalPro.id,
295+
cancelAtPeriodEnd: false,
294296
reason: 'member-left-paid-org',
295297
})
296298

@@ -411,9 +413,10 @@ export async function pauseProSubscriptionForOrgCoverage(
411413
.where(eq(subscriptionTable.id, personalPro.id))
412414

413415
if (personalPro.stripeSubscriptionId) {
414-
await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END, {
416+
await enqueueCancelAtPeriodEndSync(tx, {
415417
stripeSubscriptionId: personalPro.stripeSubscriptionId,
416418
subscriptionId: personalPro.id,
419+
cancelAtPeriodEnd: true,
417420
reason: 'covered-by-organization',
418421
})
419422
}
@@ -859,9 +862,10 @@ async function applyPaidOrgJoinBillingTx(
859862
.where(eq(subscriptionTable.id, personalPro.id))
860863

861864
if (personalPro.stripeSubscriptionId) {
862-
await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END, {
865+
await enqueueCancelAtPeriodEndSync(tx, {
863866
stripeSubscriptionId: personalPro.stripeSubscriptionId,
864867
subscriptionId: personalPro.id,
868+
cancelAtPeriodEnd: true,
865869
reason: 'joined-paid-org',
866870
...(options.sourceOperationId ? { sourceOperationId: options.sourceOperationId } : {}),
867871
})

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

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

59
vi.mock('@/lib/billing/storage/payer-transfer', () => ({
@@ -8,11 +12,12 @@ vi.mock('@/lib/billing/storage/payer-transfer', () => ({
812
changeWorkspaceStoragePayersInTx: vi.fn(),
913
}))
1014
vi.mock('@/lib/core/outbox/service', () => outboxServiceMock)
15+
vi.mock('@/lib/billing/webhooks/subscription-sync', () => billingSubscriptionSyncMock)
1116

1217
import { pauseProSubscriptionForOrgCoverage } from '@/lib/billing/organizations/membership'
13-
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-events'
1418

15-
const mockEnqueueOutboxEvent = outboxServiceMockFns.mockEnqueueOutboxEvent
19+
const mockEnqueueCancelAtPeriodEndSync =
20+
billingSubscriptionSyncMockFns.mockEnqueueCancelAtPeriodEndSync
1621

1722
const ACTIVE_PERSONAL_PRO = {
1823
id: 'sub-personal',
@@ -68,15 +73,12 @@ describe('pauseProSubscriptionForOrgCoverage', () => {
6873
organizationId: 'org-1',
6974
})
7075
expect(dbChainMockFns.set).toHaveBeenCalledWith({ cancelAtPeriodEnd: true })
71-
expect(mockEnqueueOutboxEvent).toHaveBeenCalledWith(
72-
expect.anything(),
73-
OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END,
74-
{
75-
stripeSubscriptionId: 'stripe-sub-personal',
76-
subscriptionId: 'sub-personal',
77-
reason: 'covered-by-organization',
78-
}
79-
)
76+
expect(mockEnqueueCancelAtPeriodEndSync).toHaveBeenCalledWith(expect.anything(), {
77+
stripeSubscriptionId: 'stripe-sub-personal',
78+
subscriptionId: 'sub-personal',
79+
cancelAtPeriodEnd: true,
80+
reason: 'covered-by-organization',
81+
})
8082
})
8183

8284
it('reports covered even when no entitled personal Pro row exists', async () => {
@@ -94,7 +96,7 @@ describe('pauseProSubscriptionForOrgCoverage', () => {
9496
organizationId: 'org-1',
9597
})
9698
expect(dbChainMockFns.update).not.toHaveBeenCalled()
97-
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
99+
expect(mockEnqueueCancelAtPeriodEndSync).not.toHaveBeenCalled()
98100
})
99101

100102
it('reports covered without pausing again when the personal Pro is already pausing', async () => {
@@ -113,6 +115,6 @@ describe('pauseProSubscriptionForOrgCoverage', () => {
113115
organizationId: 'org-1',
114116
})
115117
expect(dbChainMockFns.update).not.toHaveBeenCalled()
116-
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
118+
expect(mockEnqueueCancelAtPeriodEndSync).not.toHaveBeenCalled()
117119
})
118120
})

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

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
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'
48
import { dbChainMockFns, resetDbChainMock } from '@sim/testing/mocks/database.mock'
59
import {
610
organizationMembershipMock,
711
organizationMembershipMockFns,
812
} from '@sim/testing/mocks/organization-membership.mock'
9-
import { outboxServiceMock, outboxServiceMockFns } from '@sim/testing/mocks/outbox-service.mock'
13+
import { outboxServiceMock } from '@sim/testing/mocks/outbox-service.mock'
1014
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
1115

1216
const {
@@ -41,6 +45,8 @@ vi.mock('@/lib/billing/plans', () => ({
4145

4246
vi.mock('@/lib/core/outbox/service', () => outboxServiceMock)
4347

48+
vi.mock('@/lib/billing/webhooks/subscription-sync', () => billingSubscriptionSyncMock)
49+
4450
vi.mock('@/lib/billing/webhooks/outbox-handlers', () => billingOutboxHandlersMock)
4551

4652
import { ensureTeamOrganizationForAcceptance } from '@/lib/billing/organizations/provision-seat'
@@ -49,15 +55,21 @@ const { mockAcquireOrganizationMutationLock } = organizationMembershipMockFns
4955
const mockGetOrganizationSubscription = billingCoreMockFns.mockGetOrganizationSubscription
5056
const mockGetHighestPriorityPersonalSubscription =
5157
billingPlanMockFns.mockGetHighestPriorityPersonalSubscription
52-
const enqueueMock = outboxServiceMockFns.mockEnqueueOutboxEvent
58+
const { mockEnqueueSubscriptionSeatsSync, mockEnqueueCancelAtPeriodEndSync } =
59+
billingSubscriptionSyncMockFns
5360

5461
function testExecutor(onUpdate: () => void = () => {}) {
5562
return {
5663
update: () => ({
5764
set: (values: Record<string, unknown>) => {
5865
onUpdate()
5966
updateCalls.value.push(values)
60-
return { where: () => Promise.resolve([]) }
67+
return {
68+
where: () =>
69+
Object.assign(Promise.resolve([]), {
70+
returning: () => Promise.resolve([{ seats: 1 }]),
71+
}),
72+
}
6173
},
6274
}),
6375
} as never
@@ -113,10 +125,9 @@ describe('ensureTeamOrganizationForAcceptance', () => {
113125
})
114126
expect(updateCalls.value).toContainEqual(expect.objectContaining({ plan: 'team_6000' }))
115127
// The Pro→Team price migration is durably enqueued at conversion time.
116-
expect(enqueueMock).toHaveBeenCalledWith(
128+
expect(mockEnqueueSubscriptionSeatsSync).toHaveBeenCalledWith(
117129
executor,
118-
'stripe.sync-subscription-seats',
119-
expect.objectContaining({ subscriptionId: 'sub-pro' })
130+
expect.objectContaining({ subscriptionId: 'sub-pro', seats: 1 })
120131
)
121132
expect(mockGetOrganizationSubscription).toHaveBeenCalledWith(
122133
'org-1',
@@ -203,19 +214,14 @@ describe('ensureTeamOrganizationForAcceptance', () => {
203214
expect.objectContaining({ plan: 'team_6000', referenceId: 'owner-1' })
204215
)
205216
// The plan change enqueues the price seat-sync...
206-
expect(enqueueMock).toHaveBeenCalledWith(
217+
expect(mockEnqueueSubscriptionSeatsSync).toHaveBeenCalledWith(
207218
executor,
208-
'stripe.sync-subscription-seats',
209-
expect.objectContaining({ subscriptionId: 'sub-pro' })
219+
expect.objectContaining({ subscriptionId: 'sub-pro', seats: 1 })
210220
)
211221
expect(dbChainMockFns.transaction).not.toHaveBeenCalled()
212222
expect(lockOrder).toEqual(['organization', 'subscription'])
213223
// ...but with no scheduled cancellation there is no cancel-sync event.
214-
expect(enqueueMock).not.toHaveBeenCalledWith(
215-
expect.anything(),
216-
'stripe.sync-cancel-at-period-end',
217-
expect.anything()
218-
)
224+
expect(mockEnqueueCancelAtPeriodEndSync).not.toHaveBeenCalled()
219225
})
220226

221227
it('blocks personal Pro conversion when the reused organization has unresolved Enterprise', async () => {
@@ -243,7 +249,8 @@ describe('ensureTeamOrganizationForAcceptance', () => {
243249
})
244250
).rejects.toThrow('Enterprise issuance is unfinished')
245251
expect(updateCalls.value).toHaveLength(0)
246-
expect(enqueueMock).not.toHaveBeenCalled()
252+
expect(mockEnqueueSubscriptionSeatsSync).not.toHaveBeenCalled()
253+
expect(mockEnqueueCancelAtPeriodEndSync).not.toHaveBeenCalled()
247254
})
248255

249256
it('provisions an org for a legacy personal-scoped Team subscription without a plan change', async () => {
@@ -271,7 +278,8 @@ describe('ensureTeamOrganizationForAcceptance', () => {
271278
expect.objectContaining({ plan: 'team', referenceId: 'owner-1' })
272279
)
273280
// No plan change and no scheduled cancellation: nothing to push to Stripe.
274-
expect(enqueueMock).not.toHaveBeenCalled()
281+
expect(mockEnqueueSubscriptionSeatsSync).not.toHaveBeenCalled()
282+
expect(mockEnqueueCancelAtPeriodEndSync).not.toHaveBeenCalled()
275283
})
276284

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

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

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ import {
1616
} from '@/lib/billing/plan-helpers'
1717
import { getPlanByName } from '@/lib/billing/plans'
1818
import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils'
19-
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-events'
20-
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
19+
import {
20+
enqueueCancelAtPeriodEndSync,
21+
enqueueSubscriptionSeatsSync,
22+
} from '@/lib/billing/webhooks/subscription-sync'
2123
import type { DbOrTx, DbTransaction } from '@/lib/db/types'
2224

2325
const logger = createLogger('ProvisionSeat')
@@ -248,22 +250,25 @@ async function activateTeamSubscription(
248250
Boolean(sub.cancelAtPeriodEnd) && Boolean(sub.stripeSubscriptionId)
249251

250252
const apply = async (tx: DbOrTx) => {
251-
await tx
253+
const [activated] = await tx
252254
.update(subscriptionTable)
253255
.set({ plan: targetPlan, cancelAtPeriodEnd: false })
254256
.where(eq(subscriptionTable.id, sub.id))
257+
.returning({ seats: subscriptionTable.seats })
255258

256259
if (planChanged) {
257-
await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_SUBSCRIPTION_SEATS, {
260+
await enqueueSubscriptionSeatsSync(tx, {
258261
subscriptionId: sub.id,
262+
seats: activated?.seats ?? 1,
259263
reason: 'pro-to-team-conversion',
260264
})
261265
}
262266

263267
if (shouldClearCancellation) {
264-
await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END, {
268+
await enqueueCancelAtPeriodEndSync(tx, {
265269
stripeSubscriptionId: sub.stripeSubscriptionId as string,
266270
subscriptionId: sub.id,
271+
cancelAtPeriodEnd: false,
267272
reason: 'pro-to-team-conversion',
268273
})
269274
}

0 commit comments

Comments
 (0)