diff --git a/packages/auth/package.json b/packages/auth/package.json index 60f6ec0..43eb1d3 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@pickforge/auth", - "version": "0.9.0", + "version": "0.10.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 d565409..0fef9ab 100644 --- a/packages/billing/package.json +++ b/packages/billing/package.json @@ -1,6 +1,6 @@ { "name": "@pickforge/billing", - "version": "0.9.0", + "version": "0.10.0", "description": "UI-free Stripe billing and credit-ledger helpers for Pickforge apps.", "license": "MIT", "repository": { diff --git a/packages/billing/src/index.ts b/packages/billing/src/index.ts index 5318855..cd46685 100644 --- a/packages/billing/src/index.ts +++ b/packages/billing/src/index.ts @@ -80,7 +80,10 @@ interface StripeRefundEventObject { export interface StripeClientLike { checkout: { sessions: { - create(params: StripeCheckoutSessionCreateParams): Promise; + create( + params: StripeCheckoutSessionCreateParams, + options: { idempotencyKey: string }, + ): Promise; }; }; customers: { @@ -200,6 +203,12 @@ export interface CreateCreditCheckoutSessionOptions { successUrl: string; cancelUrl: string; existingCustomerId?: string; + /** + * Stable per-attempt identity for the Stripe idempotency key. Retries of the + * same purchase must reuse the same value; a genuinely new purchase must pass + * a fresh value (or omit it, in which case a fresh one is generated). + */ + requestId?: string; } export interface GetCreditBalanceOptions { @@ -285,26 +294,39 @@ export async function createCreditCheckoutSession({ successUrl, cancelUrl, existingCustomerId, + requestId, }: 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 === undefined ? { customer_creation: "always" as const } : { customer }), - client_reference_id: validUserId, - line_items: [ - { - price: validPriceId, - quantity: 1, - }, - ], - success_url: validSuccessUrl, - cancel_url: validCancelUrl, - }); + // Deterministic idempotency key mirroring the refund pattern: the same purchase + // attempt (network/SDK/app retry) reuses this key and Stripe returns the one + // Session, while a distinct purchase supplies a fresh requestId and therefore a + // distinct key, so a legitimately new purchase can never collide with an old one. + const purchaseRequestId = + typeof requestId === "string" && requestId.trim().length > 0 + ? requestId.trim() + : globalThis.crypto.randomUUID(); + const idempotencyKey = `checkout-session:${validUserId}:${purchaseRequestId}`; + + return stripe.checkout.sessions.create( + { + mode: "payment", + ...(customer === undefined ? { customer_creation: "always" as const } : { customer }), + client_reference_id: validUserId, + line_items: [ + { + price: validPriceId, + quantity: 1, + }, + ], + success_url: validSuccessUrl, + cancel_url: validCancelUrl, + }, + { idempotencyKey }, + ); } export async function getCreditBalanceCents({ @@ -435,6 +457,7 @@ async function processCheckoutSessionMoneyEvent({ stripe, sessionId, eventId: event.id, + eventType: event.type, paymentIntentId, amountCents, cleanupLateCustomer: reconciliation === "refund_missing_user", @@ -496,9 +519,10 @@ async function processRefundLifecycleEvent({ return { handled: true, duplicate: false, reconciliation: "deletion_race_refunded" }; } if (summary.hasNonterminal) { - throw new BillingError( + throw refundReconciliationError( "refund_incomplete", "The PaymentIntent still has another nonterminal Refund", + { eventId: event.id, eventType: event.type, sessionId }, ); } await recordCheckoutRefundFailure(supabase, sessionId, event.id, refundId, "partial"); @@ -507,6 +531,7 @@ async function processRefundLifecycleEvent({ stripe, sessionId, eventId: event.id, + eventType: event.type, paymentIntentId, amountCents: Number(data.amount_cents), cleanupLateCustomer, @@ -536,9 +561,10 @@ async function processRefundLifecycleEvent({ return { handled: true, duplicate: false, reconciliation: "deletion_race_refunded" }; } if (refundSummary.hasNonterminal) { - throw new BillingError( + throw refundReconciliationError( "refund_incomplete", "The PaymentIntent still has a nonterminal Refund and is not safe to retry", + { eventId: event.id, eventType: event.type, sessionId }, ); } await performDeletionRefund({ @@ -546,6 +572,7 @@ async function processRefundLifecycleEvent({ stripe, sessionId, eventId: event.id, + eventType: event.type, paymentIntentId, amountCents: Number(data.amount_cents), cleanupLateCustomer, @@ -583,6 +610,7 @@ async function performDeletionRefund({ stripe, sessionId, eventId, + eventType, paymentIntentId, amountCents, cleanupLateCustomer, @@ -592,6 +620,7 @@ async function performDeletionRefund({ stripe: Pick; sessionId: string; eventId: string; + eventType: string; paymentIntentId: string; amountCents: number; cleanupLateCustomer: boolean; @@ -608,7 +637,11 @@ async function performDeletionRefund({ !Number.isSafeInteger(prepared.data.attempt) || Number(prepared.data.attempt) <= 0 ) { - throw new BillingError("refund_incomplete", "Checkout Session refund state is not recoverable"); + throw refundReconciliationError( + "refund_incomplete", + "Checkout Session refund state is not recoverable", + { eventId, eventType, sessionId }, + ); } const attempt = Number(prepared.data.attempt); const mustCleanupCustomer = @@ -626,9 +659,10 @@ async function performDeletionRefund({ summary.succeededRefunds, ); if (summary.hasNonterminal) { - throw new BillingError( + throw refundReconciliationError( "refund_incomplete", "The PaymentIntent still has a nonterminal Refund and is not safe to retry", + { eventId, eventType, sessionId }, ); } const succeededCents = Math.max(summary.succeededCents, knownSucceededCents); @@ -699,9 +733,10 @@ async function performDeletionRefund({ message: `Expected retry_required, received ${String(reconciliation.status)}`, }); } - throw new BillingError( + throw refundReconciliationError( "refund_terminal_failure", `The deletion-race refund reached terminal status ${refundStatus}`, + { eventId, eventType, sessionId }, ); } if (refundStatus !== "succeeded") { @@ -710,7 +745,11 @@ async function performDeletionRefund({ message: `Expected pending, received ${String(reconciliation.status)}`, }); } - throw new BillingError("refund_incomplete", "The deletion-race refund has not succeeded"); + throw refundReconciliationError( + "refund_incomplete", + "The deletion-race refund has not succeeded", + { eventId, eventType, sessionId }, + ); } if (reconciliation.status !== "succeeded") { throw databaseError("Invalid succeeded Stripe Refund reconciliation", { @@ -736,6 +775,7 @@ async function performDeletionRefund({ stripe, sessionId, eventId, + eventType, paymentIntentId, amountCents, cleanupLateCustomer: mustCleanupCustomer, @@ -920,14 +960,30 @@ async function recordStripeEventBestEffort( supabase: SupabaseClientLike, event: StripeEventLike, ): Promise { + // The money-path effect already committed; recording the event is best-effort. + // A 23505 unique-violation is the expected duplicate and is silently ignorable; + // every other swallowed failure is logged so a lost idempotency record is visible. try { - await (supabase.from("stripe_events") as SupabaseQueryBuilderLike) + const { error } = await (supabase.from("stripe_events") as SupabaseQueryBuilderLike) .insert({ event_id: event.id, type: event.type, }); - } catch { - return; + if (error !== null && error.code !== "23505") { + logMoneyPathError({ + operation: "record_stripe_event", + event_id: event.id, + event_type: event.type, + error_code: error.code ?? null, + }); + } + } catch (error) { + logMoneyPathError({ + operation: "record_stripe_event", + event_id: event.id, + event_type: event.type, + error_code: errorCode(error), + }); } } @@ -1107,3 +1163,40 @@ function isUniqueViolation(error: SupabaseErrorLike): boolean { function databaseError(message: string, cause: SupabaseErrorLike): BillingError { return new BillingError("database_error", message, { cause }); } + +function refundReconciliationError( + code: Extract, + message: string, + context: { eventId: string; eventType: string; sessionId: string }, +): BillingError { + logMoneyPathError({ + operation: "refund_reconciliation", + event_id: context.eventId, + event_type: context.eventType, + checkout_session_id: context.sessionId, + error_code: code, + }); + return new BillingError(code, message); +} + +function logMoneyPathError(fields: { + operation: string; + event_id?: string; + event_type?: string; + checkout_session_id?: string; + error_code?: string | null; +}): void { + // Structured, secret-free money-path diagnostics: only ids and error codes are + // emitted — never tokens, Stripe secrets, or request/response bodies. + console.error(JSON.stringify({ scope: "billing", ...fields })); +} + +function errorCode(error: unknown): string { + if (isRecord(error) && typeof error.code === "string" && error.code.length > 0) { + return error.code; + } + if (error instanceof Error && error.name.length > 0) { + return error.name; + } + return "unknown"; +} diff --git a/packages/billing/test/billing.test.ts b/packages/billing/test/billing.test.ts index e4e6065..c59639b 100644 --- a/packages/billing/test/billing.test.ts +++ b/packages/billing/test/billing.test.ts @@ -517,14 +517,30 @@ describe("@pickforge/billing", () => { amount: 1000, status, }); - - await expect( - processStripeEvent({ - supabase, - stripe, - event: checkoutSessionEvent({ sessionId: `cs_${status}` }), - }), - ).rejects.toMatchObject({ code: "refund_terminal_failure" }); + 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({ @@ -726,19 +742,33 @@ describe("@pickforge/billing", () => { expect(supabase.tables.stripe_events).toHaveLength(0); }); - it("does not lose or duplicate ledger rows when post-effect stripe_events recording fails", async () => { + it("logs and swallows a non-unique post-effect stripe_events failure without losing or duplicating ledger rows", async () => { const supabase = new MemorySupabase(); supabase.failNextInsert("stripe_events", transientDatabaseError()); const event = checkoutSessionEvent(); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); - 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, - }); + try { + 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, + }); + + expect(consoleError).toHaveBeenCalledTimes(1); + expect(JSON.parse(consoleError.mock.calls[0]![0] as string)).toEqual({ + scope: "billing", + operation: "record_stripe_event", + event_id: "evt_checkout", + event_type: "checkout.session.completed", + error_code: "XX000", + }); + } finally { + consoleError.mockRestore(); + } expect(supabase.tables.credit_ledger).toHaveLength(1); expect(supabase.tables.stripe_events).toEqual([ @@ -748,6 +778,26 @@ describe("@pickforge/billing", () => { ]); }); + it("silently ignores a 23505 unique-violation when recording the post-effect stripe_events row", async () => { + const supabase = new MemorySupabase(); + supabase.failNextInsert("stripe_events", { code: "23505", message: "duplicate key value" }); + const event = checkoutSessionEvent(); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect(processStripeEvent({ supabase, stripe: fakeStripe(), event })).resolves.toEqual({ + handled: true, + duplicate: false, + }); + + expect(consoleError).not.toHaveBeenCalled(); + } finally { + consoleError.mockRestore(); + } + + expect(supabase.tables.credit_ledger).toHaveLength(1); + }); + it("uses stripe_events as the dedupe record for unknown events", async () => { const supabase = new MemorySupabase(); const event: StripeEventLike = { @@ -769,7 +819,7 @@ describe("@pickforge/billing", () => { expect(supabase.tables.credit_ledger).toHaveLength(0); }); - it("creates payment checkout sessions with a persistent customer request and no credit metadata", async () => { + it("creates payment checkout sessions with a persistent customer request and a deterministic idempotency key", async () => { const stripe = fakeStripe(); await createCreditCheckoutSession({ @@ -778,16 +828,20 @@ describe("@pickforge/billing", () => { priceId: "price_123", successUrl: "https://pickforge.dev/success", cancelUrl: "https://pickforge.dev/cancel", + requestId: "req_first", }); - expect(stripe.checkout.sessions.create).toHaveBeenCalledWith({ - mode: "payment", - customer_creation: "always", - client_reference_id: USER_ID, - line_items: [{ price: "price_123", quantity: 1 }], - success_url: "https://pickforge.dev/success", - cancel_url: "https://pickforge.dev/cancel", - }); + expect(stripe.checkout.sessions.create).toHaveBeenCalledWith( + { + mode: "payment", + customer_creation: "always", + client_reference_id: USER_ID, + line_items: [{ price: "price_123", quantity: 1 }], + success_url: "https://pickforge.dev/success", + cancel_url: "https://pickforge.dev/cancel", + }, + { idempotencyKey: `checkout-session:${USER_ID}:req_first` }, + ); }); it("reuses an existing Stripe customer for repeat credit purchases", async () => { @@ -800,16 +854,82 @@ describe("@pickforge/billing", () => { successUrl: "https://pickforge.dev/success", cancelUrl: "https://pickforge.dev/cancel", existingCustomerId: "cus_123", + requestId: "req_repeat", + }); + + 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", + }, + { idempotencyKey: `checkout-session:${USER_ID}:req_repeat` }, + ); + }); + + it("reuses one idempotency key across retries of a purchase attempt but never across distinct purchases", async () => { + const stripe = fakeStripe(); + + await createCreditCheckoutSession({ + stripe, + userId: USER_ID, + priceId: "price_123", + successUrl: "https://pickforge.dev/success", + cancelUrl: "https://pickforge.dev/cancel", + requestId: "attempt_a", + }); + await createCreditCheckoutSession({ + stripe, + userId: USER_ID, + priceId: "price_123", + successUrl: "https://pickforge.dev/success", + cancelUrl: "https://pickforge.dev/cancel", + requestId: "attempt_a", + }); + await createCreditCheckoutSession({ + stripe, + userId: USER_ID, + priceId: "price_123", + successUrl: "https://pickforge.dev/success", + cancelUrl: "https://pickforge.dev/cancel", + requestId: "attempt_b", }); - 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", + const keys = stripe.checkout.sessions.create.mock.calls.map(([, options]) => options.idempotencyKey); + expect(keys).toEqual([ + `checkout-session:${USER_ID}:attempt_a`, + `checkout-session:${USER_ID}:attempt_a`, + `checkout-session:${USER_ID}:attempt_b`, + ]); + }); + + it("generates a fresh non-colliding idempotency key when no requestId is supplied", async () => { + const stripe = fakeStripe(); + + await createCreditCheckoutSession({ + stripe, + userId: USER_ID, + priceId: "price_123", + successUrl: "https://pickforge.dev/success", + cancelUrl: "https://pickforge.dev/cancel", }); + await createCreditCheckoutSession({ + stripe, + userId: USER_ID, + priceId: "price_123", + successUrl: "https://pickforge.dev/success", + cancelUrl: "https://pickforge.dev/cancel", + }); + + const [firstKey, secondKey] = stripe.checkout.sessions.create.mock.calls.map( + ([, options]) => options.idempotencyKey, + ); + expect(firstKey).toMatch(new RegExp(`^checkout-session:${USER_ID}:[0-9a-f-]{36}$`)); + expect(secondKey).toMatch(new RegExp(`^checkout-session:${USER_ID}:[0-9a-f-]{36}$`)); + expect(firstKey).not.toBe(secondKey); }); it("reads balances through the credit_balance_cents rpc", async () => { @@ -888,7 +1008,7 @@ function fakeStripe(options: { event?: StripeEventLike } = {}) { return { checkout: { sessions: { - create: vi.fn(async (params) => ({ + create: vi.fn(async (params, _options: { idempotencyKey: string }) => ({ id: "cs_created", ...params, })), diff --git a/packages/billing/test/migration.test.ts b/packages/billing/test/migration.test.ts index a501511..b1964dd 100644 --- a/packages/billing/test/migration.test.ts +++ b/packages/billing/test/migration.test.ts @@ -7,6 +7,11 @@ const migration = readFileSync( "utf8", ); +const balanceIndexMigration = readFileSync( + join(import.meta.dirname, "../../../supabase/migrations/20260719000000_credit_ledger_balance_covering_index.sql"), + "utf8", +); + describe("billing customers and credit ledger migration", () => { it("enables RLS on all billing tables", () => { expect(migration).toContain("alter table public.billing_customers enable row level security"); @@ -51,3 +56,18 @@ describe("billing customers and credit ledger migration", () => { expect(migration).toContain("coalesce(sum(amount_cents), 0)::integer"); }); }); + +describe("credit ledger balance covering index migration", () => { + it("adds a covering index that carries amount_cents for balance sums", () => { + expect(balanceIndexMigration).toContain( + "create index if not exists credit_ledger_user_id_amount_cents_idx", + ); + expect(balanceIndexMigration).toContain("on public.credit_ledger (user_id) include (amount_cents)"); + }); + + it("stays additive: no drop/renumber of the existing balance path", () => { + expect(balanceIndexMigration).not.toMatch(/drop\s+index/i); + expect(balanceIndexMigration).not.toMatch(/drop\s+table/i); + expect(balanceIndexMigration).not.toMatch(/alter\s+table[\s\S]*drop/i); + }); +}); diff --git a/packages/brand/package.json b/packages/brand/package.json index 51d58e6..6f5e08d 100644 --- a/packages/brand/package.json +++ b/packages/brand/package.json @@ -1,6 +1,6 @@ { "name": "@pickforge/brand", - "version": "0.9.0", + "version": "0.10.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 4c8e415..b59af67 100644 --- a/packages/edge-shared/package.json +++ b/packages/edge-shared/package.json @@ -1,6 +1,6 @@ { "name": "@pickforge/edge-shared", - "version": "0.9.0", + "version": "0.10.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 f846d2c..4567aa7 100644 --- a/packages/edge-shared/src/index.ts +++ b/packages/edge-shared/src/index.ts @@ -725,12 +725,12 @@ export function createDeleteAccountHandler({ } } - await settleDeletionFixpoint(admin, userId, sessionIds, customerIds); - await finalizeAccountDeletion(admin, userId, customerIds); - await deleteStripeCustomers(stripe, customerIds, deletedCustomerIds); - await settleDeletionFixpoint(admin, userId, sessionIds, customerIds); - await finalizeAccountDeletion(admin, userId, customerIds); - await deleteStripeCustomers(stripe, customerIds, deletedCustomerIds); + // Two settle→finalize→delete passes: deleting a customer can terminalize a + // late refund/session (see the "rechecks and deletes a customer terminalized + // after the first frozen snapshot" contract test), so a second pass re-reads + // the lifecycle and cleans up anything the first pass's deletions revealed. + await runDeletionSettlementPass(admin, stripe, userId, sessionIds, customerIds, deletedCustomerIds); + await runDeletionSettlementPass(admin, stripe, userId, sessionIds, customerIds, deletedCustomerIds); await deleteAuthUserAtomically(admin, userId); @@ -814,6 +814,70 @@ export function corsPreflightResponse(): Response { return new Response(null, { status: 204, headers: corsHeaders() }); } +// --- Deno Edge Function adapter helpers ------------------------------------- +// Hoisted from the individual supabase/functions to remove duplicated boilerplate. +// Kept runtime-agnostic (no Deno globals, no @supabase/supabase-js import) via +// dependency injection so the package stays UI-free and Node/Bun-testable. + +export interface CallerSupabaseClientOptions { + auth: { autoRefreshToken: false; persistSession: false }; + global: { headers: { Authorization: string } }; +} + +export interface CreateCallerSupabaseFactoryOptions { + createClient: ( + supabaseUrl: string, + supabaseKey: string, + options: CallerSupabaseClientOptions, + ) => TClient; + supabaseUrl: string; + supabaseAnonKey: string; +} + +/** + * Builds a required-env reader over an injected env source (e.g. `Deno.env`), + * throwing when a variable is unset or empty. + */ +export function createRequiredEnv( + env: { get(name: string): string | undefined }, +): (name: string) => string { + return (name: string): string => { + const value = env.get(name); + if (value === undefined || value.length === 0) { + throw new Error(`${name} is required`); + } + + return value; + }; +} + +/** + * Builds a per-request caller-scoped Supabase client factory that forwards the + * inbound Authorization header, without persisting sessions. + */ +export function createCallerSupabaseFactory({ + createClient, + supabaseUrl, + supabaseAnonKey, +}: CreateCallerSupabaseFactoryOptions): (req: Request) => TClient { + return (req: Request): TClient => + createClient(supabaseUrl, supabaseAnonKey, { + auth: { autoRefreshToken: false, persistSession: false }, + global: { headers: { Authorization: req.headers.get("authorization") ?? "" } }, + }); +} + +/** Merges CORS headers onto a handler response without mutating its body. */ +export async function withCors(response: 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 }); +} + export function createStripeWebhookHandler({ stripe, supabase, @@ -848,6 +912,14 @@ export function createStripeWebhookHandler 0) { + return error.code; + } + if (error instanceof Error && error.name.length > 0) { + return error.name; + } + + return "unknown"; +} + function accountErrorResponse(error: unknown): Response { if (error instanceof EdgeSharedError) { 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" }); + if (error.code === "deletion_incomplete") { + // Deletion blocked on pending refund/cleanup reconciliation — money-adjacent. + logMoneyPathError({ operation: "account_deletion_incomplete", error_code: error.code }); + return jsonResponse(503, { error: error.code }); + } + if (error.code === "database_error") { + logMoneyPathError({ operation: "account_database_error", error_code: error.code }); + return jsonResponse(500, { error: "internal_error" }); + } return jsonResponse(400, { error: error.code }); } + logMoneyPathError({ operation: "account_internal_error", error_code: errorCode(error) }); return jsonResponse(500, { error: "internal_error" }); } @@ -1071,6 +1174,19 @@ async function markCheckoutSessionExpired( } } +async function runDeletionSettlementPass( + admin: AccountAdminClientLike, + stripe: StripeCustomerClientLike, + userId: string, + sessionIds: Set, + customerIds: Set, + deletedCustomerIds: Set, +): Promise { + await settleDeletionFixpoint(admin, userId, sessionIds, customerIds); + await finalizeAccountDeletion(admin, userId, customerIds); + await deleteStripeCustomers(stripe, customerIds, deletedCustomerIds); +} + async function settleDeletionFixpoint( admin: AccountAdminClientLike, userId: string, diff --git a/packages/edge-shared/test/edge-shared.test.ts b/packages/edge-shared/test/edge-shared.test.ts index e71e6ee..e1585ad 100644 --- a/packages/edge-shared/test/edge-shared.test.ts +++ b/packages/edge-shared/test/edge-shared.test.ts @@ -6,7 +6,9 @@ import { createDeleteAccountHandler, createExportAccountHandler, createOperatorRouterHandler, + createCallerSupabaseFactory, createRegisteredCheckoutSession, + createRequiredEnv, createStripeWebhookHandler, debitCredits, getBearerToken, @@ -14,6 +16,7 @@ import { getUserFromRequest, jsonResponse, newIdempotencyKey, + withCors, operatorRouterSystemPrompt, requireEntitlement, type EdgeSharedJson, @@ -478,7 +481,21 @@ describe("@pickforge/edge-shared", () => { resolveUserId: vi.fn(async () => USER_ID), }); - const response = await handler(new Request("https://edge.test", { method: "POST" })); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + let response: Response; + try { + response = await handler(new Request("https://edge.test", { method: "POST" })); + + expect(consoleError).toHaveBeenCalledWith( + JSON.stringify({ + scope: "edge-shared", + operation: "account_deletion_incomplete", + error_code: "deletion_incomplete", + }), + ); + } finally { + consoleError.mockRestore(); + } expect(response.status).toBe(503); expect(admin.auth.admin.deleteUser).not.toHaveBeenCalled(); @@ -1060,13 +1077,28 @@ describe("@pickforge/edge-shared", () => { }), }); - const response = await handler( - new Request("https://edge.test", { - method: "POST", - headers: { "stripe-signature": "sig_123" }, - body: "{}", - }), - ); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + let response: Response; + try { + response = await handler( + new Request("https://edge.test", { + method: "POST", + headers: { "stripe-signature": "sig_123" }, + body: "{}", + }), + ); + + expect(consoleError).toHaveBeenCalledTimes(1); + expect(JSON.parse(consoleError.mock.calls[0]![0] as string)).toEqual({ + scope: "edge-shared", + operation: "stripe_webhook_processing_failed", + event_id: "evt_123", + event_type: "checkout.session.completed", + error_code: "Error", + }); + } finally { + consoleError.mockRestore(); + } expect(response.status).toBe(500); await expect(response.json()).resolves.toEqual({ error: "webhook_processing_failed" }); @@ -1398,6 +1430,64 @@ describe("@pickforge/edge-shared", () => { }); }); +describe("Deno Edge Function adapter helpers", () => { + it("reads required env vars and throws on missing or empty values", () => { + const env = new Map([ + ["SUPABASE_URL", "https://project.supabase.co"], + ["EMPTY", ""], + ]); + const requiredEnv = createRequiredEnv({ get: (name) => env.get(name) }); + + expect(requiredEnv("SUPABASE_URL")).toBe("https://project.supabase.co"); + expect(() => requiredEnv("EMPTY")).toThrow("EMPTY is required"); + expect(() => requiredEnv("MISSING")).toThrow("MISSING is required"); + }); + + it("builds a caller-scoped Supabase client that forwards the Authorization header", () => { + const createClient = vi.fn((url: string, key: string, options: unknown) => ({ url, key, options })); + const createCallerSupabase = createCallerSupabaseFactory({ + createClient, + supabaseUrl: "https://project.supabase.co", + supabaseAnonKey: "anon-key", + }); + + const client = createCallerSupabase( + new Request("https://edge.test", { headers: { Authorization: "Bearer caller-token" } }), + ); + + expect(client).toEqual({ + url: "https://project.supabase.co", + key: "anon-key", + options: { + auth: { autoRefreshToken: false, persistSession: false }, + global: { headers: { Authorization: "Bearer caller-token" } }, + }, + }); + }); + + it("defaults the forwarded Authorization header to an empty string when absent", () => { + const createClient = vi.fn((_url: string, _key: string, options: { global: { headers: { Authorization: string } } }) => options); + const createCallerSupabase = createCallerSupabaseFactory({ + createClient, + supabaseUrl: "https://project.supabase.co", + supabaseAnonKey: "anon-key", + }); + + const options = createCallerSupabase(new Request("https://edge.test")); + + expect(options.global.headers.Authorization).toBe(""); + }); + + it("merges CORS headers onto a handler response without altering its body", async () => { + const merged = await withCors(jsonResponse(201, { ok: true })); + + expect(merged.status).toBe(201); + expect(merged.headers.get("access-control-allow-origin")).toBe("*"); + expect(merged.headers.get("content-type")).toBe("application/json"); + await expect(merged.json()).resolves.toEqual({ ok: true }); + }); +}); + interface EntitlementRow { user_id: string; key: string; diff --git a/packages/flags/package.json b/packages/flags/package.json index a55d28d..3dd4303 100644 --- a/packages/flags/package.json +++ b/packages/flags/package.json @@ -1,6 +1,6 @@ { "name": "@pickforge/flags", - "version": "0.9.0", + "version": "0.10.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 7f74415..f6768ee 100644 --- a/packages/sync/package.json +++ b/packages/sync/package.json @@ -1,6 +1,6 @@ { "name": "@pickforge/sync", - "version": "0.9.0", + "version": "0.10.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 fcd38ca..6401e66 100644 --- a/packages/tauri-release/package.json +++ b/packages/tauri-release/package.json @@ -1,6 +1,6 @@ { "name": "@pickforge/tauri-release", - "version": "0.9.0", + "version": "0.10.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 f80cd53..917b618 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.9.0", - "@pickforge/edge-shared": "npm:@pickforge/edge-shared@0.9.0" + "@pickforge/billing": "npm:@pickforge/billing@0.10.0", + "@pickforge/edge-shared": "npm:@pickforge/edge-shared@0.10.0" } } diff --git a/supabase/functions/create-credit-checkout/index.ts b/supabase/functions/create-credit-checkout/index.ts index ebc833e..d5d2d98 100644 --- a/supabase/functions/create-credit-checkout/index.ts +++ b/supabase/functions/create-credit-checkout/index.ts @@ -4,18 +4,22 @@ import { createCreditCheckoutSession } from "@pickforge/billing"; import { corsHeaders, corsPreflightResponse, - EdgeSharedError, + createCallerSupabaseFactory, createRegisteredCheckoutSession, + createRequiredEnv, + EdgeSharedError, getUserFromRequest, jsonResponse, } from "@pickforge/edge-shared"; +const requiredEnv = createRequiredEnv(Deno.env); 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")); +const createCallerSupabase = createCallerSupabaseFactory({ createClient, supabaseUrl, supabaseAnonKey }); Deno.serve(async (req) => { if (req.method === "OPTIONS") { @@ -28,6 +32,10 @@ Deno.serve(async (req) => { try { const { userId } = await getUserFromRequest({ supabase: createCallerSupabase(req), req }); const { pack } = await readCheckoutRequest(req); + // Optional client-supplied idempotency token: retries of one purchase reuse it + // (Stripe returns the same Session); a new purchase sends a fresh token, and an + // absent header lets billing generate a fresh key so distinct purchases never collide. + const requestId = req.headers.get("x-idempotency-key") ?? undefined; const existingCustomerId = await readExistingCustomerId(userId); const session = await createRegisteredCheckoutSession({ stripe, @@ -41,6 +49,7 @@ Deno.serve(async (req) => { successUrl: requiredEnv("CHECKOUT_SUCCESS_URL"), cancelUrl: requiredEnv("CHECKOUT_CANCEL_URL"), existingCustomerId, + requestId, }); if (!isRecord(created)) { throw new Error("Stripe returned an invalid Checkout Session"); @@ -67,13 +76,6 @@ Deno.serve(async (req) => { } }); -function createCallerSupabase(req: Request) { - return createClient(supabaseUrl, supabaseAnonKey, { - auth: { autoRefreshToken: false, persistSession: false }, - global: { headers: { Authorization: req.headers.get("authorization") ?? "" } }, - }); -} - async function readExistingCustomerId(userId: string): Promise { const { data, error } = await serviceSupabase .from("billing_customers") @@ -150,15 +152,6 @@ async function readCheckoutRequest(req: Request): Promise<{ pack: unknown }> { return { pack: body.pack }; } -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; -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/supabase/functions/delete-account/deno.json b/supabase/functions/delete-account/deno.json index 3ecddf2..499496a 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.9.0" + "@pickforge/edge-shared": "npm:@pickforge/edge-shared@0.10.0" } } diff --git a/supabase/functions/delete-account/index.ts b/supabase/functions/delete-account/index.ts index 3506f29..8ac8fe5 100644 --- a/supabase/functions/delete-account/index.ts +++ b/supabase/functions/delete-account/index.ts @@ -3,17 +3,22 @@ import { createClient } from "@supabase/supabase-js"; import { corsHeaders, corsPreflightResponse, + createCallerSupabaseFactory, createDeleteAccountHandler, + createRequiredEnv, getUserFromRequest, jsonResponse, + withCors, } from "@pickforge/edge-shared"; +const requiredEnv = createRequiredEnv(Deno.env); 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")); +const createCallerSupabase = createCallerSupabaseFactory({ createClient, supabaseUrl, supabaseAnonKey }); Deno.serve(async (req) => { if (req.method === "OPTIONS") { @@ -33,28 +38,3 @@ Deno.serve(async (req) => { }); 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 index ebcd82c..8882a0e 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.9.0" + "@pickforge/edge-shared": "npm:@pickforge/edge-shared@0.10.0" } } diff --git a/supabase/functions/export-account-data/index.ts b/supabase/functions/export-account-data/index.ts index 0225502..9eec938 100644 --- a/supabase/functions/export-account-data/index.ts +++ b/supabase/functions/export-account-data/index.ts @@ -2,16 +2,21 @@ import { createClient } from "@supabase/supabase-js"; import { corsHeaders, corsPreflightResponse, + createCallerSupabaseFactory, createExportAccountHandler, + createRequiredEnv, getUserFromRequest, jsonResponse, + withCors, } from "@pickforge/edge-shared"; +const requiredEnv = createRequiredEnv(Deno.env); 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 createCallerSupabase = createCallerSupabaseFactory({ createClient, supabaseUrl, supabaseAnonKey }); Deno.serve(async (req) => { if (req.method === "OPTIONS") { @@ -23,35 +28,10 @@ Deno.serve(async (req) => { const handler = createExportAccountHandler({ admin: serviceSupabase, - resolveUserId: async (request) => { + resolveUserId: async (request: 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/operator-router/deno.json b/supabase/functions/operator-router/deno.json index c717776..0f33fcb 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.9.0", - "@pickforge/edge-shared": "npm:@pickforge/edge-shared@0.9.0" + "@pickforge/billing": "npm:@pickforge/billing@0.10.0", + "@pickforge/edge-shared": "npm:@pickforge/edge-shared@0.10.0" } } diff --git a/supabase/functions/operator-router/index.ts b/supabase/functions/operator-router/index.ts index d918daa..569dc85 100644 --- a/supabase/functions/operator-router/index.ts +++ b/supabase/functions/operator-router/index.ts @@ -1,19 +1,24 @@ import { createClient } from "@supabase/supabase-js"; import { getCreditBalanceCents } from "@pickforge/billing"; import { + createCallerSupabaseFactory, createOperatorRouterHandler, + createRequiredEnv, corsHeaders, corsPreflightResponse, debitCredits, EdgeSharedError, jsonResponse, + withCors, } from "@pickforge/edge-shared"; +const requiredEnv = createRequiredEnv(Deno.env); 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 createCallerSupabase = createCallerSupabaseFactory({ createClient, supabaseUrl, supabaseAnonKey }); Deno.serve(async (req) => { if (req.method === "OPTIONS") { @@ -41,12 +46,7 @@ Deno.serve(async (req) => { creditCostCents: readPositiveIntegerEnv("ROUTER_CREDIT_COST_CENTS", 2), }); - const response = await handler(req); - const headers = new Headers(response.headers); - for (const [name, value] of Object.entries(corsHeaders())) { - headers.set(name, value); - } - return new Response(response.body, { status: response.status, headers }); + return withCors(handler(req)); }); async function findCompletedRoute(userId: string, idempotencyKey: string) { @@ -94,13 +94,6 @@ function respond(status: number, body: unknown): Response { return jsonResponse(status, body, corsHeaders()); } -function createCallerSupabase(req: Request) { - return createClient(supabaseUrl, supabaseAnonKey, { - auth: { autoRefreshToken: false, persistSession: false }, - global: { headers: { Authorization: req.headers.get("authorization") ?? "" } }, - }); -} - async function chatComplete({ model, apiKey, @@ -155,12 +148,3 @@ function readPositiveIntegerEnv(name: string, fallback: number): number { return parsed; } - -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/stripe-webhook/index.ts b/supabase/functions/stripe-webhook/index.ts index 49c16c1..7427daf 100644 --- a/supabase/functions/stripe-webhook/index.ts +++ b/supabase/functions/stripe-webhook/index.ts @@ -1,8 +1,9 @@ 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.9.0"; -import { createStripeWebhookHandler } from "npm:@pickforge/edge-shared@0.9.0"; +import { processStripeEvent, verifyStripeEvent } from "npm:@pickforge/billing@0.10.0"; +import { createRequiredEnv, createStripeWebhookHandler } from "npm:@pickforge/edge-shared@0.10.0"; +const requiredEnv = createRequiredEnv(Deno.env); const stripe = new Stripe(requiredEnv("STRIPE_SECRET_KEY")); const supabase = createClient(requiredEnv("SUPABASE_URL"), requiredEnv("SUPABASE_SERVICE_ROLE_KEY"), { auth: { @@ -19,12 +20,3 @@ const handleWebhookRequest = createStripeWebhookHandler({ }); Deno.serve(handleWebhookRequest); - -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/migrations/20260719000000_credit_ledger_balance_covering_index.sql b/supabase/migrations/20260719000000_credit_ledger_balance_covering_index.sql new file mode 100644 index 0000000..d4f4ecb --- /dev/null +++ b/supabase/migrations/20260719000000_credit_ledger_balance_covering_index.sql @@ -0,0 +1,20 @@ +-- Additive-only migration (per #35): no renumbering, no destructive change. +-- +-- Both public.credit_balance_cents() and public.debit_credits() derive a balance +-- from sum(amount_cents) over every ledger row of a user, and the operator router +-- computes it twice per request (getCreditBalance + debit). The ledger grows one +-- row per ~2c call, so the scan cost per request grows without bound. +-- +-- The existing credit_ledger_user_id_created_at_idx (user_id, created_at desc) +-- can locate a user's rows but does not carry amount_cents, so the aggregate must +-- fetch every matching heap tuple. This covering index carries amount_cents in the +-- index payload, letting the balance sum run as an index-only scan for the user's +-- range without touching the heap. +-- +-- A cached/running balance (trigger-maintained aggregate row) is intentionally +-- deferred, not attempted here: it would alter the ledger write path and requires +-- its own concurrency correctness proof (interaction with the per-user advisory +-- lock in debit_credits and with purchase/grant/refund inserts). The covering +-- index is the safe additive minimum this policy allows. +create index if not exists credit_ledger_user_id_amount_cents_idx + on public.credit_ledger (user_id) include (amount_cents); diff --git a/supabase/tests/database/credit_ledger_balance_covering_index.test.sql b/supabase/tests/database/credit_ledger_balance_covering_index.test.sql new file mode 100644 index 0000000..baaf6a8 --- /dev/null +++ b/supabase/tests/database/credit_ledger_balance_covering_index.test.sql @@ -0,0 +1,72 @@ +begin; + +select plan(5); + +-- The covering index exists on the ledger. +select ok( + ( + select count(*)::integer + from pg_indexes + where schemaname = 'public' + and tablename = 'credit_ledger' + and indexname = 'credit_ledger_user_id_amount_cents_idx' + ) = 1, + 'the balance covering index is present on credit_ledger' +); + +-- It is keyed on user_id and carries amount_cents as an INCLUDE payload column. +select ok( + ( + select pg_get_indexdef(oid) + from pg_class + where relname = 'credit_ledger_user_id_amount_cents_idx' + ) like '%(user_id) INCLUDE (amount_cents)%', + 'the covering index is keyed on user_id with amount_cents as an INCLUDE column' +); + +-- The additive index does not displace the pre-existing lookup index. +select ok( + ( + select count(*)::integer + from pg_indexes + where schemaname = 'public' + and tablename = 'credit_ledger' + and indexname = 'credit_ledger_user_id_created_at_idx' + ) = 1, + 'the original user_id/created_at index still exists' +); + +-- The balance function remains a stable aggregate. +select is( + (select provolatile from pg_proc where proname = 'credit_balance_cents'), + 's', + 'credit_balance_cents remains a stable function' +); + +-- The balance function sums amount_cents correctly through the new index. +-- An anonymous user is used so the welcome-credit grant trigger does not fire. +insert into auth.users ( + id, aud, role, raw_app_meta_data, raw_user_meta_data, + is_anonymous, created_at, updated_at +) +values ( + '00000000-0000-0000-0000-000000000201', + 'authenticated', 'authenticated', + '{}'::jsonb, '{}'::jsonb, true, now(), now() +); + +insert into public.credit_ledger (user_id, amount_cents, kind, description) +values + ('00000000-0000-0000-0000-000000000201', 1000, 'purchase', 'seed purchase'), + ('00000000-0000-0000-0000-000000000201', -2, 'usage', 'router call'), + ('00000000-0000-0000-0000-000000000201', -3, 'usage', 'router call'); + +select is( + public.credit_balance_cents('00000000-0000-0000-0000-000000000201'), + 995, + 'the balance function sums amount_cents correctly with the covering index' +); + +select * from finish(); + +rollback;