Add compound tax support (#753)

* test(taxes): cover compound_tax API semantics and validate it as boolean

* feat(taxes): restore the compound tax toggle in tax type settings

* feat(taxes): compute document-level compound tax amounts

Two-pass recalculation: simple taxes on the discounted subtotal, compound
taxes on the subtotal plus all simple taxes (v2 parity; branch order
fixed -> compound -> inclusive back-out -> simple). Amounts now also
recalculate on tax add/remove and on the tax-inclusive toggle, which
previously never triggered recalculation.

* feat(taxes): support compound taxes in per-item mode

Per-item tax rows now carry the compound flag and charge compound taxes
on the item's discounted base plus its simple taxes, through the same
calcTaxAmount branch order as document-level taxes. The item row splits
its tax sums into simple and compound so a compound row can never widen
its own base.

Also fixes two pre-existing bugs: the per-item tax dropdown read
window.__taxTypes, which nothing ever assigned, so it always rendered
empty (tax types are now fetched by the items table and passed down);
and removing a tax row hard-zeroed the item's totals instead of
re-syncing them.

* fix(invoices): skip placeholder tax rows when persisting item taxes

The document form keeps one empty placeholder tax row per item in
per-item tax mode. Its empty name is nullified by the framework's
empty-string middleware, so inserting it violated the NOT NULL
constraint and turned every per-item save into a 500. The equivalent
v2 guard keyed on a null amount, which the v3 stub (amount: 0) evades;
keying on the missing tax_type_id catches it.
This commit is contained in:
Darko Gjorgjijoski
2026-08-14 00:17:52 +02:00
committed by GitHub
parent 844d2c82ed
commit 13aa087faf
15 changed files with 384 additions and 105 deletions
@@ -161,18 +161,36 @@ export function calcItemTotal(subtotal: number, discountVal: number): number {
return subtotal - discountVal
}
/** Calculate tax amount for a given total and tax config */
/**
* Calculate tax amount for a given total and tax config.
*
* A compound tax is charged on the base plus every simple (non-compound) tax
* already applied, and is never backed out of a tax-inclusive total.
*
* @param total Base amount in cents (document subtotal after discount, or an item total after discount)
* @param percent Percentage rate, when the tax is percentage based
* @param fixedAmount Flat amount in cents, when the tax is fixed
* @param calculationType `'fixed'` or `'percentage'`
* @param taxIncluded Whether the base already includes the tax (back it out)
* @param compoundTax Whether the tax is charged on top of the simple taxes
* @param simpleTaxTotal Sum of the non-compound tax amounts in cents
*/
export function calcTaxAmount(
total: number,
percent: number | null,
fixedAmount: number | null,
calculationType: string | null,
taxIncluded: boolean | null,
compoundTax = false,
simpleTaxTotal = 0,
): number {
if (calculationType === 'fixed' && fixedAmount != null) {
return fixedAmount
}
if (!total || !percent) return 0
if (compoundTax) {
return Math.round(((total + simpleTaxTotal) * percent) / 100)
}
if (taxIncluded) {
return Math.round(total - total / (1 + percent / 100))
}