From a8733f6a11d985f7eb3e1eeb0f9755d37d98b460 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Sun, 19 Jul 2026 11:58:13 -0300 Subject: [PATCH] test(billing): characterize checkout lifecycle against local Postgres Adds a checkout-lifecycle contract lane that exercises the real checkout_lifecycle_* SQL RPCs in supabase/migrations/20260712193633_checkout_deletion_lifecycle.sql through the production processStripeEvent/getCreditBalanceCents billing policy, with only Stripe mocked. Covers concurrent completion+deletion races, missing vs fenced users, registered/ unregistered sessions, refund states (partial/pending/failed/ canceled/succeeded), crash-window recovery, and replayed events. The lane runs via `bun test` (needs bun:sql) against 127.0.0.1:54322, guarded to a disposable local Supabase database only, and skips cleanly with a clear message when unreachable so plain `bun test`/vitest and CI's default check job stay green. CI's database job runs it for real via `bun run test:supabase` after `supabase db start`. Removes the checkout-lifecycle-policy cases duplicated in the MemorySupabase billing.test.ts fake now that the contract lane characterizes that behavior against the durable SQL directly, keeping the fake and its remaining fast non-lifecycle unit tests (webhook verification, checkout-session creation/idempotency, validation, and database-error resilience). Refs #37 (CAND-1). --- package.json | 2 +- packages/billing/test/billing.test.ts | 486 ----------- .../test/checkout-lifecycle.contract.test.ts | 778 ++++++++++++++++++ packages/billing/test/lifecycle-fixtures.ts | 123 +++ .../billing/test/postgres-lifecycle-client.ts | 239 ++++++ vitest.config.ts | 5 + 6 files changed, 1146 insertions(+), 487 deletions(-) create mode 100644 packages/billing/test/checkout-lifecycle.contract.test.ts create mode 100644 packages/billing/test/lifecycle-fixtures.ts create mode 100644 packages/billing/test/postgres-lifecycle-client.ts diff --git a/package.json b/package.json index 90eba5a..019041c 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "scripts": { "build": "bun run --cwd packages/tauri-release build && bun run --cwd packages/auth build && bun run --cwd packages/brand build && bun run --cwd packages/flags build && bun run --cwd packages/billing build && bun run --cwd packages/edge-shared build && bun run --cwd packages/sync build", "test": "vitest run", - "test:supabase": "supabase test db supabase/tests/database --local && bun run supabase/tests/welcome-credits-concurrency.ts", + "test:supabase": "supabase test db supabase/tests/database --local && bun run supabase/tests/welcome-credits-concurrency.ts && bun test packages/billing/test/checkout-lifecycle.contract.test.ts", "test:coverage": "vitest run --coverage", "typecheck": "tsc -p tsconfig.json --noEmit", "check": "bun run typecheck && bun run test && bun run test:coverage && bun run build" diff --git a/packages/billing/test/billing.test.ts b/packages/billing/test/billing.test.ts index c59639b..3e2f734 100644 --- a/packages/billing/test/billing.test.ts +++ b/packages/billing/test/billing.test.ts @@ -34,136 +34,6 @@ describe("@pickforge/billing", () => { expect(stripe.webhooks.constructEventAsync).toHaveBeenCalledWith("{}", "sig", "secret"); }); - it("handles checkout.session.completed with customer upsert, purchase ledger row, and post-effect event record", async () => { - const supabase = new MemorySupabase(); - - await expect( - processStripeEvent({ supabase, stripe: fakeStripe(), event: checkoutSessionEvent() }), - ).resolves.toEqual({ handled: true, duplicate: false }); - - expect(supabase.tables.billing_customers).toEqual([ - expect.objectContaining({ - stripe_customer_id: "cus_123", - user_id: USER_ID, - }), - ]); - expect(supabase.tables.credit_ledger).toEqual([ - expect.objectContaining({ - amount_cents: 1000, - kind: "purchase", - stripe_checkout_session_id: "cs_123", - stripe_event_id: "evt_checkout", - user_id: USER_ID, - }), - ]); - expect(supabase.tables.stripe_events).toEqual([ - expect.objectContaining({ - event_id: "evt_checkout", - type: "checkout.session.completed", - }), - ]); - }); - - it("idempotently refunds a completed Session for a fenced user without granting credits", async () => { - const supabase = new MemorySupabase(); - supabase.fencedUsers.add(USER_ID); - const stripe = fakeStripe(); - const event = checkoutSessionEvent({ sessionId: "cs_fenced" }); - - await expect( - processStripeEvent({ supabase, stripe, event }), - ).resolves.toEqual({ - handled: true, - duplicate: false, - reconciliation: "deletion_race_refunded", - }); - await expect( - processStripeEvent({ supabase, stripe, event }), - ).resolves.toEqual({ - handled: false, - duplicate: true, - reconciliation: "deletion_race_refunded", - }); - - expect(stripe.refunds.create).toHaveBeenCalledOnce(); - expect(stripe.refunds.create).toHaveBeenCalledWith( - { payment_intent: "pi_123", amount: 1000 }, - { idempotencyKey: "checkout-deletion:cs_fenced:attempt:1" }, - ); - expect(supabase.tables.credit_ledger).toHaveLength(0); - expect(supabase.lifecycleSessions.get("cs_fenced")).toBe("refunded"); - }); - - it("does not refund a pre-lifecycle credited purchase retried after fencing", async () => { - const supabase = new MemorySupabase(); - const stripe = fakeStripe(); - await processStripeEvent({ - supabase, - stripe, - event: checkoutSessionEvent({ sessionId: "cs_pre_lifecycle" }), - }); - supabase.lifecycleSessions.delete("cs_pre_lifecycle"); - supabase.fencedUsers.add(USER_ID); - - await expect(processStripeEvent({ - supabase, - stripe, - event: checkoutSessionEvent({ - id: "evt_pre_lifecycle_retry", - sessionId: "cs_pre_lifecycle", - }), - })).resolves.toMatchObject({ - handled: false, - duplicate: true, - }); - - expect(stripe.refunds.create).not.toHaveBeenCalled(); - expect(supabase.tables.credit_ledger).toHaveLength(1); - expect(supabase.lifecycleSessions.get("cs_pre_lifecycle")).toBe("completed"); - }); - - it("refunds and cleans a late customer after deletion finalization while auth still exists", async () => { - const supabase = new MemorySupabase(); - supabase.fencedUsers.add(USER_ID); - supabase.finalizedUsers.add(USER_ID); - const stripe = fakeStripe(); - const event = checkoutSessionEvent({ sessionId: "cs_after_finalize" }); - - await expect(processStripeEvent({ supabase, stripe, event })).resolves.toMatchObject({ - reconciliation: "deletion_race_refunded", - }); - - expect(stripe.refunds.create).toHaveBeenCalledOnce(); - expect(stripe.customers.del).toHaveBeenCalledWith("cus_123"); - expect(supabase.customerCleanupPending.has("cs_after_finalize")).toBe(false); - expect(supabase.lifecycleSessions.get("cs_after_finalize")).toBe("refunded"); - expect(supabase.tables.credit_ledger).toHaveLength(0); - }); - - it("keeps a fenced completion retryable when Stripe refunding fails", async () => { - const supabase = new MemorySupabase(); - supabase.fencedUsers.add(USER_ID); - const stripe = fakeStripe(); - stripe.refunds.create.mockRejectedValueOnce(new Error("Stripe unavailable")); - const event = checkoutSessionEvent({ sessionId: "cs_refund_retry" }); - - await expect( - processStripeEvent({ supabase, stripe, event }), - ).rejects.toThrow("Stripe unavailable"); - expect(supabase.tables.credit_ledger).toHaveLength(0); - expect(supabase.lifecycleSessions.get("cs_refund_retry")).toBe("refund_pending"); - - await expect( - processStripeEvent({ supabase, stripe, event }), - ).resolves.toMatchObject({ reconciliation: "deletion_race_refunded" }); - expect(stripe.refunds.create).toHaveBeenNthCalledWith( - 2, - { payment_intent: "pi_123", amount: 1000 }, - { idempotencyKey: "checkout-deletion:cs_refund_retry:attempt:1" }, - ); - expect(supabase.lifecycleSessions.get("cs_refund_retry")).toBe("refunded"); - }); - it("ignores untracked Refunds before validating checkout-specific fields", async () => { const supabase = new MemorySupabase(); @@ -178,61 +48,6 @@ describe("@pickforge/billing", () => { })).resolves.toEqual({ handled: false, duplicate: false }); }); - it("waits for an asynchronous Stripe refund to succeed before completing reconciliation", async () => { - const supabase = new MemorySupabase(); - supabase.fencedUsers.add(USER_ID); - const stripe = fakeStripe(); - stripe.refunds.create.mockResolvedValue({ id: "re_pending", amount: 1000, status: "pending" }); - stripe.refunds.retrieve.mockResolvedValueOnce({ - id: "re_pending", - amount: 1000, - status: "pending", - }); - const event = checkoutSessionEvent({ sessionId: "cs_async_refund" }); - - await expect( - processStripeEvent({ supabase, stripe, event }), - ).rejects.toMatchObject({ code: "refund_incomplete" }); - expect(supabase.lifecycleSessions.get("cs_async_refund")).toBe("refund_pending"); - expect(supabase.tables.credit_ledger).toHaveLength(0); - - await expect( - processStripeEvent({ - supabase, - stripe, - event: refundEvent({ refundId: "re_pending", status: "succeeded", type: "refund.created" }), - }), - ).resolves.toMatchObject({ reconciliation: "deletion_race_refunded" }); - expect(stripe.refunds.retrieve).toHaveBeenCalledOnce(); - expect(supabase.lifecycleSessions.get("cs_async_refund")).toBe("refunded"); - }); - - it("recovers an attached Refund after a crash before retrieval", async () => { - const supabase = new MemorySupabase(); - supabase.fencedUsers.add(USER_ID); - const stripe = fakeStripe(); - stripe.refunds.create.mockResolvedValue({ - id: "re_attached", - amount: 1000, - status: "pending", - }); - stripe.refunds.retrieve - .mockResolvedValueOnce({ id: "re_attached", amount: 1000, status: "pending" }) - .mockResolvedValueOnce({ id: "re_attached", amount: 1000, status: "succeeded" }); - const event = checkoutSessionEvent({ sessionId: "cs_attached_recovery" }); - - await expect(processStripeEvent({ supabase, stripe, event })).rejects.toMatchObject({ - code: "refund_incomplete", - }); - await expect(processStripeEvent({ supabase, stripe, event })).resolves.toMatchObject({ - reconciliation: "deletion_race_refunded", - }); - - expect(stripe.refunds.create).toHaveBeenCalledOnce(); - expect(stripe.refunds.retrieve).toHaveBeenCalledTimes(2); - expect(supabase.lifecycleSessions.get("cs_attached_recovery")).toBe("refunded"); - }); - it("uses durable cleanup state even when reconciliation returns ordinary refund", async () => { const supabase = new MemorySupabase(); supabase.fencedUsers.add(USER_ID); @@ -250,38 +65,6 @@ describe("@pickforge/billing", () => { expect(supabase.customerCleanupPending.has("cs_durable_cleanup")).toBe(false); }); - it("refunds only the remaining amount after a pre-existing partial refund", async () => { - const supabase = new MemorySupabase(); - supabase.fencedUsers.add(USER_ID); - const stripe = fakeStripe(); - stripe.refunds.list.mockResolvedValue({ - data: [{ id: "re_prior", amount: 400, status: "succeeded" }], - has_more: false, - }); - stripe.refunds.create.mockResolvedValue({ - id: "re_remaining", - amount: 600, - status: "succeeded", - }); - stripe.refunds.retrieve.mockResolvedValue({ - id: "re_remaining", - amount: 600, - status: "succeeded", - }); - - await expect(processStripeEvent({ - supabase, - stripe, - event: checkoutSessionEvent({ sessionId: "cs_partial_remaining" }), - })).resolves.toMatchObject({ reconciliation: "deletion_race_refunded" }); - - expect(stripe.refunds.create).toHaveBeenCalledWith( - { payment_intent: "pi_123", amount: 600 }, - { idempotencyKey: "checkout-deletion:cs_partial_remaining:attempt:1" }, - ); - expect(supabase.lifecycleSessions.get("cs_partial_remaining")).toBe("refunded"); - }); - it("continues automatically when a signed succeeded Refund leaves a shortfall", async () => { const supabase = new MemorySupabase(); supabase.lifecycleSessions.set("cs_signed_partial", "refund_pending"); @@ -373,42 +156,6 @@ describe("@pickforge/billing", () => { expect(stripe.refunds.create).toHaveBeenCalledTimes(1); }); - it("retries a signed terminal Refund with a new durable attempt key", async () => { - - const supabase = new MemorySupabase(); - supabase.fencedUsers.add(USER_ID); - const stripe = fakeStripe(); - stripe.refunds.create - .mockResolvedValueOnce({ id: "re_failed_attempt", amount: 1000, status: "pending" }) - .mockResolvedValueOnce({ id: "re_retry_succeeded", amount: 1000, status: "succeeded" }); - stripe.refunds.retrieve - .mockResolvedValueOnce({ id: "re_failed_attempt", amount: 1000, status: "pending" }) - .mockResolvedValueOnce({ id: "re_retry_succeeded", amount: 1000, status: "succeeded" }); - - await expect(processStripeEvent({ - supabase, - stripe, - event: checkoutSessionEvent({ sessionId: "cs_signed_retry" }), - })).rejects.toMatchObject({ code: "refund_incomplete" }); - - await expect(processStripeEvent({ - supabase, - stripe, - event: refundEvent({ - refundId: "re_failed_attempt", - status: "failed", - type: "refund.failed", - }), - })).resolves.toMatchObject({ reconciliation: "deletion_race_refunded" }); - - expect(stripe.refunds.create).toHaveBeenNthCalledWith( - 2, - { payment_intent: "pi_123", amount: 1000 }, - { idempotencyKey: "checkout-deletion:cs_signed_retry:attempt:2" }, - ); - expect(supabase.lifecycleSessions.get("cs_signed_retry")).toBe("refunded"); - }); - it("reopens a formerly succeeded Refund that later fails and compensates again", async () => { const supabase = new MemorySupabase(); supabase.deletedUsers.add(USER_ID); @@ -448,163 +195,6 @@ describe("@pickforge/billing", () => { expect(supabase.lifecycleSessions.get("cs_late_failure")).toBe("refunded"); }); - it("ignores an unknown signed Refund without mutating lifecycle", async () => { - const supabase = new MemorySupabase(); - const stripe = fakeStripe(); - - await expect(processStripeEvent({ - supabase, - stripe, - event: refundEvent({ refundId: "re_unknown" }), - })).resolves.toEqual({ handled: false, duplicate: false }); - - expect(stripe.refunds.create).not.toHaveBeenCalled(); - expect(stripe.customers.del).not.toHaveBeenCalled(); - }); - - it("accepts charge_already_refunded only after verifying a full succeeded refund", async () => { - const supabase = new MemorySupabase(); - supabase.fencedUsers.add(USER_ID); - const stripe = fakeStripe(); - stripe.refunds.create.mockRejectedValue({ code: "charge_already_refunded" }); - stripe.refunds.list.mockResolvedValue({ - data: [{ id: "re_existing", amount: 1000, status: "succeeded" }], - has_more: false, - }); - - await expect( - processStripeEvent({ - supabase, - stripe, - event: checkoutSessionEvent({ sessionId: "cs_already_refunded" }), - }), - ).resolves.toMatchObject({ reconciliation: "deletion_race_refunded" }); - - expect(stripe.refunds.retrieve).not.toHaveBeenCalled(); - expect(supabase.lifecycleSessions.get("cs_already_refunded")).toBe("refunded"); - }); - - it("does not accept charge_already_refunded when only a partial refund succeeded", async () => { - const supabase = new MemorySupabase(); - supabase.fencedUsers.add(USER_ID); - const stripe = fakeStripe(); - stripe.refunds.create.mockRejectedValue({ code: "charge_already_refunded" }); - stripe.refunds.list.mockResolvedValue({ - data: [{ id: "re_partial", amount: 500, status: "succeeded" }], - has_more: false, - }); - - await expect( - processStripeEvent({ - supabase, - stripe, - event: checkoutSessionEvent({ sessionId: "cs_partially_refunded" }), - }), - ).rejects.toMatchObject({ code: "charge_already_refunded" }); - - expect(supabase.lifecycleSessions.get("cs_partially_refunded")).toBe("refund_pending"); - }); - - it.each(["failed", "canceled"] as const)( - "durably records terminal refund status %s without releasing the deletion fence", - async (status) => { - const supabase = new MemorySupabase(); - supabase.fencedUsers.add(USER_ID); - const stripe = fakeStripe(); - stripe.refunds.create.mockResolvedValue({ id: "re_terminal", amount: 1000, status }); - stripe.refunds.retrieve.mockResolvedValue({ - id: "re_terminal", - amount: 1000, - status, - }); - const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); - - try { - await expect( - processStripeEvent({ - supabase, - stripe, - event: checkoutSessionEvent({ sessionId: `cs_${status}` }), - }), - ).rejects.toMatchObject({ code: "refund_terminal_failure" }); - - expect(consoleError).toHaveBeenCalledWith( - JSON.stringify({ - scope: "billing", - operation: "refund_reconciliation", - event_id: "evt_checkout", - event_type: "checkout.session.completed", - checkout_session_id: `cs_${status}`, - error_code: "refund_terminal_failure", - }), - ); - } finally { - consoleError.mockRestore(); - } - - expect(supabase.lifecycleSessions.get(`cs_${status}`)).toBe("refund_pending"); - expect(supabase.refundFailures.get(`cs_${status}`)).toEqual({ - refundId: "re_terminal", - status, - }); - expect(supabase.tables.credit_ledger).toHaveLength(0); - }, - ); - - it("idempotently refunds a completion whose user is already missing", async () => { - const supabase = new MemorySupabase(); - supabase.deletedUsers.add(USER_ID); - const stripe = fakeStripe(); - - await expect( - processStripeEvent({ - supabase, - stripe, - event: checkoutSessionEvent({ sessionId: "cs_deleted" }), - }), - ).resolves.toMatchObject({ reconciliation: "deletion_race_refunded" }); - - expect(stripe.refunds.create).toHaveBeenCalledWith( - { payment_intent: "pi_123", amount: 1000 }, - { idempotencyKey: "checkout-deletion:cs_deleted:attempt:1" }, - ); - expect(supabase.tables.billing_customers).toHaveLength(0); - expect(stripe.customers.del).toHaveBeenCalledWith("cus_123"); - expect(supabase.customerCleanupPending.has("cs_deleted")).toBe(false); - expect(supabase.tables.credit_ledger).toHaveLength(0); - expect(supabase.lifecycleSessions.get("cs_deleted")).toBe("refunded"); - }); - - it("retries missing-user customer cleanup without issuing a second refund", async () => { - const supabase = new MemorySupabase(); - supabase.deletedUsers.add(USER_ID); - const stripe = fakeStripe(); - stripe.customers.del.mockRejectedValueOnce(new Error("Stripe unavailable")); - const event = checkoutSessionEvent({ sessionId: "cs_cleanup_retry" }); - - await expect(processStripeEvent({ supabase, stripe, event })).rejects.toThrow( - "Stripe unavailable", - ); - expect(supabase.lifecycleSessions.get("cs_cleanup_retry")).toBe("refunded"); - expect(supabase.customerCleanupPending.has("cs_cleanup_retry")).toBe(true); - - const replayWithWrongCustomer = checkoutSessionEvent({ - id: "evt_cleanup_retry", - sessionId: "cs_cleanup_retry", - customer: "cus_wrong", - }); - await expect( - processStripeEvent({ supabase, stripe, event: replayWithWrongCustomer }), - ).resolves.toMatchObject({ - reconciliation: "deletion_race_refunded", - }); - expect(stripe.refunds.create).toHaveBeenCalledOnce(); - expect(stripe.customers.del).toHaveBeenCalledTimes(2); - expect(stripe.customers.del).toHaveBeenNthCalledWith(2, "cus_123"); - expect(stripe.customers.del).not.toHaveBeenCalledWith("cus_wrong"); - expect(supabase.customerCleanupPending.has("cs_cleanup_retry")).toBe(false); - }); - it("lets Stripe retry after a non-unique ledger failure and creates exactly one ledger row", async () => { const supabase = new MemorySupabase(); supabase.failNextInsert("credit_ledger", transientDatabaseError()); @@ -622,66 +212,6 @@ describe("@pickforge/billing", () => { expect(supabase.tables.stripe_events).toHaveLength(1); }); - it("treats a replayed completed event as duplicate through the ledger boundary", async () => { - const supabase = new MemorySupabase(); - const event = checkoutSessionEvent(); - - await processStripeEvent({ supabase, stripe: fakeStripe(), event }); - await expect(processStripeEvent({ supabase, stripe: fakeStripe(), event })).resolves.toEqual({ - handled: false, - duplicate: true, - }); - - expect(supabase.tables.credit_ledger).toHaveLength(1); - }); - - it("dedupes completed and async payment events for the same checkout session", async () => { - const supabase = new MemorySupabase(); - - await processStripeEvent({ supabase, stripe: fakeStripe(), event: checkoutSessionEvent() }); - await expect( - processStripeEvent({ supabase, stripe: fakeStripe(), event: checkoutSessionEvent({ - id: "evt_async", - type: "checkout.session.async_payment_succeeded", - }) }), - ).resolves.toEqual({ handled: false, duplicate: true }); - - expect(supabase.tables.credit_ledger).toHaveLength(1); - expect(supabase.tables.stripe_events).toHaveLength(2); - }); - - it("terminalizes checkout.session.async_payment_failed without granting credits", async () => { - const supabase = new MemorySupabase(); - supabase.lifecycleSessions.set("cs_async_failed", "open"); - const event = checkoutSessionEvent({ - id: "evt_async_failed", - sessionId: "cs_async_failed", - type: "checkout.session.async_payment_failed", - }); - - await expect(processStripeEvent({ supabase, stripe: fakeStripe(), event })).resolves.toEqual({ - handled: true, - duplicate: false, - }); - await expect(processStripeEvent({ supabase, stripe: fakeStripe(), event })).resolves.toEqual({ - handled: false, - duplicate: true, - }); - await expect(processStripeEvent({ - supabase, - stripe: fakeStripe(), - event: checkoutSessionEvent({ - id: "evt_paid_after_async_failure", - sessionId: "cs_async_failed", - type: "checkout.session.completed", - }), - })).resolves.toEqual({ handled: false, duplicate: true }); - - expect(supabase.lifecycleSessions.get("cs_async_failed")).toBe("payment_failed"); - expect(supabase.lifecycleCustomerIds.get("cs_async_failed")).toBe("cus_123"); - expect(supabase.tables.credit_ledger).toHaveLength(0); - }); - it("does not mint credits for completed sessions that are not paid", async () => { const supabase = new MemorySupabase(); @@ -708,22 +238,6 @@ describe("@pickforge/billing", () => { expect(stripe.refunds.create).not.toHaveBeenCalled(); }); - it("mints paid sessions with a null customer without upserting billing_customers", async () => { - const supabase = new MemorySupabase(); - - await expect( - processStripeEvent({ supabase, stripe: fakeStripe(), event: checkoutSessionEvent({ customer: null }) }), - ).resolves.toEqual({ handled: true, duplicate: false }); - - expect(supabase.tables.billing_customers).toHaveLength(0); - expect(supabase.tables.credit_ledger).toEqual([ - expect.objectContaining({ - amount_cents: 1000, - user_id: USER_ID, - }), - ]); - }); - it.each([ ["missing", undefined], ["zero", 0], diff --git a/packages/billing/test/checkout-lifecycle.contract.test.ts b/packages/billing/test/checkout-lifecycle.contract.test.ts new file mode 100644 index 0000000..2ab9624 --- /dev/null +++ b/packages/billing/test/checkout-lifecycle.contract.test.ts @@ -0,0 +1,778 @@ +// Checkout lifecycle contract lane (issue #37, CAND-1). +// +// Exercises the production `processStripeEvent` / `getCreditBalanceCents` +// billing policy against the REAL durable `checkout_lifecycle_*` SQL RPCs in +// supabase/migrations/20260712193633_checkout_deletion_lifecycle.sql, running +// on a local Supabase Postgres, with only Stripe mocked. Locks, transitions, +// idempotency, attempt history, and cleanup ordering all stay behind the +// `SupabaseClientLike` interface — this file never reimplements lifecycle +// policy, it only asserts on outcomes. +// +// Prerequisite: a local Supabase Postgres via `supabase start` (requires +// Docker). When it is not reachable — e.g. the default CI `check` job, which +// runs `bun run test` without Postgres — every test below is skipped with a +// console message instead of failing the run. CI's `database` job starts +// Supabase and runs this lane for real via `bun run test:supabase`. +import { afterAll, beforeAll, describe, expect, it, mock } from "bun:test"; +import type { SQL } from "bun"; +import { + getCreditBalanceCents, + processStripeEvent, + type StripeClientLike, + type StripeEventLike, + type StripeRefundLike, + type SupabaseClientLike, + type SupabaseQueryResult, +} from "../src/index.js"; +import { + connectToLocalDatabase, + DEFAULT_DATABASE_URL, + insertAuthUser, + resolveLocalDatabaseUrl, + uniqueId, + withRollback, + cleanupLifecycleFixtures, +} from "./lifecycle-fixtures.js"; +import { createPostgresSupabaseClient } from "./postgres-lifecycle-client.js"; + +const sql = await connectToLocalDatabase(); + +if (sql === null) { + console.log( + "[billing contract lane] Skipping checkout-lifecycle Postgres contract tests: " + + `no local Supabase Postgres reachable at ${resolveLocalDatabaseUrl() ?? process.env.SUPABASE_DB_URL ?? DEFAULT_DATABASE_URL}. ` + + "Run `supabase start` (requires Docker) to exercise this lane locally; " + + "CI's `database` job runs it automatically via `bun run test:supabase`.", + ); +} + +describe.skipIf(sql === null)("@pickforge/billing checkout lifecycle contract (local Postgres)", () => { + const db = sql as SQL; + let welcomeCreditsCampaignWasEnabled = true; + + beforeAll(async () => { + // The launch welcome-credit campaign grants an extra 100 cents to every + // new auth user while under its cap. That is orthogonal to checkout + // lifecycle policy, so disable it for this lane's duration rather than + // coupling credit-balance assertions to however many users other test + // runs have already created. + const [campaign] = await db.unsafe( + "select enabled from welcome_credits_private.campaigns where campaign_key = 'launch_welcome_first_50'", + ); + welcomeCreditsCampaignWasEnabled = Boolean(campaign?.enabled); + await db.unsafe( + "update welcome_credits_private.campaigns set enabled = false where campaign_key = 'launch_welcome_first_50'", + ); + }); + + afterAll(async () => { + await db.unsafe( + "update welcome_credits_private.campaigns set enabled = $1 where campaign_key = 'launch_welcome_first_50'", + [welcomeCreditsCampaignWasEnabled], + ); + await db.close(); + }); + + it("credits an unfenced completion once, upserts the Stripe customer, and treats replay as duplicate", async () => { + await withRollback(db, async (tx) => { + const client = createPostgresSupabaseClient(tx); + const userId = crypto.randomUUID(); + const sessionId = uniqueId("cs_credited"); + await insertAuthUser(tx, { id: userId, email: `${uniqueId("contract")}@example.invalid` }); + const stripe = fakeStripe(); + + await expect( + processStripeEvent({ + supabase: client, + stripe, + event: checkoutSessionEvent(userId, { sessionId }), + }), + ).resolves.toEqual({ handled: true, duplicate: false }); + + const [customer] = await tx.unsafe( + "select stripe_customer_id from public.billing_customers where user_id = $1", + [userId], + ); + expect(customer).toMatchObject({ stripe_customer_id: "cus_123" }); + + await expect(getCreditBalanceCents({ supabase: client, userId })).resolves.toBe(1000); + + await expect( + processStripeEvent({ + supabase: client, + stripe, + event: checkoutSessionEvent(userId, { sessionId, id: "evt_replay" }), + }), + ).resolves.toEqual({ handled: false, duplicate: true }); + + await expect(getCreditBalanceCents({ supabase: client, userId })).resolves.toBe(1000); + }); + }); + + it("mints a paid session with a null customer without creating a billing_customers row", async () => { + await withRollback(db, async (tx) => { + const client = createPostgresSupabaseClient(tx); + const userId = crypto.randomUUID(); + await insertAuthUser(tx, { id: userId, email: `${uniqueId("contract")}@example.invalid` }); + + await expect( + processStripeEvent({ + supabase: client, + stripe: fakeStripe(), + event: checkoutSessionEvent(userId, { customer: null }), + }), + ).resolves.toEqual({ handled: true, duplicate: false }); + + const rows = await tx.unsafe( + "select 1 from public.billing_customers where user_id = $1", + [userId], + ); + expect(rows).toHaveLength(0); + }); + }); + + it("refunds a fenced user's completion without granting credit, and is idempotent on replay", async () => { + await withRollback(db, async (tx) => { + const client = createPostgresSupabaseClient(tx); + const userId = crypto.randomUUID(); + const sessionId = uniqueId("cs_fenced"); + await insertAuthUser(tx, { id: userId, email: `${uniqueId("contract")}@example.invalid` }); + await rpc(client, "checkout_lifecycle_fence_deletion", { target_user: userId }); + const stripe = fakeStripe(); + const event = checkoutSessionEvent(userId, { sessionId }); + + await expect(processStripeEvent({ supabase: client, stripe, event })).resolves.toEqual({ + handled: true, + duplicate: false, + reconciliation: "deletion_race_refunded", + }); + await expect(processStripeEvent({ supabase: client, stripe, event })).resolves.toEqual({ + handled: false, + duplicate: true, + reconciliation: "deletion_race_refunded", + }); + + expect(stripe.refunds.create).toHaveBeenCalledTimes(1); + expect(stripe.refunds.create).toHaveBeenCalledWith( + { payment_intent: "pi_123", amount: 1000 }, + { idempotencyKey: `checkout-deletion:${sessionId}:attempt:1` }, + ); + const rows = await tx.unsafe( + "select 1 from public.credit_ledger where stripe_checkout_session_id = $1", + [sessionId], + ); + expect(rows).toHaveLength(0); + await expectSessionState(tx, sessionId, "refunded"); + }); + }); + + it("refunds and cleans up a late customer after deletion finalizes before the completion event lands", async () => { + await withRollback(db, async (tx) => { + const client = createPostgresSupabaseClient(tx); + const userId = crypto.randomUUID(); + const sessionId = uniqueId("cs_after_finalize"); + await insertAuthUser(tx, { id: userId, email: `${uniqueId("contract")}@example.invalid` }); + await rpc(client, "checkout_lifecycle_fence_deletion", { target_user: userId }); + const finalized = await rpc(client, "checkout_lifecycle_finalize_deletion", { + target_user: userId, + }); + expect(finalized.data).toMatchObject({ status: "finalized" }); + const stripe = fakeStripe(); + + await expect( + processStripeEvent({ + supabase: client, + stripe, + event: checkoutSessionEvent(userId, { sessionId }), + }), + ).resolves.toMatchObject({ reconciliation: "deletion_race_refunded" }); + + expect(stripe.refunds.create).toHaveBeenCalledTimes(1); + expect(stripe.customers.del).toHaveBeenCalledWith("cus_123"); + await expectSessionState(tx, sessionId, "refunded"); + + // Auth user still exists (deletion not yet atomically applied); the + // race un-fenced deletion so a future retry can settle safely. + const [fence] = await tx.unsafe( + "select finalized_at is null as unfenced from checkout_lifecycle_private.deletion_fences where user_id = $1", + [userId], + ); + expect(fence).toMatchObject({ unfenced: true }); + }); + }); + + it("immediately refunds a completion whose user is already deleted, retrying customer cleanup without a second refund", async () => { + await withRollback(db, async (tx) => { + const client = createPostgresSupabaseClient(tx); + const userId = crypto.randomUUID(); + const sessionId = uniqueId("cs_deleted"); + // The user never existed in this transaction: reconcile_completion's + // `not exists (select 1 from auth.users ...)` branch fires directly. + await rpc(client, "checkout_lifecycle_fence_deletion", { target_user: userId }); + const stripe = fakeStripe(); + stripe.customers.del.mockRejectedValueOnce(new Error("Stripe unavailable")); + const event = checkoutSessionEvent(userId, { sessionId }); + + await expect(processStripeEvent({ supabase: client, stripe, event })).rejects.toThrow( + "Stripe unavailable", + ); + await expectSessionState(tx, sessionId, "refunded"); + + // Replay carries a different (wrong) customer id; cleanup must still + // target the durably recorded customer, not the replay event's field. + const replay = checkoutSessionEvent(userId, { + sessionId, + id: "evt_cleanup_retry", + customer: "cus_wrong", + }); + await expect( + processStripeEvent({ supabase: client, stripe, event: replay }), + ).resolves.toMatchObject({ reconciliation: "deletion_race_refunded" }); + + expect(stripe.refunds.create).toHaveBeenCalledTimes(1); + expect(stripe.customers.del).toHaveBeenCalledTimes(2); + expect(stripe.customers.del).toHaveBeenNthCalledWith(2, "cus_123"); + expect(stripe.customers.del).not.toHaveBeenCalledWith("cus_wrong"); + const [cleanup] = await tx.unsafe( + "select customer_cleanup_pending from checkout_lifecycle_private.checkout_sessions where stripe_checkout_session_id = $1", + [sessionId], + ); + expect(cleanup).toMatchObject({ customer_cleanup_pending: false }); + }); + }); + + it("keeps a fenced refund retryable when Stripe fails, and recovers with the same durable attempt", async () => { + await withRollback(db, async (tx) => { + const client = createPostgresSupabaseClient(tx); + const userId = crypto.randomUUID(); + const sessionId = uniqueId("cs_refund_retry"); + await insertAuthUser(tx, { id: userId, email: `${uniqueId("contract")}@example.invalid` }); + await rpc(client, "checkout_lifecycle_fence_deletion", { target_user: userId }); + const stripe = fakeStripe(); + stripe.refunds.create.mockRejectedValueOnce(new Error("Stripe unavailable")); + const event = checkoutSessionEvent(userId, { sessionId }); + + await expect(processStripeEvent({ supabase: client, stripe, event })).rejects.toThrow( + "Stripe unavailable", + ); + await expectSessionState(tx, sessionId, "refund_pending"); + + await expect( + processStripeEvent({ supabase: client, stripe, event }), + ).resolves.toMatchObject({ reconciliation: "deletion_race_refunded" }); + expect(stripe.refunds.create).toHaveBeenNthCalledWith( + 2, + { payment_intent: "pi_123", amount: 1000 }, + { idempotencyKey: `checkout-deletion:${sessionId}:attempt:1` }, + ); + await expectSessionState(tx, sessionId, "refunded"); + }); + }); + + it("recovers an attached Refund after a crash before retrieval", async () => { + await withRollback(db, async (tx) => { + const client = createPostgresSupabaseClient(tx); + const userId = crypto.randomUUID(); + const sessionId = uniqueId("cs_attached_recovery"); + await insertAuthUser(tx, { id: userId, email: `${uniqueId("contract")}@example.invalid` }); + await rpc(client, "checkout_lifecycle_fence_deletion", { target_user: userId }); + const stripe = fakeStripe(); + stripe.refunds.create.mockResolvedValue({ id: "re_attached", amount: 1000, status: "pending" }); + stripe.refunds.retrieve + .mockResolvedValueOnce({ id: "re_attached", amount: 1000, status: "pending" }) + .mockResolvedValueOnce({ id: "re_attached", amount: 1000, status: "succeeded" }); + const event = checkoutSessionEvent(userId, { sessionId }); + + await expect(processStripeEvent({ supabase: client, stripe, event })).rejects.toMatchObject({ + code: "refund_incomplete", + }); + await expect(processStripeEvent({ supabase: client, stripe, event })).resolves.toMatchObject({ + reconciliation: "deletion_race_refunded", + }); + + expect(stripe.refunds.create).toHaveBeenCalledTimes(1); + expect(stripe.refunds.retrieve).toHaveBeenCalledTimes(2); + await expectSessionState(tx, sessionId, "refunded"); + }); + }); + + it("completes reconciliation once a replayed asynchronous refund event reports success", async () => { + await withRollback(db, async (tx) => { + const client = createPostgresSupabaseClient(tx); + const userId = crypto.randomUUID(); + const sessionId = uniqueId("cs_async_refund"); + await insertAuthUser(tx, { id: userId, email: `${uniqueId("contract")}@example.invalid` }); + await rpc(client, "checkout_lifecycle_fence_deletion", { target_user: userId }); + const stripe = fakeStripe(); + stripe.refunds.create.mockResolvedValue({ id: "re_pending", amount: 1000, status: "pending" }); + stripe.refunds.retrieve.mockResolvedValueOnce({ id: "re_pending", amount: 1000, status: "pending" }); + const event = checkoutSessionEvent(userId, { sessionId }); + + await expect(processStripeEvent({ supabase: client, stripe, event })).rejects.toMatchObject({ + code: "refund_incomplete", + }); + await expectSessionState(tx, sessionId, "refund_pending"); + + await expect( + processStripeEvent({ + supabase: client, + stripe, + event: refundEvent({ refundId: "re_pending", status: "succeeded", type: "refund.created" }), + }), + ).resolves.toMatchObject({ reconciliation: "deletion_race_refunded" }); + await expectSessionState(tx, sessionId, "refunded"); + }); + }); + + it("retries a signed terminal Refund with a new durable attempt key", async () => { + await withRollback(db, async (tx) => { + const client = createPostgresSupabaseClient(tx); + const userId = crypto.randomUUID(); + const sessionId = uniqueId("cs_signed_retry"); + await insertAuthUser(tx, { id: userId, email: `${uniqueId("contract")}@example.invalid` }); + await rpc(client, "checkout_lifecycle_fence_deletion", { target_user: userId }); + const stripe = fakeStripe(); + stripe.refunds.create + .mockResolvedValueOnce({ id: "re_failed_attempt", amount: 1000, status: "pending" }) + .mockResolvedValueOnce({ id: "re_retry_succeeded", amount: 1000, status: "succeeded" }); + stripe.refunds.retrieve + .mockResolvedValueOnce({ id: "re_failed_attempt", amount: 1000, status: "pending" }) + .mockResolvedValueOnce({ id: "re_retry_succeeded", amount: 1000, status: "succeeded" }); + + await expect( + processStripeEvent({ supabase: client, stripe, event: checkoutSessionEvent(userId, { sessionId }) }), + ).rejects.toMatchObject({ code: "refund_incomplete" }); + + await expect( + processStripeEvent({ + supabase: client, + stripe, + event: refundEvent({ refundId: "re_failed_attempt", status: "failed", type: "refund.failed" }), + }), + ).resolves.toMatchObject({ reconciliation: "deletion_race_refunded" }); + + expect(stripe.refunds.create).toHaveBeenNthCalledWith( + 2, + { payment_intent: "pi_123", amount: 1000 }, + { idempotencyKey: `checkout-deletion:${sessionId}:attempt:2` }, + ); + await expectSessionState(tx, sessionId, "refunded"); + }); + }); + + it("refunds only the remaining amount after Stripe reports a pre-existing partial refund", async () => { + await withRollback(db, async (tx) => { + const client = createPostgresSupabaseClient(tx); + const userId = crypto.randomUUID(); + const sessionId = uniqueId("cs_partial_remaining"); + await insertAuthUser(tx, { id: userId, email: `${uniqueId("contract")}@example.invalid` }); + await rpc(client, "checkout_lifecycle_fence_deletion", { target_user: userId }); + const stripe = fakeStripe(); + stripe.refunds.list.mockResolvedValue({ + data: [{ id: "re_prior", amount: 400, status: "succeeded" }], + has_more: false, + }); + stripe.refunds.create.mockResolvedValue({ id: "re_remaining", amount: 600, status: "succeeded" }); + stripe.refunds.retrieve.mockResolvedValue({ id: "re_remaining", amount: 600, status: "succeeded" }); + + await expect( + processStripeEvent({ + supabase: client, + stripe, + event: checkoutSessionEvent(userId, { sessionId }), + }), + ).resolves.toMatchObject({ reconciliation: "deletion_race_refunded" }); + + expect(stripe.refunds.create).toHaveBeenCalledWith( + { payment_intent: "pi_123", amount: 600 }, + { idempotencyKey: `checkout-deletion:${sessionId}:attempt:1` }, + ); + await expectSessionState(tx, sessionId, "refunded"); + }); + }); + + it("does not refund a pre-lifecycle credited purchase retried after fencing", async () => { + await withRollback(db, async (tx) => { + const client = createPostgresSupabaseClient(tx); + const userId = crypto.randomUUID(); + const sessionId = uniqueId("cs_pre_lifecycle"); + await insertAuthUser(tx, { id: userId, email: `${uniqueId("contract")}@example.invalid` }); + // Simulates a purchase credited before the lifecycle registry ever saw + // the Session (no checkout_lifecycle_private row exists for it yet). + await tx.unsafe( + `insert into public.credit_ledger ( + user_id, amount_cents, kind, stripe_event_id, stripe_checkout_session_id, idempotency_key + ) values ($1, 1000, 'purchase', 'evt_pre_lifecycle', $2, $3)`, + [userId, sessionId, `stripe:${sessionId}`], + ); + await rpc(client, "checkout_lifecycle_fence_deletion", { target_user: userId }); + const stripe = fakeStripe(); + + await expect( + processStripeEvent({ + supabase: client, + stripe, + event: checkoutSessionEvent(userId, { id: "evt_pre_lifecycle_retry", sessionId }), + }), + ).resolves.toMatchObject({ handled: false, duplicate: true }); + + expect(stripe.refunds.create).not.toHaveBeenCalled(); + const rows = await tx.unsafe( + "select 1 from public.credit_ledger where stripe_checkout_session_id = $1", + [sessionId], + ); + expect(rows).toHaveLength(1); + await expectSessionState(tx, sessionId, "completed"); + }); + }); + + it("dedupes checkout.session.completed and async_payment_succeeded events for the same session", async () => { + await withRollback(db, async (tx) => { + const client = createPostgresSupabaseClient(tx); + const userId = crypto.randomUUID(); + const sessionId = uniqueId("cs_dedupe"); + await insertAuthUser(tx, { id: userId, email: `${uniqueId("contract")}@example.invalid` }); + + await processStripeEvent({ + supabase: client, + stripe: fakeStripe(), + event: checkoutSessionEvent(userId, { sessionId }), + }); + await expect( + processStripeEvent({ + supabase: client, + stripe: fakeStripe(), + event: checkoutSessionEvent(userId, { + sessionId, + id: "evt_async", + type: "checkout.session.async_payment_succeeded", + }), + }), + ).resolves.toEqual({ handled: false, duplicate: true }); + + const rows = await tx.unsafe( + "select 1 from public.credit_ledger where stripe_checkout_session_id = $1", + [sessionId], + ); + expect(rows).toHaveLength(1); + }); + }); + + it("terminalizes checkout.session.async_payment_failed without granting credit, and a later completion stays non-credit", async () => { + await withRollback(db, async (tx) => { + const client = createPostgresSupabaseClient(tx); + const userId = crypto.randomUUID(); + const sessionId = uniqueId("cs_async_failed"); + await insertAuthUser(tx, { id: userId, email: `${uniqueId("contract")}@example.invalid` }); + await rpc(client, "checkout_lifecycle_register_session", { + target_user: userId, + checkout_session_id: sessionId, + }); + const event = checkoutSessionEvent(userId, { + id: "evt_async_failed", + sessionId, + type: "checkout.session.async_payment_failed", + }); + + await expect( + processStripeEvent({ supabase: client, stripe: fakeStripe(), event }), + ).resolves.toEqual({ handled: true, duplicate: false }); + await expect( + processStripeEvent({ supabase: client, stripe: fakeStripe(), event }), + ).resolves.toEqual({ handled: false, duplicate: true }); + await expect( + processStripeEvent({ + supabase: client, + stripe: fakeStripe(), + event: checkoutSessionEvent(userId, { + id: "evt_paid_after_async_failure", + sessionId, + type: "checkout.session.completed", + }), + }), + ).resolves.toEqual({ handled: false, duplicate: true }); + + await expectSessionState(tx, sessionId, "payment_failed"); + const rows = await tx.unsafe( + "select 1 from public.credit_ledger where stripe_checkout_session_id = $1", + [sessionId], + ); + expect(rows).toHaveLength(0); + }); + }); + + it("ignores a Refund event for an untracked refund id without mutating lifecycle", async () => { + await withRollback(db, async (tx) => { + const client = createPostgresSupabaseClient(tx); + + await expect( + processStripeEvent({ + supabase: client, + stripe: fakeStripe(), + event: refundEvent({ refundId: "re_unknown" }), + }), + ).resolves.toEqual({ handled: false, duplicate: false }); + }); + }); + + it.each(["failed", "canceled"] as const)( + "durably records a terminal refund status %s without releasing the deletion fence", + async (status) => { + await withRollback(db, async (tx) => { + const client = createPostgresSupabaseClient(tx); + const userId = crypto.randomUUID(); + const sessionId = uniqueId(`cs_${status}`); + await insertAuthUser(tx, { id: userId, email: `${uniqueId("contract")}@example.invalid` }); + await rpc(client, "checkout_lifecycle_fence_deletion", { target_user: userId }); + const stripe = fakeStripe(); + stripe.refunds.create.mockResolvedValue({ id: "re_terminal", amount: 1000, status }); + stripe.refunds.retrieve.mockResolvedValue({ id: "re_terminal", amount: 1000, status }); + + await expect( + processStripeEvent({ + supabase: client, + stripe, + event: checkoutSessionEvent(userId, { sessionId }), + }), + ).rejects.toMatchObject({ code: "refund_terminal_failure" }); + + await expectSessionState(tx, sessionId, "refund_pending"); + const rows = await tx.unsafe( + "select 1 from public.credit_ledger where stripe_checkout_session_id = $1", + [sessionId], + ); + expect(rows).toHaveLength(0); + }); + }, + ); + + it("accepts charge_already_refunded only after verifying a full succeeded refund", async () => { + await withRollback(db, async (tx) => { + const client = createPostgresSupabaseClient(tx); + const userId = crypto.randomUUID(); + const sessionId = uniqueId("cs_already_refunded"); + await insertAuthUser(tx, { id: userId, email: `${uniqueId("contract")}@example.invalid` }); + await rpc(client, "checkout_lifecycle_fence_deletion", { target_user: userId }); + const stripe = fakeStripe(); + stripe.refunds.create.mockRejectedValue({ code: "charge_already_refunded" }); + stripe.refunds.list.mockResolvedValue({ + data: [{ id: "re_existing", amount: 1000, status: "succeeded" }], + has_more: false, + }); + + await expect( + processStripeEvent({ + supabase: client, + stripe, + event: checkoutSessionEvent(userId, { sessionId }), + }), + ).resolves.toMatchObject({ reconciliation: "deletion_race_refunded" }); + + expect(stripe.refunds.retrieve).not.toHaveBeenCalled(); + await expectSessionState(tx, sessionId, "refunded"); + }); + }); + + it("does not accept charge_already_refunded when only a partial refund succeeded", async () => { + await withRollback(db, async (tx) => { + const client = createPostgresSupabaseClient(tx); + const userId = crypto.randomUUID(); + const sessionId = uniqueId("cs_partially_refunded"); + await insertAuthUser(tx, { id: userId, email: `${uniqueId("contract")}@example.invalid` }); + await rpc(client, "checkout_lifecycle_fence_deletion", { target_user: userId }); + const stripe = fakeStripe(); + stripe.refunds.create.mockRejectedValue({ code: "charge_already_refunded" }); + stripe.refunds.list.mockResolvedValue({ + data: [{ id: "re_partial", amount: 500, status: "succeeded" }], + has_more: false, + }); + + await expect( + processStripeEvent({ + supabase: client, + stripe, + event: checkoutSessionEvent(userId, { sessionId }), + }), + ).rejects.toMatchObject({ code: "charge_already_refunded" }); + + await expectSessionState(tx, sessionId, "refund_pending"); + }); + }); + + it("blocks deletion finalization while a Checkout Session is still open, then allows it once settled", async () => { + await withRollback(db, async (tx) => { + const client = createPostgresSupabaseClient(tx); + const userId = crypto.randomUUID(); + const sessionId = uniqueId("cs_open_blocks_finalize"); + await insertAuthUser(tx, { id: userId, email: `${uniqueId("contract")}@example.invalid` }); + + const registered = await rpc(client, "checkout_lifecycle_register_session", { + target_user: userId, + checkout_session_id: sessionId, + }); + expect(registered.data).toBe(false); + + await rpc(client, "checkout_lifecycle_fence_deletion", { target_user: userId }); + const blocked = await rpc(client, "checkout_lifecycle_finalize_deletion", { + target_user: userId, + }); + expect(blocked.data).toMatchObject({ status: "unsafe" }); + + await rpc(client, "checkout_lifecycle_mark_expired", { checkout_session_id: sessionId }); + const finalized = await rpc(client, "checkout_lifecycle_finalize_deletion", { + target_user: userId, + }); + expect(finalized.data).toMatchObject({ status: "finalized" }); + + const deleted = await rpc(client, "checkout_lifecycle_delete_auth_user", { + target_user: userId, + }); + expect(deleted.data).toBe("deleted"); + + const [user] = await tx.unsafe("select 1 as found from auth.users where id = $1", [userId]); + expect(user).toBeUndefined(); + }); + }); + + it("resolves a concurrent completion and deletion fence to exactly one consistent durable outcome", async () => { + const userId = crypto.randomUUID(); + const sessionId = uniqueId("cs_concurrent_race"); + await insertAuthUser(db, { id: userId, email: `${uniqueId("contract")}@example.invalid` }); + + try { + const stripe = fakeStripe(); + const event = checkoutSessionEvent(userId, { sessionId }); + + const [completionOutcome] = await Promise.all([ + db.begin(async (tx) => { + const client = createPostgresSupabaseClient(tx); + return processStripeEvent({ supabase: client, stripe, event }); + }), + db.begin(async (tx) => { + await tx.unsafe( + "select public.checkout_lifecycle_fence_deletion(target_user => $1)", + [userId], + ); + }), + ]); + + const ledgerRows = await db.unsafe( + "select amount_cents from public.credit_ledger where stripe_checkout_session_id = $1", + [sessionId], + ); + const [session] = await db.unsafe( + "select state from checkout_lifecycle_private.checkout_sessions where stripe_checkout_session_id = $1", + [sessionId], + ); + + // The advisory lock in both the completion reconciliation and the fence + // RPC serializes the race: whichever transaction commits first durably + // decides the outcome, and the two effects are mutually exclusive. + if (completionOutcome.handled && !completionOutcome.duplicate && completionOutcome.reconciliation === undefined) { + expect(ledgerRows).toHaveLength(1); + expect(session).toMatchObject({ state: "completed" }); + } else { + expect(completionOutcome).toMatchObject({ reconciliation: "deletion_race_refunded" }); + expect(ledgerRows).toHaveLength(0); + // The mock Stripe refund succeeds synchronously, so the fenced branch + // resolves all the way to "refunded" within this single call, exactly + // like the equivalent non-concurrent fenced-completion test. + expect(session).toMatchObject({ state: "refunded" }); + expect(stripe.refunds.create).toHaveBeenCalledTimes(1); + } + } finally { + await cleanupLifecycleFixtures(db, { userIds: [userId], sessionIds: [sessionId] }); + } + }); +}); + +function checkoutSessionEvent( + userId: string, + overrides: Partial<{ + amountTotal: unknown; + customer: unknown; + id: string; + paymentIntent: unknown; + paymentStatus: string; + sessionId: string; + type: string; + }> = {}, +): StripeEventLike { + const session: Record = { + id: overrides.sessionId ?? "cs_123", + amount_total: "amountTotal" in overrides ? overrides.amountTotal : 1000, + customer: "customer" in overrides ? overrides.customer : "cus_123", + client_reference_id: userId, + payment_status: overrides.paymentStatus ?? "paid", + payment_intent: "paymentIntent" in overrides ? overrides.paymentIntent : "pi_123", + }; + + return { + id: overrides.id ?? "evt_checkout", + type: overrides.type ?? "checkout.session.completed", + data: { object: session }, + }; +} + +function refundEvent(overrides: { + refundId?: string; + status?: string; + amount?: number; + paymentIntent?: unknown; + type?: string; +} = {}): StripeEventLike { + return { + id: `evt_${overrides.refundId ?? "refund"}`, + type: overrides.type ?? "refund.updated", + data: { + object: { + id: overrides.refundId ?? "re_123", + amount: overrides.amount ?? 1000, + status: overrides.status ?? "succeeded", + payment_intent: "paymentIntent" in overrides ? overrides.paymentIntent : "pi_123", + }, + }, + }; +} + +function fakeStripe(options: { event?: StripeEventLike } = {}) { + return { + checkout: { + sessions: { + create: mock(async (params, _options: { idempotencyKey: string }) => ({ + id: "cs_created", + ...params, + })), + }, + }, + customers: { + del: mock(async () => ({})), + }, + refunds: { + create: mock(async () => ({ id: "re_123", amount: 1000, status: "succeeded" })), + retrieve: mock(async (refundId: string) => ({ id: refundId, amount: 1000, status: "succeeded" })), + list: mock(async () => ({ data: [] as StripeRefundLike[], has_more: false })), + }, + webhooks: { + constructEventAsync: mock(async () => options.event ?? checkoutSessionEvent(crypto.randomUUID())), + }, + } satisfies StripeClientLike; +} + +// Mirrors the cast every production RPC call site applies to the +// intentionally loose `SupabaseClientLike.rpc()` signature. +function rpc( + client: SupabaseClientLike, + fn: string, + args?: Record, +): Promise> { + return client.rpc(fn, args) as Promise>; +} + +async function expectSessionState(tx: SQL, sessionId: string, state: string): Promise { + const [session] = await tx.unsafe( + "select state from checkout_lifecycle_private.checkout_sessions where stripe_checkout_session_id = $1", + [sessionId], + ); + expect(session).toMatchObject({ state }); +} diff --git a/packages/billing/test/lifecycle-fixtures.ts b/packages/billing/test/lifecycle-fixtures.ts new file mode 100644 index 0000000..b5df2e6 --- /dev/null +++ b/packages/billing/test/lifecycle-fixtures.ts @@ -0,0 +1,123 @@ +// Local-Postgres contract-lane fixtures. Mirrors the `SUPABASE_DB_URL` / +// localhost-only guard used by `supabase/tests/welcome-credits-concurrency.ts` +// so this lane can never accidentally target a non-disposable database, and +// gracefully reports why it is skipped when no local Supabase is running. +import { SQL } from "bun"; + +export const DEFAULT_DATABASE_URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; + +export function resolveLocalDatabaseUrl(): string | null { + const raw = process.env.SUPABASE_DB_URL ?? DEFAULT_DATABASE_URL; + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return null; + } + const isDisposableLocalDatabase = + ["127.0.0.1", "localhost", "[::1]"].includes(parsed.hostname) && + parsed.port === "54322" && + parsed.pathname === "/postgres" && + parsed.username === "postgres"; + return isDisposableLocalDatabase ? raw : null; +} + +/** + * Connects to the local Supabase Postgres started by `supabase start`. Returns + * `null` (never throws) when the database is unreachable so the contract lane + * can skip cleanly instead of failing CI runs that have no local Postgres. + */ +export async function connectToLocalDatabase(): Promise { + const databaseUrl = resolveLocalDatabaseUrl(); + if (databaseUrl === null) { + return null; + } + + const sql = new SQL(databaseUrl, { max: 10 }); + try { + await sql.unsafe("select 1"); + } catch { + await sql.close().catch(() => {}); + return null; + } + return sql; +} + +export function uniqueId(prefix: string): string { + return `${prefix}_${crypto.randomUUID().replaceAll("-", "")}`; +} + +export async function insertAuthUser( + sql: Pick, + user: { id: string; email: string }, +): Promise { + await sql.unsafe( + `insert into auth.users ( + id, aud, role, email, raw_app_meta_data, raw_user_meta_data, is_anonymous, created_at, updated_at + ) values ($1, 'authenticated', 'authenticated', $2, '{}'::jsonb, '{}'::jsonb, false, now(), now())`, + [user.id, user.email], + ); +} + +/** + * Cleans up rows the contract lane cannot roll back transactionally: used only + * by the true-concurrency test, which needs two independent connections/ + * transactions racing for real, so it commits instead of rolling back. + */ +export async function cleanupLifecycleFixtures( + sql: Pick, + fixtures: { userIds?: string[]; sessionIds?: string[]; eventIds?: string[] }, +): Promise { + const sessionIds = fixtures.sessionIds ?? []; + const userIds = fixtures.userIds ?? []; + const eventIds = fixtures.eventIds ?? []; + + if (sessionIds.length > 0) { + const sessionIdArray = sql.array(sessionIds, "text"); + await sql.unsafe( + "delete from checkout_lifecycle_private.refund_attempts where stripe_checkout_session_id = any($1::text[])", + [sessionIdArray], + ); + await sql.unsafe( + "delete from checkout_lifecycle_private.checkout_sessions where stripe_checkout_session_id = any($1::text[])", + [sessionIdArray], + ); + } + if (eventIds.length > 0) { + await sql.unsafe("delete from public.stripe_events where event_id = any($1::text[])", [ + sql.array(eventIds, "text"), + ]); + } + if (userIds.length > 0) { + const userIdArray = sql.array(userIds, "uuid"); + await sql.unsafe( + "delete from checkout_lifecycle_private.deletion_fences where user_id = any($1::uuid[])", + [userIdArray], + ); + // Cascades billing_customers and credit_ledger rows for the same user. + await sql.unsafe("delete from auth.users where id = any($1::uuid[])", [userIdArray]); + } +} + +/** + * Runs `run` inside a transaction that always rolls back, so each contract + * test is fully isolated without needing bespoke per-table cleanup. Only the + * true-concurrency test opts out of this helper, since it needs two + * independently committing connections. + */ +export async function withRollback( + sql: SQL, + run: (tx: SQL) => Promise, +): Promise { + const rollbackSentinel = Symbol("contract-lane-rollback"); + try { + await sql.begin(async (tx) => { + await run(tx); + throw rollbackSentinel; + }); + } catch (error) { + if (error !== rollbackSentinel) { + throw error; + } + } +} diff --git a/packages/billing/test/postgres-lifecycle-client.ts b/packages/billing/test/postgres-lifecycle-client.ts new file mode 100644 index 0000000..16c1436 --- /dev/null +++ b/packages/billing/test/postgres-lifecycle-client.ts @@ -0,0 +1,239 @@ +// Local-Postgres contract-lane adapter. Implements the same `SupabaseClientLike` +// boundary the production `processStripeEvent` / `getCreditBalanceCents` / +// `listLedgerEntries` code is written against, but backed by a real `bun:sql` +// connection instead of the in-memory fake. No lifecycle transition vocabulary +// is reimplemented here: every `checkout_lifecycle_*` decision is made by the +// durable SQL functions in supabase/migrations, exactly as production does. +import type { SQL } from "bun"; +import type { + SupabaseClientLike, + SupabaseErrorLike, + SupabaseQueryBuilderLike, + SupabaseQueryResult, +} from "../src/index.js"; + +// The subset of checkout_lifecycle_* RPCs whose SQL return type is jsonb: bun's +// postgres driver hands jsonb columns back as text, so these need JSON.parse. +const JSONB_RPCS = new Set([ + "checkout_lifecycle_prepare_refund_attempt", + "checkout_lifecycle_reconcile_refund_event", + "checkout_lifecycle_get_customer_cleanup", + "checkout_lifecycle_finalize_deletion", +]); + +const KNOWN_TABLES = ["billing_customers", "credit_ledger", "stripe_events"] as const; +type KnownTable = (typeof KNOWN_TABLES)[number]; + +export function createPostgresSupabaseClient(sql: SQL): SupabaseClientLike { + return { + from(table: string) { + if (!isKnownTable(table)) { + throw new Error(`Unknown table: ${table}`); + } + return new PostgresQuery(sql, table); + }, + rpc(fn: string, args: Record = {}) { + return callRpc(sql, fn, args); + }, + }; +} + +/** + * Production RPC/query calls each run as their own independent PostgREST + * request/transaction, so a caught error (e.g. a duplicate-key retry) on one + * call never affects another. The contract lane instead nests every call + * inside one outer per-test transaction (for cheap rollback-based cleanup), + * so each call is wrapped in its own SAVEPOINT: an error rolls back only that + * call, keeping the outer transaction usable, exactly like independent + * requests would behave in production. + */ +async function withIsolatedQuery(sql: SQL, run: (handle: SQL) => Promise): Promise { + const transactional = sql as SQL & { + savepoint?: (fn: (sp: SQL) => Promise) => Promise; + }; + if (typeof transactional.savepoint === "function") { + return transactional.savepoint((sp) => run(sp)); + } + return run(sql); +} + +async function callRpc( + sql: SQL, + fn: string, + args: Record, +): Promise> { + const keys = Object.keys(args); + const placeholders = keys.map((key, index) => `${key} => $${index + 1}`).join(", "); + const values = keys.map((key) => toSqlParam(args[key])); + const text = `select public.${fn}(${placeholders}) as result`; + + try { + const rows = await withIsolatedQuery(sql, (handle) => handle.unsafe(text, values)); + const raw = rows[0]?.result; + if (JSONB_RPCS.has(fn)) { + return { data: typeof raw === "string" ? JSON.parse(raw) : (raw ?? null), error: null }; + } + return { data: raw === "" ? null : raw, error: null }; + } catch (error) { + return { data: null, error: toSupabaseError(error) }; + } +} + +function toSqlParam(value: unknown): unknown { + if (value !== null && typeof value === "object") { + return JSON.stringify(value); + } + return value; +} + +function toSupabaseError(error: unknown): SupabaseErrorLike { + if (error !== null && typeof error === "object" && "message" in error) { + const pgError = error as { message: unknown; code?: unknown; errno?: unknown }; + const code = + typeof pgError.errno === "string" + ? pgError.errno + : typeof pgError.code === "string" + ? pgError.code + : undefined; + return { + code, + message: typeof pgError.message === "string" ? pgError.message : String(pgError.message), + }; + } + return { message: String(error) }; +} + +class PostgresQuery implements SupabaseQueryBuilderLike { + private action: "select" | "insert" | "upsert" = "select"; + private values: Record | null = null; + private onConflict: string | undefined; + private readonly filters: Array<{ column: string; value: unknown }> = []; + private orderBy: { column: string; ascending: boolean } | null = null; + private limitCount: number | null = null; + private columns = "*"; + + constructor( + private readonly sql: SQL, + private readonly table: KnownTable, + ) {} + + select(columns = "*"): SupabaseQueryBuilderLike { + this.action = "select"; + this.columns = columns; + return this; + } + + insert(values: unknown): SupabaseQueryBuilderLike { + this.action = "insert"; + this.values = values as Record; + return this; + } + + upsert(values: unknown, options?: { onConflict?: string }): SupabaseQueryBuilderLike { + this.action = "upsert"; + this.values = values as Record; + this.onConflict = options?.onConflict; + return this; + } + + eq(column: string, value: unknown): SupabaseQueryBuilderLike { + this.filters.push({ column, value }); + return this; + } + + order(column: string, options?: { ascending?: boolean }): SupabaseQueryBuilderLike { + this.orderBy = { column, ascending: options?.ascending !== false }; + return this; + } + + limit(count: number): SupabaseQueryBuilderLike { + this.limitCount = count; + return this; + } + + async maybeSingle(): Promise> { + const result = await this.execute(); + const rows = Array.isArray(result.data) ? result.data : result.data === null ? [] : [result.data]; + return { data: (rows[0] ?? null) as T | null, error: result.error }; + } + + then, TResult2 = never>( + onfulfilled?: ((value: SupabaseQueryResult) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): PromiseLike { + return this.execute().then(onfulfilled, onrejected); + } + + private async execute(): Promise> { + try { + if (this.action === "insert") { + const values = this.values ?? {}; + return await withIsolatedQuery(this.sql, (handle) => this.runInsert(handle, values)); + } + if (this.action === "upsert") { + const values = this.values ?? {}; + const onConflict = this.onConflict; + return await withIsolatedQuery(this.sql, (handle) => this.runUpsert(handle, values, onConflict)); + } + return await withIsolatedQuery(this.sql, (handle) => this.runSelect(handle)); + } catch (error) { + return { data: null, error: toSupabaseError(error) }; + } + } + + private async runInsert(handle: SQL, values: Record): Promise> { + const keys = Object.keys(values); + const placeholders = keys.map((_, index) => `$${index + 1}`).join(", "); + const params = keys.map((key) => toSqlParam(values[key])); + const text = `insert into public.${this.table} (${keys.join(",")}) values (${placeholders}) returning *`; + const rows = await handle.unsafe(text, params); + return { data: rows[0] as T, error: null }; + } + + private async runUpsert( + handle: SQL, + values: Record, + onConflict?: string, + ): Promise> { + const keys = Object.keys(values); + const placeholders = keys.map((_, index) => `$${index + 1}`).join(", "); + const params = keys.map((key) => toSqlParam(values[key])); + const conflictColumn = onConflict ?? "id"; + const updates = keys + .filter((key) => key !== conflictColumn) + .map((key) => `${key} = excluded.${key}`) + .join(", "); + const text = `insert into public.${this.table} (${keys.join(",")}) values (${placeholders}) + on conflict (${conflictColumn}) do update set ${updates} + returning *`; + const rows = await handle.unsafe(text, params); + return { data: rows[0] as T, error: null }; + } + + private async runSelect(handle: SQL): Promise> { + const params: unknown[] = []; + const where = this.filters + .map((filter) => { + params.push(filter.value); + return `${filter.column} = $${params.length}`; + }) + .join(" and "); + let text = `select ${this.columns} from public.${this.table}`; + if (where.length > 0) { + text += ` where ${where}`; + } + if (this.orderBy !== null) { + text += ` order by ${this.orderBy.column} ${this.orderBy.ascending ? "asc" : "desc"}`; + } + if (this.limitCount !== null) { + params.push(this.limitCount); + text += ` limit $${params.length}`; + } + const rows = await handle.unsafe(text, params); + return { data: rows as T, error: null }; + } +} + +function isKnownTable(table: string): table is KnownTable { + return (KNOWN_TABLES as readonly string[]).includes(table); +} diff --git a/vitest.config.ts b/vitest.config.ts index 2edbce6..7b0fa3d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,3 +1,4 @@ +import { defaultExclude } from "vitest/config"; import { defineConfig } from "vitest/config"; export default defineConfig({ @@ -15,5 +16,9 @@ export default defineConfig({ }, }, include: ["packages/**/*.test.ts"], + // Runs under `bun test` (needs `bun:sql`/`bun:test`) against a local + // Supabase Postgres via `bun run test:supabase`; excluded here so plain + // `vitest run` stays green without Postgres. + exclude: [...defaultExclude, "packages/**/*.contract.test.ts"], }, });