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();