Files
invoices/web/DocumentEditor.tsx
pastilhasandClaude Opus 5 06203f97b3 wrap the document, record and send-mail editors in real forms
typing into a field and pressing enter did nothing in the document editor, the
customer/item/payment/expense dialogs and the send-document dialog — inputs sat
in a div, so the button was the only way to submit. now each wraps in a
<form onSubmit>, with every non-submit button (cancel, close, add/remove line,
"same as billing") marked type="button" so it doesn't submit instead of doing
its own thing.

the record editors share one dialog shell (EditorShell), so a single form
there covers customer, item, payment and expense at once — kept the dialog's
grid gap intact with display:contents rather than nesting a box.

left the list-view filter boxes and the reports date-range picker alone: none
of them has a submit action, so there's nothing for enter to do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 10:43:59 +00:00

736 lines
30 KiB
TypeScript
Raw Permalink 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 = (ev?: React.FormEvent) => {
ev?.preventDefault();
// 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 (
<form onSubmit={save} 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" type="button" className="h-7 text-xs" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button type="submit" size="sm" className="h-7 text-xs" disabled={!canSave || busy}>
{busy ? 'Saving…' : 'Save'}
</Button>
<Button variant="ghost" size="sm" type="button" 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"
type="button"
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"
type="button"
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>
</form>
);
};
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),
};
}