Skip to content

feat(payments): Phase 0b β€” create-stripe-subscription + fix webhook template_user_id (closes #102)#111

Merged
TortoiseWolfe merged 1 commit into
mainfrom
feat/phase-0b-stripe-subscription
May 25, 2026
Merged

feat(payments): Phase 0b β€” create-stripe-subscription + fix webhook template_user_id (closes #102)#111
TortoiseWolfe merged 1 commit into
mainfrom
feat/phase-0b-stripe-subscription

Conversation

@TortoiseWolfe
Copy link
Copy Markdown
Owner

Summary

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 prevent subscription rows from being inserted.

Closes #102.

Why a webhook fix lives in the same PR

The subscriptions table requires template_user_id NOT NULL. The existing handleSubscriptionEvent in stripe-webhook never set it β€” the bug was latent because no production flow lit up the subscription path. Phase 0b activates that path, so the webhook fix is required to keep it working.

What ships

Path What
supabase/functions/create-stripe-subscription/ POST {price_id, customer_email} β†’ creates Stripe Checkout Session in subscription mode β†’ returns {sessionId}. Sets subscription_data.metadata.template_user_id so the webhook can satisfy the NOT NULL constraint.
supabase/functions/stripe-webhook/index.ts handleSubscriptionEvent now reads template_user_id + customer_email from subscription.metadata and writes both on the upsert. Returns {handled:false} (with logged error) when metadata is absent.
docs/PAYMENT-DEPLOYMENT.md Status table updated β†’ Phase 0a + 0b shipped. Step 4.2 lists the three deployable functions. Operator note clarifies stripe-webhook must be re-deployed after this PR lands.

Why no subscriptions row is inserted at session-creation time

At session-creation we don't have provider_subscription_id β€” Stripe assigns it post-checkout when the customer completes payment. The table's provider_subscription_id is NOT NULL UNIQUE. The clean handoff:

create-stripe-subscription:
  β†’ Creates Stripe Checkout Session (with metadata)
  β†’ Returns sessionId
  β†’ Browser redirects user to Stripe-hosted checkout

Customer completes checkout β†’ Stripe fires:
  β†’ customer.subscription.created (with metadata propagated from session)
  β†’ stripe-webhook upserts subscriptions row with status='active'

Security model

Identical to Phase 0a:

  • JWT verified via NEXT_PUBLIC_SUPABASE_ANON_KEY
  • Caller's user_id flows into Stripe metadata so the webhook can attribute the subscription row to the right user
  • Email validation matches payment-service.ts regex

Operator deploy steps post-merge

supabase functions deploy create-stripe-subscription
supabase functions deploy stripe-webhook  # re-deploy due to handler fix

Then SubscriptionManager subscribe flow works end-to-end against Stripe sandbox.

What this PR does NOT do

Test plan

πŸ€– Generated with Claude Code

…emplate_user_id (#102)

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: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@TortoiseWolfe TortoiseWolfe merged commit 174e5ac into main May 25, 2026
28 checks passed
@TortoiseWolfe TortoiseWolfe deleted the feat/phase-0b-stripe-subscription branch May 25, 2026 21:28
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 0b] Stripe subscription: create-stripe-subscription (~3h)

2 participants