diff --git a/app/api/stripe/platform-webhook/route.ts b/app/api/stripe/platform-webhook/route.ts index 94f18e03..f8cbc468 100644 --- a/app/api/stripe/platform-webhook/route.ts +++ b/app/api/stripe/platform-webhook/route.ts @@ -15,6 +15,79 @@ function getPlatformWebhookSecret(): string { return secret } +// webhook_events is keyed by (provider, provider_event_id). The student Connect +// endpoint logs under 'stripe', so platform-billing events get their own +// namespace rather than sharing a key space with it. +const WEBHOOK_PROVIDER = 'stripe_platform' + +// platform_subscriptions.status is CHECK-constrained (20260217040000_platform_billing.sql). +// Stripe's vocabulary is a superset — 'paused' is not in the CHECK, and future +// versions may add more. Anything unrecognised collapses to 'past_due': the +// "needs attention, not terminal" bucket the billing-health dashboard and the +// expiry cron already understand. Passing Stripe's value straight through would +// fail the constraint, and the failure used to be discarded behind a 200. +const ALLOWED_SUB_STATUS = new Set([ + 'active', + 'past_due', + 'canceled', + 'trialing', + 'incomplete', + 'incomplete_expired', + 'unpaid', +]) + +function mapSubscriptionStatus(status: unknown): string { + if (typeof status === 'string' && ALLOWED_SUB_STATUS.has(status)) return status + console.warn(`[platform-webhook] unmapped Stripe subscription status ${String(status)} -> past_due`) + return 'past_due' +} + +// platform_subscriptions.interval is CHECK-constrained to monthly|yearly and the +// value arrives from checkout-session metadata — pin it to the allowed set. +function mapInterval(interval: unknown): 'monthly' | 'yearly' { + return interval === 'yearly' ? 'yearly' : 'monthly' +} + +const toIso = (unix: unknown): string | undefined => + typeof unix === 'number' && Number.isFinite(unix) + ? new Date(unix * 1000).toISOString() + : undefined + +// API 2026-02-25.clover (pinned in lib/stripe.ts): current_period_start/end moved +// off the Subscription onto its SubscriptionItems, and Invoice.subscription moved +// under parent.subscription_details. Same accessors as the student webhook — +// lib/payments/stripe-provider.ts:454-460. The `??` fallbacks read the pre-clover +// shape so a replayed old-version payload still resolves; toIso() then degrades a +// missing/garbage value to undefined instead of `new Date(NaN).toISOString()`, +// which threw RangeError and 500'd the whole delivery. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const subPeriodStart = (s: any): string | undefined => + toIso(s?.items?.data?.[0]?.current_period_start ?? s?.current_period_start) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const subPeriodEnd = (s: any): string | undefined => + toIso(s?.items?.data?.[0]?.current_period_end ?? s?.current_period_end) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const invoiceSubId = (inv: any): string | undefined => { + const sub = inv?.parent?.subscription_details?.subscription ?? inv?.subscription + return (typeof sub === 'string' ? sub : sub?.id) ?? undefined +} + +/** + * Await a Supabase read/write and throw if it failed. Every DB call in this + * route goes through here: a swallowed `.error` used to leave the route + * answering 200 on a write that never landed, so Stripe never retried it. + */ +async function unwrap( + label: string, + op: PromiseLike<{ data: T; error: { message?: string } | null }> +): Promise { + const { data, error } = await op + if (error) { + throw new Error(`${label} failed: ${error.message ?? JSON.stringify(error)}`) + } + return data +} + export async function POST(req: NextRequest) { const body = await req.text() const sig = req.headers.get('stripe-signature') @@ -34,6 +107,53 @@ export async function POST(req: NextRequest) { const adminClient = await createAdminClient() + // Idempotency: Stripe redelivers, and retries on our 500s. Record the event + // before handling it and no-op anything already marked processed. Mirrors the + // provider-agnostic route, app/api/payments/webhook/[provider]/route.ts. + const { data: existing, error: lookupErr } = await adminClient + .from('webhook_events') + .select('id, processed_at') + .eq('provider', WEBHOOK_PROVIDER) + .eq('provider_event_id', event.id) + .maybeSingle() + + if (lookupErr) { + console.error('[platform-webhook] event lookup failed:', lookupErr) + return NextResponse.json({ error: 'Event lookup failed' }, { status: 500 }) + } + + if (existing?.processed_at) { + return NextResponse.json({ received: true, duplicate: true }) + } + + // A row without processed_at is a previous attempt that failed mid-flight; + // reuse it and try again rather than inserting a second one. + let eventRowId = existing?.id as string | undefined + if (!eventRowId) { + const { data: inserted, error: insertErr } = await adminClient + .from('webhook_events') + .insert({ + provider: WEBHOOK_PROVIDER, + provider_event_id: event.id, + event_type: event.type, + payload: event as unknown as Record, + }) + .select('id') + .single() + + if (insertErr) { + // 23505 = unique violation: a concurrent delivery of the same event won. + if ((insertErr as { code?: string }).code === '23505') { + return NextResponse.json({ received: true, duplicate: true }) + } + console.error('[platform-webhook] failed to persist event:', insertErr) + return NextResponse.json({ error: 'Failed to persist event' }, { status: 500 }) + } + eventRowId = inserted.id + } + + const now = new Date().toISOString() + try { switch (event.type) { case 'checkout.session.completed': { @@ -43,7 +163,7 @@ export async function POST(req: NextRequest) { const tenantId = session.metadata?.tenant_id const planId = session.metadata?.plan_id const planSlug = session.metadata?.plan_slug - const interval = session.metadata?.interval || 'monthly' + const interval = mapInterval(session.metadata?.interval) if (!tenantId || !planId) { console.error('Missing metadata in checkout session:', session.id) @@ -56,50 +176,72 @@ export async function POST(req: NextRequest) { session.subscription as string ) + const periodStart = subPeriodStart(subscription) + const periodEnd = subPeriodEnd(subscription) + if (!periodEnd) { + // Degrade rather than 500: the plan still activates and the stored + // period is left alone. Loud, because the expiry cron reads it. + console.error( + `[platform-webhook] no current_period_end on subscription ${subscription?.id} — activating without a period` + ) + } + // Upsert platform subscription - await adminClient - .from('platform_subscriptions') - .upsert({ - tenant_id: tenantId, - plan_id: planId, - stripe_subscription_id: subscription.id, - stripe_customer_id: session.customer as string, - status: 'active', - payment_method: 'stripe', - interval: interval, - current_period_start: new Date(subscription.current_period_start * 1000).toISOString(), - current_period_end: new Date(subscription.current_period_end * 1000).toISOString(), - updated_at: new Date().toISOString(), - }, { onConflict: 'tenant_id' }) + await unwrap( + 'platform_subscriptions upsert', + adminClient + .from('platform_subscriptions') + .upsert({ + tenant_id: tenantId, + plan_id: planId, + stripe_subscription_id: subscription.id, + stripe_customer_id: session.customer as string, + status: 'active', + payment_method: 'stripe', + interval: interval, + ...(periodStart ? { current_period_start: periodStart } : {}), + ...(periodEnd ? { current_period_end: periodEnd } : {}), + updated_at: now, + }, { onConflict: 'tenant_id' }) + ) // Update tenant plan - await adminClient - .from('tenants') - .update({ - plan: planSlug, - billing_status: 'active', - billing_period_end: new Date(subscription.current_period_end * 1000).toISOString(), - stripe_customer_id: session.customer as string, - updated_at: new Date().toISOString(), - }) - .eq('id', tenantId) + await unwrap( + 'tenants plan update', + adminClient + .from('tenants') + .update({ + plan: planSlug, + billing_status: 'active', + ...(periodEnd ? { billing_period_end: periodEnd } : {}), + stripe_customer_id: session.customer as string, + updated_at: now, + }) + .eq('id', tenantId) + ) // Update revenue splits based on new plan's transaction fee - const { data: plan } = await adminClient - .from('platform_plans') - .select('transaction_fee_percent') - .eq('plan_id', planId) - .single() + const plan = await unwrap( + 'platform_plans lookup', + adminClient + .from('platform_plans') + .select('transaction_fee_percent') + .eq('plan_id', planId) + .maybeSingle() + ) if (plan) { - await adminClient - .from('revenue_splits') - .upsert({ - tenant_id: tenantId, - platform_percentage: plan.transaction_fee_percent, - school_percentage: 100 - plan.transaction_fee_percent, - updated_at: new Date().toISOString(), - }, { onConflict: 'tenant_id' }) + await unwrap( + 'revenue_splits upsert', + adminClient + .from('revenue_splits') + .upsert({ + tenant_id: tenantId, + platform_percentage: plan.transaction_fee_percent, + school_percentage: 100 - plan.transaction_fee_percent, + updated_at: now, + }, { onConflict: 'tenant_id' }) + ) } console.log(`Plan activated: tenant=${tenantId}, plan=${planSlug}`) @@ -113,6 +255,37 @@ export async function POST(req: NextRequest) { if (!tenantId) break + const periodStart = subPeriodStart(subscription) + const periodEnd = subPeriodEnd(subscription) + + // Ordering guard. Stripe does not guarantee delivery order, and this + // handler overwrites status, interval, both period columns and the + // cancel fields wholesale. A late-arriving older event would rewind + // current_period_end / tenants.billing_period_end — which drive the + // expiry cron and the "next payment" date — and could rewind the plan + // through applyPortalPlanChange. Strictly-older events are dropped + // whole; an equal period is a legitimate in-period update (a + // cancel_at_period_end toggle) and still applies. + const stored = await unwrap( + 'platform_subscriptions period lookup', + adminClient + .from('platform_subscriptions') + .select('current_period_end') + .eq('tenant_id', tenantId) + .maybeSingle() + ) + + if ( + periodEnd && + stored?.current_period_end && + new Date(periodEnd) < new Date(stored.current_period_end) + ) { + console.warn( + `[platform-webhook] dropping out-of-order customer.subscription.updated for tenant ${tenantId}: event period_end ${periodEnd} < stored ${stored.current_period_end}` + ) + break + } + // Derive the billing interval from the subscription item's price so an // interval switch (monthly <-> yearly) is reflected locally — the // plan-mapped reconciler below treats a same-plan interval change as a @@ -121,32 +294,36 @@ export async function POST(req: NextRequest) { const localInterval = priceInterval === 'year' ? 'yearly' : priceInterval === 'month' ? 'monthly' : undefined + const status = mapSubscriptionStatus(subscription.status) + // Update subscription period and status - await adminClient - .from('platform_subscriptions') - .update({ - status: subscription.status === 'active' ? 'active' - : subscription.status === 'past_due' ? 'past_due' - : subscription.status, - ...(localInterval ? { interval: localInterval } : {}), - current_period_start: new Date(subscription.current_period_start * 1000).toISOString(), - current_period_end: new Date(subscription.current_period_end * 1000).toISOString(), - cancel_at_period_end: subscription.cancel_at_period_end, - canceled_at: subscription.canceled_at - ? new Date(subscription.canceled_at * 1000).toISOString() - : null, - updated_at: new Date().toISOString(), - }) - .eq('tenant_id', tenantId) - - await adminClient - .from('tenants') - .update({ - billing_status: subscription.status, - billing_period_end: new Date(subscription.current_period_end * 1000).toISOString(), - updated_at: new Date().toISOString(), - }) - .eq('id', tenantId) + await unwrap( + 'platform_subscriptions update', + adminClient + .from('platform_subscriptions') + .update({ + status, + ...(localInterval ? { interval: localInterval } : {}), + ...(periodStart ? { current_period_start: periodStart } : {}), + ...(periodEnd ? { current_period_end: periodEnd } : {}), + cancel_at_period_end: subscription.cancel_at_period_end, + canceled_at: toIso(subscription.canceled_at) ?? null, + updated_at: now, + }) + .eq('tenant_id', tenantId) + ) + + await unwrap( + 'tenants billing update', + adminClient + .from('tenants') + .update({ + billing_status: status, + ...(periodEnd ? { billing_period_end: periodEnd } : {}), + updated_at: now, + }) + .eq('id', tenantId) + ) // If plan changed via Stripe portal, apply it with downgrade-limit // enforcement (over-limit downgrades are reverted on Stripe and admins @@ -177,34 +354,44 @@ export async function POST(req: NextRequest) { case 'invoice.payment_failed': { // eslint-disable-next-line @typescript-eslint/no-explicit-any const invoice = event.data.object as any - const subscriptionId = invoice.subscription as string + const subscriptionId = invoiceSubId(invoice) if (!subscriptionId) break - const { data: sub } = await adminClient - .from('platform_subscriptions') - .select('tenant_id') - .eq('stripe_subscription_id', subscriptionId) - .single() + const sub = await unwrap( + 'platform_subscriptions lookup by stripe id', + adminClient + .from('platform_subscriptions') + .select('tenant_id') + .eq('stripe_subscription_id', subscriptionId) + .maybeSingle() + ) if (sub) { - await adminClient - .from('platform_subscriptions') - .update({ status: 'past_due', updated_at: new Date().toISOString() }) - .eq('tenant_id', sub.tenant_id) + await unwrap( + 'platform_subscriptions past_due', + adminClient + .from('platform_subscriptions') + .update({ status: 'past_due', updated_at: now }) + .eq('tenant_id', sub.tenant_id) + ) - await adminClient - .from('tenants') - .update({ billing_status: 'past_due', updated_at: new Date().toISOString() }) - .eq('id', sub.tenant_id) + await unwrap( + 'tenants past_due', + adminClient + .from('tenants') + .update({ billing_status: 'past_due', updated_at: now }) + .eq('id', sub.tenant_id) + ) - // Email the school admin about the failed payment + // Email the school admin about the failed payment. Best-effort: a + // mail failure must not undo the past_due writes above by 500-ing. try { const { data: tenantRow } = await adminClient .from('tenants') .select('name') .eq('id', sub.tenant_id) - .single() + .maybeSingle() const { data: adminUsers } = await adminClient .from('tenant_users') @@ -217,8 +404,9 @@ export async function POST(req: NextRequest) { .from('platform_subscriptions') .select('platform_plans(name)') .eq('tenant_id', sub.tenant_id) - .single() + .maybeSingle() + // eslint-disable-next-line @typescript-eslint/no-explicit-any const planName = (planRow?.platform_plans as any)?.name || 'your plan' const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.example.com' @@ -244,36 +432,63 @@ export async function POST(req: NextRequest) { case 'invoice.paid': { // eslint-disable-next-line @typescript-eslint/no-explicit-any const invoice = event.data.object as any - const subscriptionId = invoice.subscription as string + const subscriptionId = invoiceSubId(invoice) if (!subscriptionId) break - const { data: sub } = await adminClient - .from('platform_subscriptions') - .select('tenant_id, status') - .eq('stripe_subscription_id', subscriptionId) - .single() + const sub = await unwrap( + 'platform_subscriptions lookup by stripe id', + adminClient + .from('platform_subscriptions') + .select('tenant_id, status') + .eq('stripe_subscription_id', subscriptionId) + .maybeSingle() + ) // Idempotency: only update if not already active if (sub && sub.status !== 'active') { - await adminClient - .from('platform_subscriptions') - .update({ status: 'active', updated_at: new Date().toISOString() }) - .eq('tenant_id', sub.tenant_id) + await unwrap( + 'platform_subscriptions active', + adminClient + .from('platform_subscriptions') + .update({ status: 'active', updated_at: now }) + .eq('tenant_id', sub.tenant_id) + ) - await adminClient - .from('tenants') - .update({ billing_status: 'active', updated_at: new Date().toISOString() }) - .eq('id', sub.tenant_id) + await unwrap( + 'tenants active', + adminClient + .from('tenants') + .update({ billing_status: 'active', updated_at: now }) + .eq('id', sub.tenant_id) + ) } break } } - - return NextResponse.json({ received: true }) } catch (error) { console.error('Platform webhook error:', error) + // processed_at stays unset so a Stripe retry re-runs the handler. + const { error: recordErr } = await adminClient + .from('webhook_events') + .update({ error: error instanceof Error ? error.message : String(error) }) + .eq('id', eventRowId) + if (recordErr) { + console.error('[platform-webhook] failed to record handler error:', recordErr) + } return NextResponse.json({ error: 'Webhook handler error' }, { status: 500 }) } + + const { error: markErr } = await adminClient + .from('webhook_events') + .update({ processed_at: new Date().toISOString() }) + .eq('id', eventRowId) + if (markErr) { + // The event will be redelivered and re-run. Handlers converge (status + // writes are absolute, periods come from the event), so log, don't fail. + console.error(`[platform-webhook] failed to mark event ${event.id} processed:`, markErr) + } + + return NextResponse.json({ received: true }) } diff --git a/tests/unit/platform-webhook.test.ts b/tests/unit/platform-webhook.test.ts new file mode 100644 index 00000000..744b19c3 --- /dev/null +++ b/tests/unit/platform-webhook.test.ts @@ -0,0 +1,575 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import type { NextRequest } from 'next/server' + +/** + * Drives the platform Stripe webhook route end-to-end (issue #544). + * + * Every fixture here is shaped the way API version 2026-02-25.clover actually + * delivers it — periods on `subscription.items.data[0]`, never on the + * Subscription; the invoice's subscription under + * `parent.subscription_details.subscription`, never `invoice.subscription`. + * Before the fix the route read the pre-clover paths through an `any` cast, so + * `checkout.session.completed` and `customer.subscription.updated` threw + * `RangeError: Invalid time value` before writing anything, and both invoice + * handlers hit a falsy id and silently broke out. Nothing in the suite caught + * it because the only existing test called `applyPortalPlanChange` directly + * with a hand-built object that omitted the period fields entirely. + * + * Fluent Supabase fake records every write, same approach as + * platform-plan-change.test.ts / webhook-dispatch.test.ts. + */ + +interface Write { + table: string + op: 'insert' | 'update' | 'upsert' + values: Record +} + +const state: { + existingEvent: { id: string; processed_at: string | null } | null + insertErrorCode: string | null + storedSub: Record | null + plan: { transaction_fee_percent: number } | null + // eslint-disable-next-line @typescript-eslint/no-explicit-any + retrievedSub: any + adminUsers: string[] + failOn: ((w: Write) => { message: string } | null) | null + writes: Write[] + selects: { table: string; cols: string }[] + emails: { to: string; subject: string }[] +} = { + existingEvent: null, + insertErrorCode: null, + storedSub: null, + plan: null, + retrievedSub: null, + adminUsers: [], + failOn: null, + writes: [], + selects: [], + emails: [], +} + +function readRow(table: string, cols: string): unknown { + if (table === 'webhook_events') return state.existingEvent + if (table === 'platform_plans') return state.plan + if (table === 'platform_subscriptions') { + // The email path joins the plan name off the same table. + if (cols.includes('platform_plans')) return { platform_plans: { name: 'Pro' } } + return state.storedSub + } + if (table === 'tenants') return { name: 'Test School' } + if (table === 'tenant_users') return state.adminUsers.map((user_id) => ({ user_id })) + return null +} + +function makeAdmin() { + function builder(table: string) { + let cols = '' + let pending: Write | null = null + + function settle() { + if (pending) { + const err = state.failOn?.(pending) ?? null + if (err) return { data: null, error: err } + if (table === 'webhook_events' && pending.op === 'insert') { + if (state.insertErrorCode) { + return { data: null, error: { message: 'duplicate key', code: state.insertErrorCode } } + } + return { data: { id: 'wh-row-1' }, error: null } + } + return { data: null, error: null } + } + return { data: readRow(table, cols), error: null } + } + + const b: Record = { + select(c: string) { + cols = c + state.selects.push({ table, cols: c }) + return b + }, + insert(values: Record) { + pending = { table, op: 'insert', values } + state.writes.push(pending) + return b + }, + update(values: Record) { + pending = { table, op: 'update', values } + state.writes.push(pending) + return b + }, + upsert(values: Record) { + pending = { table, op: 'upsert', values } + state.writes.push(pending) + return b + }, + eq: () => b, + maybeSingle: () => Promise.resolve(settle()), + single: () => Promise.resolve(settle()), + then: (resolve: (v: unknown) => unknown) => Promise.resolve(settle()).then(resolve), + } + return b + } + + return { + from: (table: string) => builder(table), + auth: { + admin: { + getUserById: (id: string) => + Promise.resolve({ data: { user: { email: `${id}@example.com` } }, error: null }), + }, + }, + } +} + +// Signature verification is Stripe's own code; the route's contract is "reject +// anything constructEvent throws on", so the fake throws for a bad signature +// and otherwise hands back the parsed body as the event. +vi.mock('@/lib/stripe', () => ({ + getStripe: () => ({ + webhooks: { + constructEvent: (body: string, sig: string) => { + if (sig !== 'sig_ok') throw new Error('No signatures found matching the expected signature') + return JSON.parse(body) + }, + }, + subscriptions: { + retrieve: () => Promise.resolve(state.retrievedSub), + }, + }), +})) + +vi.mock('@/lib/supabase/admin', () => ({ createAdminClient: () => makeAdmin() })) + +vi.mock('@/lib/email/send', () => ({ + sendEmail: (o: { to: string; subject: string }) => { + state.emails.push({ to: o.to, subject: o.subject }) + return Promise.resolve(true) + }, +})) + +vi.mock('@/lib/billing/downgrade-tenant', () => ({ + downgradeTenantToFree: vi.fn(() => Promise.resolve(10)), +})) + +// The reconciler itself is out of scope (#544 explicitly leaves +// platform-plan-change.ts alone and platform-plan-change.test.ts covers it). +// What matters here is that the handler REACHES it — it never did in production. +vi.mock('@/lib/payments/platform-plan-change', () => ({ + applyPortalPlanChange: vi.fn(() => Promise.resolve({ action: 'noop' })), +})) + +import { POST } from '@/app/api/stripe/platform-webhook/route' +import { applyPortalPlanChange } from '@/lib/payments/platform-plan-change' +import { downgradeTenantToFree } from '@/lib/billing/downgrade-tenant' + +const TENANT = '00000000-0000-0000-0000-000000000001' +const PLAN_ID = 'f9318c3a-815d-448d-802e-cf356c2791a4' + +const PERIOD_START = 1_770_000_000 +const PERIOD_END = 1_772_592_000 +const START_ISO = new Date(PERIOD_START * 1000).toISOString() +const END_ISO = new Date(PERIOD_END * 1000).toISOString() + +/** A clover-shaped Subscription: periods live on the item, not the subscription. */ +function cloverSubscription( + over: { priceId?: string; recurringInterval?: string } = {} +) { + const { priceId = 'price_pro_m', recurringInterval = 'month' } = over + + return { + id: 'sub_123', + object: 'subscription', + status: 'active', + cancel_at_period_end: false, + canceled_at: null, + metadata: { tenant_id: TENANT }, + items: { + object: 'list', + data: [ + { + id: 'si_1', + object: 'subscription_item', + current_period_start: PERIOD_START, + current_period_end: PERIOD_END, + price: { id: priceId, recurring: { interval: recurringInterval } }, + }, + ], + }, + } +} + +function makeEvent(type: string, object: unknown, id = 'evt_1') { + return { id, type, api_version: '2026-02-25.clover', data: { object } } +} + +function makeReq(event: unknown, sig: string | null = 'sig_ok'): NextRequest { + const body = JSON.stringify(event) + return { + text: () => Promise.resolve(body), + headers: new Headers(sig ? { 'stripe-signature': sig } : {}), + } as unknown as NextRequest +} + +const writesTo = (table: string, op?: Write['op']) => + state.writes.filter((w) => w.table === table && (!op || w.op === op)) + +const businessWrites = () => state.writes.filter((w) => w.table !== 'webhook_events') + +beforeEach(() => { + process.env.STRIPE_PLATFORM_WEBHOOK_SECRET = 'whsec_test_not_real' + state.existingEvent = null + state.insertErrorCode = null + state.storedSub = null + state.plan = { transaction_fee_percent: 2 } + state.retrievedSub = cloverSubscription() + state.adminUsers = [] + state.failOn = null + state.writes = [] + state.selects = [] + state.emails = [] + vi.mocked(applyPortalPlanChange).mockClear() + vi.mocked(downgradeTenantToFree).mockClear() +}) + +describe('platform webhook — signature gate', () => { + it('400s with no stripe-signature header, without touching the DB', async () => { + const res = await POST(makeReq(makeEvent('invoice.paid', {}), null)) + expect(res.status).toBe(400) + expect(state.writes).toHaveLength(0) + }) + + it('400s on a signature constructEvent rejects', async () => { + const res = await POST(makeReq(makeEvent('invoice.paid', {}), 'sig_bad')) + expect(res.status).toBe(400) + expect(state.writes).toHaveLength(0) + }) +}) + +describe('platform webhook — checkout.session.completed', () => { + const session = { + id: 'cs_1', + mode: 'subscription', + customer: 'cus_1', + subscription: 'sub_123', + metadata: { tenant_id: TENANT, plan_id: PLAN_ID, plan_slug: 'pro', interval: 'monthly' }, + } + + it('activates the plan from a clover subscription (periods read off the item)', async () => { + const res = await POST(makeReq(makeEvent('checkout.session.completed', session))) + expect(res.status).toBe(200) + + // Regression: with the periods only on items.data[0], the old code did + // `new Date(undefined * 1000).toISOString()` and threw before this upsert. + const sub = writesTo('platform_subscriptions', 'upsert')[0] + expect(sub).toBeDefined() + expect(sub.values).toMatchObject({ + tenant_id: TENANT, + plan_id: PLAN_ID, + stripe_subscription_id: 'sub_123', + stripe_customer_id: 'cus_1', + status: 'active', + payment_method: 'stripe', + interval: 'monthly', + current_period_start: START_ISO, + current_period_end: END_ISO, + }) + + const tenant = writesTo('tenants', 'update')[0] + expect(tenant.values).toMatchObject({ + plan: 'pro', + billing_status: 'active', + billing_period_end: END_ISO, + stripe_customer_id: 'cus_1', + }) + + const split = writesTo('revenue_splits', 'upsert')[0] + expect(split.values).toMatchObject({ + tenant_id: TENANT, + platform_percentage: 2, + school_percentage: 98, + }) + }) + + it('still reads a pre-clover payload that carries the periods on the subscription', async () => { + state.retrievedSub = { + id: 'sub_123', + status: 'active', + metadata: { tenant_id: TENANT }, + current_period_start: PERIOD_START, + current_period_end: PERIOD_END, + items: { data: [{ id: 'si_1', price: { id: 'price_pro_m', recurring: { interval: 'month' } } }] }, + } + const res = await POST(makeReq(makeEvent('checkout.session.completed', session))) + expect(res.status).toBe(200) + expect(writesTo('platform_subscriptions', 'upsert')[0].values).toMatchObject({ + current_period_start: START_ISO, + current_period_end: END_ISO, + }) + }) + + it('degrades instead of 500-ing when no period is resolvable at all', async () => { + // Neither shape carries a period — a hypothetical future move of the field. + state.retrievedSub = { + id: 'sub_123', + status: 'active', + metadata: { tenant_id: TENANT }, + items: { data: [{ id: 'si_1', price: { id: 'price_pro_m', recurring: { interval: 'month' } } }] }, + } + + const res = await POST(makeReq(makeEvent('checkout.session.completed', session))) + expect(res.status).toBe(200) + + // Plan still activates; the period columns are omitted rather than written + // as an invalid date (which is what used to throw). + const values = writesTo('platform_subscriptions', 'upsert')[0].values + expect(values.status).toBe('active') + expect(values).not.toHaveProperty('current_period_start') + expect(values).not.toHaveProperty('current_period_end') + expect(writesTo('tenants', 'update')[0].values).not.toHaveProperty('billing_period_end') + }) + + it('pins a junk metadata interval onto the CHECK-allowed set', async () => { + const junk = { ...session, metadata: { ...session.metadata, interval: 'weekly' } } + await POST(makeReq(makeEvent('checkout.session.completed', junk))) + expect(writesTo('platform_subscriptions', 'upsert')[0].values.interval).toBe('monthly') + }) + + it('ignores non-subscription checkout sessions', async () => { + await POST(makeReq(makeEvent('checkout.session.completed', { ...session, mode: 'payment' }))) + expect(businessWrites()).toHaveLength(0) + }) + + it('500s and leaves the event unprocessed when a write fails', async () => { + state.failOn = (w) => + w.table === 'platform_subscriptions' ? { message: 'new row violates check constraint' } : null + + const res = await POST(makeReq(makeEvent('checkout.session.completed', session))) + expect(res.status).toBe(500) + + // The failure is recorded and processed_at stays unset so Stripe retries. + const eventWrites = writesTo('webhook_events', 'update') + expect(eventWrites).toHaveLength(1) + expect(eventWrites[0].values.error).toContain('check constraint') + expect(eventWrites.some((w) => 'processed_at' in w.values)).toBe(false) + // It also stopped before the downstream writes. + expect(writesTo('tenants', 'update')).toHaveLength(0) + expect(writesTo('revenue_splits', 'upsert')).toHaveLength(0) + }) +}) + +describe('platform webhook — customer.subscription.updated', () => { + it('writes status, interval and both periods, then reaches the plan reconciler', async () => { + const res = await POST(makeReq(makeEvent('customer.subscription.updated', cloverSubscription()))) + expect(res.status).toBe(200) + + expect(writesTo('platform_subscriptions', 'update')[0].values).toMatchObject({ + status: 'active', + interval: 'monthly', + current_period_start: START_ISO, + current_period_end: END_ISO, + cancel_at_period_end: false, + canceled_at: null, + }) + expect(writesTo('tenants', 'update')[0].values).toMatchObject({ + billing_status: 'active', + billing_period_end: END_ISO, + }) + // #461's reconciler had never once executed — the handler threw at the + // period access four lines earlier. + expect(applyPortalPlanChange).toHaveBeenCalledTimes(1) + }) + + it('tracks a monthly → yearly interval switch off the item price', async () => { + const yearly = cloverSubscription({ priceId: 'price_pro_y', recurringInterval: 'year' }) + await POST(makeReq(makeEvent('customer.subscription.updated', yearly))) + expect(writesTo('platform_subscriptions', 'update')[0].values.interval).toBe('yearly') + }) + + it('maps a status outside the CHECK set (paused) onto past_due', async () => { + const paused = { ...cloverSubscription(), status: 'paused' } + await POST(makeReq(makeEvent('customer.subscription.updated', paused))) + expect(writesTo('platform_subscriptions', 'update')[0].values.status).toBe('past_due') + expect(writesTo('tenants', 'update')[0].values.billing_status).toBe('past_due') + }) + + it('passes a canceled_at through as an ISO date', async () => { + const canceled = { ...cloverSubscription(), canceled_at: PERIOD_START, cancel_at_period_end: true } + await POST(makeReq(makeEvent('customer.subscription.updated', canceled))) + expect(writesTo('platform_subscriptions', 'update')[0].values).toMatchObject({ + canceled_at: START_ISO, + cancel_at_period_end: true, + }) + }) + + it('drops an out-of-order event carrying an older period — no rewind', async () => { + // Stored row is already on the NEXT period; a late redelivery of the + // previous one must not roll current_period_end / billing_period_end back. + const laterEnd = new Date((PERIOD_END + 86_400) * 1000).toISOString() + state.storedSub = { tenant_id: TENANT, status: 'active', current_period_end: laterEnd } + + const res = await POST(makeReq(makeEvent('customer.subscription.updated', cloverSubscription()))) + expect(res.status).toBe(200) + + expect(writesTo('platform_subscriptions', 'update')).toHaveLength(0) + expect(writesTo('tenants', 'update')).toHaveLength(0) + expect(applyPortalPlanChange).not.toHaveBeenCalled() + // Still marked processed: the event is handled (as a deliberate no-op), + // so Stripe must not retry it. + expect(writesTo('webhook_events', 'update')[0].values).toHaveProperty('processed_at') + }) + + it('applies an in-period update whose period end equals the stored one', async () => { + state.storedSub = { tenant_id: TENANT, status: 'active', current_period_end: END_ISO } + const toggled = { ...cloverSubscription(), cancel_at_period_end: true } + + await POST(makeReq(makeEvent('customer.subscription.updated', toggled))) + + expect(writesTo('platform_subscriptions', 'update')[0].values.cancel_at_period_end).toBe(true) + expect(applyPortalPlanChange).toHaveBeenCalledTimes(1) + }) + + it('ignores a subscription with no tenant_id in metadata', async () => { + const orphan = { ...cloverSubscription(), metadata: {} } + await POST(makeReq(makeEvent('customer.subscription.updated', orphan))) + expect(businessWrites()).toHaveLength(0) + expect(applyPortalPlanChange).not.toHaveBeenCalled() + }) +}) + +describe('platform webhook — customer.subscription.deleted', () => { + it('downgrades the tenant to free', async () => { + const res = await POST(makeReq(makeEvent('customer.subscription.deleted', cloverSubscription()))) + expect(res.status).toBe(200) + expect(downgradeTenantToFree).toHaveBeenCalledTimes(1) + expect(vi.mocked(downgradeTenantToFree).mock.calls[0][1]).toBe(TENANT) + }) +}) + +describe('platform webhook — invoice handlers', () => { + // Clover: the subscription id is under parent.subscription_details, and there + // is no `invoice.subscription`. The old `invoice.subscription as string` read + // undefined and both handlers broke out without doing anything. + const cloverInvoice = { + id: 'in_1', + object: 'invoice', + parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_123' } }, + } + + it('invoice.payment_failed → past_due on both tables + dunning email', async () => { + state.storedSub = { tenant_id: TENANT, status: 'active' } + state.adminUsers = ['admin-a', 'admin-b'] + + const res = await POST(makeReq(makeEvent('invoice.payment_failed', cloverInvoice))) + expect(res.status).toBe(200) + + expect(writesTo('platform_subscriptions', 'update')[0].values.status).toBe('past_due') + expect(writesTo('tenants', 'update')[0].values.billing_status).toBe('past_due') + expect(state.emails.map((e) => e.to).sort()).toEqual([ + 'admin-a@example.com', + 'admin-b@example.com', + ]) + }) + + it('invoice.payment_failed resolves the id from an expanded subscription object', async () => { + state.storedSub = { tenant_id: TENANT, status: 'active' } + const expanded = { + ...cloverInvoice, + parent: { subscription_details: { subscription: { id: 'sub_123', object: 'subscription' } } }, + } + + await POST(makeReq(makeEvent('invoice.payment_failed', expanded))) + expect(writesTo('platform_subscriptions', 'update')[0].values.status).toBe('past_due') + }) + + it('invoice.payment_failed still reads a pre-clover invoice.subscription', async () => { + state.storedSub = { tenant_id: TENANT, status: 'active' } + await POST(makeReq(makeEvent('invoice.payment_failed', { id: 'in_1', subscription: 'sub_123' }))) + expect(writesTo('platform_subscriptions', 'update')[0].values.status).toBe('past_due') + }) + + it('invoice.payment_failed with no resolvable subscription is a no-op', async () => { + await POST(makeReq(makeEvent('invoice.payment_failed', { id: 'in_1', parent: null }))) + expect(businessWrites()).toHaveLength(0) + }) + + it('invoice.paid clears past_due back to active', async () => { + state.storedSub = { tenant_id: TENANT, status: 'past_due' } + + const res = await POST(makeReq(makeEvent('invoice.paid', cloverInvoice))) + expect(res.status).toBe(200) + expect(writesTo('platform_subscriptions', 'update')[0].values.status).toBe('active') + expect(writesTo('tenants', 'update')[0].values.billing_status).toBe('active') + }) + + it('invoice.paid on an already-active subscription writes nothing', async () => { + state.storedSub = { tenant_id: TENANT, status: 'active' } + await POST(makeReq(makeEvent('invoice.paid', cloverInvoice))) + expect(businessWrites()).toHaveLength(0) + }) + + it('a failed past_due write 500s so Stripe retries', async () => { + state.storedSub = { tenant_id: TENANT, status: 'active' } + state.failOn = (w) => (w.table === 'tenants' ? { message: 'deadlock detected' } : null) + + const res = await POST(makeReq(makeEvent('invoice.payment_failed', cloverInvoice))) + expect(res.status).toBe(500) + expect(writesTo('webhook_events', 'update')[0].values.error).toContain('deadlock') + }) +}) + +describe('platform webhook — idempotency', () => { + it('records the event under its own provider namespace and marks it processed', async () => { + state.storedSub = { tenant_id: TENANT, status: 'past_due' } + await POST(makeReq(makeEvent('invoice.paid', { id: 'in_1', subscription: 'sub_123' }, 'evt_abc'))) + + const inserted = writesTo('webhook_events', 'insert')[0] + expect(inserted.values).toMatchObject({ + provider: 'stripe_platform', + provider_event_id: 'evt_abc', + event_type: 'invoice.paid', + }) + expect(writesTo('webhook_events', 'update')[0].values).toHaveProperty('processed_at') + }) + + it('replaying a processed event.id is a complete no-op', async () => { + state.existingEvent = { id: 'wh-row-1', processed_at: '2026-07-01T00:00:00.000Z' } + state.storedSub = { tenant_id: TENANT, status: 'past_due' } + + const res = await POST(makeReq(makeEvent('invoice.paid', { id: 'in_1', subscription: 'sub_123' }))) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ received: true, duplicate: true }) + expect(state.writes).toHaveLength(0) + }) + + it('re-runs an event whose previous attempt failed before processed_at was set', async () => { + state.existingEvent = { id: 'wh-row-1', processed_at: null } + state.storedSub = { tenant_id: TENANT, status: 'past_due' } + + const res = await POST(makeReq(makeEvent('invoice.paid', { id: 'in_1', subscription: 'sub_123' }))) + expect(res.status).toBe(200) + // Reuses the row rather than inserting a second one. + expect(writesTo('webhook_events', 'insert')).toHaveLength(0) + expect(writesTo('platform_subscriptions', 'update')[0].values.status).toBe('active') + }) + + it('treats a concurrent delivery losing the unique race as a duplicate', async () => { + state.insertErrorCode = '23505' + state.storedSub = { tenant_id: TENANT, status: 'past_due' } + + const res = await POST(makeReq(makeEvent('invoice.paid', { id: 'in_1', subscription: 'sub_123' }))) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ received: true, duplicate: true }) + expect(businessWrites()).toHaveLength(0) + }) + + it('500s (no handling) when the event cannot be persisted at all', async () => { + state.insertErrorCode = '08006' + state.storedSub = { tenant_id: TENANT, status: 'past_due' } + + const res = await POST(makeReq(makeEvent('invoice.paid', { id: 'in_1', subscription: 'sub_123' }))) + expect(res.status).toBe(500) + expect(businessWrites()).toHaveLength(0) + }) +})