feat(payments): Phase 0a β create-stripe-checkout + verify-stripe-session (closes #101)#110
Merged
Merged
Conversation
β¦sion (#101) Implements the first sub-issue of the Phase 0 epic (#100): the two outbound Edge Functions for Stripe one-off checkout. With this PR the `/payment-demo` Stripe tab can actually charge a card end-to-end once sandbox keys are configured. Before this, clicking Pay hit a 404 against a nonexistent function URL (per the audit in PR #99 / issue #100). WHAT SHIPS 1. `supabase/functions/_shared/cors.ts` (new) β shared CORS helper used by all Phase 0 functions. Restricts origins to the configured site URL (no wildcards β these functions move money). 2. `supabase/functions/_shared/auth.ts` (new) β extracts the caller's user_id from the Authorization Bearer JWT. Throws UnauthorizedError on missing / malformed / invalid tokens. Service-role calls bypass RLS, so manual ownership checks against the JWT-derived user_id are mandatory. 3. `supabase/functions/create-stripe-checkout/` (new) β POST endpoint: Body: { payment_intent_id: string } Auth: required (Authorization: Bearer <jwt>) Logic: - JWT β user_id - Look up payment_intents row by id (service-role) - Verify intent.template_user_id === caller user_id (403 if not) - Verify intent has not expired (410 if past expires_at) - Verify intent.type === 'one_time' (400 if 'recurring') - Call stripe.checkout.sessions.create with: - mode: 'payment' - line_items from intent.amount/currency/description - customer_email from intent - payment_intent_data.metadata.intent_id = intent.id (critical β stripe-webhook reads this to correlate payment_intent.succeeded events back to our row) - client_reference_id = intent.id - success_url: /payment-result?session_id={...}&status=succeeded - cancel_url: /payment-result?status=cancelled - Return { sessionId } 4. `supabase/functions/verify-stripe-session/` (new) β POST endpoint: Body: { session_id: string } Auth: required Logic: - JWT β user_id - stripe.checkout.sessions.retrieve(session_id, expand:[payment_intent]) - Read payment_intent.metadata.intent_id (403 if absent) - Look up our payment_intents row, verify caller owns it - Return { payment_status } (Stripe enum: paid | unpaid | no_payment_required) Note: this is for the immediate post-redirect UI confirmation. The authoritative status lands when stripe-webhook writes payment_results.status='succeeded' (no change to that path). 5. `src/lib/payments/stripe.ts` β adds an Authorization header to all three fetch sites (createCheckoutSession, handleStripeRedirect, createSubscriptionCheckout). Uses a getAuthHeader() helper that pulls supabase.auth.getSession() and throws if no active session. Subscription path benefits even though Phase 0b hasn't shipped yet β preserves the same call convention across all three. 6. `docs/PAYMENT-DEPLOYMENT.md` β status table updated to "Partially shipped (Phase 0a β )". Step 4.2 lists the 2 deployable functions from this PR + the 6 remaining tracked in #102-#105. WHY THIS IS A WORKING CONTRACT, NOT JUST SCAFFOLDING - The webhook correlation key (metadata.intent_id) is verified against what stripe-webhook actually reads at index.ts:171 - The ownership check enforces what the RLS policy already encodes for client-side reads β making the service-role bypass safe - The success_url + cancel_url shape matches what /payment-result already parses (search params: session_id, status) - The Stripe API version (2024-06-20) matches the existing stripe-webhook so type signatures align WHAT THIS DOES NOT DO - Does not write `create-stripe-subscription` β that's Phase 0b (#102) - Does not write any PayPal functions β Phase 0c-0d (#103, #104) - Does not write cancel/resume-subscription β Phase 0e (#105) - Does not un-skip the E2E payment tests β Phase 0f (#106) batches un-skip across all 8 functions once they all ship - Does not seed deterministic test data β #109 (deferred) VERIFICATION - 81/81 payment unit tests pass (no regression in browser code) - Type-check clean - Lint clean - Deno tests are contract / shape tests β full E2E happy path is exercised by tests/e2e/payment/01-stripe-onetime.spec.ts under Playwright once sandbox keys land (un-skipped in #106) NEXT (for the operator going live) After this PR merges + sandbox keys configured: supabase functions deploy create-stripe-checkout supabase functions deploy verify-stripe-session Then `/payment-demo` Stripe tab works end-to-end. PayPal still 404s until Phase 0c (#103). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7 tasks
TortoiseWolfe
added a commit
that referenced
this pull request
May 25, 2026
β¦emplate_user_id (#102) (#111) 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: <caller user_id>, customer_email: <validated 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: TurtleWolfe <TurtleWolfe@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
First of 6 sub-issues of the Phase 0 epic (#100). Ships the 2 outbound Edge Functions for Stripe one-off checkout + the shared CORS / auth helpers that all subsequent Phase 0 PRs will reuse.
With this PR,
/payment-demoStripe tab actually charges a card end-to-end once sandbox keys are configured. Before: clicking Pay hit a 404 against a nonexistent function URL.Closes #101.
What ships
supabase/functions/_shared/cors.tssupabase/functions/_shared/auth.tsAuthorization: Bearer <jwt>. ThrowsUnauthorizedErroron absent/malformed/invalid.supabase/functions/create-stripe-checkout/POST {payment_intent_id}β creates Stripe Checkout Session withmetadata.intent_idfor webhook correlation β returns{sessionId}. Owner-check enforced.supabase/functions/verify-stripe-session/POST {session_id}β wrapsstripe.checkout.sessions.retrieveβ returns{payment_status}. Owner-check via session'spayment_intent.metadata.intent_id.src/lib/payments/stripe.tsAuthorization: Bearer <jwt>to all 3 fetch sites via agetAuthHeader()helper.vitest.config.tssupabase/functions/**from Vitest discovery (Deno test files usehttps://imports that the Node ESM loader rejects).docs/PAYMENT-DEPLOYMENT.mdSecurity model
NEXT_PUBLIC_SUPABASE_ANON_KEYin the Edge Functionpayment_intents.template_user_id === caller.user_id)metadata.intent_idis the correlation key thatstripe-webhook(already shipped atstripe-webhook/index.ts:171) reads to map provider events back to our DB rowsisPaymentIntentExpiredinpayment-service.tstype='recurring'intents (those go through Phase 0b'screate-stripe-subscription)What this PR does NOT do
create-stripe-subscription) β tracked in [Phase 0b] Stripe subscription: create-stripe-subscription (~3h)Β #102create-paypal-order+capture-paypal-order) β [Phase 0c] PayPal one-off: create-paypal-order + capture-paypal-order (~4h)Β #103create-paypal-subscription) β [Phase 0d] PayPal subscription: create-paypal-subscription (~2h)Β #104cancel-subscription+resume-subscription) β [Phase 0e] Subscription lifecycle: cancel-subscription + resume-subscription (~3h)Β #105payment_intents/payment_results/subscriptionsschema (no migration needed; the existingpayment_results.transaction_idis the linkage)Test plan
supabase functions deploy create-stripe-checkout verify-stripe-sessionworks/payment-demoStripe tab completes a test-card checkout end-to-end (validated as part of [Phase 0f] Shared infra + un-skip 86 E2E payment tests (~2h)Β #106 once all 8 functions ship and tests un-skip)Operator note (post-merge)
After this lands and the operator follows
docs/PAYMENT-DEPLOYMENT.mdSteps 1-3 (project link, migration, env vars), they can:And the Stripe tab on
/payment-demowill work end-to-end. PayPal tab still 404s until Phase 0c.π€ Generated with Claude Code