Skip to content

feat(payments): Phase 0a β€” create-stripe-checkout + verify-stripe-session (closes #101)#110

Merged
TortoiseWolfe merged 1 commit into
mainfrom
feat/phase-0a-stripe-one-off
May 25, 2026
Merged

feat(payments): Phase 0a β€” create-stripe-checkout + verify-stripe-session (closes #101)#110
TortoiseWolfe merged 1 commit into
mainfrom
feat/phase-0a-stripe-one-off

Conversation

@TortoiseWolfe
Copy link
Copy Markdown
Owner

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-demo Stripe 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

Path What
supabase/functions/_shared/cors.ts Allowed-origin echo + JSON response builder. No wildcards (these functions move money).
supabase/functions/_shared/auth.ts Extracts user_id from Authorization: Bearer <jwt>. Throws UnauthorizedError on absent/malformed/invalid.
supabase/functions/create-stripe-checkout/ POST {payment_intent_id} β†’ creates Stripe Checkout Session with metadata.intent_id for webhook correlation β†’ returns {sessionId}. Owner-check enforced.
supabase/functions/verify-stripe-session/ POST {session_id} β†’ wraps stripe.checkout.sessions.retrieve β†’ returns {payment_status}. Owner-check via session's payment_intent.metadata.intent_id.
src/lib/payments/stripe.ts Adds Authorization: Bearer <jwt> to all 3 fetch sites via a getAuthHeader() helper.
vitest.config.ts Excludes supabase/functions/** from Vitest discovery (Deno test files use https:// imports that the Node ESM loader rejects).
docs/PAYMENT-DEPLOYMENT.md Status table β†’ "Partially shipped". Step 4.2 lists the 2 deployable functions.

Security model

  • JWT verified via NEXT_PUBLIC_SUPABASE_ANON_KEY in the Edge Function
  • Service-role client used only AFTER ownership is verified (payment_intents.template_user_id === caller.user_id)
  • Stripe metadata.intent_id is the correlation key that stripe-webhook (already shipped at stripe-webhook/index.ts:171) reads to map provider events back to our DB rows
  • Expiry check matches isPaymentIntentExpired in payment-service.ts
  • Type check: this function rejects type='recurring' intents (those go through Phase 0b's create-stripe-subscription)

What this PR does NOT do

Test plan

  • 302 test files, 3329 unit tests pass
  • Type-check clean (strict mode)
  • Lint clean
  • No regression in 81 existing payment unit tests
  • Browser-side fetch sites still type-check after Authorization header addition
  • CI green on this PR
  • After merge + sandbox keys deployed: supabase functions deploy create-stripe-checkout verify-stripe-session works
  • /payment-demo Stripe 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.md Steps 1-3 (project link, migration, env vars), they can:

supabase functions deploy create-stripe-checkout
supabase functions deploy verify-stripe-session

And the Stripe tab on /payment-demo will work end-to-end. PayPal tab still 404s until Phase 0c.

πŸ€– Generated with Claude Code

…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>
@TortoiseWolfe TortoiseWolfe merged commit 6180134 into main May 25, 2026
28 checks passed
@TortoiseWolfe TortoiseWolfe deleted the feat/phase-0a-stripe-one-off branch May 25, 2026 18:28
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Phase 0a] Stripe one-off: create-stripe-checkout + verify-stripe-session (~6h)

2 participants