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],
@@ -37,6 +37,9 @@
<template v-else>
{{ option.percent }} %
</template>
<BaseBadge v-if="option.compound_tax" class="ml-2 text-xs">
{{ $t('tax_types.compound_tax') }}
</BaseBadge>
</template>
<template v-if="canAddTax" #action>
@@ -77,16 +80,9 @@ import { useModalStore } from '../../../stores/modal.store'
import type { TaxType } from '../../../types/domain/tax'
import type { Currency } from '../../../types/domain/currency'
import type { DocumentFormData, DocumentTax } from './use-document-calculations'
declare global {
interface Window {
__taxTypes?: TaxType[]
__userHasAbility?: (ability: string) => boolean
}
}
import { calcTaxAmount } from './use-document-calculations'
interface Props {
ability: string
store: Record<string, unknown>
storeProp: string
itemIndex: number
@@ -94,10 +90,13 @@ interface Props {
taxData: DocumentTax
taxes: DocumentTax[]
total: number
totalTax: number
/** Sum of the item's non-compound tax amounts, i.e. the compound base widener. */
totalSimpleTax: number
discountedTotal: number
currency: Currency | Record<string, unknown>
updateItems: () => void
taxTypes?: TaxType[]
canAddTax?: boolean
discount?: number
}
@@ -107,7 +106,8 @@ interface Emits {
}
const props = withDefaults(defineProps<Props>(), {
ability: '',
taxTypes: () => [],
canAddTax: false,
discount: 0,
})
@@ -116,23 +116,13 @@ const emit = defineEmits<Emits>()
const { t } = useI18n()
const modalStore = useModalStore()
const taxTypes = computed<TaxType[]>(() => {
return (window.__taxTypes ?? []).filter(
(taxType) => taxType.transaction_type === 'sales',
)
})
const canAddTax = computed(() => {
return window.__userHasAbility?.(props.ability) ?? false
})
const selectedTax = ref<TaxType | null>(null)
const localTax = reactive<DocumentTax>({ ...props.taxData })
const storeData = computed(() => props.store[props.storeProp] as DocumentFormData)
const filteredTypes = computed<(TaxType & { disabled?: boolean })[]>(() => {
const clonedTypes = taxTypes.value.map((a) => ({ ...a, disabled: false }))
const clonedTypes = props.taxTypes.map((a) => ({ ...a, disabled: false }))
return clonedTypes.map((taxType) => {
const found = props.taxes.find((tax) => tax.tax_type_id === taxType.id)
@@ -141,27 +131,52 @@ const filteredTypes = computed<(TaxType & { disabled?: boolean })[]>(() => {
})
})
/**
* The item's taxable base, shared by every tax branch.
*
* With a per-item discount the item total is already net of it. With a
* document-level discount the item carries its proportional share of that
* discount instead.
*/
const effectiveBase = computed<number>(() => {
if (storeData.value.discount_per_item === 'YES') {
return props.discountedTotal
}
const modelDiscount = storeData.value.discount ?? 0
if (modelDiscount <= 0) {
return props.discountedTotal
}
const itemsTotal = storeData.value.items.reduce(
(sum: number, item) => sum + (item.total ?? 0),
0,
)
if (!itemsTotal) {
return props.discountedTotal
}
const proportion = parseFloat((props.discountedTotal / itemsTotal).toFixed(2))
const discount =
storeData.value.discount_type === 'fixed'
? modelDiscount * 100
: (itemsTotal * modelDiscount) / 100
return props.discountedTotal - Math.round(discount * proportion)
})
const taxAmount = computed<number>(() => {
if (localTax.calculation_type === 'fixed') {
return localTax.fixed_amount
}
if (props.discountedTotal) {
const taxPerItemEnabled = storeData.value.tax_per_item === 'YES'
const discountPerItemEnabled = storeData.value.discount_per_item === 'YES'
if (taxPerItemEnabled && !discountPerItemEnabled) {
return getTaxAmount()
}
if (storeData.value.tax_included) {
return Math.round(
props.discountedTotal -
props.discountedTotal / (1 + (localTax.percent ?? 0) / 100),
)
}
return Math.round((props.discountedTotal * (localTax.percent ?? 0)) / 100)
}
return 0
return calcTaxAmount(
effectiveBase.value,
localTax.percent,
localTax.fixed_amount,
localTax.calculation_type,
storeData.value.tax_included ?? false,
localTax.compound_tax ?? false,
props.totalSimpleTax,
)
})
watch(
@@ -169,8 +184,9 @@ watch(
() => updateRowTax(),
)
// A sibling simple tax landing later widens this row's base when it is compound.
watch(
() => props.totalTax,
() => props.totalSimpleTax,
() => updateRowTax(),
)
@@ -179,11 +195,18 @@ watch(
() => updateRowTax(),
)
// Initialize selected tax if editing
if (props.taxData.tax_type_id > 0) {
selectedTax.value =
taxTypes.value.find((_type) => _type.id === props.taxData.tax_type_id) ?? null
}
// Resolve the selected tax type when editing. The list is fetched by the parent
// table, so it usually arrives after this row has been set up.
watch(
() => props.taxTypes,
(types) => {
if (localTax.tax_type_id > 0) {
selectedTax.value =
types.find((_type) => _type.id === localTax.tax_type_id) ?? selectedTax.value
}
},
{ immediate: true },
)
updateRowTax()
@@ -194,6 +217,7 @@ function onSelectTax(val: TaxType): void {
val.calculation_type === 'fixed' ? val.fixed_amount : 0
localTax.tax_type_id = val.id
localTax.name = val.name
localTax.compound_tax = val.compound_tax ?? false
updateRowTax()
}
@@ -229,43 +253,9 @@ function removeTax(index: number): void {
const store = props.store as Record<string, Record<string, unknown>>
const formData = store[props.storeProp] as DocumentFormData
formData.items[props.itemIndex].taxes?.splice(index, 1)
const item = formData.items[props.itemIndex]
item.tax = 0
item.totalTax = 0
}
function getTaxAmount(): number {
if (localTax.calculation_type === 'fixed') {
return localTax.fixed_amount
}
let itemsTotal = 0
let discount = 0
const itemTotal = props.discountedTotal
const modelDiscount = storeData.value.discount ?? 0
const type = storeData.value.discount_type
let discountedTotal = props.discountedTotal
if (modelDiscount > 0) {
storeData.value.items.forEach((item) => {
itemsTotal += item.total ?? 0
})
const proportion = parseFloat((itemTotal / itemsTotal).toFixed(2))
discount =
type === 'fixed'
? modelDiscount * 100
: (itemsTotal * modelDiscount) / 100
const itemDiscount = Math.round(discount * proportion)
discountedTotal = itemTotal - itemDiscount
}
if (storeData.value.tax_included) {
return Math.round(
discountedTotal -
discountedTotal / (1 + (localTax.percent ?? 0) / 100),
)
}
return Math.round((discountedTotal * (localTax.percent ?? 0)) / 100)
// Re-sync the item so the remaining rows re-base off the new simple total
// instead of leaving stale `tax` / `totalTax` values behind.
props.updateItems()
}
</script>
@@ -92,6 +92,8 @@
:currency="defaultCurrency"
:item-validation-scope="itemValidationScope"
:invoice-items="formData.items"
:tax-types="availableTaxTypes"
:can-add-tax="canAddTax"
:store="store"
:store-prop="storeProp"
/>
@@ -110,11 +112,15 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onMounted, ref } from 'vue'
import draggable from 'vuedraggable'
import DocumentItemRow from './DocumentItemRow.vue'
import ItemModal from '@/scripts/features/company/items/components/ItemModal.vue'
import { useUserStore } from '../../../stores/user.store'
import { taxTypeService } from '../../../api/services/tax-type.service'
import { ABILITIES } from '../../../config/abilities'
import type { Currency } from '../../../types/domain/currency'
import type { TaxType } from '../../../types/domain/tax'
import type { DocumentFormData } from './use-document-calculations'
interface Props {
@@ -134,6 +140,25 @@ const props = withDefaults(defineProps<Props>(), {
taxIncludedSetting: 'NO',
})
const userStore = useUserStore()
const availableTaxTypes = ref<TaxType[]>([])
const canAddTax = computed<boolean>(() => {
return userStore.hasAbilities(ABILITIES.CREATE_TAX_TYPE)
})
onMounted(async () => {
try {
const response = await taxTypeService.list({
limit: 'all',
transaction_type: 'sales',
})
availableTaxTypes.value = response.data
} catch {
// Silently fail
}
})
const formData = computed<DocumentFormData>(() => {
return props.store[props.storeProp] as DocumentFormData
})
@@ -241,6 +241,7 @@ import { ABILITIES } from '../../../config/abilities'
import type { Currency } from '../../../types/domain/currency'
import type { TaxType } from '../../../types/domain/tax'
import type { DocumentFormData, DocumentTax, DocumentStore, DocumentItem } from './use-document-calculations'
import { calcTaxAmount } from './use-document-calculations'
interface Props {
store: DocumentStore & {
@@ -316,6 +317,13 @@ watch(
{ deep: true },
)
watch(
() => formData.value.tax_included,
() => {
recalculateGlobalTaxes()
},
)
const totalDiscount = computed<number>({
get: () => formData.value.discount,
set: (newValue: number) => {
@@ -341,6 +349,7 @@ interface AggregatedTax {
name: string
calculation_type: string | null
fixed_amount: number
compound_tax: boolean
}
const itemWiseTaxes = computed<AggregatedTax[]>(() => {
@@ -359,6 +368,7 @@ const itemWiseTaxes = computed<AggregatedTax[]>(() => {
name: tax.name ?? '',
calculation_type: tax.calculation_type ?? null,
fixed_amount: tax.fixed_amount ?? 0,
compound_tax: tax.compound_tax ?? false,
})
}
})
@@ -382,11 +392,39 @@ function recalculateGlobalTaxes(): void {
if (formData.value.tax_per_item === 'YES') return
const subtotalWithDiscount = props.store.getSubtotalWithDiscount
const taxIncluded = formData.value.tax_included ?? false
// Pass 1: simple (non-compound) taxes are charged on the discounted subtotal.
let simpleTotal = 0
formData.value.taxes.forEach((tax: DocumentTax) => {
if (tax.compound_tax) return
if (tax.calculation_type === 'percentage' && tax.percent) {
tax.amount = Math.round((subtotalWithDiscount * tax.percent) / 100)
tax.amount = calcTaxAmount(
subtotalWithDiscount,
tax.percent,
null,
'percentage',
taxIncluded,
)
}
// Fixed taxes keep their amount as-is, but still count toward the compound base
simpleTotal += tax.amount ?? 0
})
// Pass 2: compound taxes are charged on the discounted subtotal plus the simple taxes.
formData.value.taxes.forEach((tax: DocumentTax) => {
if (!tax.compound_tax) return
if (tax.calculation_type === 'percentage' && tax.percent) {
tax.amount = calcTaxAmount(
subtotalWithDiscount,
tax.percent,
null,
'percentage',
taxIncluded,
true,
simpleTotal,
)
}
// Fixed taxes keep their amount as-is
})
}
@@ -404,18 +442,15 @@ function selectPercentage(): void {
}
function onSelectTax(selectedTax: TaxType): void {
let amount = 0
if (
selectedTax.calculation_type === 'percentage' &&
props.store.getSubtotalWithDiscount &&
selectedTax.percent
) {
amount = Math.round(
(props.store.getSubtotalWithDiscount * selectedTax.percent) / 100,
)
} else if (selectedTax.calculation_type === 'fixed') {
amount = selectedTax.fixed_amount
}
const amount = calcTaxAmount(
props.store.getSubtotalWithDiscount,
selectedTax.percent,
selectedTax.fixed_amount,
selectedTax.calculation_type,
formData.value.tax_included ?? false,
selectedTax.compound_tax ?? false,
props.store.getTotalSimpleTax,
)
const data: DocumentTax = {
id: generateClientId(),
@@ -431,6 +466,10 @@ function onSelectTax(selectedTax: TaxType): void {
props.store.$patch((state: Record<string, unknown>) => {
;(state[props.storeProp] as DocumentFormData).taxes.push({ ...data })
})
// Adding a simple tax widens the base of any compound tax already present,
// so the insertion order must not matter.
recalculateGlobalTaxes()
}
function updateTax(data: DocumentTax): void {
@@ -445,5 +484,8 @@ function removeTax(id: number | string): void {
props.store.$patch((state: Record<string, unknown>) => {
;(state[props.storeProp] as DocumentFormData).taxes.splice(index, 1)
})
// Removing a simple tax shrinks the base of any remaining compound tax.
recalculateGlobalTaxes()
}
</script>
@@ -64,6 +64,9 @@
</template>
<template v-else>
{{ taxType.percent }} %
<BaseBadge v-if="taxType.compound_tax" class="text-xs">
{{ $t('tax_types.compound_tax') }}
</BaseBadge>
</template>
</label>
</div>
@@ -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))
}