fix(payments): read the clover field paths in the platform Stripe webhook (#544) - #551
Conversation
…hook (#544) lib/stripe.ts pins apiVersion 2026-02-25.clover, where Subscription.current_period_start/end moved onto SubscriptionItem and Invoice.subscription moved to parent.subscription_details.subscription. The student webhook was migrated for this; the platform webhook was not, and it reads through an `any` cast so TypeScript never flagged it. Every handler was therefore dead in production: - checkout.session.completed and customer.subscription.updated did `new Date(undefined * 1000).toISOString()` → RangeError, thrown while building the upsert object, before any write. A school paid and got no platform_subscriptions row, no tenants.plan, no revenue split; the route 500'd and Stripe retried forever. - invoice.payment_failed / invoice.paid resolved an undefined subscription id and silently broke out, so past_due was never set and the dunning email never sent. Mirrors the accessors in lib/payments/stripe-provider.ts:454-460, with the pre-clover paths kept as `??` fallbacks. toIso() guards the numeric check so a future shape change degrades (period column omitted, error logged) instead of 500-ing. Also in the same route: - Idempotency: events are recorded in webhook_events under provider 'stripe_platform' before handling and replayed ids are a no-op, same as app/api/payments/webhook/[provider]. - Ordering: customer.subscription.updated compares the event's period end against the stored one and drops strictly-older events whole, so an out-of-order delivery cannot rewind current_period_end / tenants.billing_period_end (which feed the expiry cron and the "next payment" date) or the plan via applyPortalPlanChange. - Errors: every read/write goes through unwrap() and a failure now 500s so Stripe retries, instead of being discarded behind a 200. - Stripe statuses outside the platform_subscriptions CHECK set (today 'paused') map to past_due instead of failing the constraint; the checkout metadata interval is pinned to monthly|yearly the same way. tests/unit/platform-webhook.test.ts drives POST with clover-shaped fixtures for all five handled event types. 18 of its 28 cases fail against the pre-fix route. Closes #544 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RnvphWuXtdxC2fkRCcw3t9
Manual QA — done, Stripe test mode, all criteria passReal Checkout run against local Supabase + The clover path is genuinely exercisedThe important part — the object There is no top-level period to read. The old code's (Note for anyone repeating this: 1. Checkout → activationLogged in
All 13 forwarded events returned 200 and are recorded processed under On master this run produces nothing at all: no subscription row, no plan change, no split. 2–4. Hand-signed clover-only
|
| # | Event | Response | current_period_end after |
|---|---|---|---|
| A | newer period | 200 {"received":true} |
advanced to 2026-09-25 ✅ |
| B | older period (out-of-order) | 200 {"received":true} |
unchanged 2026-09-25 — no rewind ✅ |
| C | replay of A's event.id |
200 {"received":true,"duplicate":true} |
unchanged ✅ |
| D | status: "paused" (outside the CHECK set) |
200 {"received":true} |
status = past_due, no constraint violation ✅ |
Server logs confirm the guards fired rather than silently succeeding:
[platform-webhook] dropping out-of-order customer.subscription.updated for tenant 00000000-...-0001:
event period_end 2026-07-27T01:13:49.000Z < stored 2026-09-25T01:13:49+00:00
[platform-webhook] unmapped Stripe subscription status paused -> past_due
Cleanup
Test subscription canceled ("status": "canceled"), local tenant restored to free/free, seeded platform_plans.stripe_price_id_monthly cleared, QA webhook_events rows deleted. No new objects were created in the Stripe account — an existing active test price was reused. Working tree clean.
Checklist boxes for role coverage / loading states / i18n stay unticked: this is a service-role webhook with no caller identity and no UI.
What
app/api/stripe/platform-webhook/route.tsread Stripe fields that no longer exist on the pinned API version, so every handler in it was dead. This fixes the three field paths and, in the same route, addswebhook_eventsidempotency, an ordering guard on the period write, real error checking on every write, and a status map onto the CHECK-allowed set.New
tests/unit/platform-webhook.test.tsdrivesPOSTwith clover-shaped fixtures for all five handled event types — 18 of its 28 cases fail against the pre-fix route.Closes #544
Why
lib/stripe.ts:13pinsapiVersion: '2026-02-25.clover'. On that versionSubscription.current_period_start/endmoved ontoSubscriptionItem, andInvoice.subscriptionmoved toInvoice.parent.subscription_details.subscription. The student webhook was migrated for this (lib/payments/stripe-provider.ts:454-460); the platform one was not, and it reads through ananycast so TypeScript never flagged it.checkout.session.completednew Date(undefined * 1000).toISOString()→RangeError: Invalid time value, thrown while building the upsert, before any write. School pays, gets noplatform_subscriptionsrow, notenants.plan, no revenue split; route 500s and Stripe retries forever with the same result.customer.subscription.updatedinvoice.payment_failed/invoice.paidundefinedsubscription id → falsy → silentbreak.past_duenever set, dunning email never sent, recovery never cleared it.The route also had no idempotency, no ordering guard, and discarded
.erroron every write while still answering 200 — so a CHECK-constraint failure (Stripe'spausedis not in the allowed set) looked like success.How
stripe-provider.ts:457-460, with the pre-clover paths kept as??fallbacks.toIso()guards the numeric check, so a future shape change degrades — period column omitted, error logged — instead of 500-ing.webhook_eventsunder providerstripe_platformbefore handling; a replayedevent.idreturns{received, duplicate}without touching anything. A row with noprocessed_at(previous attempt died mid-flight) is reused and retried rather than duplicated. Same shape asapp/api/payments/webhook/[provider]/route.ts.customer.subscription.updatedcompares the event's period end against the stored one and drops strictly-older events whole — so a late redelivery cannot rewindcurrent_period_end/tenants.billing_period_end(which feed the expiry cron and the "next payment" date), nor the plan viaapplyPortalPlanChange. An equal period end still applies, since that is a legitimate in-period update (acancel_at_period_endtoggle).unwrap(); a failure 500s so Stripe retries, and the message is recorded on thewebhook_eventsrow withprocessed_atleft unset.platform_subscriptions.statusCHECK set collapses topast_due(the bucket the billing-health dashboard and expiry cron already understand); the checkout metadataintervalis pinned tomonthly|yearly.No migration. No API-version bump.
lib/payments/platform-plan-change.tsuntouched — it is mocked in the new tests so the assertion is that the handler reaches it, which it never did; its own logic stays covered byplatform-plan-change.test.ts.How to QA
No DB reset needed for the automated part:
npm run test:unit→ 398 passing (370 baseline + 28 new).npm run typecheck,npm run build→ both clean.Manual (Stripe test mode,
owner@e2etest.comondefault.lvh.me:3000), from the last acceptance criterion on the issue:stripe listen --forward-to localhost:3000/api/stripe/platform-webhookwithSTRIPE_PLATFORM_WEBHOOK_SECRETset to the printed secret./dashboard/admin/billingand pay with4242 4242 4242 4242.platform_subscriptionsrow with both period columns populated,tenants.plan = 'starter', arevenue_splitsrow at the Starter fee, and a processedwebhook_eventsrow withprovider = 'stripe_platform'. Re-send the same event from the Stripe dashboard → second delivery is aduplicateno-op.Screenshots / GIF
n/a — server-side webhook route, no user-visible surface.
Checklist
npm run typecheckandnpm run test:unitpassnpm run buildpassestenant_id(or the table genuinely has no such column) —webhook_eventsis a service-role ingestion log with notenant_id; every other query is already keyed bytenant_id/idmessages/en.jsonandmessages/es.json— n/anpm run db:reset, has RLS policies, andlib/database.types.tswas regenerated — no migration;webhook_eventsalready exists (20260615130000)Note
Per the issue: fixing this unmasks the
forceTenantPlanChangeinteraction from the school-billing sub-issue of epic #540 — a comped school can get billed for the comped plan on the next routine event, becausecustomer.subscription.updatednow actually runs. That is out of scope here but should land in the same cycle.🤖 Generated with Claude Code
https://claude.ai/code/session_01RnvphWuXtdxC2fkRCcw3t9