Skip to content
Draft
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
20 changes: 14 additions & 6 deletions apps/api/src/billing-routes.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ActiveSubscriptionCheckoutError } from "@sentrabot/db";
import { Hono } from "hono";

export interface BillingActor {
Expand Down Expand Up @@ -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,
Expand Down
82 changes: 79 additions & 3 deletions packages/db/src/platform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ describe("platform control-plane repositories", () => {
$transaction: async (operation: (tx: any) => Promise<void>) =>
operation({
runtimeLease: { findUnique: vi.fn(), upsert: vi.fn() },
entitlementState: { upsert: vi.fn() },
entitlementState: {
findUnique: vi.fn().mockResolvedValue(null),
upsert: vi.fn(),
},
outboxEvent,
paymentEvent,
subscription,
Expand All @@ -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<void>) =>
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<void>) =>
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 = {
Expand All @@ -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<void>) =>
Expand Down Expand Up @@ -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<void>) =>
Expand Down
123 changes: 92 additions & 31 deletions packages/db/src/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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,
},
});
});
}

Expand All @@ -564,6 +582,9 @@ interface PlatformTransaction {
}): Promise<RuntimeLeaseRecord>;
};
entitlementState: {
findUnique(input: {
where: { workspaceId: string };
}): Promise<{ state: string } | null>;
upsert(input: {
where: { workspaceId: string };
create: { workspaceId: string; planCode: string; state: string; version: number };
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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") {
Expand All @@ -670,16 +699,40 @@ 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",
entitlementPlanCode: "free",
entitlementState: "free",
graceEndsAt: null,
updateEntitlements: true,
touchEntitlement: true,
};
}
if (currentSubscriptionState === "active_plus") {
Expand All @@ -690,6 +743,7 @@ function resolvePaymentEntitlementOutcome(
entitlementState: "grace_period",
graceEndsAt: addSevenCalendarDays(now),
updateEntitlements: true,
touchEntitlement: true,
};
}
return {
Expand All @@ -699,6 +753,7 @@ function resolvePaymentEntitlementOutcome(
entitlementState: "free",
graceEndsAt: null,
updateEntitlements: false,
touchEntitlement: true,
};
}

Expand All @@ -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({
Expand Down Expand Up @@ -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}`,
Expand Down
Loading