Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/sim/lib/billing/organization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ describe('ensureOrganizationForTeamSubscription', () => {
it('transfers a user-referenced team subscription onto the org the user administers', async () => {
mockIsSubscriptionOrgScoped.mockResolvedValueOnce(false)
queueWhereResponses([
// referenced user lookup
[{ name: 'Owner', email: 'owner@example.com' }],
// membership lookup: user owns an org
[{ id: 'member-1', organizationId: 'org-owned', role: 'owner' }],
// locked membership re-read inside the transfer transaction
Expand Down
28 changes: 17 additions & 11 deletions apps/sim/lib/billing/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { acquireUserBillingIdentityLock } from '@/lib/billing/organizations/bill
import { createOrganizationWithOwner } from '@/lib/billing/organizations/create-organization'
import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership'
import { isEnterprise, isOrgPlan, isPaid } from '@/lib/billing/plan-helpers'
import { SubscriptionReferenceNotFoundError } from '@/lib/billing/subscriptions/errors'
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { toDecimal } from '@/lib/billing/utils/decimal'
import type { DbTransaction } from '@/lib/db/types'
Expand Down Expand Up @@ -127,8 +128,21 @@ export async function ensureOrganizationForTeamSubscription(
* checks under the org mutation lock) or creates a new organization. This
* keeps re-homing deterministic in the webhook flow instead of depending on
* a client-side transfer call after checkout.
*
* A reference that is neither an organization nor a user (an organization
* deleted under a live subscription) has no one to re-home onto, so it
* fails as a dangling reference instead of seeding an organization owned
* by a nonexistent user.
*/
const userId = subscription.referenceId
const [userData] = await db
.select({ name: user.name, email: user.email })
.from(user)
.where(eq(user.id, userId))
.limit(1)
if (!userData) {
throw new SubscriptionReferenceNotFoundError(subscription.referenceId)
}

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

const [userData] = await db
.select({ name: user.name, email: user.email })
.from(user)
.where(eq(user.id, userId))
.limit(1)

const orgId = await createOrganizationForTeamPlan(
userId,
userData?.name || undefined,
userData?.email || undefined
userData.name || undefined,
userData.email || undefined
)

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

if (users.length === 0) {
throw new Error(
`Subscription reference ${subscription.referenceId} does not match a user or organization`
)
throw new SubscriptionReferenceNotFoundError(subscription.referenceId)
}

await syncUsageLimitsFromSubscription(subscription.referenceId)
Expand Down
15 changes: 15 additions & 0 deletions apps/sim/lib/billing/subscriptions/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* A subscription whose `referenceId` matches neither an organization nor a user — typically an
* organization deleted while its Stripe subscription stayed live. The condition is permanent: no
* retry can resolve the reference, so webhook paths acknowledge the event and log it instead of
* failing it into Stripe's retry loop.
*/
export class SubscriptionReferenceNotFoundError extends Error {
readonly referenceId: string

constructor(referenceId: string) {
super(`Subscription reference ${referenceId} does not match a user or organization`)
this.name = 'SubscriptionReferenceNotFoundError'
this.referenceId = referenceId
}
}
Loading
Loading