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