Skip to content

Commit 96fd2a8

Browse files
Bill Leoutsakoscursoragent
authored andcommitted
fix(e2e): preserve replacement org coverage
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8904420 commit 96fd2a8

5 files changed

Lines changed: 96 additions & 29 deletions

File tree

apps/sim/e2e/fixtures/factories/billing.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { db } from '@sim/db'
22
import { member, organization, subscription, user, userStats } from '@sim/db/schema'
33
import { generateId } from '@sim/utils/id'
44
import { and, eq, inArray } from 'drizzle-orm'
5+
import { hasOtherEntitledOrganizationSubscription } from '@/lib/billing/subscriptions/organization-coverage'
56
import { detachOrganizationWorkspaces } from '@/lib/workspaces/organization-workspaces'
67

78
const CREDITS_PER_DOLLAR = 200
@@ -67,6 +68,8 @@ export async function arrangeSubscription(input: SubscriptionArrangement): Promi
6768
metadata,
6869
})
6970

71+
if (status === 'canceled') return
72+
7073
if (input.plan.startsWith('pro_')) {
7174
await tx
7275
.update(userStats)
@@ -142,6 +145,13 @@ export async function lapseOrganizationSubscription(input: {
142145
periodEnd: now,
143146
})
144147
.where(eq(subscription.id, input.subscriptionId))
148+
})
149+
150+
if (await hasOtherEntitledOrganizationSubscription(input.organizationId, input.subscriptionId)) {
151+
return
152+
}
153+
154+
await db.transaction(async (tx) => {
145155
await tx
146156
.update(organization)
147157
.set({ orgUsageLimit: null, updatedAt: now })

apps/sim/e2e/foundation/scenario-validation.spec.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,37 @@ test.describe('pure scenario validation', () => {
226226
expectInvalid(scenario, /current entitled subscription "replacement-team-subscription"/)
227227
})
228228

229+
test('allows a lapsed history while a replacement subscription retains organization coverage', () => {
230+
const scenario = validScenario()
231+
const lapsed = scenario.subscriptions.find(({ key }) => key === 'lapsed-team-subscription')!
232+
scenario.subscriptions = [
233+
...scenario.subscriptions,
234+
{ ...lapsed, key: 'replacement-team-subscription', status: 'active' },
235+
]
236+
scenario.workspaces = scenario.workspaces.map((workspace) =>
237+
workspace.key === 'lapsed-organization-workspace'
238+
? { ...workspace, subscriptionKey: 'replacement-team-subscription' }
239+
: workspace
240+
)
241+
scenario.personas = scenario.personas.map((persona) =>
242+
persona.key === 'freeOrganizationOwner'
243+
? {
244+
...persona,
245+
workspaces: persona.workspaces.map((expectation) => ({
246+
...expectation,
247+
hostContext: {
248+
...expectation.hostContext,
249+
payerScope: 'organization',
250+
plan: 'team_6000',
251+
},
252+
})),
253+
}
254+
: persona
255+
)
256+
257+
expect(() => validateScenario(scenario)).not.toThrow()
258+
})
259+
229260
test('rejects permission groups without active Enterprise organization/workspace scope', () => {
230261
const scenario = validScenario()
231262
scenario.permissionGroups = scenario.permissionGroups.map((group) => ({

apps/sim/e2e/scripts/seed-world.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ async function createOrganizationsAndSubscriptions(
176176
const created = await arrangeSubscription({
177177
referenceId,
178178
plan: subscription.plan,
179-
status: subscription.status === 'lapsed' ? 'active' : subscription.status,
179+
status: initialSubscriptionStatus(world.scenario, subscription),
180180
seats: subscription.seats,
181181
memberUserIds,
182182
enterprise: subscription.enterprise
@@ -729,6 +729,28 @@ function resolveBillingReference(world: E2EWorld, subscription: ScenarioSubscrip
729729
).id
730730
}
731731

732+
function initialSubscriptionStatus(
733+
scenario: ResolvedScenario,
734+
definition: ScenarioSubscription
735+
): 'active' | 'past_due' | 'canceled' {
736+
if (definition.status !== 'lapsed') return definition.status
737+
const hasEntitledReplacement = scenario.definition.subscriptions.some(
738+
(candidate) =>
739+
candidate.key !== definition.key &&
740+
(candidate.status === 'active' || candidate.status === 'past_due') &&
741+
sameBillingReference(candidate, definition)
742+
)
743+
return hasEntitledReplacement ? 'canceled' : 'active'
744+
}
745+
746+
function sameBillingReference(left: ScenarioSubscription, right: ScenarioSubscription): boolean {
747+
const identity = (subscription: ScenarioSubscription): string =>
748+
subscription.billingReference.kind === 'user'
749+
? `user/${subscription.billingReference.userKey}`
750+
: `organization/${subscription.billingReference.organizationKey}`
751+
return identity(left) === identity(right)
752+
}
753+
732754
function expectedUsageLimit(scenario: ResolvedScenario, userKey: string): string | null {
733755
const membership = scenario.definition.organizationMemberships.find(
734756
(candidate) => candidate.userKey === userKey
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { db } from '@sim/db'
2+
import { subscription } from '@sim/db/schema'
3+
import { and, eq, inArray, ne } from 'drizzle-orm'
4+
import { isEnterprise, isTeam } from '@/lib/billing/plan-helpers'
5+
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
6+
7+
/**
8+
* Returns whether another entitled Team or Enterprise subscription still covers
9+
* the organization after a specific subscription ends.
10+
*/
11+
export async function hasOtherEntitledOrganizationSubscription(
12+
organizationId: string,
13+
excludeSubscriptionId: string | null
14+
): Promise<boolean> {
15+
const filters = [
16+
eq(subscription.referenceId, organizationId),
17+
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES),
18+
]
19+
if (excludeSubscriptionId) {
20+
filters.push(ne(subscription.id, excludeSubscriptionId))
21+
}
22+
23+
const rows = await db
24+
.select({ plan: subscription.plan })
25+
.from(subscription)
26+
.where(and(...filters))
27+
28+
return rows.some(({ plan }) => isTeam(plan) || isEnterprise(plan))
29+
}

apps/sim/lib/billing/webhooks/subscription.ts

Lines changed: 3 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import { and, eq, inArray, ne } from 'drizzle-orm'
66
import { calculateSubscriptionOverage, isSubscriptionOrgScoped } from '@/lib/billing/core/billing'
77
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
88
import { restoreUserProSubscription } from '@/lib/billing/organizations/membership'
9-
import { isEnterprise, isPaid, isPro, isTeam } from '@/lib/billing/plan-helpers'
9+
import { isEnterprise, isPaid, isPro } from '@/lib/billing/plan-helpers'
1010
import { requireStripeClient } from '@/lib/billing/stripe-client'
11+
import { hasOtherEntitledOrganizationSubscription } from '@/lib/billing/subscriptions/organization-coverage'
1112
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
1213
import { stripeWebhookIdempotency } from '@/lib/billing/webhooks/idempotency'
1314
import {
@@ -82,32 +83,6 @@ export interface OrganizationDormantTransitionResult {
8283
organizationRetainsTeamOrEnterprise: boolean
8384
}
8485

85-
/**
86-
* Returns true when the organization is still covered by an **active
87-
* Team or Enterprise** subscription other than `excludeSubscriptionId`.
88-
* The org keeps its team-owned workspaces only while such a sub exists;
89-
* a Pro sub on the org does not count.
90-
*/
91-
async function hasOtherActiveTeamOrEnterpriseSubscription(
92-
organizationId: string,
93-
excludeSubscriptionId: string | null
94-
): Promise<boolean> {
95-
const filters = [
96-
eq(subscription.referenceId, organizationId),
97-
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES),
98-
]
99-
if (excludeSubscriptionId) {
100-
filters.push(ne(subscription.id, excludeSubscriptionId))
101-
}
102-
103-
const rows = await db
104-
.select({ plan: subscription.plan })
105-
.from(subscription)
106-
.where(and(...filters))
107-
108-
return rows.some((row) => isTeam(row.plan) || isEnterprise(row.plan))
109-
}
110-
11186
async function transitionOrganizationToDormantState(
11287
organizationId: string,
11388
triggeringSubscriptionId: string | null
@@ -117,7 +92,7 @@ async function transitionOrganizationToDormantState(
11792
.from(member)
11893
.where(eq(member.organizationId, organizationId))
11994

120-
if (await hasOtherActiveTeamOrEnterpriseSubscription(organizationId, triggeringSubscriptionId)) {
95+
if (await hasOtherEntitledOrganizationSubscription(organizationId, triggeringSubscriptionId)) {
12196
logger.info(
12297
'Skipping dormant transition - another Team/Enterprise subscription still covers this organization',
12398
{ organizationId, triggeringSubscriptionId }

0 commit comments

Comments
 (0)