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',