diff --git a/apps/api/src/xendit-webhook.test.ts b/apps/api/src/xendit-webhook.test.ts index 1d338ac5..ce58e90e 100644 --- a/apps/api/src/xendit-webhook.test.ts +++ b/apps/api/src/xendit-webhook.test.ts @@ -56,4 +56,37 @@ describe("Xendit billing webhook", () => { expect.objectContaining({ providerEventId: "ps-1", lifecycle: "paid" }), ); }); + + it("forwards an expired checkout session as renewal_failed for state-aware handling", async () => { + const { mountXenditWebhookRoutes } = await import("./xendit-webhook.js"); + const { Hono } = await import("hono"); + const app = new Hono(); + const resolvePaymentTarget = vi.fn().mockResolvedValue({ + userId: "user-1", + workspaceId: "workspace-1", + }); + const applyVerifiedPayment = vi.fn().mockResolvedValue({ applied: true }); + mountXenditWebhookRoutes(app, { + callbackToken: "callback-token", + resolvePaymentTarget, + applyVerifiedPayment, + }); + + const response = await app.request("/v1/billing/xendit/webhook", { + method: "POST", + headers: { "content-type": "application/json", "x-callback-token": "callback-token" }, + body: JSON.stringify({ + event: "payment_session.expired", + data: { + payment_session_id: "ps-expired-1", + reference_id: "checkout-1", + }, + }), + }); + + expect(response.status).toBe(204); + expect(applyVerifiedPayment).toHaveBeenCalledWith( + expect.objectContaining({ providerEventId: "ps-expired-1", lifecycle: "renewal_failed" }), + ); + }); }); diff --git a/packages/db/src/platform.test.ts b/packages/db/src/platform.test.ts index b41075d9..a42cdd3a 100644 --- a/packages/db/src/platform.test.ts +++ b/packages/db/src/platform.test.ts @@ -8,7 +8,10 @@ describe("platform control-plane repositories", () => { findUnique: vi.fn().mockResolvedValue(null), create: vi.fn().mockResolvedValue({ id: "payment-event-1" }), }; - const subscription = { upsert: vi.fn().mockResolvedValue({ id: "subscription-1" }) }; + const subscription = { + findUnique: vi.fn().mockResolvedValue({ state: "checkout_pending" }), + upsert: vi.fn().mockResolvedValue({ id: "subscription-1" }), + }; const outboxEvent = { create: vi.fn().mockResolvedValue({ id: "outbox-1" }) }; const db = { $transaction: async (operation: (tx: any) => Promise) => @@ -37,6 +40,94 @@ describe("platform control-plane repositories", () => { ); }); + it("reverts an abandoned checkout to free instead of entering grace period", 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 = { 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-1", + 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: "free", state: "free", graceEndsAt: null }), + }), + ); + expect(entitlementState.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ planCode: "free", state: "free" }), + }), + ); + }); + + it("enters grace period only when an active subscription renewal fails", 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: "active_plus" }), + upsert: vi.fn().mockResolvedValue({ id: "subscription-1" }), + }; + const entitlementState = { 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: "renewal-failed-1", + 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: "grace_period" }), + }), + ); + expect(entitlementState.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ planCode: "free", state: "grace_period" }), + }), + ); + }); + it("expires but retains a released lease so the next owner receives a newer epoch", async () => { const { releaseRuntimeLease } = await import("./platform.js"); const upsert = vi.fn().mockResolvedValue({ diff --git a/packages/db/src/platform.ts b/packages/db/src/platform.ts index 58cd4f0a..f6d7c061 100644 --- a/packages/db/src/platform.ts +++ b/packages/db/src/platform.ts @@ -597,6 +597,9 @@ interface PlatformTransaction { }): Promise<{ id: string }>; }; subscription: { + findUnique(input: { + where: { workspaceId: string }; + }): Promise<{ state: string } | null>; upsert(input: { where: { workspaceId: string }; create: { @@ -635,6 +638,11 @@ export function createPlatformDatabase(db: Db): PlatformDatabase { create: (input) => tx.paymentEvent.create(input), }, subscription: { + findUnique: (input) => + tx.subscription.findUnique({ + where: input.where, + select: { state: true }, + }), upsert: (input) => tx.subscription.upsert(input), }, }), @@ -642,6 +650,60 @@ export function createPlatformDatabase(db: Db): PlatformDatabase { }; } +interface PaymentEntitlementOutcome { + subscriptionPlanCode: string; + subscriptionState: string; + entitlementPlanCode: string; + entitlementState: string; + graceEndsAt: Date | null; + updateEntitlements: boolean; +} + +function resolvePaymentEntitlementOutcome( + lifecycle: VerifiedPaymentEventInput["lifecycle"], + currentSubscriptionState: string | undefined, + now: Date, +): PaymentEntitlementOutcome { + if (lifecycle === "paid") { + return { + subscriptionPlanCode: "plus", + subscriptionState: "active_plus", + entitlementPlanCode: "plus", + entitlementState: "active_plus", + graceEndsAt: null, + updateEntitlements: true, + }; + } + if (currentSubscriptionState === "checkout_pending") { + return { + subscriptionPlanCode: "free", + subscriptionState: "free", + entitlementPlanCode: "free", + entitlementState: "free", + graceEndsAt: null, + updateEntitlements: true, + }; + } + if (currentSubscriptionState === "active_plus") { + return { + subscriptionPlanCode: "plus", + subscriptionState: "grace_period", + entitlementPlanCode: "free", + entitlementState: "grace_period", + graceEndsAt: addSevenCalendarDays(now), + updateEntitlements: true, + }; + } + return { + subscriptionPlanCode: "free", + subscriptionState: "free", + entitlementPlanCode: "free", + entitlementState: "free", + graceEndsAt: null, + updateEntitlements: false, + }; +} + export async function applyVerifiedPaymentEvent( db: PlatformDatabase, input: VerifiedPaymentEventInput, @@ -656,7 +718,14 @@ export async function applyVerifiedPaymentEvent( }, }); if (existing) return { applied: false }; - const paid = input.lifecycle === "paid"; + const currentSubscription = await tx.subscription.findUnique({ + where: { workspaceId: input.workspaceId }, + }); + const outcome = resolvePaymentEntitlementOutcome( + input.lifecycle, + currentSubscription?.state, + input.now, + ); await tx.paymentEvent.create({ data: { provider: input.provider, @@ -667,32 +736,33 @@ export async function applyVerifiedPaymentEvent( verifiedAt: input.now, }, }); + if (!outcome.updateEntitlements) return { applied: true }; await tx.subscription.upsert({ where: { workspaceId: input.workspaceId }, create: { userId: input.userId, workspaceId: input.workspaceId, - planCode: "plus", - state: paid ? "active_plus" : "grace_period", + planCode: outcome.subscriptionPlanCode, + state: outcome.subscriptionState, provider: input.provider, }, update: { - planCode: "plus", - state: paid ? "active_plus" : "grace_period", - graceEndsAt: paid ? null : addSevenCalendarDays(input.now), + planCode: outcome.subscriptionPlanCode, + state: outcome.subscriptionState, + graceEndsAt: outcome.graceEndsAt, }, }); await tx.entitlementState.upsert({ where: { workspaceId: input.workspaceId }, create: { workspaceId: input.workspaceId, - planCode: paid ? "plus" : "free", - state: paid ? "active_plus" : "grace_period", + planCode: outcome.entitlementPlanCode, + state: outcome.entitlementState, version: 1, }, update: { - planCode: paid ? "plus" : "free", - state: paid ? "active_plus" : "grace_period", + planCode: outcome.entitlementPlanCode, + state: outcome.entitlementState, version: { increment: 1 }, }, });