From c28c94abb9ccd42f1a6c6bf44ad1e05af15d7012 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Sun, 19 Jul 2026 14:09:58 -0300 Subject: [PATCH] refactor(edge,billing): move fence/register/expire RPCs next to the deep lifecycle CAND-2 (issue #37), first slice. create-credit-checkout was the only production adapter for the checkout_lifecycle_is_deletion_fenced / checkout_lifecycle_register_session / checkout_lifecycle_mark_expired RPCs, hand-rolling all three with raw serviceSupabase.rpc() calls even though edge-shared already owns the register/ expire choreography contract via createRegisteredCheckoutSession and already has an equivalent private markCheckoutSessionExpired helper for the deletion flow. The RPC names and call order were duplicated instead of owned once. - edge-shared: export isCheckoutDeletionFenced and registerCheckoutSession (new), and export the existing markCheckoutSessionExpired, so the fence/ register/expire RPC contract for Checkout Session creation lives next to createRegisteredCheckoutSession and createDeleteAccountHandler instead of in a Deno adapter. - create-credit-checkout: drop the three hand-rolled RPC functions and wire the new edge-shared exports into createRegisteredCheckoutSession's callbacks. The adapter is now environment/CORS/request glue only. - Bump @pickforge/* 0.10.0 -> 0.11.0 and re-pin every Deno function's npm: import (existing publish-then-pin convention). Behavior is unchanged: createRegisteredCheckoutSession's DI signature and call order are untouched, so every existing contract/unit test still passes unmodified, and the CAND-1 local-Postgres lifecycle lane (bun run test:supabase) passes unchanged. Remaining orchestration (billing's completion/refund reconciliation vs. edge-shared's deletion fencing/fixpoint/finalization) stays split for a follow-up slice to keep this diff reviewable. --- packages/auth/package.json | 2 +- packages/billing/package.json | 2 +- packages/brand/package.json | 2 +- packages/edge-shared/README.md | 2 +- packages/edge-shared/package.json | 2 +- packages/edge-shared/src/index.ts | 45 ++++++++++++- packages/edge-shared/test/edge-shared.test.ts | 66 +++++++++++++++++++ packages/flags/package.json | 2 +- packages/sync/package.json | 2 +- packages/tauri-release/package.json | 2 +- .../create-credit-checkout/deno.json | 4 +- .../functions/create-credit-checkout/index.ts | 43 ++---------- supabase/functions/delete-account/deno.json | 2 +- .../functions/export-account-data/deno.json | 2 +- supabase/functions/operator-router/deno.json | 4 +- supabase/functions/stripe-webhook/index.ts | 4 +- 16 files changed, 132 insertions(+), 54 deletions(-) diff --git a/packages/auth/package.json b/packages/auth/package.json index 43eb1d3..e5cf176 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@pickforge/auth", - "version": "0.10.0", + "version": "0.11.0", "description": "UI-free Supabase Auth and entitlements wrapper for Pickforge apps.", "license": "MIT", "repository": { diff --git a/packages/billing/package.json b/packages/billing/package.json index 0fef9ab..cadb674 100644 --- a/packages/billing/package.json +++ b/packages/billing/package.json @@ -1,6 +1,6 @@ { "name": "@pickforge/billing", - "version": "0.10.0", + "version": "0.11.0", "description": "UI-free Stripe billing and credit-ledger helpers for Pickforge apps.", "license": "MIT", "repository": { diff --git a/packages/brand/package.json b/packages/brand/package.json index 6f5e08d..105428c 100644 --- a/packages/brand/package.json +++ b/packages/brand/package.json @@ -1,6 +1,6 @@ { "name": "@pickforge/brand", - "version": "0.10.0", + "version": "0.11.0", "description": "Pickforge CSS tokens, fonts, reset, and primitives.", "license": "MIT", "repository": { diff --git a/packages/edge-shared/README.md b/packages/edge-shared/README.md index 8f0db61..bb31059 100644 --- a/packages/edge-shared/README.md +++ b/packages/edge-shared/README.md @@ -73,7 +73,7 @@ Deno.serve(async (req) => { Requesting deletion is terminal: once the fence exists, checkout stays disabled and the deletion endpoint is retry-only until every cleanup or refund finishes and auth deletion succeeds. Registry state `open` means “registered and not yet reconciled or durably marked expired”; it does not assert that Stripe still reports the Session as open. -The lifecycle-aware helpers require a `0.9.0` package publish before deploying functions that import them. +The lifecycle-aware helpers require a `0.9.0` package publish before deploying functions that import them. `isCheckoutDeletionFenced`, `registerCheckoutSession`, and `markCheckoutSessionExpired` wrap the `checkout_lifecycle_is_deletion_fenced`, `checkout_lifecycle_register_session`, and `checkout_lifecycle_mark_expired` RPCs used by `createRegisteredCheckoutSession`'s callbacks, so a Checkout Session creation adapter never re-implements that RPC contract. ## Cross-repo imports diff --git a/packages/edge-shared/package.json b/packages/edge-shared/package.json index b59af67..bfed844 100644 --- a/packages/edge-shared/package.json +++ b/packages/edge-shared/package.json @@ -1,6 +1,6 @@ { "name": "@pickforge/edge-shared", - "version": "0.10.0", + "version": "0.11.0", "description": "Deno-compatible shared helpers for Pickforge Edge Functions.", "license": "MIT", "repository": { diff --git a/packages/edge-shared/src/index.ts b/packages/edge-shared/src/index.ts index 4567aa7..23ebcf5 100644 --- a/packages/edge-shared/src/index.ts +++ b/packages/edge-shared/src/index.ts @@ -1162,7 +1162,46 @@ async function fenceAccountDeletion(admin: AccountAdminClientLike, userId: strin } } -async function markCheckoutSessionExpired( +/** + * Reads whether an account deletion fence is already in effect for a user. + * The sole production caller registers a new Checkout Session (see + * `createRegisteredCheckoutSession`'s `isDeletionFenced` callback); colocated + * here so the RPC name and its result contract have one owner. + */ +export async function isCheckoutDeletionFenced( + admin: Pick, + userId: string, +): Promise { + const { data, error } = await accountRpc(admin, "checkout_lifecycle_is_deletion_fenced", { + target_user: userId, + }); + if (error !== null || typeof data !== "boolean") { + throw databaseError("Failed to read account deletion fence", error ?? invalidRpcResultCause()); + } + return data; +} + +/** + * Registers a newly created Checkout Session against the durable lifecycle, + * returning whether an account-deletion fence raced the registration. See + * `isCheckoutDeletionFenced` for why this lives next to that check. + */ +export async function registerCheckoutSession( + admin: Pick, + userId: string, + sessionId: string, +): Promise { + const { data, error } = await accountRpc(admin, "checkout_lifecycle_register_session", { + target_user: userId, + checkout_session_id: sessionId, + }); + if (error !== null || typeof data !== "boolean") { + throw databaseError("Failed to register Checkout Session", error ?? invalidRpcResultCause()); + } + return data; +} + +export async function markCheckoutSessionExpired( admin: Pick, sessionId: string, ): Promise { @@ -1174,6 +1213,10 @@ async function markCheckoutSessionExpired( } } +function invalidRpcResultCause(): SupabaseErrorLike { + return { message: "rpc returned an invalid result" }; +} + async function runDeletionSettlementPass( admin: AccountAdminClientLike, stripe: StripeCustomerClientLike, diff --git a/packages/edge-shared/test/edge-shared.test.ts b/packages/edge-shared/test/edge-shared.test.ts index e1585ad..74dfb88 100644 --- a/packages/edge-shared/test/edge-shared.test.ts +++ b/packages/edge-shared/test/edge-shared.test.ts @@ -14,8 +14,11 @@ import { getBearerToken, expireCheckoutSession, getUserFromRequest, + isCheckoutDeletionFenced, jsonResponse, + markCheckoutSessionExpired, newIdempotencyKey, + registerCheckoutSession, withCors, operatorRouterSystemPrompt, requireEntitlement, @@ -370,6 +373,69 @@ describe("@pickforge/edge-shared", () => { expect(markSessionExpired).toHaveBeenCalledWith("cs_raced"); }); + it("reads the account deletion fence used to gate new Checkout Session registration", async () => { + const rpc = vi.fn(async (fn: string, args?: Record) => { + expect(fn).toBe("checkout_lifecycle_is_deletion_fenced"); + expect(args).toEqual({ target_user: USER_ID }); + return { data: true, error: null }; + }); + + await expect(isCheckoutDeletionFenced({ rpc }, USER_ID)).resolves.toBe(true); + }); + + it("raises a database_error when the deletion fence RPC fails or returns a non-boolean", async () => { + await expect( + isCheckoutDeletionFenced({ rpc: async () => ({ data: null, error: { message: "down" } }) }, USER_ID), + ).rejects.toMatchObject({ code: "database_error" } satisfies Partial); + + await expect( + isCheckoutDeletionFenced({ rpc: async () => ({ data: "nope", error: null }) }, USER_ID), + ).rejects.toMatchObject({ code: "database_error" } satisfies Partial); + }); + + it("registers a Checkout Session against the durable lifecycle", async () => { + const rpc = vi.fn(async (fn: string, args?: Record) => { + expect(fn).toBe("checkout_lifecycle_register_session"); + expect(args).toEqual({ target_user: USER_ID, checkout_session_id: "cs_registered" }); + return { data: false, error: null }; + }); + + await expect(registerCheckoutSession({ rpc }, USER_ID, "cs_registered")).resolves.toBe(false); + }); + + it("raises a database_error when Checkout Session registration fails or returns a non-boolean", async () => { + await expect( + registerCheckoutSession( + { rpc: async () => ({ data: null, error: { message: "down" } }) }, + USER_ID, + "cs_x", + ), + ).rejects.toMatchObject({ code: "database_error" } satisfies Partial); + + await expect( + registerCheckoutSession({ rpc: async () => ({ data: 1, error: null }) }, USER_ID, "cs_x"), + ).rejects.toMatchObject({ code: "database_error" } satisfies Partial); + }); + + it("marks a Checkout Session expired against the durable lifecycle", async () => { + const rpc = vi.fn(async (fn: string, args?: Record) => { + expect(fn).toBe("checkout_lifecycle_mark_expired"); + expect(args).toEqual({ checkout_session_id: "cs_expired" }); + return { data: null, error: null }; + }); + + await expect(markCheckoutSessionExpired({ rpc }, "cs_expired")).resolves.toBeUndefined(); + }); + + it("raises a database_error when marking a Checkout Session expired fails", async () => { + await expect( + markCheckoutSessionExpired( + { rpc: async () => ({ data: null, error: { message: "down" } }) }, + "cs_expired", + ), + ).rejects.toMatchObject({ code: "database_error" } satisfies Partial); + }); + it("recognizes a completed Session without trying to expire it", async () => { const stripe = checkoutStripe(); stripe.checkout.sessions.retrieve = vi.fn(async () => ({ diff --git a/packages/flags/package.json b/packages/flags/package.json index 3dd4303..15b6935 100644 --- a/packages/flags/package.json +++ b/packages/flags/package.json @@ -1,6 +1,6 @@ { "name": "@pickforge/flags", - "version": "0.10.0", + "version": "0.11.0", "description": "UI-free feature-flag registry for release gating in Pickforge apps.", "license": "MIT", "repository": { diff --git a/packages/sync/package.json b/packages/sync/package.json index f6768ee..f1d2662 100644 --- a/packages/sync/package.json +++ b/packages/sync/package.json @@ -1,6 +1,6 @@ { "name": "@pickforge/sync", - "version": "0.10.0", + "version": "0.11.0", "description": "UI-free settings sync helpers for Pickforge apps.", "license": "MIT", "repository": { diff --git a/packages/tauri-release/package.json b/packages/tauri-release/package.json index 6401e66..7a28a19 100644 --- a/packages/tauri-release/package.json +++ b/packages/tauri-release/package.json @@ -1,6 +1,6 @@ { "name": "@pickforge/tauri-release", - "version": "0.10.0", + "version": "0.11.0", "description": "Signed Tauri release and updater-feed automation for Pickforge apps.", "license": "MIT", "repository": { diff --git a/supabase/functions/create-credit-checkout/deno.json b/supabase/functions/create-credit-checkout/deno.json index 917b618..36cf97a 100644 --- a/supabase/functions/create-credit-checkout/deno.json +++ b/supabase/functions/create-credit-checkout/deno.json @@ -2,7 +2,7 @@ "imports": { "stripe": "npm:stripe@19.1.0", "@supabase/supabase-js": "npm:@supabase/supabase-js@2.110.0", - "@pickforge/billing": "npm:@pickforge/billing@0.10.0", - "@pickforge/edge-shared": "npm:@pickforge/edge-shared@0.10.0" + "@pickforge/billing": "npm:@pickforge/billing@0.11.0", + "@pickforge/edge-shared": "npm:@pickforge/edge-shared@0.11.0" } } diff --git a/supabase/functions/create-credit-checkout/index.ts b/supabase/functions/create-credit-checkout/index.ts index d5d2d98..de5dc24 100644 --- a/supabase/functions/create-credit-checkout/index.ts +++ b/supabase/functions/create-credit-checkout/index.ts @@ -9,7 +9,10 @@ import { createRequiredEnv, EdgeSharedError, getUserFromRequest, + isCheckoutDeletionFenced, jsonResponse, + markCheckoutSessionExpired, + registerCheckoutSession, } from "@pickforge/edge-shared"; const requiredEnv = createRequiredEnv(Deno.env); @@ -40,7 +43,7 @@ Deno.serve(async (req) => { const session = await createRegisteredCheckoutSession({ stripe, userId, - isDeletionFenced, + isDeletionFenced: (id) => isCheckoutDeletionFenced(serviceSupabase, id), createSession: async () => { const created = await createCreditCheckoutSession({ stripe, @@ -56,8 +59,8 @@ Deno.serve(async (req) => { } return { id: created.id, url: created.url }; }, - registerSession: registerCheckoutSession, - markSessionExpired: markCheckoutSessionExpired, + registerSession: (id, sessionId) => registerCheckoutSession(serviceSupabase, id, sessionId), + markSessionExpired: (sessionId) => markCheckoutSessionExpired(serviceSupabase, sessionId), }); return respond(200, { url: session.url }); @@ -92,40 +95,6 @@ async function readExistingCustomerId(userId: string): Promise { - const { data, error } = await serviceSupabase - .rpc("checkout_lifecycle_is_deletion_fenced", { target_user: userId }) - .overrideTypes(); - if (error !== null || typeof data !== "boolean") { - throw new Error("Failed to read account deletion fence", { cause: error }); - } - - return data; -} - -async function registerCheckoutSession(userId: string, sessionId: string): Promise { - const { data, error } = await serviceSupabase - .rpc("checkout_lifecycle_register_session", { - target_user: userId, - checkout_session_id: sessionId, - }) - .overrideTypes(); - if (error !== null || typeof data !== "boolean") { - throw new Error("Failed to register Checkout Session", { cause: error }); - } - - return data; -} - -async function markCheckoutSessionExpired(sessionId: string): Promise { - const { error } = await serviceSupabase.rpc("checkout_lifecycle_mark_expired", { - checkout_session_id: sessionId, - }); - if (error !== null) { - throw new Error("Failed to mark Checkout Session expired", { cause: error }); - } -} - function respond(status: number, body: unknown): Response { return jsonResponse(status, body, corsHeaders()); } diff --git a/supabase/functions/delete-account/deno.json b/supabase/functions/delete-account/deno.json index 499496a..3cdd028 100644 --- a/supabase/functions/delete-account/deno.json +++ b/supabase/functions/delete-account/deno.json @@ -2,6 +2,6 @@ "imports": { "stripe": "npm:stripe@19.1.0", "@supabase/supabase-js": "npm:@supabase/supabase-js@2.110.0", - "@pickforge/edge-shared": "npm:@pickforge/edge-shared@0.10.0" + "@pickforge/edge-shared": "npm:@pickforge/edge-shared@0.11.0" } } diff --git a/supabase/functions/export-account-data/deno.json b/supabase/functions/export-account-data/deno.json index 8882a0e..8471f20 100644 --- a/supabase/functions/export-account-data/deno.json +++ b/supabase/functions/export-account-data/deno.json @@ -1,6 +1,6 @@ { "imports": { "@supabase/supabase-js": "npm:@supabase/supabase-js@2.110.0", - "@pickforge/edge-shared": "npm:@pickforge/edge-shared@0.10.0" + "@pickforge/edge-shared": "npm:@pickforge/edge-shared@0.11.0" } } diff --git a/supabase/functions/operator-router/deno.json b/supabase/functions/operator-router/deno.json index 0f33fcb..d97951e 100644 --- a/supabase/functions/operator-router/deno.json +++ b/supabase/functions/operator-router/deno.json @@ -1,7 +1,7 @@ { "imports": { "@supabase/supabase-js": "npm:@supabase/supabase-js@2.110.0", - "@pickforge/billing": "npm:@pickforge/billing@0.10.0", - "@pickforge/edge-shared": "npm:@pickforge/edge-shared@0.10.0" + "@pickforge/billing": "npm:@pickforge/billing@0.11.0", + "@pickforge/edge-shared": "npm:@pickforge/edge-shared@0.11.0" } } diff --git a/supabase/functions/stripe-webhook/index.ts b/supabase/functions/stripe-webhook/index.ts index 7427daf..2134101 100644 --- a/supabase/functions/stripe-webhook/index.ts +++ b/supabase/functions/stripe-webhook/index.ts @@ -1,7 +1,7 @@ import Stripe from "npm:stripe@19.1.0"; import { createClient } from "npm:@supabase/supabase-js@2.110.0"; -import { processStripeEvent, verifyStripeEvent } from "npm:@pickforge/billing@0.10.0"; -import { createRequiredEnv, createStripeWebhookHandler } from "npm:@pickforge/edge-shared@0.10.0"; +import { processStripeEvent, verifyStripeEvent } from "npm:@pickforge/billing@0.11.0"; +import { createRequiredEnv, createStripeWebhookHandler } from "npm:@pickforge/edge-shared@0.11.0"; const requiredEnv = createRequiredEnv(Deno.env); const stripe = new Stripe(requiredEnv("STRIPE_SECRET_KEY"));