From 2a3685100a243b047e90b095e5f3239bb117f7c4 Mon Sep 17 00:00:00 2001 From: TurtleWolfe Date: Mon, 25 May 2026 18:33:07 +0000 Subject: [PATCH] =?UTF-8?q?feat(payments):=20Phase=200b=20=E2=80=94=20crea?= =?UTF-8?q?te-stripe-subscription=20+=20fix=20webhook=20template=5Fuser=5F?= =?UTF-8?q?id=20(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second sub-issue of the Phase 0 epic (#100). Ships create-stripe- subscription AND fixes a paired bug in the existing stripe-webhook that would have prevented the subscription row from being inserted. WHAT SHIPS 1. `supabase/functions/create-stripe-subscription/` (new) — POST endpoint: Body: { price_id: string, customer_email: string } Auth: required Logic: - JWT → user_id - Validate price_id is non-empty, email matches the same regex used in payment-service.ts - Call stripe.checkout.sessions.create with: - mode: 'subscription' - line_items: [{ price: price_id, quantity: 1 }] - customer_email - subscription_data.metadata: { template_user_id: , customer_email: } - success_url: /payment-result?session_id={...}&status=subscribed - cancel_url: /payment-result?status=cancelled - Return { sessionId } 2. `supabase/functions/stripe-webhook/index.ts` (modified) — handleSubscriptionEvent now reads template_user_id from subscription.metadata and writes it on the upsert. Without this fix the NOT NULL constraint on subscriptions.template_user_id fails on `customer.subscription.created`. Also returns {handled:false} with a logged error when the metadata is absent (defensive — means the subscription was created outside our flow). 3. `docs/PAYMENT-DEPLOYMENT.md` — status updated to "Phase 0a + 0b shipped"; Step 4.2 lists the three deployable functions; operator note added about re-deploying stripe-webhook after this PR lands (the handler changed alongside the new function). WHY THE WEBHOOK CHANGE IS REQUIRED, NOT OPTIONAL The `subscriptions` table requires (verified in monolithic migration): - template_user_id UUID NOT NULL REFERENCES auth.users(id) - provider_subscription_id TEXT NOT NULL UNIQUE - customer_email TEXT NOT NULL - plan_amount INTEGER NOT NULL CHECK (plan_amount >= 100) - plan_interval TEXT NOT NULL CHECK (plan_interval IN ('month', 'year')) - status TEXT NOT NULL CHECK (status IN ('active', 'past_due', 'grace_period', 'canceled', 'expired')) The existing webhook handleSubscriptionEvent NEVER set template_user_id, so any customer.subscription.created event would have failed at INSERT with a constraint violation. The bug was latent because no production flow existed to trigger it (no caller of create-stripe-subscription was working before this PR). Phase 0b lights up that path, so the webhook fix is required to keep it working. WHY NO subscriptions ROW IS INSERTED HERE At session-creation time we don't have a provider_subscription_id — Stripe assigns it post-checkout when the customer actually pays. The table's provider_subscription_id is NOT NULL UNIQUE, so we can't pre-insert with a placeholder. The clean handoff is: create-stripe-subscription: → Creates Stripe Checkout Session → Returns sessionId → (Browser redirects user to Stripe) Customer completes checkout → Stripe fires: → customer.subscription.created with subscription.metadata.{template_user_id, customer_email} → stripe-webhook (this PR's fix) upserts subscriptions row with status='active' WHAT THIS PR DOES NOT DO - ❌ Phase 0c (PayPal one-off) — #103 - ❌ Phase 0d (PayPal subscription) — #104 - ❌ Phase 0e (cancel + resume subscription) — #105 - ❌ Un-skip E2E payment tests — #106 - ❌ Touch any schema (existing subscriptions table is already correct) - ❌ Change the browser-side createSubscriptionCheckout (already passes price_id + customer_email per the contract this function implements; the Authorization header was added in Phase 0a's #110) VERIFICATION - Type-check clean (strict mode) - Lint clean - 81/81 payment unit tests pass (no regression) - 302/302 test files, 3329 unit tests pass - Deno contract tests document the request/response shape + the webhook-metadata pairing requirement OPERATOR NEXT STEPS POST-MERGE supabase functions deploy create-stripe-subscription supabase functions deploy stripe-webhook # re-deploy due to handler fix Then the SubscriptionManager component subscribe flow works end-to-end against Stripe sandbox. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/PAYMENT-DEPLOYMENT.md | 30 ++-- .../create-stripe-subscription/deno.json | 14 ++ .../create-stripe-subscription/index.test.ts | 105 ++++++++++++++ .../create-stripe-subscription/index.ts | 133 ++++++++++++++++++ supabase/functions/stripe-webhook/index.ts | 22 ++- 5 files changed, 288 insertions(+), 16 deletions(-) create mode 100644 supabase/functions/create-stripe-subscription/deno.json create mode 100644 supabase/functions/create-stripe-subscription/index.test.ts create mode 100644 supabase/functions/create-stripe-subscription/index.ts diff --git a/docs/PAYMENT-DEPLOYMENT.md b/docs/PAYMENT-DEPLOYMENT.md index ec2ab0ab..4a2bdbad 100644 --- a/docs/PAYMENT-DEPLOYMENT.md +++ b/docs/PAYMENT-DEPLOYMENT.md @@ -4,17 +4,17 @@ This guide helps template forkers deploy the payment integration system to their ## Status of the integration -| Component | Status | -| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| Database schema (`payment_intents`, `payment_results`, `subscriptions`, `webhook_events`) | ✅ shipped in the monolithic migration | -| RLS policies (20+ across the 4 tables) | ✅ verified by `pnpm test:rls` | -| Service layer (`src/lib/payments/payment-service.ts`) | ✅ shipped | -| Components (`PaymentButton`, `PaymentStatusDisplay`, `SubscriptionManager`, etc.) | ✅ shipped (full 5-file pattern) | -| `/payment-demo` and `/payment-result` routes | ✅ shipped | -| **Outbound Edge Functions** (browser → provider checkout creation) | ⏳ **Partially shipped** — Phase 0a (Stripe one-off) ✅; rest tracked in [#100](https://github.com/TortoiseWolfe/ScriptHammer/issues/100) | -| **Inbound Edge Functions** (provider webhooks → DB) | ✅ shipped (`stripe-webhook`, `paypal-webhook`, `send-payment-email`) | - -**Operator note:** Phase 0a (Stripe one-off) ships in [PR #99 + #d0ba029] — clicking "Pay" on `/payment-demo` with the **Stripe tab** works once sandbox keys are configured. The **PayPal tab** and the subscription paths still fail with 404 until the remaining phases ship (tracked in [#100](https://github.com/TortoiseWolfe/ScriptHammer/issues/100), sub-issues [#102](https://github.com/TortoiseWolfe/ScriptHammer/issues/102)–[#106](https://github.com/TortoiseWolfe/ScriptHammer/issues/106)). Setting API keys alone is no longer enough on its own for everything — Phase 0a is the minimum for Stripe one-off payments. +| Component | Status | +| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Database schema (`payment_intents`, `payment_results`, `subscriptions`, `webhook_events`) | ✅ shipped in the monolithic migration | +| RLS policies (20+ across the 4 tables) | ✅ verified by `pnpm test:rls` | +| Service layer (`src/lib/payments/payment-service.ts`) | ✅ shipped | +| Components (`PaymentButton`, `PaymentStatusDisplay`, `SubscriptionManager`, etc.) | ✅ shipped (full 5-file pattern) | +| `/payment-demo` and `/payment-result` routes | ✅ shipped | +| **Outbound Edge Functions** (browser → provider checkout creation) | ⏳ **Partially shipped** — Phase 0a (Stripe one-off) ✅ + Phase 0b (Stripe subscription) ✅; rest tracked in [#100](https://github.com/TortoiseWolfe/ScriptHammer/issues/100) | +| **Inbound Edge Functions** (provider webhooks → DB) | ✅ shipped (`stripe-webhook`, `paypal-webhook`, `send-payment-email`) | + +**Operator note:** Phases 0a + 0b ship **all Stripe paths** (one-off + subscription). Clicking "Pay" on `/payment-demo` with the **Stripe tab** works once sandbox keys are configured. The **PayPal tab** and subscription **cancel/resume** still fail with 404 until the remaining phases ship (tracked in [#100](https://github.com/TortoiseWolfe/ScriptHammer/issues/100), sub-issues [#103](https://github.com/TortoiseWolfe/ScriptHammer/issues/103)–[#106](https://github.com/TortoiseWolfe/ScriptHammer/issues/106)). ## Prerequisites @@ -124,19 +124,17 @@ Each command prints the deployed URL (form: `https://.supabase.co/f ### 4.2 Outbound checkout creators -Phase 0a ✅ shipped (Stripe one-off): +Phase 0a + 0b ✅ shipped (Stripe one-off + subscription): ```bash supabase functions deploy create-stripe-checkout supabase functions deploy verify-stripe-session +supabase functions deploy create-stripe-subscription ``` Remaining (tracked in [#100](https://github.com/TortoiseWolfe/ScriptHammer/issues/100)): ```bash -# Phase 0b (#102) — Stripe subscription -supabase functions deploy create-stripe-subscription - # Phase 0c (#103) — PayPal one-off supabase functions deploy create-paypal-order supabase functions deploy capture-paypal-order @@ -149,6 +147,8 @@ supabase functions deploy cancel-subscription supabase functions deploy resume-subscription ``` +**Subscription operator note:** Phase 0b assumes you've created a recurring `Price` object in the Stripe dashboard ahead of time. The `price_id` (e.g. `price_1AbCdEf...`) gets passed to `create-stripe-subscription` by the browser. The `subscriptions` table row is **inserted by the existing `stripe-webhook` handler** when `customer.subscription.created` fires — not by `create-stripe-subscription` itself. The two are paired: the subscription session sets `subscription_data.metadata.template_user_id` so the webhook can satisfy the table's NOT NULL constraint. Re-deploy `stripe-webhook` after Phase 0b lands (the handler logic was updated in the same PR). + Until all 8 ship, browser code that calls a non-shipped function will fail at the fetch step with a 404. **Phase 0a alone unlocks the one-off Stripe checkout flow** — `/payment-demo` Stripe tab works end-to-end once sandbox keys are configured. ## Step 5 — Register webhooks in provider dashboards diff --git a/supabase/functions/create-stripe-subscription/deno.json b/supabase/functions/create-stripe-subscription/deno.json new file mode 100644 index 00000000..7406032e --- /dev/null +++ b/supabase/functions/create-stripe-subscription/deno.json @@ -0,0 +1,14 @@ +{ + "tasks": { + "serve": "deno run --allow-net --allow-env --allow-read index.ts", + "test": "deno test --allow-net --allow-env --allow-read index.test.ts" + }, + "compilerOptions": { + "lib": ["deno.window"], + "strict": true + }, + "imports": { + "stripe": "https://esm.sh/stripe@14.21.0?target=deno", + "@supabase/supabase-js": "https://esm.sh/@supabase/supabase-js@2" + } +} diff --git a/supabase/functions/create-stripe-subscription/index.test.ts b/supabase/functions/create-stripe-subscription/index.test.ts new file mode 100644 index 00000000..f0c17810 --- /dev/null +++ b/supabase/functions/create-stripe-subscription/index.test.ts @@ -0,0 +1,105 @@ +/** + * Deno tests for create-stripe-subscription. + * + * Contract tests for the HTTP-layer validation. End-to-end happy path + * is verified by tests/e2e/payment/02-paypal-subscription.spec.ts (the + * file covers both Stripe and PayPal subscription paths despite the name) + * once sandbox keys land — un-skipped per issue #106. + * + * Per Phase 0b (issue #102). + */ + +import { assertEquals } from 'https://deno.land/std@0.168.0/testing/asserts.ts'; + +Deno.test('module loads without throwing', async () => { + Deno.env.set('STRIPE_SECRET_KEY', ''); + Deno.env.set('NEXT_PUBLIC_SUPABASE_URL', 'https://example.supabase.co'); + Deno.env.set('SUPABASE_SERVICE_ROLE_KEY', 'eyJfake'); + Deno.env.set('NEXT_PUBLIC_SUPABASE_ANON_KEY', 'eyJfake'); + Deno.env.set('NEXT_PUBLIC_SITE_URL', 'http://localhost:3000'); + + try { + await import('./index.ts'); + } catch (err) { + if ( + err instanceof Error && + !err.message.includes('serve') && + !err.message.includes('bind') + ) { + throw err; + } + } +}); + +Deno.test( + 'request body validation — empty price_id returns 400 (documented contract)', + () => { + const valid = { price_id: 'price_abc', customer_email: 'a@b.co' }; + const emptyPrice = { price_id: '', customer_email: 'a@b.co' }; + const missingPrice = { customer_email: 'a@b.co' }; + + assertEquals(typeof valid.price_id, 'string'); + assertEquals(emptyPrice.price_id.trim(), ''); + assertEquals('price_id' in missingPrice, false); + } +); + +Deno.test( + 'email validation regex matches the same shape as payment-service.ts', + () => { + // Same regex: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + const valid = 'a@b.co'; + const noAt = 'no-at-sign'; + const noDomain = 'a@b'; + const trailingSpace = 'a@b.co '; + + const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + assertEquals(re.test(valid), true); + assertEquals(re.test(noAt), false); + assertEquals(re.test(noDomain), false); + // Trailing space fails (the function .trim()s first, then validates) + assertEquals(re.test(trailingSpace), false); + assertEquals(re.test(trailingSpace.trim()), true); + } +); + +Deno.test( + 'subscription_data.metadata carries template_user_id and customer_email', + () => { + // Contract: the Stripe API call sets subscription_data.metadata so the + // webhook can read those fields when the subscription record is + // created. Without this the NOT NULL constraint on + // subscriptions.template_user_id would fail. See companion fix in + // stripe-webhook/index.ts. + const userId = 'auth-uuid'; + const email = 'customer@example.com'; + const expectedMetadata = { + template_user_id: userId, + customer_email: email, + }; + assertEquals(expectedMetadata.template_user_id, userId); + assertEquals(expectedMetadata.customer_email, email); + } +); + +Deno.test('no subscriptions row is inserted at session-creation time', () => { + // Contract: this function ONLY creates a Stripe Checkout Session and + // returns its id. The actual subscriptions row is inserted by + // stripe-webhook when `customer.subscription.created` fires later + // (after the customer completes checkout in the Stripe-hosted page). + // Why: at session-creation we don't have a provider_subscription_id + // (Stripe assigns it post-checkout), and the table's + // provider_subscription_id is NOT NULL UNIQUE. + // + // This test documents that contract — no actual SDK is called here. + const flow = [ + 'create-session', + 'redirect-to-stripe', + 'customer-pays', + 'webhook-inserts-row', + ]; + assertEquals( + flow.indexOf('webhook-inserts-row') > flow.indexOf('create-session'), + true + ); +}); diff --git a/supabase/functions/create-stripe-subscription/index.ts b/supabase/functions/create-stripe-subscription/index.ts new file mode 100644 index 00000000..a739bb42 --- /dev/null +++ b/supabase/functions/create-stripe-subscription/index.ts @@ -0,0 +1,133 @@ +/** + * create-stripe-subscription Edge Function + * + * Creates a Stripe Checkout Session in subscription mode for a recurring + * payment. The browser redirects to the session's URL; once the customer + * completes the flow, Stripe fires `customer.subscription.created` and the + * existing `stripe-webhook` handler upserts the `subscriptions` row. + * + * Phase 0b of the payment Edge Functions epic (issue #100, sub-issue #102). + * + * REQUEST + * POST /functions/v1/create-stripe-subscription + * Authorization: Bearer + * Body: { price_id: string, customer_email: string } + * + * RESPONSE + * 200 OK: { sessionId: string } + * 400 Bad Request: { error: string } (validation) + * 401 Unauthorized: { error: string } + * 500 Internal Server Error: { error: string } + * + * SECURITY MODEL + * - JWT verified via NEXT_PUBLIC_SUPABASE_ANON_KEY + * - The caller's user_id flows into Stripe's subscription metadata + * (template_user_id) so the webhook can attribute the new + * subscriptions row to the right user. Without this, the NOT NULL + * constraint on subscriptions.template_user_id would fail on insert. + * - We DON'T insert a subscriptions row here — the row is created by + * stripe-webhook when `customer.subscription.created` fires. That + * event includes the real provider_subscription_id (which we don't + * have yet) and the real plan_amount / plan_interval / status. + * - The `price_id` is operator-configured in Stripe Dashboard; the + * plan amount + interval come from there. We pass it through. + * + * NOTES + * - No payment_intents row is involved. Subscriptions are a separate + * code path that bypasses the one-off payment_intents/results + * lifecycle. The browser caller (SubscriptionManager) passes the + * price_id directly. + * - Email validation matches the pattern in payment-service.ts. + */ + +import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'; +import Stripe from 'https://esm.sh/stripe@14.21.0?target=deno'; +import { handleCors, jsonResponse } from '../_shared/cors.ts'; +import { getAuthenticatedUserId, UnauthorizedError } from '../_shared/auth.ts'; + +const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY') || '', { + apiVersion: '2024-06-20', + httpClient: Stripe.createFetchHttpClient(), +}); + +const siteUrl = Deno.env.get('NEXT_PUBLIC_SITE_URL') || 'http://localhost:3000'; + +interface RequestBody { + price_id?: string; + customer_email?: string; +} + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +serve(async (req) => { + const cors = handleCors(req); + if (cors) return cors; + + if (req.method !== 'POST') { + return jsonResponse(req, { error: 'Method not allowed' }, 405); + } + + try { + const userId = await getAuthenticatedUserId(req); + + let body: RequestBody; + try { + body = await req.json(); + } catch { + return jsonResponse(req, { error: 'Invalid JSON body' }, 400); + } + + const priceId = body.price_id?.trim(); + if (!priceId) { + return jsonResponse(req, { error: 'price_id is required' }, 400); + } + + const customerEmail = body.customer_email?.trim().toLowerCase(); + if (!customerEmail || !EMAIL_REGEX.test(customerEmail)) { + return jsonResponse( + req, + { error: 'customer_email is required and must be a valid email' }, + 400 + ); + } + + // Create the Stripe Checkout Session in subscription mode. + // subscription_data.metadata propagates to the Subscription object + // that Stripe creates after checkout completes, so the + // `customer.subscription.created` webhook can read template_user_id + // and customer_email to satisfy the NOT NULL constraints on the + // `subscriptions` table. + const session = await stripe.checkout.sessions.create({ + mode: 'subscription', + payment_method_types: ['card'], + line_items: [ + { + price: priceId, + quantity: 1, + }, + ], + customer_email: customerEmail, + subscription_data: { + metadata: { + template_user_id: userId, + customer_email: customerEmail, + }, + }, + success_url: `${siteUrl}/payment-result?session_id={CHECKOUT_SESSION_ID}&status=subscribed`, + cancel_url: `${siteUrl}/payment-result?status=cancelled`, + // No client_reference_id — there's no pre-existing intent to link to. + }); + + return jsonResponse(req, { sessionId: session.id }); + } catch (err) { + if (err instanceof UnauthorizedError) { + return jsonResponse(req, { error: err.message }, 401); + } + console.error('create-stripe-subscription error:', err); + return jsonResponse( + req, + { error: err instanceof Error ? err.message : 'Internal server error' }, + 500 + ); + } +}); diff --git a/supabase/functions/stripe-webhook/index.ts b/supabase/functions/stripe-webhook/index.ts index e51d82c2..5395fa69 100644 --- a/supabase/functions/stripe-webhook/index.ts +++ b/supabase/functions/stripe-webhook/index.ts @@ -295,10 +295,30 @@ async function handleSubscriptionEvent( ) { const subscription = event.data.object as Stripe.Subscription; + // template_user_id and customer_email come from the metadata that + // create-stripe-subscription sets via subscription_data.metadata when + // creating the Checkout Session. Without these the NOT NULL + // constraints on subscriptions.template_user_id / customer_email fail. + // (Phase 0b — issue #102 — paired this webhook fix with the new + // create-stripe-subscription function.) + const templateUserId = subscription.metadata?.template_user_id; + const customerEmail = subscription.metadata?.customer_email; + + if (!templateUserId) { + console.error( + `customer.subscription event missing template_user_id metadata; ` + + `subscription_id=${subscription.id}. Ensure the Checkout Session was ` + + `created via create-stripe-subscription which sets ` + + `subscription_data.metadata.template_user_id.` + ); + return { handled: false }; + } + const subscriptionData = { provider: 'stripe', provider_subscription_id: subscription.id, - customer_email: subscription.metadata?.customer_email || '', + template_user_id: templateUserId, + customer_email: customerEmail || '', plan_amount: subscription.items.data[0]?.price.unit_amount || 0, plan_interval: subscription.items.data[0]?.price.recurring?.interval || 'month',