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
33 changes: 33 additions & 0 deletions apps/api/src/xendit-webhook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }),
);
});
});
93 changes: 92 additions & 1 deletion packages/db/src/platform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>) =>
Expand Down Expand Up @@ -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<void>) =>
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<void>) =>
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({
Expand Down
90 changes: 80 additions & 10 deletions packages/db/src/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -635,13 +638,72 @@ 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),
},
}),
),
};
}

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,
Expand All @@ -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,
Expand All @@ -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 },
},
});
Expand Down
Loading