Skip to content

Commit 37eb50d

Browse files
committed
fix(billing): compute org coverage independently of the personal-sub lookup and fail closed on unverifiable plan writes
1 parent e9ac1db commit 37eb50d

4 files changed

Lines changed: 85 additions & 23 deletions

File tree

apps/sim/lib/auth/stripe-adapter-guard.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,37 @@ describe('guardSubscriptionPlanWrites', () => {
6868
expect(base.update).not.toHaveBeenCalled()
6969
})
7070

71+
it('strips the plan when the pre-update lookup finds no row (fail closed)', async () => {
72+
const base = createBaseAdapter()
73+
base.findOne.mockResolvedValueOnce(null)
74+
75+
const guarded = asAdapter(base)
76+
await guarded.update({
77+
model: 'subscription',
78+
where: WHERE as never,
79+
update: { plan: 'pro_6000', status: 'active' },
80+
})
81+
82+
expect(base.update).toHaveBeenCalledWith(
83+
expect.objectContaining({ update: { status: 'active' } })
84+
)
85+
})
86+
87+
it('writes nothing when a plan-only update targets no readable row', async () => {
88+
const base = createBaseAdapter()
89+
base.findOne.mockResolvedValueOnce(null)
90+
91+
const guarded = asAdapter(base)
92+
const result = await guarded.update({
93+
model: 'subscription',
94+
where: WHERE as never,
95+
update: { plan: 'pro_6000' },
96+
})
97+
98+
expect(result).toBeNull()
99+
expect(base.update).not.toHaveBeenCalled()
100+
})
101+
71102
it('passes through non-org plan updates for user-referenced rows', async () => {
72103
const base = createBaseAdapter()
73104
base.findOne.mockResolvedValueOnce({ id: 'sub-2', referenceId: 'user-1', plan: 'pro_6000' })

apps/sim/lib/auth/stripe-adapter-guard.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,26 @@ interface SanitizedSubscriptionUpdate {
139139
* periods, seats, cancellation state) are legitimate Stripe state and still
140140
* sync. Evaluating every targeted row keeps multi-row updates safe: a mixed
141141
* personal/org target set must not leak the invalid plan onto the org rows.
142+
*
143+
* Fails closed on an empty target set: a plan we could not verify against a
144+
* readable row never forwards — the row may be mid-creation by a concurrent
145+
* webhook delivery, and in the sequential no-row case the write was a no-op
146+
* anyway. A legitimate user-referenced plan dropped this way re-syncs via
147+
* `syncSubscriptionPlan` in the same webhook callback.
142148
*/
143149
async function stripPlanWhenOrgReferenced(
144150
rows: SubscriptionRowSlice[],
145151
update: Record<string, unknown>
146152
): Promise<SanitizedSubscriptionUpdate> {
153+
if (rows.length === 0) {
154+
logger.warn(
155+
'Subscription plan write targeted no readable row; stripping the unverifiable plan',
156+
{ rejectedPlan: update.plan }
157+
)
158+
const { plan: _plan, limits: _limits, ...rest } = update
159+
return { update: rest, blockedAll: Object.keys(rest).length === 0 }
160+
}
161+
147162
let organizationRow: SubscriptionRowSlice | null = null
148163
for (const row of rows) {
149164
if (await isOrganizationReference(row.referenceId)) {

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

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -357,21 +357,13 @@ export async function pauseProSubscriptionForOrgCoverage(
357357
await db.transaction(async (tx) => {
358358
await acquireUserBillingIdentityLock(tx, userId)
359359

360-
const [personalPro] = await tx
361-
.select()
362-
.from(subscriptionTable)
363-
.where(
364-
and(
365-
eq(subscriptionTable.referenceId, userId),
366-
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES),
367-
sqlIsPro(subscriptionTable.plan)
368-
)
369-
)
370-
.for('update')
371-
.limit(1)
372-
373-
if (!personalPro) return
374-
360+
/**
361+
* Coverage is determined before (and independent of) the personal-sub
362+
* lookup: `covered` reports the organization's coverage truth even when
363+
* no entitled personal Pro row exists, exactly as the result contract
364+
* promises. Callers gate free→paid transition handling on it, so a
365+
* personal-sub lookup miss must not read as "not covered".
366+
*/
375367
const memberships = await tx
376368
.select({ organizationId: member.organizationId })
377369
.from(member)
@@ -396,9 +388,25 @@ export async function pauseProSubscriptionForOrgCoverage(
396388
if (!coveringSubscription) return
397389

398390
result.covered = true
399-
result.subscriptionId = personalPro.id
400391
result.organizationId = coveringSubscription.referenceId
401392

393+
const [personalPro] = await tx
394+
.select()
395+
.from(subscriptionTable)
396+
.where(
397+
and(
398+
eq(subscriptionTable.referenceId, userId),
399+
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES),
400+
sqlIsPro(subscriptionTable.plan)
401+
)
402+
)
403+
.for('update')
404+
.limit(1)
405+
406+
if (!personalPro) return
407+
408+
result.subscriptionId = personalPro.id
409+
402410
if (personalPro.cancelAtPeriodEnd) return
403411

404412
await tx

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

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,9 @@ describe('pauseProSubscriptionForOrgCoverage', () => {
6060

6161
it('pauses the personal Pro and queues the Stripe sync when an entitled paid org covers the user', async () => {
6262
queueWhereResponses([
63-
[ACTIVE_PERSONAL_PRO],
6463
[{ organizationId: 'org-1' }],
6564
[{ plan: 'team_6000', referenceId: 'org-1' }],
65+
[ACTIVE_PERSONAL_PRO],
6666
// update ... set ... where consumes one more where() call
6767
[],
6868
])
@@ -87,21 +87,29 @@ describe('pauseProSubscriptionForOrgCoverage', () => {
8787
)
8888
})
8989

90-
it('reports not covered when the user has no entitled personal Pro', async () => {
91-
queueWhereResponses([[]])
90+
it('reports covered even when no entitled personal Pro row exists', async () => {
91+
queueWhereResponses([
92+
[{ organizationId: 'org-1' }],
93+
[{ plan: 'team_6000', referenceId: 'org-1' }],
94+
[],
95+
])
9296

9397
const result = await pauseProSubscriptionForOrgCoverage('user-1')
9498

95-
expect(result).toEqual({ covered: false, paused: false })
99+
expect(result).toEqual({
100+
covered: true,
101+
paused: false,
102+
organizationId: 'org-1',
103+
})
96104
expect(dbChainMockFns.update).not.toHaveBeenCalled()
97105
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
98106
})
99107

100108
it('reports covered without pausing again when the personal Pro is already pausing', async () => {
101109
queueWhereResponses([
102-
[{ ...ACTIVE_PERSONAL_PRO, cancelAtPeriodEnd: true }],
103110
[{ organizationId: 'org-1' }],
104111
[{ plan: 'team_6000', referenceId: 'org-1' }],
112+
[{ ...ACTIVE_PERSONAL_PRO, cancelAtPeriodEnd: true }],
105113
])
106114

107115
const result = await pauseProSubscriptionForOrgCoverage('user-1')
@@ -117,7 +125,7 @@ describe('pauseProSubscriptionForOrgCoverage', () => {
117125
})
118126

119127
it('reports not covered when the user is not a member of any organization', async () => {
120-
queueWhereResponses([[ACTIVE_PERSONAL_PRO], []])
128+
queueWhereResponses([[]])
121129

122130
const result = await pauseProSubscriptionForOrgCoverage('user-1')
123131

@@ -126,7 +134,7 @@ describe('pauseProSubscriptionForOrgCoverage', () => {
126134
})
127135

128136
it('reports not covered when no org subscription is an entitled paid plan', async () => {
129-
queueWhereResponses([[ACTIVE_PERSONAL_PRO], [{ organizationId: 'org-1' }], []])
137+
queueWhereResponses([[{ organizationId: 'org-1' }], []])
130138

131139
const result = await pauseProSubscriptionForOrgCoverage('user-1')
132140

0 commit comments

Comments
 (0)