Files
invoices/web/DocumentEditor.tsx
T
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

733 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { DocumentItem, DocumentTax, Estimate, Invoice, RecurringInvoice, TaxType } from './shared';
import { useEffect, useMemo, useState } from 'react';
import { Plus, Trash2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { ErrorState, LoadingState } from './components';
import { formatMoney, fromMajor, toInputDate, toMajor } from './format';
import { FREQUENCY_PRESETS } from './shared';
import {
useCurrency,
useLookups,
useNextNumber,
useResourceDetail,
useResourceMutations,
useTemplates,
} from './useInvoiceShelfData';
// The invoice / estimate / recurring-invoice form. One component for all three: they share every hard part
// — the line-item table, the discounts, the taxes and the totals — and differ only in a header strip.
// An invoice has a number and two dates; an estimate the same with different labels; a recurring invoice
// has no number at all (each generated invoice takes the next one) and carries a schedule instead of dates.
//
// ── the arithmetic ───────────────────────────────────────────────────────────────────────────────
// Ported from upstream's `use-document-calculations.ts`, and the rounding points are load-bearing because
// the server re-validates the totals we send:
//
// line sub_total = round(price × quantity) (price already in minor units)
// line discount = percentage → round(|sub_total| × d / 100)
// fixed → min(round(d × 100), |sub_total|) ← d is entered in MAJOR units
// line total = sub_total − discount
// document sub_total = Σ line totals
// tax base = document sub_total − document discount
// compound tax base = tax base + simple taxes
// total = tax_included ? tax base : tax base + total tax
//
// Every value posted is an integer of minor units. Amounts are shown to the user in major units and
// converted at the edges only — a float never survives past a keystroke.
type Resource = 'invoices' | 'estimates' | 'recurring-invoices';
type Line = {
key: string;
item_id: number | null;
name: string;
description: string;
quantity: number;
/** Minor units. */
price: number;
discount: number;
discount_type: 'fixed' | 'percentage';
tax_type_ids: number[];
};
type Props = { resource: Resource; id: number | 'new'; onClose: () => void };
const KIND_NOUN: Record<Resource, string> = {
invoices: 'invoice',
estimates: 'estimate',
'recurring-invoices': 'recurring invoice',
};
const newLine = (index: number): Line => ({
key: `new-${index}`,
item_id: null,
name: '',
description: '',
quantity: 1,
price: 0,
discount: 0,
discount_type: 'fixed',
tax_type_ids: [],
});
export const DocumentEditor = ({ resource, id, onClose }: Props) => {
const isNew = id === 'new';
const isRecurring = resource === 'recurring-invoices';
const isInvoice = resource === 'invoices';
const currency = useCurrency();
const { customers, items, taxTypes } = useLookups();
// A recurring invoice renders through the invoice templates — it has no gallery of its own.
const templates = useTemplates(isRecurring ? 'invoices' : resource);
const nextNumber = useNextNumber(isInvoice ? 'invoice' : 'estimate', isNew && !isRecurring);
const { record, isLoading, error } = useResourceDetail<Invoice & Estimate & RecurringInvoice>(
resource,
isNew ? null : (id as number),
);
const { create, update } = useResourceMutations(resource, isRecurring ? 'recurring invoices' : resource);
const [customerId, setCustomerId] = useState<number | null>(null);
const [number, setNumber] = useState('');
const [primaryDate, setPrimaryDate] = useState(() => toInputDate(new Date().toISOString()));
const [secondaryDate, setSecondaryDate] = useState('');
const [reference, setReference] = useState('');
const [template, setTemplate] = useState('');
const [notes, setNotes] = useState('');
const [lines, setLines] = useState<Line[]>([newLine(0)]);
const [discount, setDiscount] = useState(0);
const [discountType, setDiscountType] = useState<'fixed' | 'percentage'>('fixed');
const [docTaxIds, setDocTaxIds] = useState<number[]>([]);
const [taxPerItem, setTaxPerItem] = useState<'YES' | 'NO'>('NO');
const [taxIncluded, setTaxIncluded] = useState(false);
const [loaded, setLoaded] = useState(false);
// Recurring-only. `frequency` is a raw cron expression upstream; a schedule authored outside the preset
// list keeps its own expression and is offered back as an extra option rather than silently rewritten.
const [frequency, setFrequency] = useState<string>(FREQUENCY_PRESETS[2].cron);
const [limitBy, setLimitBy] = useState<'NONE' | 'COUNT' | 'DATE'>('NONE');
const [limitCount, setLimitCount] = useState(1);
const [limitDate, setLimitDate] = useState('');
const [sendAutomatically, setSendAutomatically] = useState(false);
const [recurringStatus, setRecurringStatus] = useState<'ACTIVE' | 'ON_HOLD'>('ACTIVE');
// Seed the form once, from either the loaded record or the reserved next number. Guarded by `loaded`
// rather than by a dependency list: react-query re-delivers `record` on every background refetch, and
// without the guard a refetch mid-edit would silently discard what the user had typed.
useEffect(() => {
if (loaded) return;
if (isNew) {
// A recurring invoice has no number of its own, so it does not wait on one.
if (!isRecurring && !nextNumber) return;
if (nextNumber) setNumber(nextNumber);
setTemplate(templates[0]?.name ?? (isInvoice || isRecurring ? 'invoice1' : 'estimate1'));
setLoaded(true);
return;
}
if (!record) return;
setCustomerId(record.customer_id ?? null);
setNumber(isInvoice ? (record.invoice_number ?? '') : (record.estimate_number ?? ''));
if (isRecurring) {
setPrimaryDate(toInputDate(record.starts_at));
setFrequency(record.frequency ?? FREQUENCY_PRESETS[2].cron);
setLimitBy(record.limit_by === 'COUNT' || record.limit_by === 'DATE' ? record.limit_by : 'NONE');
setLimitCount(record.limit_count ?? 1);
setLimitDate(toInputDate(record.limit_date));
setSendAutomatically(Boolean(record.send_automatically));
setRecurringStatus(record.status === 'ON_HOLD' ? 'ON_HOLD' : 'ACTIVE');
} else {
setPrimaryDate(toInputDate(isInvoice ? record.invoice_date : record.estimate_date));
setSecondaryDate(toInputDate(isInvoice ? record.due_date : record.expiry_date));
}
setReference(record.reference_number ?? '');
setTemplate(record.template_name ?? templates[0]?.name ?? '');
setNotes(record.notes ?? '');
setTaxPerItem(record.tax_per_item === 'YES' ? 'YES' : 'NO');
setTaxIncluded(Boolean((record as { tax_included?: boolean }).tax_included));
setDiscountType(record.discount_type === 'percentage' ? 'percentage' : 'fixed');
// A fixed document discount is stored in minor units but entered in major ones; a percentage is a
// plain number either way.
setDiscount(record.discount_type === 'percentage' ? Number(record.discount) : toMajor(record.discount_val));
setDocTaxIds((record.taxes ?? []).map((t) => t.tax_type_id).filter((t): t is number => t != null));
setLines(
(record.items ?? []).map((line, i) => ({
key: `row-${line.id ?? i}`,
item_id: line.item_id ?? null,
name: line.name,
description: line.description ?? '',
quantity: Number(line.quantity) || 0,
price: Number(line.price) || 0,
discount: line.discount_type === 'percentage' ? Number(line.discount) : toMajor(line.discount_val),
discount_type: line.discount_type === 'percentage' ? 'percentage' : 'fixed',
tax_type_ids: (line.taxes ?? []).map((t) => t.tax_type_id).filter((t): t is number => t != null),
})),
);
setLoaded(true);
}, [record, isNew, nextNumber, templates, isInvoice, isRecurring, loaded]);
const totals = useMemo(
() => computeTotals({ lines, discount, discountType, docTaxIds, taxPerItem, taxIncluded, taxTypes }),
[lines, discount, discountType, docTaxIds, taxPerItem, taxIncluded, taxTypes],
);
if (error) return <ErrorState error={error} />;
if (!isNew && isLoading) return <LoadingState />;
const patchLine = (key: string, changes: Partial<Line>) =>
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...changes } : l)));
const pickItem = (key: string, itemId: number) => {
const source = items.find((i) => i.id === itemId);
if (!source) return;
patchLine(key, {
item_id: source.id,
name: source.name,
description: source.description ?? '',
price: Number(source.price) || 0,
});
};
const canSave =
customerId != null &&
(isRecurring || number.trim() !== '') &&
primaryDate !== '' &&
template !== '' &&
lines.some((l) => l.name.trim());
const save = () => {
// The three resources agree on everything below the header, and disagree on nothing but it.
const header = isRecurring
? {
starts_at: primaryDate,
frequency,
limit_by: limitBy,
limit_count: limitBy === 'COUNT' ? limitCount : null,
limit_date: limitBy === 'DATE' ? limitDate : null,
send_automatically: sendAutomatically,
status: recurringStatus,
}
: {
[isInvoice ? 'invoice_date' : 'estimate_date']: primaryDate,
[isInvoice ? 'due_date' : 'expiry_date']: secondaryDate || null,
[isInvoice ? 'invoice_number' : 'estimate_number']: number.trim(),
};
const payload = {
...header,
customer_id: customerId,
reference_number: reference || null,
template_name: template,
notes: notes || null,
currency_id: record?.currency_id ?? currency?.id ?? null,
exchange_rate: 1,
tax_per_item: taxPerItem,
discount_per_item: lines.some((l) => l.discount) ? 'YES' : 'NO',
// The raw figure the user typed; `discount_val` alongside it is the resolved minor-unit amount.
discount,
discount_type: discountType,
discount_val: totals.documentDiscount,
sub_total: totals.subTotal,
total: totals.total,
tax: totals.totalTax,
taxes: taxPerItem === 'NO' ? docTaxIds.map((tid) => taxLine(tid, taxTypes, totals.taxBase)) : [],
items: lines
.filter((l) => l.name.trim())
.map((l) => {
const line = computeLine(l, taxTypes, taxPerItem);
return {
item_id: l.item_id,
name: l.name.trim(),
description: l.description || null,
quantity: l.quantity,
price: l.price,
discount: l.discount,
discount_type: l.discount_type,
discount_val: line.discountVal,
tax: line.tax,
total: line.total,
taxes: taxPerItem === 'YES' ? l.tax_type_ids.map((tid) => taxLine(tid, taxTypes, line.total)) : [],
};
}),
};
if (isNew) create.mutate(payload, { onSuccess: onClose });
else update.mutate({ id: id as number, payload }, { onSuccess: onClose });
};
const busy = create.isPending || update.isPending;
return (
<div className="flex h-full min-h-0 flex-col bg-background">
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2">
<div className="min-w-0 flex-1 text-xs font-semibold">
{isNew ? `New ${KIND_NOUN[resource]}` : `Edit ${number || KIND_NOUN[resource]}`}
</div>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button size="sm" className="h-7 text-xs" onClick={save} disabled={!canSave || busy}>
{busy ? 'Saving…' : 'Save'}
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={onClose} title="Close">
<X className="h-3.5 w-3.5" />
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Labelled label="Customer">
<select
value={customerId ?? ''}
onChange={(ev) => setCustomerId(ev.target.value ? Number(ev.target.value) : null)}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
>
<option value="">Select…</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</Labelled>
{!isRecurring && (
<Labelled label={isInvoice ? 'Invoice number' : 'Estimate number'}>
<Input value={number} onChange={(ev) => setNumber(ev.target.value)} className="h-8 text-xs" />
</Labelled>
)}
<Labelled label={isRecurring ? 'Starts on' : isInvoice ? 'Invoice date' : 'Estimate date'}>
<Input
type="date"
value={primaryDate}
onChange={(ev) => setPrimaryDate(ev.target.value)}
className="h-8 text-xs"
/>
</Labelled>
{!isRecurring && (
<Labelled label={isInvoice ? 'Due date' : 'Expiry date'}>
<Input
type="date"
value={secondaryDate}
onChange={(ev) => setSecondaryDate(ev.target.value)}
className="h-8 text-xs"
/>
</Labelled>
)}
{isRecurring && (
<>
<Labelled label="Repeats">
<select
value={frequency}
onChange={(ev) => setFrequency(ev.target.value)}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
>
{FREQUENCY_PRESETS.map((f) => (
<option key={f.cron} value={f.cron}>
{f.label}
</option>
))}
{!FREQUENCY_PRESETS.some((f) => f.cron === frequency) && (
<option value={frequency}>Custom ({frequency})</option>
)}
</select>
</Labelled>
<Labelled label="Ends">
<select
value={limitBy}
onChange={(ev) => setLimitBy(ev.target.value as 'NONE' | 'COUNT' | 'DATE')}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
>
<option value="NONE">Never</option>
<option value="COUNT">After a number of invoices</option>
<option value="DATE">On a date</option>
</select>
</Labelled>
{limitBy === 'COUNT' && (
<Labelled label="How many">
<Input
type="number"
min={1}
value={limitCount}
onChange={(ev) => setLimitCount(Number(ev.target.value))}
className="h-8 text-xs"
/>
</Labelled>
)}
{limitBy === 'DATE' && (
<Labelled label="Until">
<Input
type="date"
value={limitDate}
onChange={(ev) => setLimitDate(ev.target.value)}
className="h-8 text-xs"
/>
</Labelled>
)}
<Labelled label="Status">
<select
value={recurringStatus}
onChange={(ev) => setRecurringStatus(ev.target.value as 'ACTIVE' | 'ON_HOLD')}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
>
<option value="ACTIVE">Active</option>
<option value="ON_HOLD">On hold</option>
</select>
</Labelled>
</>
)}
<Labelled label="Reference">
<Input value={reference} onChange={(ev) => setReference(ev.target.value)} className="h-8 text-xs" />
</Labelled>
<Labelled label="Template">
{/* Only the names: each template also carries a `path` that is a full base64-encoded PNG
preview, several hundred KB apiece, and nothing here needs to render one. */}
<select
value={template}
onChange={(ev) => setTemplate(ev.target.value)}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
>
{templates.map((t) => (
<option key={t.name} value={t.name}>
{t.name}
</option>
))}
{template && !templates.some((t) => t.name === template) && <option value={template}>{template}</option>}
</select>
</Labelled>
<Labelled label="Tax">
<select
value={taxPerItem}
onChange={(ev) => setTaxPerItem(ev.target.value as 'YES' | 'NO')}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
>
<option value="NO">On the whole document</option>
<option value="YES">Per line</option>
</select>
</Labelled>
<Labelled label="Prices">
<select
value={taxIncluded ? 'incl' : 'excl'}
onChange={(ev) => setTaxIncluded(ev.target.value === 'incl')}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
>
<option value="excl">Tax exclusive</option>
<option value="incl">Tax inclusive</option>
</select>
</Labelled>
</div>
{isRecurring && (
<label className="mt-3 flex items-center gap-2 text-xs">
<Checkbox
checked={sendAutomatically}
onCheckedChange={(checked) => setSendAutomatically(checked === true)}
/>
Email each generated invoice to the customer automatically
</label>
)}
<div className="mt-4 rounded-lg border border-border">
<div className="grid grid-cols-[1fr_70px_110px_110px_110px_32px] items-center gap-2 border-b border-border px-2 py-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
<span>Item</span>
<span className="text-right">Qty</span>
<span className="text-right">Price</span>
<span className="text-right">Discount</span>
<span className="text-right">Total</span>
<span />
</div>
{lines.map((line) => {
const computed = computeLine(line, taxTypes, taxPerItem);
return (
<div key={line.key} className="border-b border-border/40 px-2 py-2">
<div className="grid grid-cols-[1fr_70px_110px_110px_110px_32px] items-start gap-2">
<div className="min-w-0 space-y-1">
<div className="flex gap-1">
<Input
value={line.name}
onChange={(ev) => patchLine(line.key, { name: ev.target.value, item_id: null })}
placeholder="Description of work"
className="h-8 text-xs"
/>
<select
value=""
onChange={(ev) => ev.target.value && pickItem(line.key, Number(ev.target.value))}
title="Fill from the item catalogue"
className="h-8 w-8 shrink-0 rounded-md border border-input bg-background text-xs"
>
<option value="">+</option>
{items.map((i) => (
<option key={i.id} value={i.id}>
{i.name}
</option>
))}
</select>
</div>
<Input
value={line.description}
onChange={(ev) => patchLine(line.key, { description: ev.target.value })}
placeholder="Notes (optional)"
className="h-7 text-[11px]"
/>
{taxPerItem === 'YES' && (
<TaxPicker
taxTypes={taxTypes}
selected={line.tax_type_ids}
onChange={(ids) => patchLine(line.key, { tax_type_ids: ids })}
/>
)}
</div>
<Input
type="number"
value={line.quantity}
onChange={(ev) => patchLine(line.key, { quantity: Number(ev.target.value) })}
className="h-8 text-right text-xs"
/>
<Input
type="number"
step="0.01"
value={toMajor(line.price)}
onChange={(ev) => patchLine(line.key, { price: fromMajor(ev.target.value) })}
className="h-8 text-right text-xs"
/>
<div className="flex gap-1">
<Input
type="number"
step="0.01"
value={line.discount}
onChange={(ev) => patchLine(line.key, { discount: Number(ev.target.value) })}
className="h-8 text-right text-xs"
/>
<select
value={line.discount_type}
onChange={(ev) =>
patchLine(line.key, { discount_type: ev.target.value as 'fixed' | 'percentage' })
}
className="h-8 w-9 shrink-0 rounded-md border border-input bg-background text-xs"
>
<option value="fixed">{currency?.symbol ?? '#'}</option>
<option value="percentage">%</option>
</select>
</div>
<div className="pt-2 text-right text-xs tabular-nums">{formatMoney(computed.total, currency)}</div>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground"
disabled={lines.length === 1}
onClick={() => setLines((prev) => prev.filter((l) => l.key !== line.key))}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
})}
<div className="px-2 py-2">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => setLines((prev) => [...prev, newLine(prev.length)])}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Add line
</Button>
</div>
</div>
<div className="mt-4 grid gap-3 lg:grid-cols-2">
<div>
<Labelled label="Notes">
<Textarea
value={notes}
onChange={(ev) => setNotes(ev.target.value)}
rows={6}
className="text-xs"
placeholder="Shown on the document."
/>
</Labelled>
</div>
<div className="rounded-lg border border-border p-3">
<TotalRow label="Subtotal" value={formatMoney(totals.subTotal, currency)} />
<div className="flex items-center justify-between gap-2 py-1 text-xs">
<span className="text-muted-foreground">Discount</span>
<div className="flex items-center gap-1">
<Input
type="number"
step="0.01"
value={discount}
onChange={(ev) => setDiscount(Number(ev.target.value))}
className="h-7 w-24 text-right text-xs"
/>
<select
value={discountType}
onChange={(ev) => setDiscountType(ev.target.value as 'fixed' | 'percentage')}
className="h-7 w-10 rounded-md border border-input bg-background text-xs"
>
<option value="fixed">{currency?.symbol ?? '#'}</option>
<option value="percentage">%</option>
</select>
</div>
</div>
{taxPerItem === 'NO' && (
<div className="py-1">
<div className="mb-1 text-[10px] text-muted-foreground">Taxes</div>
<TaxPicker taxTypes={taxTypes} selected={docTaxIds} onChange={setDocTaxIds} />
</div>
)}
<TotalRow label="Tax" value={formatMoney(totals.totalTax, currency)} />
<div className="mt-1 border-t border-border pt-1">
<TotalRow label="Total" value={formatMoney(totals.total, currency)} strong />
</div>
</div>
</div>
</div>
</div>
);
};
const Labelled = ({ label, children }: { label: string; children: React.ReactNode }) => (
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
{label}
{children}
</label>
);
const TotalRow = ({ label, value, strong }: { label: string; value: string; strong?: boolean }) => (
<div className="flex items-baseline justify-between gap-3 py-1 text-xs">
<span className="text-muted-foreground">{label}</span>
<span className={`tabular-nums ${strong ? 'text-sm font-semibold' : ''}`}>{value}</span>
</div>
);
/** Multi-select over the company's tax types, rendered as toggles because there are only ever a handful. */
const TaxPicker = ({
taxTypes,
selected,
onChange,
}: {
taxTypes: TaxType[];
selected: number[];
onChange: (ids: number[]) => void;
}) => {
if (!taxTypes.length) return <span className="text-[10px] text-muted-foreground">No tax types defined.</span>;
return (
<div className="flex flex-wrap gap-1">
{taxTypes.map((t) => {
const on = selected.includes(t.id);
return (
<button
key={t.id}
type="button"
onClick={() => onChange(on ? selected.filter((i) => i !== t.id) : [...selected, t.id])}
className={`rounded-full px-2 py-0.5 text-[10px] ring-1 ring-inset transition-colors ${
on ? 'bg-primary/10 text-primary ring-primary/30' : 'text-muted-foreground ring-border hover:bg-muted'
}`}
>
{t.name} {Number(t.percent)}%
</button>
);
})}
</div>
);
};
// ── arithmetic ───────────────────────────────────────────────────────────────────────────────────
type LineTotals = { subTotal: number; discountVal: number; total: number; tax: number };
function computeLine(line: Line, taxTypes: TaxType[], taxPerItem: 'YES' | 'NO'): LineTotals {
const subTotal = Math.round(line.price * (Number(line.quantity) || 0));
const abs = Math.abs(subTotal);
const discountVal =
line.discount_type === 'percentage'
? Math.round((abs * (Number(line.discount) || 0)) / 100)
: Math.min(fromMajor(line.discount), abs);
const total = subTotal - discountVal;
const tax =
taxPerItem === 'YES'
? line.tax_type_ids.reduce(
(sum, tid) =>
sum +
percentOf(
total,
taxTypes.find((t) => t.id === tid),
),
0,
)
: 0;
return { subTotal, discountVal, total, tax };
}
const percentOf = (base: number, taxType?: TaxType): number =>
taxType ? Math.round((base * (Number(taxType.percent) || 0)) / 100) : 0;
type TotalsInput = {
lines: Line[];
discount: number;
discountType: 'fixed' | 'percentage';
docTaxIds: number[];
taxPerItem: 'YES' | 'NO';
taxIncluded: boolean;
taxTypes: TaxType[];
};
function computeTotals(input: TotalsInput) {
const { lines, discount, discountType, docTaxIds, taxPerItem, taxIncluded, taxTypes } = input;
const computed = lines.map((l) => computeLine(l, taxTypes, taxPerItem));
const subTotal = computed.reduce((sum, c) => sum + c.total, 0);
const documentDiscount =
discountType === 'percentage'
? Math.round((Math.abs(subTotal) * (Number(discount) || 0)) / 100)
: Math.min(fromMajor(discount), Math.abs(subTotal));
const taxBase = subTotal - documentDiscount;
let totalTax: number;
if (taxPerItem === 'YES') {
totalTax = computed.reduce((sum, c) => sum + c.tax, 0);
} else {
const chosen = docTaxIds.map((tid) => taxTypes.find((t) => t.id === tid)).filter((t): t is TaxType => Boolean(t));
const simple = chosen.filter((t) => !t.compound_tax).reduce((sum, t) => sum + percentOf(taxBase, t), 0);
// A compound tax is charged on the base plus the simple taxes already added — the order matters and
// reversing it silently under-charges.
const compound = chosen.filter((t) => t.compound_tax).reduce((sum, t) => sum + percentOf(taxBase + simple, t), 0);
totalTax = simple + compound;
}
return {
subTotal,
documentDiscount,
taxBase,
totalTax,
total: taxIncluded ? taxBase : taxBase + totalTax,
};
}
/** The tax row shape upstream persists alongside a document or a line. */
function taxLine(taxTypeId: number, taxTypes: TaxType[], base: number): Partial<DocumentTax> & { tax_type_id: number } {
const t = taxTypes.find((x) => x.id === taxTypeId);
return {
tax_type_id: taxTypeId,
name: t?.name,
percent: Number(t?.percent) || 0,
compound_tax: t?.compound_tax ?? false,
amount: percentOf(base, t),
};
}