From 13aa087faf0161d16a91512d5b9cd02fb6c71db6 Mon Sep 17 00:00:00 2001
From: Darko Gjorgjijoski <5760249+gdarko@users.noreply.github.com>
Date: Fri, 14 Aug 2026 00:17:52 +0200
Subject: [PATCH] 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.
---
.../Sales/Application/DocumentItemService.php | 5 +
.../Taxation/Http/Requests/TaxTypeRequest.php | 1 +
database/factories/TaxFactory.php | 2 +-
lang/en.json | 1 +
.../settings/components/TaxTypeModal.vue | 34 +++-
.../company/settings/views/TaxTypesView.vue | 3 +
.../shared/document-form/DocumentItemRow.vue | 30 +++-
.../document-form/DocumentItemRowTax.vue | 158 ++++++++----------
.../document-form/DocumentItemsTable.vue | 27 ++-
.../shared/document-form/DocumentTotals.vue | 70 ++++++--
.../shared/document-form/TaxSelectPopup.vue | 3 +
.../use-document-calculations.ts | 20 ++-
tests/Feature/Admin/InvoiceTest.php | 45 +++++
tests/Feature/Admin/TaxTypeTest.php | 79 +++++++++
tests/Unit/DocumentTotalsTest.php | 11 ++
15 files changed, 384 insertions(+), 105 deletions(-)
diff --git a/app/Domains/Sales/Application/DocumentItemService.php b/app/Domains/Sales/Application/DocumentItemService.php
index d839821e..31929656 100644
--- a/app/Domains/Sales/Application/DocumentItemService.php
+++ b/app/Domains/Sales/Application/DocumentItemService.php
@@ -65,6 +65,11 @@ class DocumentItemService
if (array_key_exists('taxes', $item) && $item['taxes']) {
foreach ($item['taxes'] as $tax) {
+ if (empty($tax['tax_type_id'])) {
+ // UI placeholder row for per-item tax mode; never persist.
+ continue;
+ }
+
$tax['company_id'] = $document->company_id;
$tax['exchange_rate'] = $document->exchange_rate;
$tax['currency_id'] = $document->currency_id;
diff --git a/app/Domains/Taxation/Http/Requests/TaxTypeRequest.php b/app/Domains/Taxation/Http/Requests/TaxTypeRequest.php
index 72bbd978..8421d120 100644
--- a/app/Domains/Taxation/Http/Requests/TaxTypeRequest.php
+++ b/app/Domains/Taxation/Http/Requests/TaxTypeRequest.php
@@ -45,6 +45,7 @@ class TaxTypeRequest extends FormRequest
],
'compound_tax' => [
'nullable',
+ 'boolean',
],
'collective_tax' => [
'nullable',
diff --git a/database/factories/TaxFactory.php b/database/factories/TaxFactory.php
index 19165a37..82abb457 100644
--- a/database/factories/TaxFactory.php
+++ b/database/factories/TaxFactory.php
@@ -32,7 +32,7 @@ class TaxFactory extends Factory
},
'company_id' => User::find(1)->companies()->first()->id,
'amount' => $this->faker->randomDigitNotNull(),
- 'compound_tax' => $this->faker->randomDigitNotNull(),
+ 'compound_tax' => false,
'base_amount' => $this->faker->randomDigitNotNull(),
'currency_id' => Currency::where('name', 'US Dollar')->first()->id,
];
diff --git a/lang/en.json b/lang/en.json
index 52938781..95e0e652 100644
--- a/lang/en.json
+++ b/lang/en.json
@@ -1416,6 +1416,7 @@
"tax_per_item": "Tax Per Item",
"tax_name": "Tax Name",
"compound_tax": "Compound Tax",
+ "compound_tax_description": "Calculated on the subtotal after discount plus all non-compound taxes.",
"amount": "Amount",
"percent": "Percent",
"fixed_amount": "Fixed Amount",
diff --git a/resources/scripts/features/company/settings/components/TaxTypeModal.vue b/resources/scripts/features/company/settings/components/TaxTypeModal.vue
index fadebaf3..c5facda2 100644
--- a/resources/scripts/features/company/settings/components/TaxTypeModal.vue
+++ b/resources/scripts/features/company/settings/components/TaxTypeModal.vue
@@ -1,5 +1,5 @@
diff --git a/resources/scripts/features/shared/document-form/DocumentItemsTable.vue b/resources/scripts/features/shared/document-form/DocumentItemsTable.vue
index b9eacf96..2963d3b6 100644
--- a/resources/scripts/features/shared/document-form/DocumentItemsTable.vue
+++ b/resources/scripts/features/shared/document-form/DocumentItemsTable.vue
@@ -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 @@
diff --git a/resources/scripts/features/shared/document-form/TaxSelectPopup.vue b/resources/scripts/features/shared/document-form/TaxSelectPopup.vue
index 5c6cc969..206d636a 100644
--- a/resources/scripts/features/shared/document-form/TaxSelectPopup.vue
+++ b/resources/scripts/features/shared/document-form/TaxSelectPopup.vue
@@ -64,6 +64,9 @@
{{ taxType.percent }} %
+
+ {{ $t('tax_types.compound_tax') }}
+
diff --git a/resources/scripts/features/shared/document-form/use-document-calculations.ts b/resources/scripts/features/shared/document-form/use-document-calculations.ts
index ffdad623..ad297eeb 100644
--- a/resources/scripts/features/shared/document-form/use-document-calculations.ts
+++ b/resources/scripts/features/shared/document-form/use-document-calculations.ts
@@ -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))
}
diff --git a/tests/Feature/Admin/InvoiceTest.php b/tests/Feature/Admin/InvoiceTest.php
index 7c61b5ef..c5af29a3 100644
--- a/tests/Feature/Admin/InvoiceTest.php
+++ b/tests/Feature/Admin/InvoiceTest.php
@@ -528,6 +528,51 @@ test('create invoice with tax per item', function () {
]);
});
+test('create invoice with tax per item ignores empty placeholder tax row', function () {
+ // The frontend always keeps one empty placeholder tax row per item in
+ // per-item tax mode. It must be skipped instead of reaching the DB.
+ $stubTax = [
+ 'id' => 999,
+ 'tax_type_id' => 0,
+ 'name' => '',
+ 'amount' => 0,
+ 'percent' => null,
+ 'calculation_type' => null,
+ 'fixed_amount' => 0,
+ 'compound_tax' => false,
+ ];
+
+ $realTax = Tax::factory()->raw();
+
+ $invoice = Invoice::factory()
+ ->raw([
+ 'tax_per_item' => 'YES',
+ 'items' => [
+ InvoiceItem::factory()->raw([
+ 'taxes' => [$realTax, $stubTax],
+ ]),
+ ],
+ ]);
+
+ $response = postJson('api/v1/invoices', $invoice);
+
+ $response->assertOk();
+
+ $this->assertDatabaseHas('invoices', [
+ 'invoice_number' => $invoice['invoice_number'],
+ 'customer_id' => $invoice['customer_id'],
+ ]);
+
+ $this->assertDatabaseHas('taxes', [
+ 'tax_type_id' => $realTax['tax_type_id'],
+ 'amount' => $realTax['amount'],
+ ]);
+
+ $this->assertDatabaseMissing('taxes', [
+ 'tax_type_id' => 0,
+ ]);
+});
+
test('create invoice with EUR currency', function () {
$invoice = Invoice::factory()
->raw([
diff --git a/tests/Feature/Admin/TaxTypeTest.php b/tests/Feature/Admin/TaxTypeTest.php
index 22c9a57d..c5e74dc9 100644
--- a/tests/Feature/Admin/TaxTypeTest.php
+++ b/tests/Feature/Admin/TaxTypeTest.php
@@ -179,3 +179,82 @@ test('rejects unknown transaction types', function () {
->assertUnprocessable()
->assertJsonValidationErrors('transaction_type');
});
+
+test('creates a compound tax type', function () {
+ $taxType = TaxType::factory()->raw([
+ 'compound_tax' => true,
+ ]);
+
+ postJson('api/v1/tax-types', $taxType)
+ ->assertStatus(201)
+ ->assertJsonPath('data.compound_tax', true);
+
+ $this->assertDatabaseHas('tax_types', [
+ 'name' => $taxType['name'],
+ 'compound_tax' => 1,
+ ]);
+});
+
+test('creates a non-compound tax type when compound_tax is explicitly false', function () {
+ $taxType = TaxType::factory()->raw([
+ 'compound_tax' => false,
+ ]);
+
+ postJson('api/v1/tax-types', $taxType)
+ ->assertStatus(201)
+ ->assertJsonPath('data.compound_tax', false);
+
+ $this->assertDatabaseHas('tax_types', [
+ 'name' => $taxType['name'],
+ 'compound_tax' => 0,
+ ]);
+});
+
+test('updates a tax type to explicitly disable compound tax', function () {
+ $taxType = TaxType::factory()->create([
+ 'compound_tax' => true,
+ ]);
+
+ $payload = TaxType::factory()->raw([
+ 'compound_tax' => false,
+ ]);
+
+ putJson("api/v1/tax-types/{$taxType->id}", $payload)
+ ->assertOk()
+ ->assertJsonPath('data.compound_tax', false);
+
+ $this->assertDatabaseHas('tax_types', [
+ 'id' => $taxType->id,
+ 'compound_tax' => 0,
+ ]);
+});
+
+test('preserves compound tax when updates omit the key', function () {
+ $taxType = TaxType::factory()->create([
+ 'compound_tax' => true,
+ ]);
+
+ $payload = TaxType::factory()->raw();
+ // TaxType::factory()->raw() defaults compound_tax to 0 — unset it so the
+ // request omits the key entirely, instead of silently sending false.
+ unset($payload['compound_tax']);
+
+ putJson("api/v1/tax-types/{$taxType->id}", $payload)
+ ->assertOk()
+ ->assertJsonPath('data.compound_tax', true);
+
+ $this->assertDatabaseHas('tax_types', [
+ 'id' => $taxType->id,
+ 'compound_tax' => 1,
+ ]);
+});
+
+test('rejects non-boolean compound_tax values', function () {
+ $taxType = TaxType::factory()->raw([
+ 'compound_tax' => 'not-a-bool',
+ ]);
+
+ postJson('api/v1/tax-types', $taxType)
+ ->assertUnprocessable()
+ ->assertJsonValidationErrors('compound_tax');
+});
diff --git a/tests/Unit/DocumentTotalsTest.php b/tests/Unit/DocumentTotalsTest.php
index f26b7444..f105266c 100644
--- a/tests/Unit/DocumentTotalsTest.php
+++ b/tests/Unit/DocumentTotalsTest.php
@@ -76,3 +76,14 @@ test('supports negative quantities', function () {
expect($totals['sub_total'])->toBe(-150)->and($totals['total'])->toBe(-150);
});
+
+test('sums a compound tax line just like any other document-level tax line', function () {
+ $totals = DocumentTotals::compute(
+ [['price' => 10000, 'quantity' => 1]],
+ [['amount' => 1900], ['amount' => 119, 'compound_tax' => true]],
+ 0, 'NO', false, 'NO'
+ );
+
+ expect($totals['tax'])->toBe(2019)
+ ->and($totals['total'])->toBe(12019);
+});