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+.
This commit is contained in:
Moheeb Zara
2026-05-18 18:38:25 -07:00
parent c9c0909ad7
commit 7b0948ddd6
5 changed files with 44 additions and 15 deletions
+15 -3
View File
@@ -72,8 +72,20 @@ function escapeHtml(s: string): string {
const CURRENCY_SYMBOL: Record<string, string> = { 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}`
}
+12 -5
View File
@@ -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