Skip to content

Commit 325ab96

Browse files
authored
fix(billing): acknowledge Stripe webhooks whose subscription references a missing organization (#8809)
* fix(billing): acknowledge Stripe webhooks whose subscription references a missing organization instead of retrying for days * test(billing): prove the not-yet-matching issuance branch by redelivery; drop the logging-only update catch * test(testing): a swapped price in the Stripe fake no longer reports the previous amount
1 parent 96273a5 commit 325ab96

7 files changed

Lines changed: 495 additions & 17 deletions

File tree

‎apps/sim/lib/billing/organization.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ describe('ensureOrganizationForTeamSubscription', () => {
113113
it('transfers a user-referenced team subscription onto the org the user administers', async () => {
114114
mockIsSubscriptionOrgScoped.mockResolvedValueOnce(false)
115115
queueWhereResponses([
116+
// referenced user lookup
117+
[{ name: 'Owner', email: 'owner@example.com' }],
116118
// membership lookup: user owns an org
117119
[{ id: 'member-1', organizationId: 'org-owned', role: 'owner' }],
118120
// locked membership re-read inside the transfer transaction

‎apps/sim/lib/billing/organization.ts‎

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { acquireUserBillingIdentityLock } from '@/lib/billing/organizations/bill
1212
import { createOrganizationWithOwner } from '@/lib/billing/organizations/create-organization'
1313
import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership'
1414
import { isEnterprise, isOrgPlan, isPaid } from '@/lib/billing/plan-helpers'
15+
import { SubscriptionReferenceNotFoundError } from '@/lib/billing/subscriptions/errors'
1516
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
1617
import { toDecimal } from '@/lib/billing/utils/decimal'
1718
import type { DbTransaction } from '@/lib/db/types'
@@ -127,8 +128,21 @@ export async function ensureOrganizationForTeamSubscription(
127128
* checks under the org mutation lock) or creates a new organization. This
128129
* keeps re-homing deterministic in the webhook flow instead of depending on
129130
* a client-side transfer call after checkout.
131+
*
132+
* A reference that is neither an organization nor a user (an organization
133+
* deleted under a live subscription) has no one to re-home onto, so it
134+
* fails as a dangling reference instead of seeding an organization owned
135+
* by a nonexistent user.
130136
*/
131137
const userId = subscription.referenceId
138+
const [userData] = await db
139+
.select({ name: user.name, email: user.email })
140+
.from(user)
141+
.where(eq(user.id, userId))
142+
.limit(1)
143+
if (!userData) {
144+
throw new SubscriptionReferenceNotFoundError(subscription.referenceId)
145+
}
132146

133147
logger.info('Creating organization for team subscription', {
134148
subscriptionId: subscription.id,
@@ -267,16 +281,10 @@ export async function ensureOrganizationForTeamSubscription(
267281
throw new Error('User is already member of another organization')
268282
}
269283

270-
const [userData] = await db
271-
.select({ name: user.name, email: user.email })
272-
.from(user)
273-
.where(eq(user.id, userId))
274-
.limit(1)
275-
276284
const orgId = await createOrganizationForTeamPlan(
277285
userId,
278-
userData?.name || undefined,
279-
userData?.email || undefined
286+
userData.name || undefined,
287+
userData.email || undefined
280288
)
281289

282290
await db.transaction(async (tx) => {
@@ -509,9 +517,7 @@ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData
509517
.limit(1)
510518

511519
if (users.length === 0) {
512-
throw new Error(
513-
`Subscription reference ${subscription.referenceId} does not match a user or organization`
514-
)
520+
throw new SubscriptionReferenceNotFoundError(subscription.referenceId)
515521
}
516522

517523
await syncUsageLimitsFromSubscription(subscription.referenceId)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* A subscription whose `referenceId` matches neither an organization nor a user — typically an
3+
* organization deleted while its Stripe subscription stayed live. The condition is permanent: no
4+
* retry can resolve the reference, so webhook paths acknowledge the event and log it instead of
5+
* failing it into Stripe's retry loop.
6+
*/
7+
export class SubscriptionReferenceNotFoundError extends Error {
8+
readonly referenceId: string
9+
10+
constructor(referenceId: string) {
11+
super(`Subscription reference ${referenceId} does not match a user or organization`)
12+
this.name = 'SubscriptionReferenceNotFoundError'
13+
this.referenceId = referenceId
14+
}
15+
}

0 commit comments

Comments
 (0)