From 00ac35e06071c2287b73d21dea291d7fabfdbb2f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Oct 2026 11:16:55 +0000 Subject: [PATCH] fix(billing): prevent Plus demotion on duplicate checkout expiry beginCheckout must not overwrite active_plus or grace_period with checkout_pending. When an abandoned checkout session expires, restore the subscription from authoritative entitlement state instead of downgrading paying customers to free. Co-Authored-By: Cursor Agent Co-authored-by: drferdiiskandar --- apps/api/src/billing-routes.ts | 20 +++-- packages/db/src/platform.test.ts | 82 ++++++++++++++++++++- packages/db/src/platform.ts | 123 +++++++++++++++++++++++-------- 3 files changed, 185 insertions(+), 40 deletions(-) diff --git a/apps/api/src/billing-routes.ts b/apps/api/src/billing-routes.ts index c3d3af27..0d1270b7 100644 --- a/apps/api/src/billing-routes.ts +++ b/apps/api/src/billing-routes.ts @@ -1,3 +1,4 @@ +import { ActiveSubscriptionCheckoutError } from "@sentrabot/db"; import { Hono } from "hono"; export interface BillingActor { @@ -33,12 +34,19 @@ export function createBillingRoutes(dependencies: BillingRouteDependencies): Hon const actor = await dependencies.authenticate(context.req.raw); if (!actor) return context.json({ error: "Unauthorized" }, 401); const referenceId = dependencies.newReference(); - await dependencies.beginCheckout({ - userId: actor.userId, - workspaceId: actor.workspaceId, - provider: "xendit", - providerReference: referenceId, - }); + try { + await dependencies.beginCheckout({ + userId: actor.userId, + workspaceId: actor.workspaceId, + provider: "xendit", + providerReference: referenceId, + }); + } catch (error) { + if (error instanceof ActiveSubscriptionCheckoutError) { + return context.json({ error: "Plus subscription is already active" }, 409); + } + throw error; + } const checkout = await dependencies.createCheckout({ referenceId, amount: 79_000, diff --git a/packages/db/src/platform.test.ts b/packages/db/src/platform.test.ts index 986253bf..194e2e0d 100644 --- a/packages/db/src/platform.test.ts +++ b/packages/db/src/platform.test.ts @@ -17,7 +17,10 @@ describe("platform control-plane repositories", () => { $transaction: async (operation: (tx: any) => Promise) => operation({ runtimeLease: { findUnique: vi.fn(), upsert: vi.fn() }, - entitlementState: { upsert: vi.fn() }, + entitlementState: { + findUnique: vi.fn().mockResolvedValue(null), + upsert: vi.fn(), + }, outboxEvent, paymentEvent, subscription, @@ -40,6 +43,73 @@ describe("platform control-plane repositories", () => { ); }); + it("rejects checkout when the workspace already has active_plus", async () => { + const { beginCheckout, ActiveSubscriptionCheckoutError } = await import("./platform.js"); + const upsert = vi.fn(); + const db = { + $transaction: async (operation: (tx: any) => Promise) => + operation({ + subscription: { + findUnique: vi.fn().mockResolvedValue({ state: "active_plus" }), + upsert, + }, + }), + }; + + await expect( + beginCheckout(db as any, { + userId: "user-1", + workspaceId: "workspace-1", + provider: "xendit", + providerReference: "checkout-2", + }), + ).rejects.toBeInstanceOf(ActiveSubscriptionCheckoutError); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("does not demote a paying customer when a duplicate checkout session expires", async () => { + const { applyVerifiedPaymentEvent } = await import("./platform.js"); + const paymentEvent = { + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({ id: "payment-event-1" }), + }; + const subscription = { + findUnique: vi.fn().mockResolvedValue({ state: "checkout_pending" }), + upsert: vi.fn().mockResolvedValue({ id: "subscription-1" }), + }; + const entitlementState = { + findUnique: vi.fn().mockResolvedValue({ state: "active_plus" }), + upsert: vi.fn().mockResolvedValue({ workspaceId: "workspace-1" }), + }; + const outboxEvent = { create: vi.fn().mockResolvedValue({ id: "outbox-1" }) }; + const db = { + $transaction: async (operation: (tx: any) => Promise) => + operation({ + runtimeLease: { findUnique: vi.fn(), upsert: vi.fn() }, + entitlementState, + outboxEvent, + paymentEvent, + subscription, + }), + }; + + await applyVerifiedPaymentEvent(db as any, { + provider: "xendit", + providerEventId: "session-expired-duplicate", + userId: "user-1", + workspaceId: "workspace-1", + lifecycle: "renewal_failed", + now: new Date("2026-09-02T00:00:00.000Z"), + }); + + expect(subscription.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ planCode: "plus", state: "active_plus" }), + }), + ); + expect(entitlementState.upsert).not.toHaveBeenCalled(); + }); + it("reverts an abandoned checkout to free instead of entering grace period", async () => { const { applyVerifiedPaymentEvent } = await import("./platform.js"); const paymentEvent = { @@ -50,7 +120,10 @@ describe("platform control-plane repositories", () => { findUnique: vi.fn().mockResolvedValue({ state: "checkout_pending" }), upsert: vi.fn().mockResolvedValue({ id: "subscription-1" }), }; - const entitlementState = { upsert: vi.fn().mockResolvedValue({ workspaceId: "workspace-1" }) }; + const entitlementState = { + findUnique: vi.fn().mockResolvedValue({ state: "free" }), + upsert: vi.fn().mockResolvedValue({ workspaceId: "workspace-1" }), + }; const outboxEvent = { create: vi.fn().mockResolvedValue({ id: "outbox-1" }) }; const db = { $transaction: async (operation: (tx: any) => Promise) => @@ -94,7 +167,10 @@ describe("platform control-plane repositories", () => { findUnique: vi.fn().mockResolvedValue({ state: "active_plus" }), upsert: vi.fn().mockResolvedValue({ id: "subscription-1" }), }; - const entitlementState = { upsert: vi.fn().mockResolvedValue({ workspaceId: "workspace-1" }) }; + const entitlementState = { + findUnique: vi.fn().mockResolvedValue({ state: "active_plus" }), + upsert: vi.fn().mockResolvedValue({ workspaceId: "workspace-1" }), + }; const outboxEvent = { create: vi.fn().mockResolvedValue({ id: "outbox-1" }) }; const db = { $transaction: async (operation: (tx: any) => Promise) => diff --git a/packages/db/src/platform.ts b/packages/db/src/platform.ts index 8466f11e..e4f3b787 100644 --- a/packages/db/src/platform.ts +++ b/packages/db/src/platform.ts @@ -530,27 +530,45 @@ export async function findPaymentTargetByProviderReference(db: Db, providerRefer }); } +export class ActiveSubscriptionCheckoutError extends Error { + constructor() { + super("Workspace already has an active Plus subscription"); + this.name = "ActiveSubscriptionCheckoutError"; + } +} + +const CHECKOUT_BLOCKED_SUBSCRIPTION_STATES = new Set(["active_plus", "grace_period"]); + export async function beginCheckout( db: Db, input: { userId: string; workspaceId: string; provider: string; providerReference: string }, ): Promise { - await db.subscription.upsert({ - where: { workspaceId: input.workspaceId }, - create: { - userId: input.userId, - workspaceId: input.workspaceId, - planCode: "plus", - state: "checkout_pending", - provider: input.provider, - providerReference: input.providerReference, - }, - update: { - planCode: "plus", - state: "checkout_pending", - provider: input.provider, - providerReference: input.providerReference, - graceEndsAt: null, - }, + await db.$transaction(async (tx) => { + const current = await tx.subscription.findUnique({ + where: { workspaceId: input.workspaceId }, + select: { state: true }, + }); + if (current && CHECKOUT_BLOCKED_SUBSCRIPTION_STATES.has(current.state)) { + throw new ActiveSubscriptionCheckoutError(); + } + await tx.subscription.upsert({ + where: { workspaceId: input.workspaceId }, + create: { + userId: input.userId, + workspaceId: input.workspaceId, + planCode: "plus", + state: "checkout_pending", + provider: input.provider, + providerReference: input.providerReference, + }, + update: { + planCode: "plus", + state: "checkout_pending", + provider: input.provider, + providerReference: input.providerReference, + graceEndsAt: null, + }, + }); }); } @@ -564,6 +582,9 @@ interface PlatformTransaction { }): Promise; }; entitlementState: { + findUnique(input: { + where: { workspaceId: string }; + }): Promise<{ state: string } | null>; upsert(input: { where: { workspaceId: string }; create: { workspaceId: string; planCode: string; state: string; version: number }; @@ -626,6 +647,11 @@ export function createPlatformDatabase(db: Db): PlatformDatabase { upsert: (input) => tx.runtimeLease.upsert(input), }, entitlementState: { + findUnique: (input) => + tx.entitlementState.findUnique({ + where: input.where, + select: { state: true }, + }), upsert: (input) => tx.entitlementState.upsert(input), }, outboxEvent: { @@ -655,11 +681,14 @@ interface PaymentEntitlementOutcome { entitlementState: string; graceEndsAt: Date | null; updateEntitlements: boolean; + /** When false, only the subscription row is updated (entitlement already authoritative). */ + touchEntitlement: boolean; } function resolvePaymentEntitlementOutcome( lifecycle: VerifiedPaymentEventInput["lifecycle"], currentSubscriptionState: string | undefined, + currentEntitlementState: string | undefined, now: Date, ): PaymentEntitlementOutcome { if (lifecycle === "paid") { @@ -670,9 +699,32 @@ function resolvePaymentEntitlementOutcome( entitlementState: "active_plus", graceEndsAt: null, updateEntitlements: true, + touchEntitlement: true, }; } if (currentSubscriptionState === "checkout_pending") { + if (currentEntitlementState === "active_plus") { + return { + subscriptionPlanCode: "plus", + subscriptionState: "active_plus", + entitlementPlanCode: "plus", + entitlementState: "active_plus", + graceEndsAt: null, + updateEntitlements: true, + touchEntitlement: false, + }; + } + if (currentEntitlementState === "grace_period") { + return { + subscriptionPlanCode: "plus", + subscriptionState: "grace_period", + entitlementPlanCode: "free", + entitlementState: "grace_period", + graceEndsAt: null, + updateEntitlements: true, + touchEntitlement: false, + }; + } return { subscriptionPlanCode: "free", subscriptionState: "free", @@ -680,6 +732,7 @@ function resolvePaymentEntitlementOutcome( entitlementState: "free", graceEndsAt: null, updateEntitlements: true, + touchEntitlement: true, }; } if (currentSubscriptionState === "active_plus") { @@ -690,6 +743,7 @@ function resolvePaymentEntitlementOutcome( entitlementState: "grace_period", graceEndsAt: addSevenCalendarDays(now), updateEntitlements: true, + touchEntitlement: true, }; } return { @@ -699,6 +753,7 @@ function resolvePaymentEntitlementOutcome( entitlementState: "free", graceEndsAt: null, updateEntitlements: false, + touchEntitlement: true, }; } @@ -719,9 +774,13 @@ export async function applyVerifiedPaymentEvent( const currentSubscription = await tx.subscription.findUnique({ where: { workspaceId: input.workspaceId }, }); + const currentEntitlement = await tx.entitlementState.findUnique({ + where: { workspaceId: input.workspaceId }, + }); const outcome = resolvePaymentEntitlementOutcome( input.lifecycle, currentSubscription?.state, + currentEntitlement?.state, input.now, ); await tx.paymentEvent.create({ @@ -750,20 +809,22 @@ export async function applyVerifiedPaymentEvent( graceEndsAt: outcome.graceEndsAt, }, }); - await tx.entitlementState.upsert({ - where: { workspaceId: input.workspaceId }, - create: { - workspaceId: input.workspaceId, - planCode: outcome.entitlementPlanCode, - state: outcome.entitlementState, - version: 1, - }, - update: { - planCode: outcome.entitlementPlanCode, - state: outcome.entitlementState, - version: { increment: 1 }, - }, - }); + if (outcome.touchEntitlement) { + await tx.entitlementState.upsert({ + where: { workspaceId: input.workspaceId }, + create: { + workspaceId: input.workspaceId, + planCode: outcome.entitlementPlanCode, + state: outcome.entitlementState, + version: 1, + }, + update: { + planCode: outcome.entitlementPlanCode, + state: outcome.entitlementState, + version: { increment: 1 }, + }, + }); + } await tx.outboxEvent.create({ data: { dedupeKey: `${input.provider}:${input.providerEventId}`,