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:
@@ -85,6 +85,10 @@ describe('formatMoney', () => {
|
|||||||
expect(formatMoney(undefined, 'usd')).toBe('')
|
expect(formatMoney(undefined, 'usd')).toBe('')
|
||||||
expect(formatMoney(NaN, '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', () => {
|
describe('renderDuesEmail', () => {
|
||||||
|
|||||||
@@ -140,10 +140,16 @@ describe('laterPeriodEnd (never rewind on out-of-order events)', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('minorToMajor', () => {
|
describe('minorToMajor (currency-aware)', () => {
|
||||||
it('divides by 100, treats nullish as 0', () => {
|
it('divides by 100 for normal 2-decimal currencies', () => {
|
||||||
expect(minorToMajor(2500)).toBe(25)
|
expect(minorToMajor(2500, 'usd')).toBe(25)
|
||||||
expect(minorToMajor(null)).toBe(0)
|
expect(minorToMajor(2500, 'EUR')).toBe(25)
|
||||||
expect(minorToMajor(undefined)).toBe(0)
|
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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ export async function POST(
|
|||||||
await enqueueDues('dues_renewed', {
|
await enqueueDues('dues_renewed', {
|
||||||
memberId,
|
memberId,
|
||||||
fallbackEmail: inv.customer_email ?? null,
|
fallbackEmail: inv.customer_email ?? null,
|
||||||
amount: minorToMajor(inv.amount_paid),
|
amount: minorToMajor(inv.amount_paid, inv.currency),
|
||||||
currency: (inv.currency ?? 'usd').toUpperCase(),
|
currency: (inv.currency ?? 'usd').toUpperCase(),
|
||||||
periodEnd: invoiceLinePeriodEnd(inv),
|
periodEnd: invoiceLinePeriodEnd(inv),
|
||||||
invoiceId: inv.id,
|
invoiceId: inv.id,
|
||||||
@@ -317,7 +317,7 @@ export async function POST(
|
|||||||
await enqueueDues('dues_payment_failed', {
|
await enqueueDues('dues_payment_failed', {
|
||||||
memberId,
|
memberId,
|
||||||
fallbackEmail: inv.customer_email ?? null,
|
fallbackEmail: inv.customer_email ?? null,
|
||||||
amount: minorToMajor(inv.amount_due),
|
amount: minorToMajor(inv.amount_due, inv.currency),
|
||||||
currency: (inv.currency ?? 'usd').toUpperCase(),
|
currency: (inv.currency ?? 'usd').toUpperCase(),
|
||||||
invoiceId: inv.id,
|
invoiceId: inv.id,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -72,8 +72,20 @@ function escapeHtml(s: string): string {
|
|||||||
|
|
||||||
const CURRENCY_SYMBOL: Record<string, string> = { USD: '$', CAD: '$', EUR: '€', GBP: '£' }
|
const CURRENCY_SYMBOL: Record<string, string> = { USD: '$', CAD: '$', EUR: '€', GBP: '£' }
|
||||||
|
|
||||||
// amount is in major units (dollars), matching how the payments ledger stores
|
// Stripe zero-decimal currencies: the smallest unit IS the major unit, so
|
||||||
// it (Stripe minor units are divided by 100 by the caller).
|
// 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(
|
export function formatMoney(
|
||||||
amount: number | null | undefined,
|
amount: number | null | undefined,
|
||||||
currency: string | null | undefined,
|
currency: string | null | undefined,
|
||||||
@@ -81,7 +93,7 @@ export function formatMoney(
|
|||||||
if (amount === null || amount === undefined || Number.isNaN(amount)) return ''
|
if (amount === null || amount === undefined || Number.isNaN(amount)) return ''
|
||||||
const code = (currency || 'USD').toUpperCase()
|
const code = (currency || 'USD').toUpperCase()
|
||||||
const sym = CURRENCY_SYMBOL[code]
|
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}`
|
return sym ? `${sym}${n} ${code}` : `${n} ${code}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
// around it, including the Stripe Basil (2025-03-31 / 2026-04-22.dahlia)
|
// 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.
|
// 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 CustomerRef = string | { id?: string | null } | null | undefined
|
||||||
|
|
||||||
type SubscriptionLike = {
|
type SubscriptionLike = {
|
||||||
@@ -81,7 +83,7 @@ export function stripeInvoiceToPaymentRow(args: {
|
|||||||
space_id: spaceId,
|
space_id: spaceId,
|
||||||
member_id: memberId,
|
member_id: memberId,
|
||||||
platform: 'stripe' as const,
|
platform: 'stripe' as const,
|
||||||
amount: (inv.amount_paid ?? 0) / 100,
|
amount: minorToMajor(inv.amount_paid, inv.currency),
|
||||||
currency: (inv.currency ?? 'usd').toUpperCase(),
|
currency: (inv.currency ?? 'usd').toUpperCase(),
|
||||||
description: inv.description ?? 'Stripe membership dues',
|
description: inv.description ?? 'Stripe membership dues',
|
||||||
status: linked,
|
status: linked,
|
||||||
@@ -94,10 +96,15 @@ export function stripeInvoiceToPaymentRow(args: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Major-units amount for the dues email (amount_paid for renewals,
|
// Stripe sends most amounts in minor units (cents) but zero-decimal
|
||||||
// amount_due for failed-payment notices).
|
// currencies (JPY, KRW, ...) already in the major unit — dividing those by
|
||||||
export function minorToMajor(amount: number | null | undefined): number {
|
// 100 would understate the ledger + receipt 100x. Currency-aware.
|
||||||
return (amount ?? 0) / 100
|
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
|
// Stripe does not guarantee webhook ordering. A stale/late
|
||||||
|
|||||||
Reference in New Issue
Block a user