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
@@ -171,11 +171,12 @@
:tax-data="tax"
:taxes="itemData.taxes ?? []"
:discounted-total="total"
:total-tax="totalSimpleTax"
:total-simple-tax="totalSimpleTax"
:total="subtotal"
:currency="currency"
:update-items="syncItemToStore"
:ability="'create-invoice'"
:tax-types="taxTypes"
:can-add-tax="canAddTax"
:store="store"
:store-prop="storeProp"
:discount="discount"
@@ -199,6 +200,7 @@ import DocumentItemRowTax from './DocumentItemRowTax.vue'
import DragIcon from '@/scripts/components/icons/DragIcon.vue'
import { generateClientId } from '../../../utils'
import type { Currency } from '../../../types/domain/currency'
import type { TaxType } from '../../../types/domain/tax'
import type { DocumentItem, DocumentFormData, DocumentTax } from './use-document-calculations'
interface Props {
@@ -215,6 +217,8 @@ interface Props {
currency: Currency | Record<string, unknown>
invoiceItems: DocumentItem[]
itemValidationScope?: string
taxTypes?: TaxType[]
canAddTax?: boolean
}
interface Emits {
@@ -227,6 +231,8 @@ const props = withDefaults(defineProps<Props>(), {
type: '',
loading: false,
itemValidationScope: '',
taxTypes: () => [],
canAddTax: false,
})
const emit = defineEmits<Emits>()
@@ -286,16 +292,33 @@ const showRemoveButton = computed<boolean>(() => {
return formData.value.items.length > 1
})
// Base handed down to the tax rows: only the non-compound taxes count, so a
// compound row can never widen its own base through this value.
const totalSimpleTax = computed<number>(() => {
const taxes = props.itemData.taxes ?? []
return Math.round(
taxes.reduce((sum: number, tax: Partial<DocumentTax>) => {
if (tax.compound_tax) {
return sum
}
return sum + (tax.amount ?? 0)
}, 0),
)
})
const totalTax = computed<number>(() => totalSimpleTax.value)
const totalCompoundTax = computed<number>(() => {
const taxes = props.itemData.taxes ?? []
return Math.round(
taxes.reduce((sum: number, tax: Partial<DocumentTax>) => {
if (tax.compound_tax) {
return sum + (tax.amount ?? 0)
}
return sum
}, 0),
)
})
const totalTax = computed<number>(() => totalSimpleTax.value + totalCompoundTax.value)
const companyCurrency = computed(() => companyStore.selectedCompanyCurrency)
@@ -436,6 +459,7 @@ function syncItemToStore(): void {
total: total.value,
sub_total: subtotal.value,
totalSimpleTax: totalSimpleTax.value,
totalCompoundTax: totalCompoundTax.value,
totalTax: totalTax.value,
tax: totalTax.value,
taxes: [...itemTaxes],