From 7b0948ddd6d431bd7e740543a69998ebc591b3af Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Mon, 18 May 2026 18:38:25 -0700 Subject: [PATCH] fix(money): zero-decimal currency handling (D7) 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+. --- __tests__/notifications-logic.test.ts | 4 ++++ __tests__/stripe-webhook-logic.test.ts | 16 +++++++++++----- app/api/stripe/webhook/[space]/route.ts | 4 ++-- lib/notifications-logic.ts | 18 +++++++++++++++--- lib/stripe/webhook-logic.ts | 17 ++++++++++++----- 5 files changed, 44 insertions(+), 15 deletions(-) diff --git a/__tests__/notifications-logic.test.ts b/__tests__/notifications-logic.test.ts index 913a435..a89e5f2 100644 --- a/__tests__/notifications-logic.test.ts +++ b/__tests__/notifications-logic.test.ts @@ -85,6 +85,10 @@ describe('formatMoney', () => { expect(formatMoney(undefined, 'usd')).toBe('') expect(formatMoney(NaN, 'usd')).toBe('') }) + it('renders zero-decimal currencies with no fractional part', () => { + expect(formatMoney(3000, 'jpy')).toBe('3000 JPY') + expect(formatMoney(50000, 'KRW')).toBe('50000 KRW') + }) }) describe('renderDuesEmail', () => { diff --git a/__tests__/stripe-webhook-logic.test.ts b/__tests__/stripe-webhook-logic.test.ts index 663be73..3808ec0 100644 --- a/__tests__/stripe-webhook-logic.test.ts +++ b/__tests__/stripe-webhook-logic.test.ts @@ -140,10 +140,16 @@ describe('laterPeriodEnd (never rewind on out-of-order events)', () => { }) }) -describe('minorToMajor', () => { - it('divides by 100, treats nullish as 0', () => { - expect(minorToMajor(2500)).toBe(25) - expect(minorToMajor(null)).toBe(0) - expect(minorToMajor(undefined)).toBe(0) +describe('minorToMajor (currency-aware)', () => { + it('divides by 100 for normal 2-decimal currencies', () => { + expect(minorToMajor(2500, 'usd')).toBe(25) + expect(minorToMajor(2500, 'EUR')).toBe(25) + expect(minorToMajor(null, 'usd')).toBe(0) + expect(minorToMajor(undefined, undefined)).toBe(0) + }) + it('does NOT divide zero-decimal currencies (JPY/KRW already major)', () => { + expect(minorToMajor(3000, 'jpy')).toBe(3000) + expect(minorToMajor(50000, 'KRW')).toBe(50000) + expect(minorToMajor(1000, 'VND')).toBe(1000) }) }) diff --git a/app/api/stripe/webhook/[space]/route.ts b/app/api/stripe/webhook/[space]/route.ts index a5239ac..d8563be 100644 --- a/app/api/stripe/webhook/[space]/route.ts +++ b/app/api/stripe/webhook/[space]/route.ts @@ -298,7 +298,7 @@ export async function POST( await enqueueDues('dues_renewed', { memberId, fallbackEmail: inv.customer_email ?? null, - amount: minorToMajor(inv.amount_paid), + amount: minorToMajor(inv.amount_paid, inv.currency), currency: (inv.currency ?? 'usd').toUpperCase(), periodEnd: invoiceLinePeriodEnd(inv), invoiceId: inv.id, @@ -317,7 +317,7 @@ export async function POST( await enqueueDues('dues_payment_failed', { memberId, fallbackEmail: inv.customer_email ?? null, - amount: minorToMajor(inv.amount_due), + amount: minorToMajor(inv.amount_due, inv.currency), currency: (inv.currency ?? 'usd').toUpperCase(), invoiceId: inv.id, }) diff --git a/lib/notifications-logic.ts b/lib/notifications-logic.ts index 4cc972d..d7b6248 100644 --- a/lib/notifications-logic.ts +++ b/lib/notifications-logic.ts @@ -72,8 +72,20 @@ function escapeHtml(s: string): string { const CURRENCY_SYMBOL: Record = { USD: '$', CAD: '$', EUR: '€', GBP: '£' } -// amount is in major units (dollars), matching how the payments ledger stores -// it (Stripe minor units are divided by 100 by the caller). +// Stripe zero-decimal currencies: the smallest unit IS the major unit, so +// amounts are NOT scaled by 100 and have no fractional display. Source: +// Stripe "zero-decimal currencies" list. +export const ZERO_DECIMAL_CURRENCIES = new Set([ + 'BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW', 'MGA', 'PYG', 'RWF', + 'UGX', 'VND', 'VUV', 'XAF', 'XOF', 'XPF', +]) + +export function isZeroDecimalCurrency(currency: string | null | undefined): boolean { + return ZERO_DECIMAL_CURRENCIES.has((currency || 'USD').toUpperCase()) +} + +// amount is in major units, matching how the payments ledger stores it. +// Zero-decimal currencies render with no fractional part. export function formatMoney( amount: number | null | undefined, currency: string | null | undefined, @@ -81,7 +93,7 @@ export function formatMoney( if (amount === null || amount === undefined || Number.isNaN(amount)) return '' const code = (currency || 'USD').toUpperCase() const sym = CURRENCY_SYMBOL[code] - const n = amount.toFixed(2) + const n = isZeroDecimalCurrency(code) ? String(Math.round(amount)) : amount.toFixed(2) return sym ? `${sym}${n} ${code}` : `${n} ${code}` } diff --git a/lib/stripe/webhook-logic.ts b/lib/stripe/webhook-logic.ts index d103091..391cccc 100644 --- a/lib/stripe/webhook-logic.ts +++ b/lib/stripe/webhook-logic.ts @@ -7,6 +7,8 @@ // 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 = { @@ -81,7 +83,7 @@ export function stripeInvoiceToPaymentRow(args: { space_id: spaceId, member_id: memberId, platform: 'stripe' as const, - amount: (inv.amount_paid ?? 0) / 100, + amount: minorToMajor(inv.amount_paid, inv.currency), currency: (inv.currency ?? 'usd').toUpperCase(), description: inv.description ?? 'Stripe membership dues', status: linked, @@ -94,10 +96,15 @@ export function stripeInvoiceToPaymentRow(args: { } } -// Major-units amount for the dues email (amount_paid for renewals, -// amount_due for failed-payment notices). -export function minorToMajor(amount: number | null | undefined): number { - return (amount ?? 0) / 100 +// 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