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/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/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..342440f 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" @@ -41,6 +42,9 @@ export interface SupabaseAuthLike { export interface SupabaseQueryBuilderLike extends PromiseLike> { select(columns?: string): SupabaseQueryBuilderLike; 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>; } @@ -176,6 +180,76 @@ 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 StripeCustomerMetadataRow { + metadata: EdgeSharedJson; +} + +interface AccountSyncedSettingRow { + field_group: string; + payload: EdgeSharedJson; + updated_at: string; +} + export interface StoredRouteProposal { proposalJson: string; usage: ChatCompletionUsage; @@ -441,6 +515,110 @@ 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 }, 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); + } + + 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(customerId); + } 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); + 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, creditLedger, 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), + readCreditLedgerExport(admin, 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, 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, + 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 +845,66 @@ 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) { + 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" }); +} + +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"; +} + +async function readCreditLedgerExport( + admin: Pick, + userId: string, +): Promise { + 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(columns) + .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; + } + } +} + +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 e8c1243..be1a23c 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,222 @@ describe("@pickforge/edge-shared", () => { ); }); + 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, + 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).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]!); + }); + + it("deletes the user without a Stripe customer", 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); + }); + + 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, + resolveUserId: vi.fn(async () => USER_ID), + }); + + 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" }], + credit_ledger: [{ user_id: USER_ID, metadata: { stripe_customer_id: "cus_790" } }], + }); + const missingStripe = { + customers: { del: vi.fn().mockRejectedValueOnce({ code: "resource_missing" }).mockResolvedValueOnce({}) }, + }; + const missingStripeHandler = createDeleteAccountHandler({ + admin: missingStripeAdmin, + stripe: missingStripe, + resolveUserId: vi.fn(async () => 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 () => { + 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("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 = {}; @@ -664,6 +882,79 @@ interface EntitlementRow { expires_at: string | null; } +function accountAdmin(rows: Record = {}, errors: 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] ?? [], 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[], + private readonly error: SupabaseErrorLike | null, + ) {} + + 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; + } + + 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: 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: this.error }).then(onfulfilled, onrejected); + } + + private filteredRows(): unknown[] { + 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); + } +} + class MemorySupabase implements SupabaseClientLike { readonly entitlements: EntitlementRow[] = []; readonly rpcCalls: Array<{ fn: string; args?: Record }> = []; @@ -712,6 +1003,18 @@ class MemoryQuery implements SupabaseQueryBuilderLike { return this; } + is(column: string, value: null): 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/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/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/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/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..1ac09c0 --- /dev/null +++ b/supabase/functions/delete-account/index.ts @@ -0,0 +1,60 @@ +import Stripe from "stripe"; +import { createClient } from "@supabase/supabase-js"; +import { + corsHeaders, + corsPreflightResponse, + createDeleteAccountHandler, + getUserFromRequest, + jsonResponse, +} 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(); + } + if (req.method !== "POST") { + return jsonResponse(405, { error: "method_not_allowed" }, corsHeaders()); + } + + 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..0225502 --- /dev/null +++ b/supabase/functions/export-account-data/index.ts @@ -0,0 +1,57 @@ +import { createClient } from "@supabase/supabase-js"; +import { + corsHeaders, + corsPreflightResponse, + createExportAccountHandler, + getUserFromRequest, + jsonResponse, +} 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(); + } + if (req.method !== "POST") { + return jsonResponse(405, { error: "method_not_allowed" }, corsHeaders()); + } + + 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; +} 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" } }