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+.
124 lines
5.0 KiB
TypeScript
124 lines
5.0 KiB
TypeScript
// Pure decision + mapping logic for the Stripe webhook. No Stripe SDK, no
|
|
// Supabase, no I/O — structural input types only, so every branch is unit-
|
|
// testable in isolation. The route handler keeps only signature verify,
|
|
// network calls, and DB writes; all "what should we derive/record" logic
|
|
// lives here. Status mapping itself stays in lib/stripe-logic.ts
|
|
// (duesMemberStatus / graceExceeded); this module is the webhook-shaped glue
|
|
// around it, including the Stripe Basil (2025-03-31 / 2026-04-22.dahlia)
|
|
// field relocations that are easy to get wrong and must be tested.
|
|
|
|
import { isZeroDecimalCurrency } from '@/lib/notifications-logic'
|
|
|
|
type CustomerRef = string | { id?: string | null } | null | undefined
|
|
|
|
type SubscriptionLike = {
|
|
items?: { data?: Array<{ current_period_end?: number } | null | undefined> }
|
|
}
|
|
|
|
type InvoiceLike = {
|
|
id?: string | null
|
|
amount_paid?: number | null
|
|
amount_due?: number | null
|
|
currency?: string | null
|
|
description?: string | null
|
|
customer_email?: string | null
|
|
parent?: { subscription_details?: { metadata?: Record<string, string> | null } | null } | null
|
|
lines?: { data?: Array<{ period?: { end?: number } | null } | null> } | null
|
|
}
|
|
|
|
export function isoFromUnix(s: number | null | undefined): string | null {
|
|
return typeof s === 'number' && s > 0 ? new Date(s * 1000).toISOString() : null
|
|
}
|
|
|
|
// Stripe customer is either an id string or an expanded object.
|
|
export function customerIdOf(customer: CustomerRef): string | null {
|
|
if (typeof customer === 'string') return customer || null
|
|
return customer?.id ?? null
|
|
}
|
|
|
|
// Basil moved the billing period off the top-level Subscription onto each
|
|
// item. Dues are a single-price subscription, so item 0 carries it.
|
|
export function subscriptionPeriodEnd(sub: SubscriptionLike | null | undefined): string | null {
|
|
return isoFromUnix(sub?.items?.data?.[0]?.current_period_end)
|
|
}
|
|
|
|
// Basil moved invoice subscription metadata under
|
|
// invoice.parent.subscription_details.metadata.
|
|
export function invoiceMetadataMemberId(inv: InvoiceLike | null | undefined): string | undefined {
|
|
return inv?.parent?.subscription_details?.metadata?.member_id || undefined
|
|
}
|
|
|
|
// Next renewal date for the renewal receipt: the paid invoice line's period
|
|
// end. Absent on some invoices -> null (email simply omits the date).
|
|
export function invoiceLinePeriodEnd(inv: InvoiceLike | null | undefined): string | null {
|
|
return isoFromUnix(inv?.lines?.data?.[0]?.period?.end)
|
|
}
|
|
|
|
// Build the space_members patch for a billing-driven status change. Only
|
|
// current<->late ever moves (the route additionally constrains the UPDATE to
|
|
// rows already in those states, so inactive/unverified are never touched).
|
|
// Returns null when there is no status to apply.
|
|
export function memberStatusPatch(
|
|
desired: 'current' | 'late' | null | undefined,
|
|
nowIso: string,
|
|
): { status: 'current' | 'late'; last_paid_at?: string } | null {
|
|
if (!desired) return null
|
|
if (desired === 'current') return { status: 'current', last_paid_at: nowIso }
|
|
return { status: 'late' }
|
|
}
|
|
|
|
// Stripe amounts are in minor units; the payments ledger stores major units
|
|
// (numeric, matching the existing PayPal/CSV rows). Centralized so the cents
|
|
// division + currency casing + link status can be tested once.
|
|
export function stripeInvoiceToPaymentRow(args: {
|
|
inv: InvoiceLike
|
|
spaceId: string
|
|
memberId: string | null
|
|
eventId: string
|
|
nowIso: string
|
|
}) {
|
|
const { inv, spaceId, memberId, eventId, nowIso } = args
|
|
const linked = memberId ? 'linked' : 'unlinked'
|
|
return {
|
|
space_id: spaceId,
|
|
member_id: memberId,
|
|
platform: 'stripe' as const,
|
|
amount: minorToMajor(inv.amount_paid, inv.currency),
|
|
currency: (inv.currency ?? 'usd').toUpperCase(),
|
|
description: inv.description ?? 'Stripe membership dues',
|
|
status: linked,
|
|
link_status: linked,
|
|
external_id: inv.id ?? null,
|
|
payer_email: inv.customer_email ?? null,
|
|
from_identifier: inv.customer_email ?? null,
|
|
transaction_date: nowIso,
|
|
raw_data: { event_id: eventId, invoice: inv.id ?? null },
|
|
}
|
|
}
|
|
|
|
// Stripe sends most amounts in minor units (cents) but zero-decimal
|
|
// currencies (JPY, KRW, ...) already in the major unit — dividing those by
|
|
// 100 would understate the ledger + receipt 100x. Currency-aware.
|
|
export function minorToMajor(
|
|
amount: number | null | undefined,
|
|
currency: string | null | undefined,
|
|
): number {
|
|
const a = amount ?? 0
|
|
return isZeroDecimalCurrency(currency) ? a : a / 100
|
|
}
|
|
|
|
// Stripe does not guarantee webhook ordering. A stale/late
|
|
// customer.subscription.updated carrying an OLDER period must never rewind
|
|
// the stored current_period_end (that would make graceExceeded flip a paid
|
|
// member to 'late' and fire a false "dues lapsed" email). Keep the later of
|
|
// stored vs incoming. ISO-8601 UTC ('...Z') strings sort lexically ==
|
|
// chronologically, so string compare is correct here.
|
|
export function laterPeriodEnd(
|
|
stored: string | null | undefined,
|
|
incoming: string | null | undefined,
|
|
): string | null {
|
|
if (!stored) return incoming ?? null
|
|
if (!incoming) return stored
|
|
return stored >= incoming ? stored : incoming
|
|
}
|