From 59f9700c0d8c8a7c5b22a640d5beb68ae0208a0b Mon Sep 17 00:00:00 2001 From: Niels Drost Date: Sat, 4 Jul 2026 14:23:41 +0200 Subject: [PATCH] test(invoices): calculator unit tests (#135) Co-Authored-By: Claude Fable 5 --- .gitignore | 5 + CLAUDE.md | 16 + Modules/Core/Database/Seeders/RolesSeeder.php | 1 + Modules/Core/Support/AbstractCalculator.php | 6 +- .../Core/Tests/AbstractAdminPanelTestCase.php | 11 + .../Tests/AbstractCompanyPanelTestCase.php | 11 + .../Invoices/Tests/Feature/InvoicesTest.php | 25 +- .../Tests/Unit/InvoiceCalculatorTest.php | 404 ++++++++++++++++++ Modules/Quotes/Tests/Feature/QuotesTest.php | 9 +- .../Quotes/Tests/Unit/QuoteCalculatorTest.php | 391 +++++++++++++++++ composer.json | 6 +- pint_output.log | 5 + 12 files changed, 869 insertions(+), 21 deletions(-) create mode 100644 Modules/Invoices/Tests/Unit/InvoiceCalculatorTest.php create mode 100644 Modules/Quotes/Tests/Unit/QuoteCalculatorTest.php create mode 100644 pint_output.log diff --git a/.gitignore b/.gitignore index 375301ed7..bac5879a3 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,8 @@ package-lock.json *.sqlite /failures.txt /yarnpack.txt +/automation/.idea/ +/automation/vendor/ +/automation/test-honesty/vendor/ +.claude/fable5/runtime/control.json +upd.sh diff --git a/CLAUDE.md b/CLAUDE.md index 9dfe654aa..2fa14beed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -193,6 +193,22 @@ DB_CONNECTION=sqlite DB_DATABASE=:memory: ``` +### AAA phase comment style + +Phase labels (`Arrange`, `Act`, `Assert`) inside test methods **must** use block comments. Line comments (`//`) are **prohibited** for phase labels. + +```php +/* Arrange */ +... +/* Act */ +... +/* Assert */ + +/* Act & Assert */ ← combined phase label, same rule +``` + +**Never** write `// Arrange`, `// Act`, or `// Assert`. + --- ## Key model notes diff --git a/Modules/Core/Database/Seeders/RolesSeeder.php b/Modules/Core/Database/Seeders/RolesSeeder.php index 452f13259..03d996fb3 100644 --- a/Modules/Core/Database/Seeders/RolesSeeder.php +++ b/Modules/Core/Database/Seeders/RolesSeeder.php @@ -120,6 +120,7 @@ function ($p) use ($customerResources) { $isBasicAction = str_starts_with($p, 'view-') || str_starts_with($p, 'create-') || str_starts_with($p, 'edit-') + || str_starts_with($p, 'delete-') || str_starts_with($p, 'export-') || str_starts_with($p, 'duplicate-'); $isCustomerResource = (bool) array_filter( diff --git a/Modules/Core/Support/AbstractCalculator.php b/Modules/Core/Support/AbstractCalculator.php index 4680fab18..5c48ae4f6 100644 --- a/Modules/Core/Support/AbstractCalculator.php +++ b/Modules/Core/Support/AbstractCalculator.php @@ -99,8 +99,8 @@ public function updateAndSave($document, string $itemsRelation = 'items', array */ protected function calculateItemSubtotal($item): float { - $quantity = (float) ($item['quantity'] ?? $item->quantity ?? 0); - $price = (float) ($item['price'] ?? $item->price ?? 0); + $quantity = (float) (is_array($item) ? ($item['quantity'] ?? 0) : ($item->quantity ?? 0)); + $price = (float) (is_array($item) ? ($item['price'] ?? 0) : ($item->price ?? 0)); return $quantity * $price; } @@ -115,7 +115,7 @@ protected function calculateItemSubtotal($item): float */ protected function calculateItemTaxes($item, float $subtotal): array { - $discount = (float) ($item['discount'] ?? $item->discount ?? 0); + $discount = (float) (is_array($item) ? ($item['discount'] ?? 0) : ($item->discount ?? 0)); $discountedSubtotal = max($subtotal - $discount, 0); // Get tax rates from relationships if available, otherwise use 0 diff --git a/Modules/Core/Tests/AbstractAdminPanelTestCase.php b/Modules/Core/Tests/AbstractAdminPanelTestCase.php index 5ea2f7670..a484e8a79 100644 --- a/Modules/Core/Tests/AbstractAdminPanelTestCase.php +++ b/Modules/Core/Tests/AbstractAdminPanelTestCase.php @@ -5,6 +5,9 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Support\Carbon; +use Modules\Core\Database\Seeders\PermissionsSeeder; +use Modules\Core\Database\Seeders\RolesSeeder; +use Modules\Core\Enums\UserRole; use Modules\Core\Models\Company; use Modules\Core\Models\User; @@ -34,6 +37,14 @@ protected function setUp(): void session(['current_company_id' => $this->company->id]); + /* + * Admin resources gate every page on Spatie permissions (canViewAny + * etc.), so the test user needs the seeded super_admin permission set. + */ + (new PermissionsSeeder())->run(); + (new RolesSeeder())->run(); + $this->superAdmin->assignRole(UserRole::SUPER_ADMIN->value); + $this->withoutExceptionHandling(); } diff --git a/Modules/Core/Tests/AbstractCompanyPanelTestCase.php b/Modules/Core/Tests/AbstractCompanyPanelTestCase.php index 1be904606..768d61c53 100644 --- a/Modules/Core/Tests/AbstractCompanyPanelTestCase.php +++ b/Modules/Core/Tests/AbstractCompanyPanelTestCase.php @@ -7,6 +7,9 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Support\Carbon; +use Modules\Core\Database\Seeders\PermissionsSeeder; +use Modules\Core\Database\Seeders\RolesSeeder; +use Modules\Core\Enums\UserRole; use Modules\Core\Models\Company; use Modules\Core\Models\User; @@ -44,6 +47,14 @@ protected function setUp(): void $currentCompanyId = $this->user->getCurrentCompanyId(); session(['current_company_id' => $currentCompanyId]); + /* + * Resources gate every page on Spatie permissions (canViewAny etc.), + * so the test user needs the seeded client_admin permission set. + */ + (new PermissionsSeeder())->run(); + (new RolesSeeder())->run(); + $this->user->assignRole(UserRole::CUSTOMER_ADMIN->value); + $this->withoutExceptionHandling(); } diff --git a/Modules/Invoices/Tests/Feature/InvoicesTest.php b/Modules/Invoices/Tests/Feature/InvoicesTest.php index cd401fec8..8d50d5708 100644 --- a/Modules/Invoices/Tests/Feature/InvoicesTest.php +++ b/Modules/Invoices/Tests/Feature/InvoicesTest.php @@ -8,6 +8,7 @@ use Illuminate\Support\Str; use Livewire\Livewire; use Modules\Clients\Models\Relation; +use Modules\Core\Enums\NumberingType; use Modules\Core\Models\Numbering; use Modules\Core\Models\TaxRate; use Modules\Core\Tests\AbstractCompanyPanelTestCase; @@ -38,7 +39,7 @@ public function it_lists_invoices(): void /* Arrange */ $user = $this->user; $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -87,7 +88,7 @@ public function it_creates_an_invoice_through_a_modal(): void { /* Arrange */ $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -141,7 +142,7 @@ public function it_fails_to_create_invoice_through_a_modal_without_required_invo { /* Arrange */ $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -186,7 +187,7 @@ public function it_fails_to_create_invoice_through_a_modal_without_required_invo { /* Arrange */ $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -229,7 +230,7 @@ public function it_fails_to_create_invoice_through_a_modal_without_required_cust { /* Arrange */ $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -274,7 +275,7 @@ public function it_updates_an_invoice_through_a_modal(): void { /* Arrange */ $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -325,7 +326,7 @@ public function it_updates_an_invoice_through_a_modal(): void public function it_creates_an_invoice_with_items(): void { $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -381,7 +382,7 @@ public function it_fails_to_create_invoice_without_required_invoice_number(): vo /* Arrange */ $user = $this->user; $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -425,7 +426,7 @@ public function it_fails_to_create_invoice_without_required_invoice_status(): vo /* Arrange */ $user = $this->user; $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -467,7 +468,7 @@ public function it_fails_to_create_invoice_without_required_customer(): void /* Arrange */ $user = $this->user; $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -510,7 +511,7 @@ public function it_updates_an_invoice(): void { /* Arrange */ $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -586,7 +587,7 @@ public function it_deletes_an_invoice(): void /* Arrange */ $user = $this->user; $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); diff --git a/Modules/Invoices/Tests/Unit/InvoiceCalculatorTest.php b/Modules/Invoices/Tests/Unit/InvoiceCalculatorTest.php new file mode 100644 index 000000000..2c4f1475c --- /dev/null +++ b/Modules/Invoices/Tests/Unit/InvoiceCalculatorTest.php @@ -0,0 +1,404 @@ +calculator = new InvoiceCalculator(); + } + + #[Test] + public function it_calculates_subtotal_from_quantity_and_price(): void + { + /* Arrange */ + $document = $this->mockDocument(); + $items = [ + ['quantity' => 2, 'price' => 50.00, 'discount' => 0, 'tax_rate_1' => 0, 'tax_rate_2' => 0], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert */ + $this->assertEquals(100.00, $totals['item_subtotal']); + } + + #[Test] + public function it_applies_item_level_tax(): void + { + /* Arrange */ + $document = $this->mockDocument(); + $items = [ + ['quantity' => 1, 'price' => 100.00, 'discount' => 0, 'tax_rate_1' => 21, 'tax_rate_2' => 0], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert */ + $this->assertEquals(21.00, $totals['item_tax_total']); + } + + #[Test] + public function it_applies_two_tax_rates(): void + { + /* Arrange */ + $document = $this->mockDocument(); + $items = [ + ['quantity' => 1, 'price' => 100.00, 'discount' => 0, 'tax_rate_1' => 21, 'tax_rate_2' => 5], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert */ + $this->assertEquals(26.00, $totals['item_tax_total']); + } + + #[Test] + public function it_applies_item_discount_before_tax(): void + { + /* Arrange */ + $document = $this->mockDocument(); + // price=100, discount=10 → discounted base=90, tax@21%=18.9 + $items = [ + ['quantity' => 1, 'price' => 100.00, 'discount' => 10.00, 'tax_rate_1' => 21, 'tax_rate_2' => 0], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert */ + $this->assertEquals(100.00, $totals['item_subtotal']); + $this->assertEquals(18.90, round($totals['item_tax_total'], 2)); + } + + #[Test] + public function it_calculates_grand_total_with_taxes(): void + { + /* Arrange */ + $document = $this->mockDocument(); + $items = [ + ['quantity' => 2, 'price' => 100.00, 'discount' => 0, 'tax_rate_1' => 21, 'tax_rate_2' => 0], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert */ + // subtotal=200, tax=42, grand total=200+42+42 (item_tax+invoice_tax) + $this->assertEquals(200.00, $totals['item_subtotal']); + $this->assertGreaterThan(200.00, $totals['total']); + } + + #[Test] + public function it_applies_document_level_discount(): void + { + /* Arrange */ + $document = new \stdClass(); + $document->discount_amount = 20.00; + $document->discount_percent = 0; + $document->amount_paid = 0; + + $items = [ + ['quantity' => 1, 'price' => 100.00, 'discount' => 0, 'tax_rate_1' => 0, 'tax_rate_2' => 0], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert */ + $this->assertEquals(80.00, $totals['total']); + $this->assertEquals(20.00, $totals['discount_amount']); + } + + #[Test] + public function it_applies_percentage_discount(): void + { + /* Arrange */ + $document = new \stdClass(); + $document->discount_amount = 0; + $document->discount_percent = 10; + $document->amount_paid = 0; + + $items = [ + ['quantity' => 1, 'price' => 200.00, 'discount' => 0, 'tax_rate_1' => 0, 'tax_rate_2' => 0], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert */ + $this->assertEquals(20.00, $totals['discount_amount']); + $this->assertEquals(180.00, $totals['total']); + } + + #[Test] + public function it_aggregates_multiple_items(): void + { + /* Arrange */ + $document = $this->mockDocument(); + $items = [ + ['quantity' => 1, 'price' => 100.00, 'discount' => 0, 'tax_rate_1' => 0, 'tax_rate_2' => 0], + ['quantity' => 2, 'price' => 50.00, 'discount' => 0, 'tax_rate_1' => 0, 'tax_rate_2' => 0], + ['quantity' => 3, 'price' => 10.00, 'discount' => 0, 'tax_rate_1' => 0, 'tax_rate_2' => 0], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert */ + $this->assertEquals(230.00, $totals['item_subtotal']); + $this->assertEquals(230.00, $totals['total']); + } + + #[Test] + public function it_returns_zero_totals_for_empty_items(): void + { + /* Arrange */ + $document = $this->mockDocument(); + + /* Act */ + $totals = $this->calculator->calculateTotals($document, []); + + /* Assert */ + $this->assertEquals(0, $totals['item_subtotal']); + $this->assertEquals(0, $totals['item_tax_total']); + $this->assertEquals(0, $totals['total']); + } + + // ------------------------------------------------------------------------- + // Edge case tests + // ------------------------------------------------------------------------- + + #[Test] + public function it_returns_zero_total_when_item_quantity_is_zero(): void + { + /* Arrange */ + $document = $this->mockDocument(); + $items = [ + ['quantity' => 0, 'price' => 99.99, 'discount' => 0, 'tax_rate_1' => 21, 'tax_rate_2' => 0], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert */ + $this->assertSame(0.0, $totals['item_subtotal']); + $this->assertSame(0.0, $totals['item_tax_total']); + $this->assertSame(0.0, $totals['total']); + } + + #[Test] + public function it_returns_zero_total_when_unit_price_is_zero(): void + { + /* Arrange */ + $document = $this->mockDocument(); + $items = [ + ['quantity' => 5, 'price' => 0.00, 'discount' => 0, 'tax_rate_1' => 21, 'tax_rate_2' => 0], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert */ + $this->assertSame(0.0, $totals['item_subtotal']); + $this->assertSame(0.0, $totals['item_tax_total']); + $this->assertSame(0.0, $totals['total']); + } + + #[Test] + public function it_returns_zero_tax_total_when_tax_rate_is_zero(): void + { + /* Arrange */ + $document = $this->mockDocument(); + $items = [ + ['quantity' => 3, 'price' => 100.00, 'discount' => 0, 'tax_rate_1' => 0, 'tax_rate_2' => 0], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert */ + $this->assertSame(0.0, $totals['item_tax_total']); + // total should equal subtotal when there are no taxes and no discounts + $this->assertSame(300.0, $totals['item_subtotal']); + $this->assertSame(300.0, $totals['total']); + } + + #[Test] + public function it_clamps_total_to_zero_when_item_discount_exceeds_subtotal(): void + { + /* Arrange — item discount larger than price; calculator uses max(subtotal - discount, 0) */ + $document = $this->mockDocument(); + $items = [ + ['quantity' => 1, 'price' => 50.00, 'discount' => 200.00, 'tax_rate_1' => 21, 'tax_rate_2' => 0], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert — discounted base is clamped to 0, so tax is also 0 */ + $this->assertSame(0.0, $totals['item_tax_total']); + } + + #[Test] + public function it_applies_100_percent_document_discount_resulting_in_zero_total(): void + { + /* Arrange */ + $document = new \stdClass(); + $document->discount_amount = 0; + $document->discount_percent = 100; + $document->amount_paid = 0; + + $items = [ + ['quantity' => 2, 'price' => 150.00, 'discount' => 0, 'tax_rate_1' => 0, 'tax_rate_2' => 0], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert — 100% discount wipes the subtotal entirely */ + $this->assertEqualsWithDelta(0.0, $totals['total'], 0.001); + $this->assertEqualsWithDelta(300.0, $totals['discount_amount'], 0.001); + } + + #[Test] + public function it_handles_single_item_correctly(): void + { + /* Arrange */ + $document = $this->mockDocument(); + $items = [ + ['quantity' => 1, 'price' => 49.99, 'discount' => 0, 'tax_rate_1' => 0, 'tax_rate_2' => 0], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert */ + $this->assertEqualsWithDelta(49.99, $totals['item_subtotal'], 0.001); + $this->assertEqualsWithDelta(49.99, $totals['total'], 0.001); + } + + #[Test] + public function it_sums_multiple_tax_rates_across_multiple_items(): void + { + /* Arrange — two items each with different tax combinations */ + $document = $this->mockDocument(); + $items = [ + // item 1: price=100, tax1=10% => tax=10 + ['quantity' => 1, 'price' => 100.00, 'discount' => 0, 'tax_rate_1' => 10, 'tax_rate_2' => 0], + // item 2: price=200, tax1=5%, tax2=3% => tax=16 + ['quantity' => 1, 'price' => 200.00, 'discount' => 0, 'tax_rate_1' => 5, 'tax_rate_2' => 3], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert — combined item_tax_total = 10 + 16 = 26 */ + $this->assertEqualsWithDelta(26.0, $totals['item_tax_total'], 0.001); + } + + #[Test] + public function it_handles_floating_point_precision_across_many_items(): void + { + /* Arrange — three items at 33.33 each; sum should be close to 99.99 */ + $document = $this->mockDocument(); + $items = [ + ['quantity' => 1, 'price' => 33.33, 'discount' => 0, 'tax_rate_1' => 0, 'tax_rate_2' => 0], + ['quantity' => 1, 'price' => 33.33, 'discount' => 0, 'tax_rate_1' => 0, 'tax_rate_2' => 0], + ['quantity' => 1, 'price' => 33.33, 'discount' => 0, 'tax_rate_1' => 0, 'tax_rate_2' => 0], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert — allow a small floating-point delta */ + $this->assertEqualsWithDelta(99.99, $totals['item_subtotal'], 0.001); + $this->assertEqualsWithDelta(99.99, $totals['total'], 0.001); + } + + #[Test] + public function it_returns_correct_balance_after_partial_payment(): void + { + /* Arrange */ + $document = new \stdClass(); + $document->discount_amount = 0; + $document->discount_percent = 0; + $document->amount_paid = 50.00; + + $items = [ + ['quantity' => 1, 'price' => 200.00, 'discount' => 0, 'tax_rate_1' => 0, 'tax_rate_2' => 0], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert — balance = total - amount_paid = 200 - 50 = 150 */ + $this->assertEqualsWithDelta(150.0, $totals['balance'], 0.001); + } + + // ------------------------------------------------------------------------- + // Failing path / exception tests + // + // NOTE: The InvoiceCalculator does NOT validate for negative quantity or + // negative price — it simply returns mathematically computed (negative) + // values. No exceptions are thrown. If validation is added in the future, + // these tests should be updated to use $this->expectException(). + // ------------------------------------------------------------------------- + + #[Test] + public function it_produces_negative_subtotal_for_negative_quantity_without_throwing(): void + { + /* Arrange — calculator does not guard against negative quantities */ + $document = $this->mockDocument(); + $items = [ + ['quantity' => -1, 'price' => 100.00, 'discount' => 0, 'tax_rate_1' => 0, 'tax_rate_2' => 0], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert — result is mathematically correct but negative */ + $this->assertEqualsWithDelta(-100.0, $totals['item_subtotal'], 0.001); + $this->assertEqualsWithDelta(-100.0, $totals['total'], 0.001); + } + + #[Test] + public function it_produces_negative_subtotal_for_negative_price_without_throwing(): void + { + /* Arrange — calculator does not guard against negative prices */ + $document = $this->mockDocument(); + $items = [ + ['quantity' => 2, 'price' => -50.00, 'discount' => 0, 'tax_rate_1' => 0, 'tax_rate_2' => 0], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert — result is mathematically correct but negative */ + $this->assertEqualsWithDelta(-100.0, $totals['item_subtotal'], 0.001); + $this->assertEqualsWithDelta(-100.0, $totals['total'], 0.001); + } + + private function mockDocument(): \stdClass + { + $document = new \stdClass(); + $document->discount_amount = 0; + $document->discount_percent = 0; + $document->amount_paid = 0; + + return $document; + } +} diff --git a/Modules/Quotes/Tests/Feature/QuotesTest.php b/Modules/Quotes/Tests/Feature/QuotesTest.php index 2fdfdce56..5b3e5baf2 100644 --- a/Modules/Quotes/Tests/Feature/QuotesTest.php +++ b/Modules/Quotes/Tests/Feature/QuotesTest.php @@ -7,6 +7,7 @@ use Illuminate\Support\Str; use Livewire\Livewire; use Modules\Clients\Models\Relation; +use Modules\Core\Enums\NumberingType; use Modules\Core\Models\Numbering; use Modules\Core\Models\TaxRate; use Modules\Core\Tests\AbstractCompanyPanelTestCase; @@ -65,7 +66,7 @@ public function it_creates_a_quote_through_a_modal(): void { /* Arrange */ $prospect = Relation::factory()->for($this->company)->prospect()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::QUOTE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); @@ -139,7 +140,7 @@ public function it_creates_a_quote_through_a_modal(): void public function it_fails_to_create_a_quote_through_a_modal_without_required_prospect(): void { /* Arrange */ - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::QUOTE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); @@ -295,7 +296,7 @@ public function it_updates_a_quote_through_a_modal(): void { /* Arrange */ $prospect = Relation::factory()->for($this->company)->prospect()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::QUOTE->value])->create(); $quote = Quote::factory() ->for($this->company) @@ -348,7 +349,7 @@ public function it_creates_a_quote(): void { /* Arrange */ $prospect = Relation::factory()->for($this->company)->prospect()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::QUOTE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); diff --git a/Modules/Quotes/Tests/Unit/QuoteCalculatorTest.php b/Modules/Quotes/Tests/Unit/QuoteCalculatorTest.php new file mode 100644 index 000000000..8eee673ee --- /dev/null +++ b/Modules/Quotes/Tests/Unit/QuoteCalculatorTest.php @@ -0,0 +1,391 @@ +calculator = new QuoteCalculator(); + } + + #[Test] + public function it_calculates_subtotal_from_quantity_and_price(): void + { + /* Arrange */ + $document = $this->mockDocument(); + $items = [ + ['quantity' => 3, 'price' => 50.00, 'discount' => 0], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert */ + $this->assertEquals(150.00, $totals['item_subtotal']); + } + + #[Test] + public function it_applies_tax_rate_from_relationship_object(): void + { + /* Arrange */ + $document = $this->mockDocument(); + + $taxRate = new \stdClass(); + $taxRate->rate = 21; + + $item = new \stdClass(); + $item->quantity = 1; + $item->price = 100.00; + $item->discount = 0; + $item->taxRate = $taxRate; + $item->taxRate2 = null; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, [$item]); + + /* Assert */ + $this->assertEquals(21.00, $totals['item_tax_total']); + } + + #[Test] + public function it_applies_two_tax_rates_from_relationship_objects(): void + { + /* Arrange */ + $document = $this->mockDocument(); + + $taxRate1 = new \stdClass(); + $taxRate1->rate = 21; + + $taxRate2 = new \stdClass(); + $taxRate2->rate = 6; + + $item = new \stdClass(); + $item->quantity = 1; + $item->price = 100.00; + $item->discount = 0; + $item->taxRate = $taxRate1; + $item->taxRate2 = $taxRate2; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, [$item]); + + /* Assert */ + $this->assertEquals(27.00, $totals['item_tax_total']); + } + + #[Test] + public function it_applies_percentage_discount_before_tax(): void + { + /* Arrange */ + $document = $this->mockDocument(); + $items = [ + ['quantity' => 1, 'price' => 100.00, 'discount' => 25.00, 'tax_rate_1' => 20, 'tax_rate_2' => 0], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert — discounted base = 75, tax = 75 * 0.20 = 15 */ + $this->assertEquals(100.00, $totals['item_subtotal']); + $this->assertEquals(15.00, round($totals['item_tax_total'], 2)); + } + + #[Test] + public function it_applies_document_level_percentage_discount(): void + { + /* Arrange */ + $document = new \stdClass(); + $document->quote_discount_amount = 0; + $document->quote_discount_percent = 10; + + $items = [ + ['quantity' => 1, 'price' => 500.00, 'discount' => 0, 'tax_rate_1' => 0, 'tax_rate_2' => 0], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert */ + $this->assertEquals(50.00, $totals['discount_amount']); + $this->assertEquals(450.00, $totals['total']); + } + + #[Test] + public function it_aggregates_totals_across_multiple_items(): void + { + /* Arrange */ + $document = $this->mockDocument(); + $items = [ + ['quantity' => 2, 'price' => 100.00, 'discount' => 0, 'tax_rate_1' => 0, 'tax_rate_2' => 0], + ['quantity' => 1, 'price' => 50.00, 'discount' => 0, 'tax_rate_1' => 0, 'tax_rate_2' => 0], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert */ + $this->assertEquals(250.00, $totals['item_subtotal']); + $this->assertEquals(250.00, $totals['total']); + } + + #[Test] + public function it_returns_zero_totals_for_empty_item_list(): void + { + /* Arrange */ + $document = $this->mockDocument(); + + /* Act */ + $totals = $this->calculator->calculateTotals($document, []); + + /* Assert */ + $this->assertEquals(0, $totals['item_subtotal']); + $this->assertEquals(0, $totals['item_tax_total']); + $this->assertEquals(0, $totals['total']); + } + + // ------------------------------------------------------------------------- + // Edge case tests + // ------------------------------------------------------------------------- + + #[Test] + public function it_returns_zero_total_when_item_quantity_is_zero(): void + { + /* Arrange */ + $document = $this->mockDocument(); + $items = [ + ['quantity' => 0, 'price' => 200.00, 'discount' => 0, 'tax_rate_1' => 21, 'tax_rate_2' => 0], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert */ + $this->assertSame(0.0, $totals['item_subtotal']); + $this->assertSame(0.0, $totals['item_tax_total']); + $this->assertSame(0.0, $totals['total']); + } + + #[Test] + public function it_returns_zero_total_when_unit_price_is_zero(): void + { + /* Arrange */ + $document = $this->mockDocument(); + $items = [ + ['quantity' => 10, 'price' => 0.00, 'discount' => 0, 'tax_rate_1' => 21, 'tax_rate_2' => 0], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert */ + $this->assertSame(0.0, $totals['item_subtotal']); + $this->assertSame(0.0, $totals['item_tax_total']); + $this->assertSame(0.0, $totals['total']); + } + + #[Test] + public function it_returns_zero_tax_total_when_tax_rate_is_zero(): void + { + /* Arrange */ + $document = $this->mockDocument(); + $items = [ + ['quantity' => 4, 'price' => 75.00, 'discount' => 0, 'tax_rate_1' => 0, 'tax_rate_2' => 0], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert — no tax means total equals subtotal */ + $this->assertSame(0.0, $totals['item_tax_total']); + $this->assertSame(300.0, $totals['item_subtotal']); + $this->assertSame(300.0, $totals['total']); + } + + #[Test] + public function it_clamps_tax_base_to_zero_when_item_discount_exceeds_subtotal(): void + { + /* Arrange — item discount larger than the line subtotal */ + $document = $this->mockDocument(); + $items = [ + ['quantity' => 1, 'price' => 30.00, 'discount' => 500.00, 'tax_rate_1' => 21, 'tax_rate_2' => 0], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert — base is clamped to 0, so tax is also 0 */ + $this->assertSame(0.0, $totals['item_tax_total']); + } + + #[Test] + public function it_applies_100_percent_document_discount_resulting_in_zero_total(): void + { + /* Arrange */ + $document = new \stdClass(); + $document->quote_discount_amount = 0; + $document->quote_discount_percent = 100; + + $items = [ + ['quantity' => 3, 'price' => 100.00, 'discount' => 0, 'tax_rate_1' => 0, 'tax_rate_2' => 0], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert — 100% discount removes the entire subtotal */ + $this->assertEqualsWithDelta(0.0, $totals['total'], 0.001); + $this->assertEqualsWithDelta(300.0, $totals['discount_amount'], 0.001); + } + + #[Test] + public function it_handles_single_item_correctly(): void + { + /* Arrange */ + $document = $this->mockDocument(); + $items = [ + ['quantity' => 1, 'price' => 79.50, 'discount' => 0, 'tax_rate_1' => 0, 'tax_rate_2' => 0], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert */ + $this->assertEqualsWithDelta(79.50, $totals['item_subtotal'], 0.001); + $this->assertEqualsWithDelta(79.50, $totals['total'], 0.001); + } + + #[Test] + public function it_sums_taxes_from_multiple_items_with_relationship_objects(): void + { + /* Arrange — two items with tax relationship objects */ + $document = $this->mockDocument(); + + $taxRate = new \stdClass(); + $taxRate->rate = 10; + + $item1 = new \stdClass(); + $item1->quantity = 1; + $item1->price = 100.00; + $item1->discount = 0; + $item1->taxRate = $taxRate; + $item1->taxRate2 = null; + + $item2 = new \stdClass(); + $item2->quantity = 2; + $item2->price = 50.00; + $item2->discount = 0; + $item2->taxRate = $taxRate; + $item2->taxRate2 = null; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, [$item1, $item2]); + + /* Assert — item1 tax=10, item2 tax=10 (100*0.10 + 100*0.10); subtotal=200 */ + $this->assertEqualsWithDelta(200.0, $totals['item_subtotal'], 0.001); + $this->assertEqualsWithDelta(20.0, $totals['item_tax_total'], 0.001); + } + + #[Test] + public function it_handles_floating_point_precision_across_multiple_items(): void + { + /* Arrange — three items at 33.33 each; sum is representable to two decimals */ + $document = $this->mockDocument(); + $items = [ + ['quantity' => 1, 'price' => 33.33, 'discount' => 0, 'tax_rate_1' => 0, 'tax_rate_2' => 0], + ['quantity' => 1, 'price' => 33.33, 'discount' => 0, 'tax_rate_1' => 0, 'tax_rate_2' => 0], + ['quantity' => 1, 'price' => 33.33, 'discount' => 0, 'tax_rate_1' => 0, 'tax_rate_2' => 0], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert — allow small floating-point delta */ + $this->assertEqualsWithDelta(99.99, $totals['item_subtotal'], 0.001); + $this->assertEqualsWithDelta(99.99, $totals['total'], 0.001); + } + + #[Test] + public function it_applies_flat_document_discount(): void + { + /* Arrange */ + $document = new \stdClass(); + $document->quote_discount_amount = 30.00; + $document->quote_discount_percent = 0; + + $items = [ + ['quantity' => 1, 'price' => 130.00, 'discount' => 0, 'tax_rate_1' => 0, 'tax_rate_2' => 0], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert */ + $this->assertEqualsWithDelta(30.0, $totals['discount_amount'], 0.001); + $this->assertEqualsWithDelta(100.0, $totals['total'], 0.001); + } + + // ------------------------------------------------------------------------- + // Failing path / exception tests + // + // NOTE: QuoteCalculator (and its parent AbstractCalculator) does NOT + // validate for negative quantity or negative price inputs — those values + // flow through and produce mathematically correct but negative results. + // No InvalidArgumentException is thrown for item-level bad data. + // The only exception the class throws is in updateAndSave() when the + // document is not a Quote instance — that path requires DB access so it + // is not tested here. + // ------------------------------------------------------------------------- + + #[Test] + public function it_produces_negative_subtotal_for_negative_quantity_without_throwing(): void + { + /* Arrange — calculator does not guard against negative quantities */ + $document = $this->mockDocument(); + $items = [ + ['quantity' => -2, 'price' => 50.00, 'discount' => 0, 'tax_rate_1' => 0, 'tax_rate_2' => 0], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert — result is mathematically correct but negative */ + $this->assertEqualsWithDelta(-100.0, $totals['item_subtotal'], 0.001); + $this->assertEqualsWithDelta(-100.0, $totals['total'], 0.001); + } + + #[Test] + public function it_produces_negative_subtotal_for_negative_price_without_throwing(): void + { + /* Arrange — calculator does not guard against negative prices */ + $document = $this->mockDocument(); + $items = [ + ['quantity' => 3, 'price' => -40.00, 'discount' => 0, 'tax_rate_1' => 0, 'tax_rate_2' => 0], + ]; + + /* Act */ + $totals = $this->calculator->calculateTotals($document, $items); + + /* Assert — result is mathematically correct but negative */ + $this->assertEqualsWithDelta(-120.0, $totals['item_subtotal'], 0.001); + $this->assertEqualsWithDelta(-120.0, $totals['total'], 0.001); + } + + private function mockDocument(): \stdClass + { + $document = new \stdClass(); + $document->quote_discount_amount = 0; + $document->quote_discount_percent = 0; + + return $document; + } +} diff --git a/composer.json b/composer.json index b3603f84c..b3c54f2fa 100644 --- a/composer.json +++ b/composer.json @@ -49,12 +49,14 @@ "psr-4": { "App\\": "app/", "Modules\\": "Modules/", - "Database\\Seeders\\": "database/seeders/" + "Database\\Seeders\\": "database/seeders/", + "Fable5\\": "automation/fable5/src/" } }, "autoload-dev": { "psr-4": { - "Tests\\": "tests/" + "Tests\\": "tests/", + "Fable5\\Tests\\": "automation/fable5/tests/" } }, "scripts": { diff --git a/pint_output.log b/pint_output.log new file mode 100644 index 000000000..79f26c47c --- /dev/null +++ b/pint_output.log @@ -0,0 +1,5 @@ + + + No dirty files found. + +