Skip to content

Commit 4f1c786

Browse files
committed
fix(billing): a membership writer skips only when both the row and the committed value already match
1 parent fa5e995 commit 4f1c786

6 files changed

Lines changed: 76 additions & 23 deletions

File tree

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

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ import { validateSeatAvailability } from '@/lib/billing/validation/seat-manageme
5050
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-events'
5151
import {
5252
enqueueCancelAtPeriodEndSync,
53-
readCommittedCancelAtPeriodEnd,
53+
isCancelAtPeriodEndSettled,
5454
} from '@/lib/billing/webhooks/subscription-sync'
5555
import { isBillingEnabled } from '@/lib/core/config/env-flags'
5656
import { OrchestrationError } from '@/lib/core/orchestration/types'
@@ -263,12 +263,16 @@ export async function restoreUserProSubscription(userId: string): Promise<Restor
263263
.limit(1)
264264

265265
if (!personalPro?.stripeSubscriptionId) return
266-
const pausing = await readCommittedCancelAtPeriodEnd(
267-
tx,
268-
personalPro.id,
269-
Boolean(personalPro.cancelAtPeriodEnd)
270-
)
271-
if (!pausing) return
266+
if (
267+
await isCancelAtPeriodEndSettled(
268+
tx,
269+
personalPro.id,
270+
Boolean(personalPro.cancelAtPeriodEnd),
271+
false
272+
)
273+
) {
274+
return
275+
}
272276
result.subscriptionId = personalPro.id
273277

274278
const organizationMemberships = await tx
@@ -415,10 +419,11 @@ export async function pauseProSubscriptionForOrgCoverage(
415419
result.subscriptionId = personalPro.id
416420

417421
if (
418-
await readCommittedCancelAtPeriodEnd(
422+
await isCancelAtPeriodEndSettled(
419423
tx,
420424
personalPro.id,
421-
Boolean(personalPro.cancelAtPeriodEnd)
425+
Boolean(personalPro.cancelAtPeriodEnd),
426+
true
422427
)
423428
) {
424429
return
@@ -874,10 +879,11 @@ async function applyPaidOrgJoinBillingTx(
874879

875880
const alreadyPausing =
876881
personalPro &&
877-
(await readCommittedCancelAtPeriodEnd(
882+
(await isCancelAtPeriodEndSettled(
878883
tx,
879884
personalPro.id,
880-
Boolean(personalPro.cancelAtPeriodEnd)
885+
Boolean(personalPro.cancelAtPeriodEnd),
886+
true
881887
))
882888
if (personalPro && !alreadyPausing) {
883889
await tx

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils'
1919
import {
2020
enqueueCancelAtPeriodEndSync,
2121
enqueueSubscriptionSeatsSync,
22-
readCommittedCancelAtPeriodEnd,
22+
isCancelAtPeriodEndSettled,
2323
readCommittedSeats,
2424
recordCancelAtPeriodEnd,
2525
} from '@/lib/billing/webhooks/subscription-sync'
@@ -275,7 +275,7 @@ async function activateTeamSubscription(
275275
}
276276

277277
if (!locked?.stripeSubscriptionId) return
278-
if (await readCommittedCancelAtPeriodEnd(tx, sub.id, Boolean(locked.cancelAtPeriodEnd))) {
278+
if (!(await isCancelAtPeriodEndSettled(tx, sub.id, Boolean(locked.cancelAtPeriodEnd), false))) {
279279
await enqueueCancelAtPeriodEndSync(tx, {
280280
stripeSubscriptionId: locked.stripeSubscriptionId,
281281
subscriptionId: sub.id,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ export async function reconcileOrganizationSeats({
109109
orgSubscription.seats ?? 1
110110
)
111111

112-
if (targetSeats === currentSeats) {
112+
if (targetSeats === currentSeats && targetSeats === (orgSubscription.seats ?? 1)) {
113113
return { kind: 'noop', seats: currentSeats }
114114
}
115115

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -853,6 +853,33 @@ describe('cancel_at_period_end sync', () => {
853853
expect(stripe.subscription(pro.stripeSubscriptionId).cancel_at_period_end).toBe(true)
854854
})
855855

856+
it('keeps a pause committed after the reconcile read a customer restore from Stripe', async () => {
857+
const pro = await createProUserInPaidOrganization()
858+
await pauseProSubscriptionForOrgCoverage(pro.userId)
859+
const pauseSync = await latestOutboxEventId(
860+
OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END,
861+
pro.subscriptionId
862+
)
863+
stripe.failNextUpdateAfterApplying('subscriptions')
864+
await expect(processEvent(pauseSync)).resolves.toBe('pending')
865+
866+
stripe.updateOutsideSim(pro.stripeSubscriptionId, { cancel_at_period_end: false })
867+
const liveRead = stripe.holdNextRequest('subscriptions.retrieve')
868+
const reconcilingRestore = deliver(stripe.events.at(-1) as Stripe.Event)
869+
await liveRead.reached
870+
await pauseProSubscriptionForOrgCoverage(pro.userId)
871+
liveRead.release()
872+
await reconcilingRestore
873+
874+
expect((await storedSubscription(pro.subscriptionId)).cancelAtPeriodEnd).toBe(true)
875+
const latestSync = await latestOutboxEventId(
876+
OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END,
877+
pro.subscriptionId
878+
)
879+
await expect(processEvent(latestSync)).resolves.toBe('completed')
880+
expect(stripe.subscription(pro.stripeSubscriptionId).cancel_at_period_end).toBe(true)
881+
})
882+
856883
it('restores the personal Pro when its member leaves while the plugin has overwritten the row', async () => {
857884
const pro = await createProUserInPaidOrganization()
858885
await pauseProSubscriptionForOrgCoverage(pro.userId)

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

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ export async function readRecordedSyncValue(
310310
* plugin's stale webhook payload, so a writer deciding whether a change is needed compares
311311
* against this, never the row alone. The caller holds the subscription row lock.
312312
*/
313-
export async function readCommittedCancelAtPeriodEnd(
313+
async function readCommittedCancelAtPeriodEnd(
314314
tx: DbOrTx,
315315
subscriptionId: string,
316316
stored: boolean
@@ -319,6 +319,24 @@ export async function readCommittedCancelAtPeriodEnd(
319319
return intent.status === 'value' ? intent.value : stored
320320
}
321321

322+
/**
323+
* True when both the row and the latest committed `cancelAtPeriodEnd` already hold `desired`, so
324+
* a writer has nothing to record. A writer that sees either one differ writes the row and
325+
* commits: a redundant sync of the same value is harmless, while skipping on the committed value
326+
* alone could let an older Stripe read, accepted after this writer, override it.
327+
*/
328+
export async function isCancelAtPeriodEndSettled(
329+
tx: DbOrTx,
330+
subscriptionId: string,
331+
stored: boolean,
332+
desired: boolean
333+
): Promise<boolean> {
334+
return (
335+
stored === desired &&
336+
(await readCommittedCancelAtPeriodEnd(tx, subscriptionId, stored)) === desired
337+
)
338+
}
339+
322340
/** The seat-count counterpart of {@link readCommittedCancelAtPeriodEnd}. */
323341
export async function readCommittedSeats(
324342
tx: DbOrTx,
@@ -363,9 +381,10 @@ function latestIntent<T>(
363381
}
364382

365383
/**
366-
* One indexed read of the subscription's in-flight syncs. Dead letters are not intents: they are
367-
* failed syncs awaiting an operator, kept current by every commit so a retry pushes the latest
368-
* value, but never a reason to override Stripe.
384+
* One read of the subscription's in-flight syncs, through the status index and bounded by the
385+
* in-flight backlog. Dead letters are not intents: they are failed syncs awaiting an operator,
386+
* kept current by every commit so a retry pushes the latest value, but never a reason to override
387+
* Stripe.
369388
*/
370389
async function readSyncIntents(executor: DbOrTx, subscriptionId: string) {
371390
const events = await listInflightOutboxEvents(

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { vi } from 'vitest'
33
/**
44
* Controllable mock functions for `@/lib/billing/webhooks/subscription-sync`. The enqueue
55
* functions resolve to a fixed event id; drive them with `mockResolvedValueOnce`.
6-
* `mockIsSubscriptionSyncEventType` keeps the real logic; the `mockReadCommitted*` readers
7-
* return the stored value they are given, as when nothing is in flight.
6+
* `mockIsSubscriptionSyncEventType` keeps the real logic; `mockReadCommittedSeats` and
7+
* `mockIsCancelAtPeriodEndSettled` answer from the stored value, as when nothing is in flight.
88
*
99
* @example
1010
* ```ts
@@ -33,8 +33,9 @@ export const billingSubscriptionSyncMockFns = {
3333
mockReconcileSubscriptionSyncFromStripe: vi.fn(async () => undefined),
3434
mockRecordCustomerRestoreAfterHook: vi.fn(async () => undefined),
3535
mockReadRecordedSyncValue: vi.fn(async () => undefined),
36-
mockReadCommittedCancelAtPeriodEnd: vi.fn(
37-
async (_tx: unknown, _subscriptionId: string, stored: boolean) => stored
36+
mockIsCancelAtPeriodEndSettled: vi.fn(
37+
async (_tx: unknown, _subscriptionId: string, stored: boolean, desired: boolean) =>
38+
stored === desired
3839
),
3940
mockReadCommittedSeats: vi.fn(
4041
async (_tx: unknown, _subscriptionId: string, stored: number) => stored
@@ -62,6 +63,6 @@ export const billingSubscriptionSyncMock = {
6263
billingSubscriptionSyncMockFns.mockReconcileSubscriptionSyncFromStripe,
6364
recordCustomerRestoreAfterHook: billingSubscriptionSyncMockFns.mockRecordCustomerRestoreAfterHook,
6465
readRecordedSyncValue: billingSubscriptionSyncMockFns.mockReadRecordedSyncValue,
65-
readCommittedCancelAtPeriodEnd: billingSubscriptionSyncMockFns.mockReadCommittedCancelAtPeriodEnd,
66+
isCancelAtPeriodEndSettled: billingSubscriptionSyncMockFns.mockIsCancelAtPeriodEndSettled,
6667
readCommittedSeats: billingSubscriptionSyncMockFns.mockReadCommittedSeats,
6768
}

0 commit comments

Comments
 (0)