Skip to content

fix(payments): read the clover field paths in the platform Stripe webhook (#544) - #551

Merged
guillermoscript merged 1 commit into
masterfrom
fix/platform-webhook-clover-544
Jul 26, 2026
Merged

fix(payments): read the clover field paths in the platform Stripe webhook (#544)#551
guillermoscript merged 1 commit into
masterfrom
fix/platform-webhook-clover-544

Conversation

@guillermoscript

Copy link
Copy Markdown
Owner

What

app/api/stripe/platform-webhook/route.ts read 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, adds webhook_events idempotency, 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.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

Why

lib/stripe.ts:13 pins apiVersion: '2026-02-25.clover'. On that version Subscription.current_period_start/end moved onto SubscriptionItem, and Invoice.subscription moved to Invoice.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 an any cast so TypeScript never flagged it.

Handler What actually happened
checkout.session.completed new Date(undefined * 1000).toISOString()RangeError: Invalid time value, thrown while building the upsert, before any write. School pays, gets no platform_subscriptions row, no tenants.plan, no revenue split; route 500s and Stripe retries forever with the same result.
customer.subscription.updated Threw at the same access, so #461's downgrade reconciler and #465's interval tracking have never executed in production.
invoice.payment_failed / invoice.paid Resolved an undefined subscription id → falsy → silent break. past_due never set, dunning email never sent, recovery never cleared it.

The route also had no idempotency, no ordering guard, and discarded .error on every write while still answering 200 — so a CHECK-constraint failure (Stripe's paused is not in the allowed set) looked like success.

How

  • Field paths mirror 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.
  • Idempotency: the event is written to webhook_events under provider stripe_platform before handling; a replayed event.id returns {received, duplicate} without touching anything. A row with no processed_at (previous attempt died mid-flight) is reused and retried rather than duplicated. Same shape as app/api/payments/webhook/[provider]/route.ts.
  • Ordering: customer.subscription.updated compares the event's period end against the stored one and drops strictly-older events whole — so a late redelivery cannot rewind current_period_end / tenants.billing_period_end (which feed the expiry cron and the "next payment" date), nor the plan via applyPortalPlanChange. An equal period end still applies, since that is a legitimate in-period update (a cancel_at_period_end toggle).
  • Errors: every read/write goes through unwrap(); a failure 500s so Stripe retries, and the message is recorded on the webhook_events row with processed_at left unset.
  • Status/interval mapping: anything outside the platform_subscriptions.status CHECK set collapses to past_due (the bucket the billing-health dashboard and expiry cron already understand); the checkout metadata interval is pinned to monthly|yearly.

No migration. No API-version bump. lib/payments/platform-plan-change.ts untouched — 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 by platform-plan-change.test.ts.

How to QA

No DB reset needed for the automated part:

  1. npm run test:unit → 398 passing (370 baseline + 28 new).
  2. Prove the tests catch the real bug:
    git show master:app/api/stripe/platform-webhook/route.ts > /tmp/old.ts
    cp app/api/stripe/platform-webhook/route.ts /tmp/new.ts
    cp /tmp/old.ts app/api/stripe/platform-webhook/route.ts
    npx vitest run tests/unit/platform-webhook.test.ts   # 18 failed | 10 passed
    cp /tmp/new.ts app/api/stripe/platform-webhook/route.ts
  3. npm run typecheck, npm run build → both clean.

Manual (Stripe test mode, owner@e2etest.com on default.lvh.me:3000), from the last acceptance criterion on the issue:

  1. stripe listen --forward-to localhost:3000/api/stripe/platform-webhook with STRIPE_PLATFORM_WEBHOOK_SECRET set to the printed secret.
  2. Upgrade the school to Starter from /dashboard/admin/billing and pay with 4242 4242 4242 4242.
  3. Expect a platform_subscriptions row with both period columns populated, tenants.plan = 'starter', a revenue_splits row at the Starter fee, and a processed webhook_events row with provider = 'stripe_platform'. Re-send the same event from the Stripe dashboard → second delivery is a duplicate no-op.

Screenshots / GIF

n/a — server-side webhook route, no user-visible surface.

Checklist

  • npm run typecheck and npm run test:unit pass
  • npm run build passes
  • Every new tenant-scoped query filters by tenant_id (or the table genuinely has no such column) — webhook_events is a service-role ingestion log with no tenant_id; every other query is already keyed by tenant_id / id
  • Tested with every relevant role (student / teacher / admin) — n/a, service-role webhook with no caller identity
  • Loading and error states handled — n/a, no UI
  • New UI strings added to both messages/en.json and messages/es.json — n/a
  • Migration (if any) applies cleanly on npm run db:reset, has RLS policies, and lib/database.types.ts was regenerated — no migration; webhook_events already exists (20260615130000)

Note

Per the issue: fixing this unmasks the forceTenantPlanChange interaction from the school-billing sub-issue of epic #540 — a comped school can get billed for the comped plan on the next routine event, because customer.subscription.updated now 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

…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
@guillermoscript

Copy link
Copy Markdown
Owner Author

Manual QA — done, Stripe test mode, all criteria pass

Real Checkout run against local Supabase + stripe listen, driving the browser. Every acceptance-criteria box is now verified live, not just in unit tests.

The clover path is genuinely exercised

The important part — the object checkout.session.completed retrieves through our pinned client:

$ stripe get /v1/subscriptions/sub_1TxGdWF3QAnraq1IY3mjlcze --stripe-version 2026-02-25.clover
top-level current_period_end : ABSENT
items[0].current_period_end  : 1787706829

There is no top-level period to read. The old code's new Date(undefined * 1000).toISOString() could only have thrown here — and the DB still came out correct, so the period was resolved from items[0].

(Note for anyone repeating this: stripe listen forwards in the account default API version, which here is 2020-08-27 — those payloads carry both shapes, so they do not by themselves prove the clover path. The subscriptions.retrieve above does, and cases A–D below use hand-signed clover-only payloads with no top-level period.)

1. Checkout → activation

Logged in owner@e2etest.com on default.lvh.me:3000, Upgrade → Starter (monthly) → Stripe Checkout → Stripe's documented test card 4242 4242 4242 4242.

tenants:

 plan   | billing_status | billing_period_end     | stripe_customer_id
 starter| active         | 2026-08-26 01:13:49+00 | cus_UxAvIV2q2fqR05

platform_subscriptions:

 status | interval | current_period_start   | current_period_end     | stripe_subscription_id
 active | monthly  | 2026-07-26 01:13:49+00 | 2026-08-26 01:13:49+00 | sub_1TxGdWF3QAnraq1IY3mjlcze

revenue_splits: 20/805/95 (Starter's 5% fee).

All 13 forwarded events returned 200 and are recorded processed under provider = 'stripe_platform', error null:

 stripe_platform | evt_1TxGdXF3QAnraq1I5kmMtQo7 | checkout.session.completed    | t |
 stripe_platform | evt_1TxGdYF3QAnraq1IKizAS1WV | customer.subscription.created | t |
 stripe_platform | evt_1TxGdXF3QAnraq1IcLBKV0We | invoice.paid                  | t |
 ... 13 rows, all processed, no errors

On master this run produces nothing at all: no subscription row, no plan change, no split.

2–4. Hand-signed clover-only customer.subscription.updated

No top-level current_period_* in any of these payloads.

# 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.

@guillermoscript
guillermoscript marked this pull request as ready for review July 26, 2026 01:22
@guillermoscript
guillermoscript merged commit 2acade0 into master Jul 26, 2026
2 checks passed
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.

Platform Stripe webhook reads fields removed in the pinned API version — every handler is dead

1 participant