From 3441f5f3fdd8818c3f4649c05d32a888537dd54f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Fri, 10 Jul 2026 02:01:36 -0300 Subject: [PATCH 1/4] feat(edge): add account deletion and data export functions --- packages/auth/package.json | 2 +- packages/billing/package.json | 2 +- packages/brand/package.json | 2 +- packages/edge-shared/package.json | 2 +- packages/edge-shared/src/index.ts | 173 +++++++++++++++ packages/edge-shared/test/edge-shared.test.ts | 203 ++++++++++++++++++ packages/flags/package.json | 2 +- packages/sync/package.json | 2 +- packages/tauri-release/package.json | 2 +- supabase/config.toml | 6 + supabase/functions/delete-account/deno.json | 7 + supabase/functions/delete-account/index.ts | 56 +++++ .../functions/export-account-data/deno.json | 6 + .../functions/export-account-data/index.ts | 53 +++++ 14 files changed, 511 insertions(+), 7 deletions(-) create mode 100644 supabase/functions/delete-account/deno.json create mode 100644 supabase/functions/delete-account/index.ts create mode 100644 supabase/functions/export-account-data/deno.json create mode 100644 supabase/functions/export-account-data/index.ts diff --git a/packages/auth/package.json b/packages/auth/package.json index bdb76e2..2dff72c 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@pickforge/auth", - "version": "0.7.1", + "version": "0.8.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 6f3a125..66baddc 100644 --- a/packages/billing/package.json +++ b/packages/billing/package.json @@ -1,6 +1,6 @@ { "name": "@pickforge/billing", - "version": "0.7.1", + "version": "0.8.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 fbe41ef..bb6db5b 100644 --- a/packages/brand/package.json +++ b/packages/brand/package.json @@ -1,6 +1,6 @@ { "name": "@pickforge/brand", - "version": "0.7.1", + "version": "0.8.0", "description": "Pickforge CSS tokens, fonts, reset, and primitives.", "license": "MIT", "repository": { diff --git a/packages/edge-shared/package.json b/packages/edge-shared/package.json index e48993a..78129af 100644 --- a/packages/edge-shared/package.json +++ b/packages/edge-shared/package.json @@ -1,6 +1,6 @@ { "name": "@pickforge/edge-shared", - "version": "0.7.1", + "version": "0.8.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 d4ea969..a4c6ab6 100644 --- a/packages/edge-shared/src/index.ts +++ b/packages/edge-shared/src/index.ts @@ -41,6 +41,7 @@ export interface SupabaseAuthLike { export interface SupabaseQueryBuilderLike extends PromiseLike> { select(columns?: string): SupabaseQueryBuilderLike; eq(column: string, value: unknown): SupabaseQueryBuilderLike; + is(column: string, value: null): SupabaseQueryBuilderLike; maybeSingle(): PromiseLike>; } @@ -176,6 +177,72 @@ export interface CreateOperatorRouterHandlerOptions { systemPrompt?: string; } +export interface AccountAdminClientLike { + auth: { + admin: { + deleteUser(userId: string): PromiseLike<{ error: SupabaseErrorLike | null }>; + }; + }; + from(table: string): SupabaseQueryBuilderLike; +} + +export interface StripeCustomerClientLike { + customers: { + del(customerId: string): PromiseLike; + }; +} + +export interface CreateDeleteAccountHandlerOptions { + admin: AccountAdminClientLike; + stripe: StripeCustomerClientLike; + resolveUserId(req: Request): Promise; +} + +export interface CreateExportAccountHandlerOptions { + admin: Pick; + resolveUserId(req: Request): Promise; +} + +interface BillingCustomerRow { + stripe_customer_id: string | null; +} + +interface AccountProfileRow { + id: string; + email: string; + display_name: string | null; + avatar_url: string | null; + created_at: string; + updated_at: string; +} + +interface AccountEntitlementRow { + id: string; + key: string; + value: EdgeSharedJson; + granted_at: string; + expires_at: string | null; + source: string; +} + +interface AccountCreditLedgerRow { + id: string; + amount_cents: number; + kind: string; + description: string | null; + stripe_event_id: string | null; + stripe_checkout_session_id: string | null; + metadata: EdgeSharedJson; + created_at: string; + idempotency_key: string; +} + +interface AccountSyncedSettingRow { + field_group: string; + payload: EdgeSharedJson; + updated_at: string; +} + export interface StoredRouteProposal { proposalJson: string; usage: ChatCompletionUsage; @@ -441,6 +508,100 @@ export function createOperatorRouterHandler({ }; } +export function createDeleteAccountHandler({ + admin, + stripe, + resolveUserId, +}: CreateDeleteAccountHandlerOptions): (req: Request) => Promise { + return async (req: Request): Promise => { + try { + const userId = await resolveUserId(req); + const { data: billingCustomer, error: billingCustomerError } = await admin + .from("billing_customers") + .select("stripe_customer_id") + .eq("user_id", userId) + .maybeSingle(); + if (billingCustomerError !== null) { + throw databaseError("Failed to read billing customer", billingCustomerError); + } + + if (typeof billingCustomer?.stripe_customer_id === "string" && billingCustomer.stripe_customer_id.length > 0) { + try { + // Stripe deletion is best-effort because it retains transaction records for legal and fiscal obligations. + await stripe.customers.del(billingCustomer.stripe_customer_id); + } catch {} + } + + const { error } = await admin.auth.admin.deleteUser(userId); + if (error !== null && !isUserNotFoundError(error)) { + throw new Error("Failed to delete user", { cause: error }); + } + + return jsonResponse(200, { deleted: true }); + } catch (error) { + return accountErrorResponse(error); + } + }; +} + +export function createExportAccountHandler({ + admin, + resolveUserId, +}: CreateExportAccountHandlerOptions): (req: Request) => Promise { + return async (req: Request): Promise => { + try { + const userId = await resolveUserId(req); + const [profileResult, entitlementsResult, creditLedgerResult, syncedSettingsResult, billingResult] = await Promise.all([ + admin + .from("profiles") + .select("id,email,display_name,avatar_url,created_at,updated_at") + .eq("id", userId) + .maybeSingle(), + admin + .from("entitlements") + .select("id,key,value,granted_at,expires_at,source") + .eq("user_id", userId), + admin + .from("credit_ledger") + .select("id,amount_cents,kind,description,stripe_event_id,stripe_checkout_session_id,metadata,created_at,idempotency_key") + .eq("user_id", userId), + admin + .from("settings_sync") + .select("field_group,payload,updated_at") + .eq("user_id", userId) + .is("deleted_at", null), + admin + .from("billing_customers") + .select("stripe_customer_id") + .eq("user_id", userId) + .maybeSingle(), + ]); + + for (const result of [profileResult, entitlementsResult, creditLedgerResult, syncedSettingsResult, billingResult]) { + if (result.error !== null) { + throw databaseError("Failed to export account data", result.error); + } + } + + const stripeCustomerId = billingResult.data?.stripe_customer_id ?? null; + return jsonResponse(200, { + version: 1, + exportedAt: new Date().toISOString(), + profile: profileResult.data, + entitlements: entitlementsResult.data ?? [], + creditLedger: creditLedgerResult.data ?? [], + syncedSettings: syncedSettingsResult.data ?? [], + billing: { + hasStripeCustomer: stripeCustomerId !== null, + stripeCustomerId, + }, + }); + } catch (error) { + return accountErrorResponse(error); + } + }; +} + export function jsonResponse(status: number, body: unknown, headers: HeadersInit = {}): Response { return new Response(JSON.stringify(body), { status, @@ -667,3 +828,15 @@ function invalidRpcResult(): EdgeSharedError { function databaseError(message: string, cause: SupabaseErrorLike): EdgeSharedError { return new EdgeSharedError("database_error", message, { cause }); } + +function accountErrorResponse(error: unknown): Response { + if (error instanceof EdgeSharedError) { + return jsonResponse(error.code === "unauthorized" ? 401 : 400, { error: error.code }); + } + + return jsonResponse(500, { error: "internal_error" }); +} + +function isUserNotFoundError(error: SupabaseErrorLike): boolean { + return error.code === "user_not_found" || /user not found/i.test(error.message); +} diff --git a/packages/edge-shared/test/edge-shared.test.ts b/packages/edge-shared/test/edge-shared.test.ts index e8c1243..a2509db 100644 --- a/packages/edge-shared/test/edge-shared.test.ts +++ b/packages/edge-shared/test/edge-shared.test.ts @@ -3,6 +3,8 @@ import { EdgeSharedError, assertRouteRequest, corsPreflightResponse, + createDeleteAccountHandler, + createExportAccountHandler, createOperatorRouterHandler, createStripeWebhookHandler, debitCredits, @@ -243,6 +245,148 @@ describe("@pickforge/edge-shared", () => { ); }); + it("deletes the Stripe customer before deleting the authenticated user", async () => { + const admin = accountAdmin({ billing_customers: [{ user_id: USER_ID, stripe_customer_id: "cus_123" }] }); + const stripe = { customers: { del: vi.fn(async () => ({})) } }; + const handler = createDeleteAccountHandler({ + admin, + stripe, + resolveUserId: vi.fn(async () => USER_ID), + }); + + const response = await handler(new Request("https://edge.test", { method: "POST" })); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ deleted: true }); + expect(stripe.customers.del).toHaveBeenCalledWith("cus_123"); + expect(admin.auth.admin.deleteUser).toHaveBeenCalledWith(USER_ID); + expect(stripe.customers.del.mock.invocationCallOrder[0]!).toBeLessThan(admin.auth.admin.deleteUser.mock.invocationCallOrder[0]!); + }); + + it("deletes the user without a Stripe customer and tolerates Stripe failures", async () => { + const noCustomerAdmin = accountAdmin(); + const noCustomerStripe = { customers: { del: vi.fn(async () => ({})) } }; + const noCustomerHandler = createDeleteAccountHandler({ + admin: noCustomerAdmin, + stripe: noCustomerStripe, + resolveUserId: vi.fn(async () => USER_ID), + }); + + await expect(noCustomerHandler(new Request("https://edge.test", { method: "POST" }))).resolves.toMatchObject({ + status: 200, + }); + expect(noCustomerStripe.customers.del).not.toHaveBeenCalled(); + expect(noCustomerAdmin.auth.admin.deleteUser).toHaveBeenCalledWith(USER_ID); + + const failingStripeAdmin = accountAdmin({ billing_customers: [{ user_id: USER_ID, stripe_customer_id: "cus_456" }] }); + const failingStripe = { customers: { del: vi.fn(async () => Promise.reject(new Error("Stripe unavailable"))) } }; + const failingStripeHandler = createDeleteAccountHandler({ + admin: failingStripeAdmin, + stripe: failingStripe, + resolveUserId: vi.fn(async () => USER_ID), + }); + + await expect(failingStripeHandler(new Request("https://edge.test", { method: "POST" }))).resolves.toMatchObject({ + status: 200, + }); + expect(failingStripeAdmin.auth.admin.deleteUser).toHaveBeenCalledWith(USER_ID); + }); + + it("treats a missing auth user as deleted and returns 500 for other deletion failures", async () => { + const missingUserAdmin = accountAdmin(); + missingUserAdmin.auth.admin.deleteUser.mockResolvedValue({ error: { code: "user_not_found", message: "User not found" } }); + const missingUserHandler = createDeleteAccountHandler({ + admin: missingUserAdmin, + stripe: { customers: { del: vi.fn() } }, + resolveUserId: vi.fn(async () => USER_ID), + }); + + const missingUserResponse = await missingUserHandler(new Request("https://edge.test", { method: "POST" })); + expect(missingUserResponse.status).toBe(200); + await expect(missingUserResponse.json()).resolves.toEqual({ deleted: true }); + + const failedDeleteAdmin = accountAdmin(); + failedDeleteAdmin.auth.admin.deleteUser.mockResolvedValue({ error: { message: "Database unavailable" } }); + const failedDeleteHandler = createDeleteAccountHandler({ + admin: failedDeleteAdmin, + stripe: { customers: { del: vi.fn() } }, + resolveUserId: vi.fn(async () => USER_ID), + }); + + const failedDeleteResponse = await failedDeleteHandler(new Request("https://edge.test", { method: "POST" })); + expect(failedDeleteResponse.status).toBe(500); + await expect(failedDeleteResponse.json()).resolves.toEqual({ error: "internal_error" }); + }); + + it("returns unauthorized for account deletion without an authenticated user", async () => { + const handler = createDeleteAccountHandler({ + admin: accountAdmin(), + stripe: { customers: { del: vi.fn() } }, + resolveUserId: async () => { + throw new EdgeSharedError("unauthorized", "Unauthorized"); + }, + }); + + const response = await handler(new Request("https://edge.test", { method: "POST" })); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toEqual({ error: "unauthorized" }); + }); + + it("exports every portable account data section", async () => { + const handler = createExportAccountHandler({ + admin: accountAdmin({ + profiles: [{ id: USER_ID, email: "dev@pickforge.test", display_name: "Dev", avatar_url: null, created_at: "2026-01-01T00:00:00.000Z", updated_at: "2026-01-02T00:00:00.000Z" }], + entitlements: [{ user_id: USER_ID, id: "ent_123", key: "pro", value: true, granted_at: "2026-01-01T00:00:00.000Z", expires_at: null, source: "stripe" }], + credit_ledger: [{ user_id: USER_ID, id: "ledger_123", amount_cents: 500, kind: "credit", description: "Pack", stripe_event_id: "evt_123", stripe_checkout_session_id: "cs_123", metadata: { pack: "p10" }, created_at: "2026-01-01T00:00:00.000Z", idempotency_key: "stripe:evt_123" }], + settings_sync: [ + { user_id: USER_ID, field_group: "preferences", payload: { theme: "dark" }, updated_at: "2026-01-01T00:00:00.000Z", deleted_at: null }, + { user_id: USER_ID, field_group: "archive", payload: {}, updated_at: "2026-01-01T00:00:00.000Z", deleted_at: "2026-01-02T00:00:00.000Z" }, + ], + billing_customers: [{ user_id: USER_ID, stripe_customer_id: "cus_123" }], + }), + resolveUserId: vi.fn(async () => USER_ID), + }); + + const response = await handler(new Request("https://edge.test", { method: "POST" })); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + version: 1, + profile: { id: USER_ID, email: "dev@pickforge.test" }, + entitlements: [{ id: "ent_123", key: "pro" }], + creditLedger: [{ id: "ledger_123", amount_cents: 500 }], + syncedSettings: [{ field_group: "preferences" }], + billing: { hasStripeCustomer: true, stripeCustomerId: "cus_123" }, + }); + }); + + it("exports empty account sections and returns unauthorized without an authenticated user", async () => { + const emptyHandler = createExportAccountHandler({ + admin: accountAdmin(), + resolveUserId: vi.fn(async () => USER_ID), + }); + + const emptyResponse = await emptyHandler(new Request("https://edge.test", { method: "POST" })); + await expect(emptyResponse.json()).resolves.toMatchObject({ + profile: null, + entitlements: [], + creditLedger: [], + syncedSettings: [], + billing: { hasStripeCustomer: false, stripeCustomerId: null }, + }); + + const unauthorizedHandler = createExportAccountHandler({ + admin: accountAdmin(), + resolveUserId: async () => { + throw new EdgeSharedError("unauthorized", "Unauthorized"); + }, + }); + const unauthorizedResponse = await unauthorizedHandler(new Request("https://edge.test", { method: "POST" })); + expect(unauthorizedResponse.status).toBe(401); + await expect(unauthorizedResponse.json()).resolves.toEqual({ error: "unauthorized" }); + }); + it("handles verified Stripe webhook requests", async () => { const stripe = {}; const supabase = {}; @@ -664,6 +808,61 @@ interface EntitlementRow { expires_at: string | null; } +function accountAdmin(rows: Record = {}) { + return { + auth: { + admin: { + deleteUser: vi.fn<(userId: string) => Promise<{ error: SupabaseErrorLike | null }>>( + async (_userId) => ({ error: null }), + ), + }, + }, + from(table: string): AccountQuery { + return new AccountQuery(rows[table] ?? []); + }, + }; +} + +class AccountQuery implements SupabaseQueryBuilderLike { + private readonly filters: Array<{ column: string; value: unknown }> = []; + + constructor(private readonly rows: unknown[]) {} + + select(): SupabaseQueryBuilderLike { + return this; + } + + eq(column: string, value: unknown): SupabaseQueryBuilderLike { + this.filters.push({ column, value }); + return this; + } + + is(column: string, value: null): SupabaseQueryBuilderLike { + this.filters.push({ column, value }); + return this; + } + + async maybeSingle(): Promise> { + return { data: (this.filteredRows()[0] ?? null) as T | null, error: null }; + } + + then, TResult2 = never>( + onfulfilled?: ((value: SupabaseQueryResult) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): PromiseLike { + return Promise.resolve({ data: this.filteredRows() as T, error: null }).then(onfulfilled, onrejected); + } + + private filteredRows(): unknown[] { + return this.rows.filter( + (row) => + typeof row === "object" && + row !== null && + this.filters.every((filter) => (row as Record)[filter.column] === filter.value), + ); + } +} + class MemorySupabase implements SupabaseClientLike { readonly entitlements: EntitlementRow[] = []; readonly rpcCalls: Array<{ fn: string; args?: Record }> = []; @@ -712,6 +911,10 @@ class MemoryQuery implements SupabaseQueryBuilderLike { return this; } + is(column: string, value: null): SupabaseQueryBuilderLike { + return this.eq(column, value); + } + async maybeSingle(): Promise> { const rows = this.rows(); diff --git a/packages/flags/package.json b/packages/flags/package.json index 43b5ff4..d4cd131 100644 --- a/packages/flags/package.json +++ b/packages/flags/package.json @@ -1,6 +1,6 @@ { "name": "@pickforge/flags", - "version": "0.7.1", + "version": "0.8.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 ddf5480..8b7d407 100644 --- a/packages/sync/package.json +++ b/packages/sync/package.json @@ -1,6 +1,6 @@ { "name": "@pickforge/sync", - "version": "0.7.1", + "version": "0.8.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 bc92a98..1d64480 100644 --- a/packages/tauri-release/package.json +++ b/packages/tauri-release/package.json @@ -1,6 +1,6 @@ { "name": "@pickforge/tauri-release", - "version": "0.7.1", + "version": "0.8.0", "description": "Signed Tauri release and updater-feed automation for Pickforge apps.", "license": "MIT", "repository": { diff --git a/supabase/config.toml b/supabase/config.toml index fe948c7..8b8f20d 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -6,3 +6,9 @@ verify_jwt = false [functions.create-credit-checkout] verify_jwt = false + +[functions.delete-account] +verify_jwt = false + +[functions.export-account-data] +verify_jwt = false diff --git a/supabase/functions/delete-account/deno.json b/supabase/functions/delete-account/deno.json new file mode 100644 index 0000000..969b5ee --- /dev/null +++ b/supabase/functions/delete-account/deno.json @@ -0,0 +1,7 @@ +{ + "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.8.0" + } +} diff --git a/supabase/functions/delete-account/index.ts b/supabase/functions/delete-account/index.ts new file mode 100644 index 0000000..30b42b5 --- /dev/null +++ b/supabase/functions/delete-account/index.ts @@ -0,0 +1,56 @@ +import Stripe from "stripe"; +import { createClient } from "@supabase/supabase-js"; +import { + corsHeaders, + corsPreflightResponse, + createDeleteAccountHandler, + getUserFromRequest, +} from "@pickforge/edge-shared"; + +const supabaseUrl = requiredEnv("SUPABASE_URL"); +const supabaseAnonKey = requiredEnv("SUPABASE_ANON_KEY"); +const serviceSupabase = createClient(supabaseUrl, requiredEnv("SUPABASE_SERVICE_ROLE_KEY"), { + auth: { autoRefreshToken: false, persistSession: false }, +}); +const stripe = new Stripe(requiredEnv("STRIPE_SECRET_KEY")); + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") { + return corsPreflightResponse(); + } + + const handler = createDeleteAccountHandler({ + admin: serviceSupabase, + stripe, + resolveUserId: async (request) => { + const { userId } = await getUserFromRequest({ supabase: createCallerSupabase(request), req: request }); + return userId; + }, + }); + return withCors(handler(req)); +}); + +function createCallerSupabase(req: Request) { + return createClient(supabaseUrl, supabaseAnonKey, { + auth: { autoRefreshToken: false, persistSession: false }, + global: { headers: { Authorization: req.headers.get("authorization") ?? "" } }, + }); +} + +async function withCors(response: Promise): Promise { + const resolved = await response; + const headers = new Headers(resolved.headers); + for (const [name, value] of Object.entries(corsHeaders())) { + headers.set(name, value); + } + return new Response(resolved.body, { status: resolved.status, headers }); +} + +function requiredEnv(name: string): string { + const value = Deno.env.get(name); + if (value === undefined || value.length === 0) { + throw new Error(`${name} is required`); + } + + return value; +} diff --git a/supabase/functions/export-account-data/deno.json b/supabase/functions/export-account-data/deno.json new file mode 100644 index 0000000..07d7482 --- /dev/null +++ b/supabase/functions/export-account-data/deno.json @@ -0,0 +1,6 @@ +{ + "imports": { + "@supabase/supabase-js": "npm:@supabase/supabase-js@2.110.0", + "@pickforge/edge-shared": "npm:@pickforge/edge-shared@0.8.0" + } +} diff --git a/supabase/functions/export-account-data/index.ts b/supabase/functions/export-account-data/index.ts new file mode 100644 index 0000000..6e217c4 --- /dev/null +++ b/supabase/functions/export-account-data/index.ts @@ -0,0 +1,53 @@ +import { createClient } from "@supabase/supabase-js"; +import { + corsHeaders, + corsPreflightResponse, + createExportAccountHandler, + getUserFromRequest, +} from "@pickforge/edge-shared"; + +const supabaseUrl = requiredEnv("SUPABASE_URL"); +const supabaseAnonKey = requiredEnv("SUPABASE_ANON_KEY"); +const serviceSupabase = createClient(supabaseUrl, requiredEnv("SUPABASE_SERVICE_ROLE_KEY"), { + auth: { autoRefreshToken: false, persistSession: false }, +}); + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") { + return corsPreflightResponse(); + } + + const handler = createExportAccountHandler({ + admin: serviceSupabase, + resolveUserId: async (request) => { + const { userId } = await getUserFromRequest({ supabase: createCallerSupabase(request), req: request }); + return userId; + }, + }); + return withCors(handler(req)); +}); + +function createCallerSupabase(req: Request) { + return createClient(supabaseUrl, supabaseAnonKey, { + auth: { autoRefreshToken: false, persistSession: false }, + global: { headers: { Authorization: req.headers.get("authorization") ?? "" } }, + }); +} + +async function withCors(response: Promise): Promise { + const resolved = await response; + const headers = new Headers(resolved.headers); + for (const [name, value] of Object.entries(corsHeaders())) { + headers.set(name, value); + } + return new Response(resolved.body, { status: resolved.status, headers }); +} + +function requiredEnv(name: string): string { + const value = Deno.env.get(name); + if (value === undefined || value.length === 0) { + throw new Error(`${name} is required`); + } + + return value; +} From 5bfa7e06e2bc3b9b87c9d72f017c3c2b1de911ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Fri, 10 Jul 2026 02:11:40 -0300 Subject: [PATCH 2/4] fix(edge): preserve accounts on Stripe deletion failure --- packages/edge-shared/src/index.ts | 17 ++++++++++--- packages/edge-shared/test/edge-shared.test.ts | 25 ++++++++++++++++--- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/packages/edge-shared/src/index.ts b/packages/edge-shared/src/index.ts index a4c6ab6..af2452f 100644 --- a/packages/edge-shared/src/index.ts +++ b/packages/edge-shared/src/index.ts @@ -1,5 +1,6 @@ export type EdgeSharedErrorCode = | "database_error" + | "deletion_incomplete" | "entitlement_required" | "insufficient_credits" | "invalid_debit_amount" @@ -527,9 +528,13 @@ export function createDeleteAccountHandler({ if (typeof billingCustomer?.stripe_customer_id === "string" && billingCustomer.stripe_customer_id.length > 0) { try { - // Stripe deletion is best-effort because it retains transaction records for legal and fiscal obligations. + // One-time credit packs have no recurring charges; delete Stripe first to avoid orphaning customer PII. await stripe.customers.del(billingCustomer.stripe_customer_id); - } catch {} + } catch (error) { + if (!isStripeCustomerMissingError(error)) { + throw new EdgeSharedError("deletion_incomplete", "Failed to delete Stripe customer", { cause: error }); + } + } } const { error } = await admin.auth.admin.deleteUser(userId); @@ -831,7 +836,9 @@ function databaseError(message: string, cause: SupabaseErrorLike): EdgeSharedErr function accountErrorResponse(error: unknown): Response { if (error instanceof EdgeSharedError) { - return jsonResponse(error.code === "unauthorized" ? 401 : 400, { error: error.code }); + return jsonResponse(error.code === "unauthorized" ? 401 : error.code === "deletion_incomplete" ? 503 : 400, { + error: error.code, + }); } return jsonResponse(500, { error: "internal_error" }); @@ -840,3 +847,7 @@ function accountErrorResponse(error: unknown): Response { function isUserNotFoundError(error: SupabaseErrorLike): boolean { return error.code === "user_not_found" || /user not found/i.test(error.message); } + +function isStripeCustomerMissingError(error: unknown): boolean { + return typeof error === "object" && error !== null && (error as { code?: unknown }).code === "resource_missing"; +} diff --git a/packages/edge-shared/test/edge-shared.test.ts b/packages/edge-shared/test/edge-shared.test.ts index a2509db..74e6b09 100644 --- a/packages/edge-shared/test/edge-shared.test.ts +++ b/packages/edge-shared/test/edge-shared.test.ts @@ -263,7 +263,7 @@ describe("@pickforge/edge-shared", () => { expect(stripe.customers.del.mock.invocationCallOrder[0]!).toBeLessThan(admin.auth.admin.deleteUser.mock.invocationCallOrder[0]!); }); - it("deletes the user without a Stripe customer and tolerates Stripe failures", async () => { + it("deletes the user without a Stripe customer", async () => { const noCustomerAdmin = accountAdmin(); const noCustomerStripe = { customers: { del: vi.fn(async () => ({})) } }; const noCustomerHandler = createDeleteAccountHandler({ @@ -277,7 +277,9 @@ describe("@pickforge/edge-shared", () => { }); expect(noCustomerStripe.customers.del).not.toHaveBeenCalled(); expect(noCustomerAdmin.auth.admin.deleteUser).toHaveBeenCalledWith(USER_ID); + }); + it("preserves the account when Stripe deletion fails and retries safely after a missing customer", async () => { const failingStripeAdmin = accountAdmin({ billing_customers: [{ user_id: USER_ID, stripe_customer_id: "cus_456" }] }); const failingStripe = { customers: { del: vi.fn(async () => Promise.reject(new Error("Stripe unavailable"))) } }; const failingStripeHandler = createDeleteAccountHandler({ @@ -286,10 +288,25 @@ describe("@pickforge/edge-shared", () => { resolveUserId: vi.fn(async () => USER_ID), }); - await expect(failingStripeHandler(new Request("https://edge.test", { method: "POST" }))).resolves.toMatchObject({ - status: 200, + const failedResponse = await failingStripeHandler(new Request("https://edge.test", { method: "POST" })); + expect(failedResponse.status).toBe(503); + await expect(failedResponse.json()).resolves.toEqual({ error: "deletion_incomplete" }); + expect(failingStripeAdmin.auth.admin.deleteUser).not.toHaveBeenCalled(); + + const missingStripeAdmin = accountAdmin({ billing_customers: [{ user_id: USER_ID, stripe_customer_id: "cus_789" }] }); + const missingStripe = { + customers: { del: vi.fn(async () => Promise.reject({ code: "resource_missing" })) }, + }; + const missingStripeHandler = createDeleteAccountHandler({ + admin: missingStripeAdmin, + stripe: missingStripe, + resolveUserId: vi.fn(async () => USER_ID), }); - expect(failingStripeAdmin.auth.admin.deleteUser).toHaveBeenCalledWith(USER_ID); + + const missingResponse = await missingStripeHandler(new Request("https://edge.test", { method: "POST" })); + expect(missingResponse.status).toBe(200); + await expect(missingResponse.json()).resolves.toEqual({ deleted: true }); + expect(missingStripeAdmin.auth.admin.deleteUser).toHaveBeenCalledWith(USER_ID); }); it("treats a missing auth user as deleted and returns 500 for other deletion failures", async () => { From 962d2eef027d72bdb107bfd0339048d25b5dcc10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Fri, 10 Jul 2026 02:26:17 -0300 Subject: [PATCH 3/4] fix(edge): preserve complete account deletion data --- packages/billing/src/index.ts | 8 +- packages/billing/test/billing.test.ts | 22 ++++++ packages/edge-shared/src/index.ts | 46 ++++++++--- packages/edge-shared/test/edge-shared.test.ts | 76 +++++++++++++++++-- .../functions/create-credit-checkout/index.ts | 23 ++++++ supabase/functions/delete-account/index.ts | 4 + .../functions/export-account-data/index.ts | 4 + 7 files changed, 165 insertions(+), 18 deletions(-) diff --git a/packages/billing/src/index.ts b/packages/billing/src/index.ts index d6193fb..d223fe5 100644 --- a/packages/billing/src/index.ts +++ b/packages/billing/src/index.ts @@ -51,7 +51,8 @@ export type StripeWebhookPayload = string | Uint8Array | ArrayBuffer; export interface StripeCheckoutSessionCreateParams { mode: "payment"; - customer_creation: "always"; + customer_creation?: "always"; + customer?: string; client_reference_id: string; line_items: Array<{ price: string; @@ -148,6 +149,7 @@ export interface CreateCreditCheckoutSessionOptions { priceId: string; successUrl: string; cancelUrl: string; + existingCustomerId?: string; } export interface GetCreditBalanceOptions { @@ -215,15 +217,17 @@ export async function createCreditCheckoutSession({ priceId, successUrl, cancelUrl, + existingCustomerId, }: CreateCreditCheckoutSessionOptions): Promise { const validUserId = validateUuid(userId, "userId"); const validPriceId = validateNonEmptyString(priceId, "priceId"); const validSuccessUrl = validateNonEmptyString(successUrl, "successUrl"); const validCancelUrl = validateNonEmptyString(cancelUrl, "cancelUrl"); + const customer = typeof existingCustomerId === "string" && existingCustomerId.trim().length > 0 ? existingCustomerId : undefined; return stripe.checkout.sessions.create({ mode: "payment", - customer_creation: "always", + ...(customer === undefined ? { customer_creation: "always" as const } : { customer }), client_reference_id: validUserId, line_items: [ { diff --git a/packages/billing/test/billing.test.ts b/packages/billing/test/billing.test.ts index cc1babc..1291b5e 100644 --- a/packages/billing/test/billing.test.ts +++ b/packages/billing/test/billing.test.ts @@ -234,6 +234,28 @@ describe("@pickforge/billing", () => { }); }); + it("reuses an existing Stripe customer for repeat credit purchases", async () => { + const stripe = fakeStripe(); + + await createCreditCheckoutSession({ + stripe, + userId: USER_ID, + priceId: "price_123", + successUrl: "https://pickforge.dev/success", + cancelUrl: "https://pickforge.dev/cancel", + existingCustomerId: "cus_123", + }); + + expect(stripe.checkout.sessions.create).toHaveBeenCalledWith({ + mode: "payment", + customer: "cus_123", + client_reference_id: USER_ID, + line_items: [{ price: "price_123", quantity: 1 }], + success_url: "https://pickforge.dev/success", + cancel_url: "https://pickforge.dev/cancel", + }); + }); + it("reads balances through the credit_balance_cents rpc", async () => { const supabase = new MemorySupabase(); await processStripeEvent({ supabase, event: checkoutSessionEvent() }); diff --git a/packages/edge-shared/src/index.ts b/packages/edge-shared/src/index.ts index af2452f..1cad29c 100644 --- a/packages/edge-shared/src/index.ts +++ b/packages/edge-shared/src/index.ts @@ -43,6 +43,8 @@ export interface SupabaseQueryBuilderLike extends PromiseLike; eq(column: string, value: unknown): SupabaseQueryBuilderLike; is(column: string, value: null): SupabaseQueryBuilderLike; + order(column: string, options?: { ascending?: boolean }): SupabaseQueryBuilderLike; + range(from: number, to: number): SupabaseQueryBuilderLike; maybeSingle(): PromiseLike>; } @@ -556,7 +558,7 @@ export function createExportAccountHandler({ return async (req: Request): Promise => { try { const userId = await resolveUserId(req); - const [profileResult, entitlementsResult, creditLedgerResult, syncedSettingsResult, billingResult] = await Promise.all([ + const [profileResult, entitlementsResult, creditLedger, syncedSettingsResult, billingResult] = await Promise.all([ admin .from("profiles") .select("id,email,display_name,avatar_url,created_at,updated_at") @@ -566,10 +568,7 @@ export function createExportAccountHandler({ .from("entitlements") .select("id,key,value,granted_at,expires_at,source") .eq("user_id", userId), - admin - .from("credit_ledger") - .select("id,amount_cents,kind,description,stripe_event_id,stripe_checkout_session_id,metadata,created_at,idempotency_key") - .eq("user_id", userId), + readCreditLedgerExport(admin, userId), admin .from("settings_sync") .select("field_group,payload,updated_at") @@ -582,7 +581,7 @@ export function createExportAccountHandler({ .maybeSingle(), ]); - for (const result of [profileResult, entitlementsResult, creditLedgerResult, syncedSettingsResult, billingResult]) { + for (const result of [profileResult, entitlementsResult, syncedSettingsResult, billingResult]) { if (result.error !== null) { throw databaseError("Failed to export account data", result.error); } @@ -594,7 +593,7 @@ export function createExportAccountHandler({ exportedAt: new Date().toISOString(), profile: profileResult.data, entitlements: entitlementsResult.data ?? [], - creditLedger: creditLedgerResult.data ?? [], + creditLedger, syncedSettings: syncedSettingsResult.data ?? [], billing: { hasStripeCustomer: stripeCustomerId !== null, @@ -836,9 +835,10 @@ function databaseError(message: string, cause: SupabaseErrorLike): EdgeSharedErr function accountErrorResponse(error: unknown): Response { if (error instanceof EdgeSharedError) { - return jsonResponse(error.code === "unauthorized" ? 401 : error.code === "deletion_incomplete" ? 503 : 400, { - error: error.code, - }); + if (error.code === "unauthorized") return jsonResponse(401, { error: error.code }); + if (error.code === "deletion_incomplete") return jsonResponse(503, { error: error.code }); + if (error.code === "database_error") return jsonResponse(500, { error: "internal_error" }); + return jsonResponse(400, { error: error.code }); } return jsonResponse(500, { error: "internal_error" }); @@ -851,3 +851,29 @@ function isUserNotFoundError(error: SupabaseErrorLike): boolean { function isStripeCustomerMissingError(error: unknown): boolean { return typeof error === "object" && error !== null && (error as { code?: unknown }).code === "resource_missing"; } + +async function readCreditLedgerExport( + admin: Pick, + userId: string, +): Promise { + const entries: AccountCreditLedgerRow[] = []; + const pageSize = 1000; + + for (let from = 0; ; from += pageSize) { + const { data, error } = await admin + .from("credit_ledger") + .select("id,amount_cents,kind,description,stripe_event_id,stripe_checkout_session_id,metadata,created_at,idempotency_key") + .eq("user_id", userId) + .order("id") + .range(from, from + pageSize - 1); + if (error !== null) { + throw databaseError("Failed to export credit ledger", error); + } + + const page = data ?? []; + entries.push(...page); + if (page.length < pageSize) { + return entries; + } + } +} diff --git a/packages/edge-shared/test/edge-shared.test.ts b/packages/edge-shared/test/edge-shared.test.ts index 74e6b09..2c8248d 100644 --- a/packages/edge-shared/test/edge-shared.test.ts +++ b/packages/edge-shared/test/edge-shared.test.ts @@ -404,6 +404,44 @@ describe("@pickforge/edge-shared", () => { await expect(unauthorizedResponse.json()).resolves.toEqual({ error: "unauthorized" }); }); + it("returns 500 for account data read failures", async () => { + const handler = createDeleteAccountHandler({ + admin: accountAdmin({}, { billing_customers: { message: "Database unavailable" } }), + stripe: { customers: { del: vi.fn() } }, + resolveUserId: vi.fn(async () => USER_ID), + }); + + const response = await handler(new Request("https://edge.test", { method: "POST" })); + expect(response.status).toBe(500); + await expect(response.json()).resolves.toEqual({ error: "internal_error" }); + }); + + it("exports every credit ledger page", async () => { + const creditLedger = Array.from({ length: 1001 }, (_, index) => ({ + user_id: USER_ID, + id: `ledger_${index}`, + amount_cents: index, + kind: "credit", + description: null, + stripe_event_id: null, + stripe_checkout_session_id: null, + metadata: {}, + created_at: "2026-01-01T00:00:00.000Z", + idempotency_key: `ledger:${index}`, + })); + const handler = createExportAccountHandler({ + admin: accountAdmin({ credit_ledger: creditLedger }), + resolveUserId: vi.fn(async () => USER_ID), + }); + + const response = await handler(new Request("https://edge.test", { method: "POST" })); + const body = (await response.json()) as { creditLedger: Array<{ id: string }> }; + + expect(response.status).toBe(200); + expect(body.creditLedger).toHaveLength(1001); + expect(body.creditLedger.at(-1)).toEqual(expect.objectContaining({ id: "ledger_1000" })); + }); + it("handles verified Stripe webhook requests", async () => { const stripe = {}; const supabase = {}; @@ -825,7 +863,7 @@ interface EntitlementRow { expires_at: string | null; } -function accountAdmin(rows: Record = {}) { +function accountAdmin(rows: Record = {}, errors: Record = {}) { return { auth: { admin: { @@ -835,15 +873,20 @@ function accountAdmin(rows: Record = {}) { }, }, from(table: string): AccountQuery { - return new AccountQuery(rows[table] ?? []); + return new AccountQuery(rows[table] ?? [], errors[table] ?? null); }, }; } class AccountQuery implements SupabaseQueryBuilderLike { private readonly filters: Array<{ column: string; value: unknown }> = []; + private rangeStart: number | undefined; + private rangeEnd: number | undefined; - constructor(private readonly rows: unknown[]) {} + constructor( + private readonly rows: unknown[], + private readonly error: SupabaseErrorLike | null, + ) {} select(): SupabaseQueryBuilderLike { return this; @@ -859,24 +902,37 @@ class AccountQuery implements SupabaseQueryBuilderLike { return this; } + order(_column: string, _options?: { ascending?: boolean }): SupabaseQueryBuilderLike { + return this; + } + + range(from: number, to: number): SupabaseQueryBuilderLike { + this.rangeStart = from; + this.rangeEnd = to; + return this; + } + async maybeSingle(): Promise> { - return { data: (this.filteredRows()[0] ?? null) as T | null, error: null }; + return { data: (this.filteredRows()[0] ?? null) as T | null, error: this.error }; } then, TResult2 = never>( onfulfilled?: ((value: SupabaseQueryResult) => TResult1 | PromiseLike) | null, onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, ): PromiseLike { - return Promise.resolve({ data: this.filteredRows() as T, error: null }).then(onfulfilled, onrejected); + return Promise.resolve({ data: this.filteredRows() as T, error: this.error }).then(onfulfilled, onrejected); } private filteredRows(): unknown[] { - return this.rows.filter( + const filtered = this.rows.filter( (row) => typeof row === "object" && row !== null && this.filters.every((filter) => (row as Record)[filter.column] === filter.value), ); + return this.rangeStart === undefined || this.rangeEnd === undefined + ? filtered + : filtered.slice(this.rangeStart, this.rangeEnd + 1); } } @@ -932,6 +988,14 @@ class MemoryQuery implements SupabaseQueryBuilderLike { return this.eq(column, value); } + order(_column: string, _options?: { ascending?: boolean }): SupabaseQueryBuilderLike { + return this; + } + + range(_from: number, _to: number): SupabaseQueryBuilderLike { + return this; + } + async maybeSingle(): Promise> { const rows = this.rows(); diff --git a/supabase/functions/create-credit-checkout/index.ts b/supabase/functions/create-credit-checkout/index.ts index 597c0ed..56ede0d 100644 --- a/supabase/functions/create-credit-checkout/index.ts +++ b/supabase/functions/create-credit-checkout/index.ts @@ -11,22 +11,30 @@ import { const supabaseUrl = requiredEnv("SUPABASE_URL"); const supabaseAnonKey = requiredEnv("SUPABASE_ANON_KEY"); +const serviceSupabase = createClient(supabaseUrl, requiredEnv("SUPABASE_SERVICE_ROLE_KEY"), { + auth: { autoRefreshToken: false, persistSession: false }, +}); const stripe = new Stripe(requiredEnv("STRIPE_SECRET_KEY")); Deno.serve(async (req) => { if (req.method === "OPTIONS") { return corsPreflightResponse(); } + if (req.method !== "POST") { + return respond(405, { error: "method_not_allowed" }); + } try { const { userId } = await getUserFromRequest({ supabase: createCallerSupabase(req), req }); const { pack } = await readCheckoutRequest(req); + const existingCustomerId = await readExistingCustomerId(userId); const session = await createCreditCheckoutSession<{ url: string | null }>({ stripe, userId, priceId: priceIdForPack(pack), successUrl: requiredEnv("CHECKOUT_SUCCESS_URL"), cancelUrl: requiredEnv("CHECKOUT_CANCEL_URL"), + existingCustomerId, }); if (typeof session.url !== "string" || session.url.length === 0) { throw new Error("Stripe checkout session did not include a URL"); @@ -53,6 +61,21 @@ function createCallerSupabase(req: Request) { }); } +async function readExistingCustomerId(userId: string): Promise { + const { data, error } = await serviceSupabase + .from<{ stripe_customer_id: string | null }>("billing_customers") + .select("stripe_customer_id") + .eq("user_id", userId) + .maybeSingle(); + if (error !== null) { + throw new Error("Failed to read billing customer", { cause: error }); + } + + return typeof data?.stripe_customer_id === "string" && data.stripe_customer_id.length > 0 + ? data.stripe_customer_id + : undefined; +} + function respond(status: number, body: unknown): Response { return jsonResponse(status, body, corsHeaders()); } diff --git a/supabase/functions/delete-account/index.ts b/supabase/functions/delete-account/index.ts index 30b42b5..1ac09c0 100644 --- a/supabase/functions/delete-account/index.ts +++ b/supabase/functions/delete-account/index.ts @@ -5,6 +5,7 @@ import { corsPreflightResponse, createDeleteAccountHandler, getUserFromRequest, + jsonResponse, } from "@pickforge/edge-shared"; const supabaseUrl = requiredEnv("SUPABASE_URL"); @@ -18,6 +19,9 @@ Deno.serve(async (req) => { if (req.method === "OPTIONS") { return corsPreflightResponse(); } + if (req.method !== "POST") { + return jsonResponse(405, { error: "method_not_allowed" }, corsHeaders()); + } const handler = createDeleteAccountHandler({ admin: serviceSupabase, diff --git a/supabase/functions/export-account-data/index.ts b/supabase/functions/export-account-data/index.ts index 6e217c4..0225502 100644 --- a/supabase/functions/export-account-data/index.ts +++ b/supabase/functions/export-account-data/index.ts @@ -4,6 +4,7 @@ import { corsPreflightResponse, createExportAccountHandler, getUserFromRequest, + jsonResponse, } from "@pickforge/edge-shared"; const supabaseUrl = requiredEnv("SUPABASE_URL"); @@ -16,6 +17,9 @@ Deno.serve(async (req) => { if (req.method === "OPTIONS") { return corsPreflightResponse(); } + if (req.method !== "POST") { + return jsonResponse(405, { error: "method_not_allowed" }, corsHeaders()); + } const handler = createExportAccountHandler({ admin: serviceSupabase, From cc87333c7e3cbf85d7b94e29b4f48986382905bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Fri, 10 Jul 2026 02:41:44 -0300 Subject: [PATCH 4/4] fix(edge): delete all recorded Stripe customers --- packages/edge-shared/src/index.ts | 51 +++++++++++++++---- packages/edge-shared/test/edge-shared.test.ts | 35 ++++++++++--- .../create-credit-checkout/deno.json | 4 +- supabase/functions/operator-router/deno.json | 4 +- 4 files changed, 72 insertions(+), 22 deletions(-) diff --git a/packages/edge-shared/src/index.ts b/packages/edge-shared/src/index.ts index 1cad29c..342440f 100644 --- a/packages/edge-shared/src/index.ts +++ b/packages/edge-shared/src/index.ts @@ -240,6 +240,10 @@ interface AccountCreditLedgerRow { idempotency_key: string; } +interface StripeCustomerMetadataRow { + metadata: EdgeSharedJson; +} + interface AccountSyncedSettingRow { field_group: string; payload: EdgeSharedJson; @@ -519,19 +523,28 @@ export function createDeleteAccountHandler({ return async (req: Request): Promise => { try { const userId = await resolveUserId(req); - const { data: billingCustomer, error: billingCustomerError } = await admin - .from("billing_customers") - .select("stripe_customer_id") - .eq("user_id", userId) - .maybeSingle(); + const [{ data: billingCustomer, error: billingCustomerError }, ledgerRows] = await Promise.all([ + admin + .from("billing_customers") + .select("stripe_customer_id") + .eq("user_id", userId) + .maybeSingle(), + readCreditLedgerPages(admin, userId, "metadata"), + ]); if (billingCustomerError !== null) { throw databaseError("Failed to read billing customer", billingCustomerError); } - if (typeof billingCustomer?.stripe_customer_id === "string" && billingCustomer.stripe_customer_id.length > 0) { + const customerIds = new Set(); + addStripeCustomerId(customerIds, billingCustomer?.stripe_customer_id); + for (const row of ledgerRows) { + addStripeCustomerId(customerIds, isRecord(row.metadata) ? row.metadata.stripe_customer_id : undefined); + } + + for (const customerId of customerIds) { try { // One-time credit packs have no recurring charges; delete Stripe first to avoid orphaning customer PII. - await stripe.customers.del(billingCustomer.stripe_customer_id); + await stripe.customers.del(customerId); } catch (error) { if (!isStripeCustomerMissingError(error)) { throw new EdgeSharedError("deletion_incomplete", "Failed to delete Stripe customer", { cause: error }); @@ -856,13 +869,25 @@ async function readCreditLedgerExport( admin: Pick, userId: string, ): Promise { - const entries: AccountCreditLedgerRow[] = []; + return readCreditLedgerPages( + admin, + userId, + "id,amount_cents,kind,description,stripe_event_id,stripe_checkout_session_id,metadata,created_at,idempotency_key", + ); +} + +async function readCreditLedgerPages( + admin: Pick, + userId: string, + columns: string, +): Promise { + const entries: T[] = []; const pageSize = 1000; for (let from = 0; ; from += pageSize) { const { data, error } = await admin - .from("credit_ledger") - .select("id,amount_cents,kind,description,stripe_event_id,stripe_checkout_session_id,metadata,created_at,idempotency_key") + .from("credit_ledger") + .select(columns) .eq("user_id", userId) .order("id") .range(from, from + pageSize - 1); @@ -877,3 +902,9 @@ async function readCreditLedgerExport( } } } + +function addStripeCustomerId(customerIds: Set, value: unknown): void { + if (typeof value === "string" && value.trim().length > 0) { + customerIds.add(value.trim()); + } +} diff --git a/packages/edge-shared/test/edge-shared.test.ts b/packages/edge-shared/test/edge-shared.test.ts index 2c8248d..be1a23c 100644 --- a/packages/edge-shared/test/edge-shared.test.ts +++ b/packages/edge-shared/test/edge-shared.test.ts @@ -245,8 +245,14 @@ describe("@pickforge/edge-shared", () => { ); }); - it("deletes the Stripe customer before deleting the authenticated user", async () => { - const admin = accountAdmin({ billing_customers: [{ user_id: USER_ID, stripe_customer_id: "cus_123" }] }); + it("deletes every recorded Stripe customer before deleting the authenticated user", async () => { + const admin = accountAdmin({ + billing_customers: [{ user_id: USER_ID, stripe_customer_id: "cus_current" }], + credit_ledger: [ + { user_id: USER_ID, metadata: { stripe_customer_id: "cus_history" } }, + { user_id: USER_ID, metadata: { stripe_customer_id: "cus_current" } }, + ], + }); const stripe = { customers: { del: vi.fn(async () => ({})) } }; const handler = createDeleteAccountHandler({ admin, @@ -258,7 +264,9 @@ describe("@pickforge/edge-shared", () => { expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ deleted: true }); - expect(stripe.customers.del).toHaveBeenCalledWith("cus_123"); + expect(stripe.customers.del).toHaveBeenCalledTimes(2); + expect(stripe.customers.del).toHaveBeenCalledWith("cus_current"); + expect(stripe.customers.del).toHaveBeenCalledWith("cus_history"); expect(admin.auth.admin.deleteUser).toHaveBeenCalledWith(USER_ID); expect(stripe.customers.del.mock.invocationCallOrder[0]!).toBeLessThan(admin.auth.admin.deleteUser.mock.invocationCallOrder[0]!); }); @@ -279,9 +287,16 @@ describe("@pickforge/edge-shared", () => { expect(noCustomerAdmin.auth.admin.deleteUser).toHaveBeenCalledWith(USER_ID); }); - it("preserves the account when Stripe deletion fails and retries safely after a missing customer", async () => { - const failingStripeAdmin = accountAdmin({ billing_customers: [{ user_id: USER_ID, stripe_customer_id: "cus_456" }] }); - const failingStripe = { customers: { del: vi.fn(async () => Promise.reject(new Error("Stripe unavailable"))) } }; + it("preserves the account when any Stripe deletion fails and retries safely after a missing customer", async () => { + const failingStripeAdmin = accountAdmin({ + billing_customers: [{ user_id: USER_ID, stripe_customer_id: "cus_456" }], + credit_ledger: [{ user_id: USER_ID, metadata: { stripe_customer_id: "cus_457" } }], + }); + const failingStripe = { + customers: { + del: vi.fn().mockResolvedValueOnce({}).mockRejectedValueOnce(new Error("Stripe unavailable")), + }, + }; const failingStripeHandler = createDeleteAccountHandler({ admin: failingStripeAdmin, stripe: failingStripe, @@ -291,11 +306,15 @@ describe("@pickforge/edge-shared", () => { const failedResponse = await failingStripeHandler(new Request("https://edge.test", { method: "POST" })); expect(failedResponse.status).toBe(503); await expect(failedResponse.json()).resolves.toEqual({ error: "deletion_incomplete" }); + expect(failingStripe.customers.del).toHaveBeenCalledTimes(2); expect(failingStripeAdmin.auth.admin.deleteUser).not.toHaveBeenCalled(); - const missingStripeAdmin = accountAdmin({ billing_customers: [{ user_id: USER_ID, stripe_customer_id: "cus_789" }] }); + const missingStripeAdmin = accountAdmin({ + billing_customers: [{ user_id: USER_ID, stripe_customer_id: "cus_789" }], + credit_ledger: [{ user_id: USER_ID, metadata: { stripe_customer_id: "cus_790" } }], + }); const missingStripe = { - customers: { del: vi.fn(async () => Promise.reject({ code: "resource_missing" })) }, + customers: { del: vi.fn().mockRejectedValueOnce({ code: "resource_missing" }).mockResolvedValueOnce({}) }, }; const missingStripeHandler = createDeleteAccountHandler({ admin: missingStripeAdmin, diff --git a/supabase/functions/create-credit-checkout/deno.json b/supabase/functions/create-credit-checkout/deno.json index 11f5f3d..741b968 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.7.0", - "@pickforge/edge-shared": "npm:@pickforge/edge-shared@0.7.1" + "@pickforge/billing": "npm:@pickforge/billing@0.8.0", + "@pickforge/edge-shared": "npm:@pickforge/edge-shared@0.8.0" } } diff --git a/supabase/functions/operator-router/deno.json b/supabase/functions/operator-router/deno.json index e5bbaf2..99a7b67 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.7.0", - "@pickforge/edge-shared": "npm:@pickforge/edge-shared@0.7.1" + "@pickforge/billing": "npm:@pickforge/billing@0.8.0", + "@pickforge/edge-shared": "npm:@pickforge/edge-shared@0.8.0" } }