import type { Currency, Money } from './shared'; // Presentation helpers. The money one is the load-bearing piece: InvoiceShelf stores minor units as // integers and describes how to render them in the company's `current_company_currency` record, so the // separators, the precision and the symbol side all come from data rather than from the browser locale. // Intl.NumberFormat is deliberately NOT used — it would apply the *viewer's* locale to the *company's* // currency and quietly render "1,234.56 €" for an install configured the other way round. /** * Coerce an API money field to a number of minor units. * * Upstream is genuinely inconsistent about the JSON type here — `invoice.total` arrives as the number * 450000 while `customer.due_amount` arrives as the string "1830000" — so every read goes through this * rather than trusting either. Non-finite input collapses to 0 so a missing total renders as "0,00 €" * instead of "NaN". */ export const toMinor = (value: Money): number => { if (value == null) return 0; const n = typeof value === 'number' ? value : Number(value); return Number.isFinite(n) ? n : 0; }; const groupDigits = (digits: string, separator: string): string => digits.replace(/\B(?=(\d{3})+(?!\d))/g, separator); /** * The minor-unit divisor. * * Deliberately the constant 100 and NOT 10**precision, because that is what upstream does — its * `utils/format-money.ts` opens with `amount = amountInCents / 100` and then uses `precision` only to * decide how many decimal digits to *print*. On a 3-decimal currency the two rules disagree, and matching * InvoiceShelf's own arithmetic matters more here than being independently right: a figure that differs * from the one its web UI shows for the same invoice would read as a bug in this screen. */ const MINOR_PER_MAJOR = 100; /** * Render minor units the way InvoiceShelf renders them. * * Ported from `resources/scripts/utils/format-money.ts` and matched on the details that are visible: * there is always a single space between symbol and number, the minus sign sits inside the number rather * than before the symbol (so "€ -1.234,56", not "-€ 1.234,56"), and `precision: 0` prints no decimal part * at all rather than a bare separator. */ export const formatMoney = (value: Money, currency?: Currency | null): string => { let precision = Math.abs(Number(currency?.precision ?? 2)); if (!Number.isFinite(precision)) precision = 2; const amount = toMinor(value) / MINOR_PER_MAJOR; const negativeSign = amount < 0 ? '-' : ''; const abs = Math.abs(amount); const thousands = currency?.thousand_separator ?? ','; const decimal = currency?.decimal_separator ?? '.'; const symbol = currency?.symbol ?? ''; const fixed = abs.toFixed(precision); const [whole = '0', frac = ''] = fixed.split('.'); const combined = negativeSign + groupDigits(whole, thousands) + (precision ? decimal + frac : ''); if (!symbol) return combined; // `swap_currency_symbol` arrives as a boolean from the API and as 0/1 from a settings write. return currency?.swap_currency_symbol ? `${combined} ${symbol}` : `${symbol} ${combined}`; }; /** Bare number, no symbol — for chart axes and form inputs where the symbol lives elsewhere. */ export const formatAmount = (value: Money, currency?: Currency | null): string => formatMoney(value, currency ? { ...currency, symbol: '' } : null); /** Minor units → the major-unit number an editable input holds (450000 → 4500). */ export const toMajor = (value: Money): number => toMinor(value) / MINOR_PER_MAJOR; /** The inverse, for writes. Rounds because floats reaching here have been through an . */ export const fromMajor = (value: number | string): number => { const n = typeof value === 'number' ? value : Number(String(value).replace(',', '.')); return Number.isFinite(n) ? Math.round(n * MINOR_PER_MAJOR) : 0; }; /** Strips symbol and separators back to minor units — the paste-into-a-money-field path. */ export const parseMoneyMinor = (text: string, currency?: Currency | null): number => { const thousands = currency?.thousand_separator ?? ','; const decimal = currency?.decimal_separator ?? '.'; const cleaned = text .replace(currency?.symbol ?? '', '') .split(thousands) .join('') .replace(decimal, '.') .replace(/[^\d.-]/g, ''); const n = Number(cleaned); return Number.isFinite(n) ? Math.round(n * MINOR_PER_MAJOR) : 0; }; // ── dates ──────────────────────────────────────────────────────────────────────────────────────── /** * Parse the several date shapes the API mixes: "2026-06-21", "2026-06-21 00:00:00" and full ISO. * The space form is not valid ISO-8601, so it is normalised before Date sees it. */ const parseDate = (value: string | null | undefined): Date | null => { if (!value) return null; const normalised = value.includes('T') ? value : value.replace(' ', 'T'); const d = new Date(normalised); return Number.isNaN(d.getTime()) ? null : d; }; /** * Prefer upstream's own `formatted_*` field when the caller has one: it already reflects the company's * configured date format (dd/mm/yyyy here), which we cannot reconstruct from the raw value alone. */ export const formatDate = (raw: string | null | undefined, preformatted?: string | null): string => { if (preformatted) return preformatted; const d = parseDate(raw); if (!d) return '—'; const pad = (n: number) => String(n).padStart(2, '0'); return `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()}`; }; /** ISO yyyy-mm-dd, the form every write endpoint expects and `` speaks. */ export const toInputDate = (raw: string | null | undefined): string => { const d = parseDate(raw); if (!d) return ''; const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; }; /** Whole days until `raw`; negative once past. Used for the overdue hint next to a due date. */ export const daysUntil = (raw: string | null | undefined): number | null => { const d = parseDate(raw); if (!d) return null; const today = new Date(); today.setHours(0, 0, 0, 0); d.setHours(0, 0, 0, 0); return Math.round((d.getTime() - today.getTime()) / 86_400_000); }; export const relativeDue = (raw: string | null | undefined): string => { const days = daysUntil(raw); if (days == null) return ''; if (days === 0) return 'due today'; if (days > 0) return days === 1 ? 'due tomorrow' : `due in ${days} days`; const overdue = Math.abs(days); return overdue === 1 ? '1 day overdue' : `${overdue} days overdue`; }; /** Strip the HTML upstream stores in note fields, for one-line previews. */ export const stripHtml = (html: string | null | undefined): string => (html ?? '') .replace(/<[^>]*>/g, ' ') .replace(/ /g, ' ') .replace(/&/g, '&') .replace(/\s+/g, ' ') .trim();