Files
officer 6c70eb8eb3 invoices, extracted from the platform as a plugin
Ninth plugin out, third resurrection: switched off since 2026-08-13 with the
permission reduced to api: [], so the server booted clean and every panel
request 404'd. invoiceshelf/schema was also commented out of the drizzle
barrel.

Named `invoices`, not `invoiceshelf`. The appName drives the API prefix, the
frontend route, the table prefix and the install key all at once, and before
extraction those had drifted: the API was /api/invoiceshelf while the screen
was /invoices. The URL is what a person sees and should name what the plugin
shows rather than the daemon behind it — the permission key and label were
already `invoices`, so the API prefix was the odd one out.

Cost: three call sites, and renaming invoiceshelf_accounts to invoices_accounts
to satisfy the table-prefix rule. That rename was free ONLY because the table
existed on no server, which was checked before deciding. With rows in it,
keeping the vendor name would have been the better trade: db:push sees a rename
as a drop plus a create and this project has no migration mechanism.

The sidecar keeps its Invoiceshelf-named functions deliberately — they name the
upstream API it actually speaks.

91 palette classes converted, 52 of them in STATUS_TONE, which was already the
single source of status colour but held raw palette strings. SENT and VIEWED
now share `info`: the closed six-tone set has no second shade of "in flight,
not yet actioned". Recorded in PLUGIN.md as a real loss.
2026-08-19 03:47:44 +00:00

153 lines
6.9 KiB
TypeScript

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 <input>. */
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 `<input type="date">` 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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/\s+/g, ' ')
.trim();