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);
+});