Skip to content

Commit c55e5f1

Browse files
committed
fix(admin): block org delete on any live subscription, not just entitled ones
ENTITLED_SUBSCRIPTION_STATUSES excludes trialing, so a trial — which grants no entitlement but is a live Stripe subscription that will convert — slipped past the delete guard and could be stranded against a removed organization id. Adds TERMINAL_SUBSCRIPTION_STATUSES and inverts the predicate: block unless the row is finished. Expressed as the terminal set so a status Stripe adds later defaults to blocking, which is the safe direction for a destructive operation.
1 parent a0ca8cb commit c55e5f1

3 files changed

Lines changed: 57 additions & 6 deletions

File tree

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

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
3434
import { db } from '@sim/db'
3535
import { member, organization, subscription } from '@sim/db/schema'
3636
import { createLogger } from '@sim/logger'
37-
import { and, count, eq, inArray } from 'drizzle-orm'
37+
import { and, count, eq, inArray, isNull, not, or } from 'drizzle-orm'
3838
import {
3939
adminV1DeleteOrganizationContract,
4040
adminV1GetOrganizationContract,
@@ -47,7 +47,10 @@ import {
4747
OrganizationSlugTakenError,
4848
validateOrganizationSlugOrThrow,
4949
} from '@/lib/billing/organizations/create-organization'
50-
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
50+
import {
51+
ENTITLED_SUBSCRIPTION_STATUSES,
52+
TERMINAL_SUBSCRIPTION_STATUSES,
53+
} from '@/lib/billing/subscriptions/utils'
5154
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
5255
import { detachOrganizationWorkspaces } from '@/lib/workspaces/organization-workspaces'
5356
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
@@ -243,17 +246,23 @@ export const DELETE = withRouteHandler(
243246
* would strand it, and its Stripe billing, against an id that no longer
244247
* resolves. Refuse instead of guessing whether to cancel.
245248
*
246-
* Scoped to entitled statuses. A canceled or ended row bills nobody, so
247-
* treating it as a blocker would make an organization that once had a
248-
* subscription permanently undeletable.
249+
* Scoped to non-terminal rows. A canceled row bills nobody, so treating
250+
* it as a blocker would make an organization that once had a
251+
* subscription permanently undeletable — but entitlement is the wrong
252+
* test in the other direction too, since a `trialing` subscription grants
253+
* nothing today and is still live in Stripe. A null status is unknown, so
254+
* it blocks.
249255
*/
250256
const [existingSubscription] = await db
251257
.select({ id: subscription.id, plan: subscription.plan })
252258
.from(subscription)
253259
.where(
254260
and(
255261
eq(subscription.referenceId, organizationId),
256-
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
262+
or(
263+
isNull(subscription.status),
264+
not(inArray(subscription.status, TERMINAL_SUBSCRIPTION_STATUSES))
265+
)
257266
)
258267
)
259268
.limit(1)

apps/sim/lib/billing/subscriptions/utils.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
hasPaidSubscriptionStatus,
1212
hasUsableSubscriptionAccess,
1313
hasUsableSubscriptionStatus,
14+
TERMINAL_SUBSCRIPTION_STATUSES,
1415
} from '@/lib/billing/subscriptions/utils'
1516

1617
describe('billing subscription status helpers', () => {
@@ -50,3 +51,28 @@ describe('billing subscription status helpers', () => {
5051
expect(getEffectiveSeats({ plan: 'team_8000', status: 'canceled', seats: null })).toBe(0)
5152
})
5253
})
54+
55+
describe('TERMINAL_SUBSCRIPTION_STATUSES', () => {
56+
const terminal = TERMINAL_SUBSCRIPTION_STATUSES as readonly string[]
57+
58+
it('covers only the statuses that can no longer bill', () => {
59+
expect(terminal).toEqual(['canceled', 'incomplete_expired'])
60+
})
61+
62+
it('does not treat trialing as terminal', () => {
63+
/**
64+
* A trial grants no entitlement, so it is absent from
65+
* ENTITLED_SUBSCRIPTION_STATUSES — but it is a live Stripe subscription
66+
* that will convert. Anything keying off "is this row still real" must not
67+
* reuse the entitlement set, or it will happily delete out from under an
68+
* active trial.
69+
*/
70+
expect(terminal).not.toContain('trialing')
71+
})
72+
73+
it('treats every other live status as non-terminal', () => {
74+
for (const status of ['active', 'past_due', 'unpaid', 'trialing', 'incomplete']) {
75+
expect(terminal).not.toContain(status)
76+
}
77+
})
78+
})

apps/sim/lib/billing/subscriptions/utils.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,22 @@ export const ENTITLED_SUBSCRIPTION_STATUSES = ['active', 'past_due'] as const
2020

2121
export const USABLE_SUBSCRIPTION_STATUSES = ['active'] as const
2222

23+
/**
24+
* Statuses where the subscription is finished and can no longer bill anyone.
25+
*
26+
* The inverse of this set — `active`, `past_due`, `unpaid`, `trialing`,
27+
* `incomplete` — is still attached to a live Stripe subscription that can
28+
* convert or retry, even where it grants no entitlement today. Use this, not
29+
* {@link ENTITLED_SUBSCRIPTION_STATUSES}, when the question is "would removing
30+
* the thing this row points at strand live billing?" — a `trialing`
31+
* subscription is unentitled but very much alive.
32+
*
33+
* Deliberately expressed as the terminal set rather than the live one, so a
34+
* status Stripe adds later is treated as live by default. For a destructive
35+
* operation that is the safe direction to be wrong in.
36+
*/
37+
export const TERMINAL_SUBSCRIPTION_STATUSES = ['canceled', 'incomplete_expired'] as const
38+
2339
/**
2440
* Returns true when a subscription should still count as a paid plan entitlement.
2541
*/

0 commit comments

Comments
 (0)