Stripe sends zero-decimal currencies (JPY/KRW/VND/...) already in the major unit; the unconditional /100 recorded a JPY dues charge 100x understated in the ledger + receipt. minorToMajor is now currency-aware (shared ZERO_DECIMAL_CURRENCIES/isZeroDecimalCurrency in notifications-logic); formatMoney renders no fractional part for those. Never fires for USD; closes silent ledger corruption for such tenants. Tests added; suite 526+.
342 lines
12 KiB
TypeScript
342 lines
12 KiB
TypeScript
// Per-space Stripe webhook (per-space OWN keys, NOT Connect — so we route by
|
|
// path to know which signing secret to verify with). Raw body, signature
|
|
// verify, idempotency on Stripe's stable event id, then map the subscription
|
|
// lifecycle onto member_billing + member status (grace -> late, never auto-
|
|
// inactive). Unauthenticated by design (Stripe calls it). proxy.ts auth-
|
|
// gates everything by default and redirects to /login, which Stripe would
|
|
// NOT follow — so this exact path is whitelisted in proxy.ts PUBLIC_ROUTES.
|
|
// Trust is the per-space webhook signature; every DB write is
|
|
// post-signature-verify.
|
|
import { NextRequest, NextResponse } from 'next/server'
|
|
import type Stripe from 'stripe'
|
|
import { createAdminClient } from '@/lib/supabase/admin'
|
|
import { getStripe } from '@/lib/stripe/client'
|
|
import { getStripeConfig, readStripeSecret } from '@/lib/stripe/config'
|
|
import { duesMemberStatus, graceExceeded } from '@/lib/stripe-logic'
|
|
import {
|
|
customerIdOf,
|
|
subscriptionPeriodEnd,
|
|
invoiceMetadataMemberId,
|
|
invoiceLinePeriodEnd,
|
|
memberStatusPatch,
|
|
stripeInvoiceToPaymentRow,
|
|
minorToMajor,
|
|
laterPeriodEnd,
|
|
} from '@/lib/stripe/webhook-logic'
|
|
import {
|
|
renderDuesEmail,
|
|
duesDedupeKey,
|
|
type DuesNotificationType,
|
|
} from '@/lib/notifications-logic'
|
|
|
|
export const runtime = 'nodejs'
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
export async function POST(
|
|
req: NextRequest,
|
|
{ params }: { params: Promise<{ space: string }> },
|
|
) {
|
|
const { space: spaceId } = await params
|
|
const admin = createAdminClient()
|
|
|
|
const cfg = await getStripeConfig(admin, spaceId)
|
|
const secret = cfg ? await readStripeSecret(admin, spaceId, cfg.secret_key_ref) : null
|
|
const webhookSecret = cfg ? await readStripeSecret(admin, spaceId, cfg.webhook_secret_ref) : null
|
|
if (!secret || !webhookSecret) {
|
|
return NextResponse.json({ error: 'Stripe not configured for this space' }, { status: 400 })
|
|
}
|
|
|
|
const raw = await req.text()
|
|
const sig = req.headers.get('stripe-signature')
|
|
if (!sig) return NextResponse.json({ error: 'Missing signature' }, { status: 400 })
|
|
|
|
const stripe = getStripe(secret)
|
|
let event: Stripe.Event
|
|
try {
|
|
event = stripe.webhooks.constructEvent(raw, sig, webhookSecret)
|
|
} catch (e) {
|
|
return NextResponse.json(
|
|
{ error: e instanceof Error ? e.message : 'Invalid signature' },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
// Idempotency: the unique PK rejects a replay; treat that as already-done.
|
|
const dedupe = await admin
|
|
.from('stripe_webhook_events')
|
|
.insert({ event_id: event.id, space_id: spaceId, type: event.type })
|
|
if (dedupe.error) {
|
|
if (/duplicate key|already exists|unique/i.test(dedupe.error.message)) {
|
|
return NextResponse.json({ received: true, duplicate: true })
|
|
}
|
|
return NextResponse.json({ error: dedupe.error.message }, { status: 500 })
|
|
}
|
|
|
|
const graceDays = cfg?.grace_days ?? 7
|
|
|
|
// Stripe sets Host to the configured webhook URL's host; fall back to the
|
|
// app URL. Used only to build the "manage billing" link in the email.
|
|
const hookHost = req.headers.get('x-forwarded-host') ?? req.headers.get('host')
|
|
const hookProto = req.headers.get('x-forwarded-proto') ?? 'https'
|
|
const manageUrl = `${
|
|
hookHost ? `${hookProto}://${hookHost}` : process.env.NEXT_PUBLIC_APP_URL || 'https://hackerspace.sh'
|
|
}/me`
|
|
|
|
let spaceNameMemo: string | null = null
|
|
async function getSpaceName(): Promise<string> {
|
|
if (spaceNameMemo !== null) return spaceNameMemo
|
|
const { data } = await admin.from('spaces').select('name').eq('id', spaceId).maybeSingle()
|
|
spaceNameMemo = (data?.name as string | null) ?? ''
|
|
return spaceNameMemo
|
|
}
|
|
|
|
// Enqueue a dues-lifecycle email into the notifications outbox. NEVER sends
|
|
// inline (keeps this money path fast + retry-safe); the dispatcher cron
|
|
// sends. ignoreDuplicates + the (space_id, dedupe_key) unique index make a
|
|
// Stripe event replay (new event id, same invoice/period) a no-op.
|
|
//
|
|
// BEST-EFFORT: the entire body is wrapped so a notifications-table or
|
|
// render failure can NEVER throw into the money path. The Stripe ledger /
|
|
// member_billing / status writes must finalize even if email infra is
|
|
// down; a missed enqueue is acceptable, a wedged money path is not.
|
|
async function enqueueDues(
|
|
type: DuesNotificationType,
|
|
args: {
|
|
memberId: string | null
|
|
fallbackEmail?: string | null
|
|
amount?: number | null
|
|
currency?: string | null
|
|
periodEnd?: string | null
|
|
invoiceId?: string | null
|
|
subscriptionId?: string | null
|
|
},
|
|
): Promise<void> {
|
|
try {
|
|
if (!args.memberId) return
|
|
const { data: mem } = await admin
|
|
.from('space_members')
|
|
.select('email, display_name')
|
|
.eq('id', args.memberId)
|
|
.eq('space_id', spaceId)
|
|
.maybeSingle()
|
|
const recipient = ((mem?.email as string | null) || args.fallbackEmail || '').trim()
|
|
if (!recipient) return
|
|
const { subject, html, text } = renderDuesEmail({
|
|
type,
|
|
spaceName: await getSpaceName(),
|
|
memberName: (mem?.display_name as string | null) ?? null,
|
|
amount: args.amount ?? null,
|
|
currency: args.currency ?? null,
|
|
periodEnd: args.periodEnd ?? null,
|
|
manageUrl,
|
|
})
|
|
const { error: enqErr } = await admin.from('notifications').upsert(
|
|
{
|
|
space_id: spaceId,
|
|
member_id: args.memberId,
|
|
type,
|
|
channel: 'email',
|
|
recipient,
|
|
subject,
|
|
body_html: html,
|
|
body_text: text,
|
|
status: 'pending',
|
|
dedupe_key: duesDedupeKey(type, {
|
|
invoiceId: args.invoiceId,
|
|
memberId: args.memberId,
|
|
periodEnd: args.periodEnd,
|
|
subscriptionId: args.subscriptionId,
|
|
}),
|
|
},
|
|
{ onConflict: 'space_id,dedupe_key', ignoreDuplicates: true },
|
|
)
|
|
if (enqErr) console.error(`[stripe webhook] enqueue ${type} failed:`, enqErr.message)
|
|
} catch (e) {
|
|
console.error(
|
|
`[stripe webhook] enqueue ${type} threw:`,
|
|
e instanceof Error ? e.message : e,
|
|
)
|
|
}
|
|
}
|
|
|
|
// Resolve our member: prefer metadata (set on the subscription + session),
|
|
// else map the Stripe customer id back via member_billing. Always confirm
|
|
// the member is in THIS space.
|
|
async function resolveMemberId(
|
|
metaMemberId: string | undefined,
|
|
customerId: string | null | undefined,
|
|
): Promise<string | null> {
|
|
if (metaMemberId) {
|
|
const { data } = await admin
|
|
.from('space_members')
|
|
.select('id')
|
|
.eq('id', metaMemberId)
|
|
.eq('space_id', spaceId)
|
|
.maybeSingle()
|
|
if (data) return data.id as string
|
|
}
|
|
if (customerId) {
|
|
const { data } = await admin
|
|
.from('member_billing')
|
|
.select('member_id')
|
|
.eq('space_id', spaceId)
|
|
.eq('stripe_customer_id', customerId)
|
|
.maybeSingle()
|
|
if (data) return data.member_id as string
|
|
}
|
|
return null
|
|
}
|
|
|
|
async function applySubscription(sub: Stripe.Subscription) {
|
|
const customerId = customerIdOf(sub.customer)
|
|
const memberId = await resolveMemberId(
|
|
(sub.metadata?.member_id as string | undefined) || undefined,
|
|
customerId,
|
|
)
|
|
if (!memberId) return
|
|
const incomingPeriodEnd = subscriptionPeriodEnd(sub)
|
|
|
|
// Never rewind the period on a stale/out-of-order event. Status still
|
|
// reflects the latest sub.status, but graceExceeded is computed from the
|
|
// monotonic (non-rewound) period so a late event can't false-lapse a
|
|
// paid member.
|
|
const { data: existingBilling } = await admin
|
|
.from('member_billing')
|
|
.select('current_period_end')
|
|
.eq('space_id', spaceId)
|
|
.eq('member_id', memberId)
|
|
.maybeSingle()
|
|
const periodEnd = laterPeriodEnd(
|
|
(existingBilling?.current_period_end as string | null) ?? null,
|
|
incomingPeriodEnd,
|
|
)
|
|
|
|
await admin.from('member_billing').upsert(
|
|
{
|
|
space_id: spaceId,
|
|
member_id: memberId,
|
|
stripe_customer_id: customerId,
|
|
stripe_subscription_id: sub.id,
|
|
subscription_status: sub.status,
|
|
current_period_end: periodEnd,
|
|
updated_at: new Date().toISOString(),
|
|
},
|
|
{ onConflict: 'space_id,member_id' },
|
|
)
|
|
|
|
const desired = duesMemberStatus(sub.status, graceExceeded(periodEnd, graceDays))
|
|
const patch = memberStatusPatch(desired, new Date().toISOString())
|
|
if (patch) {
|
|
// Only move between current<->late; never resurrect inactive or
|
|
// auto-approve unverified via billing.
|
|
await admin
|
|
.from('space_members')
|
|
.update(patch)
|
|
.eq('id', memberId)
|
|
.eq('space_id', spaceId)
|
|
.in('status', ['current', 'late'])
|
|
}
|
|
|
|
// Dues lapsed past grace -> one "marked late" email per lapsed period
|
|
// (dedupe is member + periodEnd, so next cycle can lapse again).
|
|
if (desired === 'late') {
|
|
await enqueueDues('dues_lapsed', { memberId, periodEnd, subscriptionId: sub.id })
|
|
}
|
|
}
|
|
|
|
try {
|
|
switch (event.type) {
|
|
case 'checkout.session.completed': {
|
|
const session = event.data.object as Stripe.Checkout.Session
|
|
const subId = typeof session.subscription === 'string' ? session.subscription : session.subscription?.id
|
|
if (subId) {
|
|
const sub = await stripe.subscriptions.retrieve(subId)
|
|
await applySubscription(sub)
|
|
}
|
|
break
|
|
}
|
|
case 'customer.subscription.created':
|
|
case 'customer.subscription.updated':
|
|
case 'customer.subscription.deleted': {
|
|
await applySubscription(event.data.object as Stripe.Subscription)
|
|
break
|
|
}
|
|
// Handle ONLY invoice.paid (Stripe also fires invoice.payment_succeeded
|
|
// for the same invoice — handling both would double the ledger).
|
|
case 'invoice.paid': {
|
|
const inv = event.data.object as Stripe.Invoice
|
|
const customerId = customerIdOf(inv.customer)
|
|
const memberId = await resolveMemberId(invoiceMetadataMemberId(inv), customerId)
|
|
// Ledger idempotency: invoice.paid + a retry are distinct event ids
|
|
// but the same invoice; never double-record one charge.
|
|
const { data: dupe } = await admin
|
|
.from('payments')
|
|
.select('id')
|
|
.eq('space_id', spaceId)
|
|
.eq('platform', 'stripe')
|
|
.eq('external_id', inv.id)
|
|
.maybeSingle()
|
|
if (!dupe) {
|
|
await admin.from('payments').insert(
|
|
stripeInvoiceToPaymentRow({
|
|
inv,
|
|
spaceId,
|
|
memberId,
|
|
eventId: event.id,
|
|
nowIso: new Date().toISOString(),
|
|
}),
|
|
)
|
|
}
|
|
if (memberId) {
|
|
await admin
|
|
.from('space_members')
|
|
.update({ last_paid_at: new Date().toISOString() })
|
|
.eq('id', memberId)
|
|
.eq('space_id', spaceId)
|
|
.in('status', ['current', 'late'])
|
|
}
|
|
await enqueueDues('dues_renewed', {
|
|
memberId,
|
|
fallbackEmail: inv.customer_email ?? null,
|
|
amount: minorToMajor(inv.amount_paid, inv.currency),
|
|
currency: (inv.currency ?? 'usd').toUpperCase(),
|
|
periodEnd: invoiceLinePeriodEnd(inv),
|
|
invoiceId: inv.id,
|
|
})
|
|
break
|
|
}
|
|
// The card failed. Send the "payment failed" notice now; the lapse-to-
|
|
// late email is driven separately by the later customer.subscription.
|
|
// updated (status past_due past grace) through applySubscription.
|
|
case 'invoice.payment_failed': {
|
|
const inv = event.data.object as Stripe.Invoice
|
|
const memberId = await resolveMemberId(
|
|
invoiceMetadataMemberId(inv),
|
|
customerIdOf(inv.customer),
|
|
)
|
|
await enqueueDues('dues_payment_failed', {
|
|
memberId,
|
|
fallbackEmail: inv.customer_email ?? null,
|
|
amount: minorToMajor(inv.amount_due, inv.currency),
|
|
currency: (inv.currency ?? 'usd').toUpperCase(),
|
|
invoiceId: inv.id,
|
|
})
|
|
break
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
} catch (e) {
|
|
// The dedupe row was written BEFORE processing; if processing failed,
|
|
// remove it so Stripe's retry of this same event is reprocessed rather
|
|
// than silently short-circuited as a duplicate (no lost events).
|
|
await admin.from('stripe_webhook_events').delete().eq('event_id', event.id)
|
|
return NextResponse.json(
|
|
{ error: e instanceof Error ? e.message : 'handler error' },
|
|
{ status: 500 },
|
|
)
|
|
}
|
|
|
|
return NextResponse.json({ received: true })
|
|
}
|