Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/auth/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion packages/billing/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
8 changes: 6 additions & 2 deletions packages/billing/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -148,6 +149,7 @@ export interface CreateCreditCheckoutSessionOptions {
priceId: string;
successUrl: string;
cancelUrl: string;
existingCustomerId?: string;
}

export interface GetCreditBalanceOptions {
Expand Down Expand Up @@ -215,15 +217,17 @@ export async function createCreditCheckoutSession<TSession = unknown>({
priceId,
successUrl,
cancelUrl,
existingCustomerId,
}: CreateCreditCheckoutSessionOptions): Promise<TSession> {
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<TSession>({
mode: "payment",
customer_creation: "always",
...(customer === undefined ? { customer_creation: "always" as const } : { customer }),
client_reference_id: validUserId,
line_items: [
{
Expand Down
22 changes: 22 additions & 0 deletions packages/billing/test/billing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() });
Expand Down
2 changes: 1 addition & 1 deletion packages/brand/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion packages/edge-shared/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
241 changes: 241 additions & 0 deletions packages/edge-shared/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export type EdgeSharedErrorCode =
| "database_error"
| "deletion_incomplete"
| "entitlement_required"
| "insufficient_credits"
| "invalid_debit_amount"
Expand Down Expand Up @@ -41,6 +42,9 @@ export interface SupabaseAuthLike {
export interface SupabaseQueryBuilderLike<T = unknown> extends PromiseLike<SupabaseQueryResult<T>> {
select(columns?: string): SupabaseQueryBuilderLike<T>;
eq(column: string, value: unknown): SupabaseQueryBuilderLike<T>;
is(column: string, value: null): SupabaseQueryBuilderLike<T>;
order(column: string, options?: { ascending?: boolean }): SupabaseQueryBuilderLike<T>;
range(from: number, to: number): SupabaseQueryBuilderLike<T>;
maybeSingle(): PromiseLike<SupabaseQueryResult<T | null>>;
}

Expand Down Expand Up @@ -176,6 +180,76 @@ export interface CreateOperatorRouterHandlerOptions {
systemPrompt?: string;
}

export interface AccountAdminClientLike {
auth: {
admin: {
deleteUser(userId: string): PromiseLike<{ error: SupabaseErrorLike | null }>;
};
};
from<T = unknown>(table: string): SupabaseQueryBuilderLike<T>;
}

export interface StripeCustomerClientLike {
customers: {
del(customerId: string): PromiseLike<unknown>;
};
}

export interface CreateDeleteAccountHandlerOptions {
admin: AccountAdminClientLike;
stripe: StripeCustomerClientLike;
resolveUserId(req: Request): Promise<string>;
}

export interface CreateExportAccountHandlerOptions {
admin: Pick<AccountAdminClientLike, "from">;
resolveUserId(req: Request): Promise<string>;
}

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;
Expand Down Expand Up @@ -441,6 +515,110 @@ export function createOperatorRouterHandler({
};
}

export function createDeleteAccountHandler({
admin,
stripe,
resolveUserId,
}: CreateDeleteAccountHandlerOptions): (req: Request) => Promise<Response> {
return async (req: Request): Promise<Response> => {
try {
const userId = await resolveUserId(req);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject non-POST account deletion requests

In the deployed wrapper only OPTIONS is handled specially, and this handler deletes immediately after resolving the bearer token, so any GET, HEAD, or DELETE request that includes a valid Authorization header will still delete the caller's account even though CORS advertises POST only. Because this is an irreversible operation, add a method guard before resolving the user or touching Stripe/Supabase.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 962d2ee — both new wrappers (and create-credit-checkout) now reject non-POST with 405 method_not_allowed after the OPTIONS check.

const [{ data: billingCustomer, error: billingCustomerError }, ledgerRows] = await Promise.all([
admin
.from<BillingCustomerRow>("billing_customers")
.select("stripe_customer_id")
.eq("user_id", userId)
.maybeSingle(),
readCreditLedgerPages<StripeCustomerMetadataRow>(admin, userId, "metadata"),
]);
if (billingCustomerError !== null) {
throw databaseError("Failed to read billing customer", billingCustomerError);
}

const customerIds = new Set<string>();
addStripeCustomerId(customerIds, billingCustomer?.stripe_customer_id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Delete the active Stripe customer last

When an account has both the current billing_customers row and historical ledger customer IDs, this inserts the active customer into the Set first, so the deletion loop deletes it before historical IDs. If a later historical stripe.customers.del fails, the handler returns 503 and skips deleteUser, leaving the account active while billing_customers.stripe_customer_id still points at a deleted customer; the checkout path added in supabase/functions/create-credit-checkout/index.ts reuses that ID, so future credit purchases can fail until deletion is retried successfully. Delete historical/non-current IDs before the active one, or clear/recreate the stale billing row on failure.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dismissing — deletion order is functionally irrelevant here because customer deletion is idempotent (resource_missing is tolerated) and a 503 retry re-gathers the full id set (billing_customers + ledger metadata) and re-attempts all of them. If any delete fails mid-list, the account is preserved and the retry converges regardless of order; already-deleted customers just return resource_missing. Ordering active-last would only shrink a harmless window, not change any outcome.

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Expire outstanding Checkout sessions before deleting users

If a user starts a Checkout Session and then deletes the account before paying, this hard-deletes the auth user while the hosted Stripe session can still be completed; this is especially reachable on a first purchase because there is no billing_customers row to delete. The later checkout.session.completed handling uses the session's client_reference_id to upsert billing_customers/credit_ledger, which will then hit the auth.users foreign keys and keep failing after the user has been charged. Expire or otherwise invalidate outstanding sessions, or make the webhook handle deleted users safely, before returning success.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring with a follow-up: an abandoned Checkout Session self-heals — Stripe auto-expires open sessions (~24h), and if a payment somehow completes for a deleted user the webhook's ledger insert fails the user_id FK (the row is gone), so no ghost data can attach to a deleted account. Proactively expiring open sessions on delete is a nice-to-have, not a correctness gap; filing a follow-up issue rather than adding session-enumeration to the erasure path.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up filed: pickforge-platform#29.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Drain pending checkout webhooks before deleting the user

If a user opens Checkout and invokes this endpoint before the checkout.session.completed/async_payment_succeeded webhook has been processed, this deleteUser removes the auth.users row while packages/billing/src/index.ts still handles those events by upserting billing_customers and inserting credit_ledger rows for client_reference_id; both tables have auth.users FKs in supabase/migrations/20260709010000_billing_customers_credit_ledger.sql. The late webhook then returns a database error/500 and Stripe will keep retrying instead of cleanly recording or ignoring the already-deleted purchase, so add a tombstone/no-op path or wait for/cancel open checkout sessions before hard-deleting the auth user.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring to pickforge-platform#29 (extended to cover this). Data integrity is protected today: if a checkout completes after deletion, the webhook's credit_ledger insert fails the user_id FK (the auth user is gone), so no ghost data attaches. The remaining edge — a user who abandons a checkout, deletes their account, then returns and pays — is rare but has real-money implications (paid, nothing to credit); the correct fix is expiring the user's open Checkout Sessions during deletion, which #29 now tracks and which should land before real payments. Synchronously 'draining' Stripe webhooks isn't cleanly implementable.

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<Response> {
return async (req: Request): Promise<Response> => {
try {
const userId = await resolveUserId(req);
const [profileResult, entitlementsResult, creditLedger, syncedSettingsResult, billingResult] = await Promise.all([
admin
.from<AccountProfileRow>("profiles")
.select("id,email,display_name,avatar_url,created_at,updated_at")
.eq("id", userId)
.maybeSingle(),
admin
.from<AccountEntitlementRow>("entitlements")
.select("id,key,value,granted_at,expires_at,source")
.eq("user_id", userId),
readCreditLedgerExport(admin, userId),
admin
.from<AccountSyncedSettingRow>("settings_sync")
.select("field_group,payload,updated_at")
.eq("user_id", userId)
.is("deleted_at", null),
admin
.from<BillingCustomerRow>("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,
Expand Down Expand Up @@ -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<AccountAdminClientLike, "from">,
userId: string,
): Promise<AccountCreditLedgerRow[]> {
return readCreditLedgerPages<AccountCreditLedgerRow>(
admin,
userId,
"id,amount_cents,kind,description,stripe_event_id,stripe_checkout_session_id,metadata,created_at,idempotency_key",
);
}

async function readCreditLedgerPages<T>(
admin: Pick<AccountAdminClientLike, "from">,
userId: string,
columns: string,
): Promise<T[]> {
const entries: T[] = [];
const pageSize = 1000;

for (let from = 0; ; from += pageSize) {
const { data, error } = await admin
.from<T[]>("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<string>, value: unknown): void {
if (typeof value === "string" && value.trim().length > 0) {
customerIds.add(value.trim());
}
}
Loading