f = receiptFixture(); $this->finance = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]); $this->validator = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]); $this->actingAs($this->f['receiver']); app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '1.000')); $this->actingAs($this->finance); $this->budget = Budget::query()->create(['tenant_id' => $this->finance->tenant_id, 'cost_center_id' => $this->f['r']->cost_center_id, 'year' => now()->year, 'amount_minor' => 20000, 'includes_tax' => true, 'created_by' => $this->finance->id, 'approved_by' => $this->validator->id, 'reason' => 'Fixture financeira isolada']); DB::transaction(function () { app(ConflictGuard::class)->lockCompany($this->finance); app(BudgetWorkflow::class)->record($this->finance, $this->f['r'], 'reserve', 10000, 10000); app(BudgetWorkflow::class)->record($this->finance, $this->f['r'], 'commit', 10000, 10000); }); $this->data = ['number' => 'FT TEST 01', 'issued_on' => today()->format('Y-m-d'), 'subtotal' => '60.00', 'tax' => '0.00', 'total' => '60.00', 'items' => [['purchase_order_item_id' => $this->f['line']->id, 'quantity' => '0.600', 'unit' => 'cx', 'unit_price' => '100.00', 'tax_rate' => '0']]]; }); function registerTestInvoice(object $test): Invoice { $test->actingAs($test->finance); return app(InvoiceWorkflow::class)->register($test->finance, $test->f['order'], $test->data); } test('partial invoice converts commitment to consumption exactly once', function () { $invoice = registerTestInvoice($this); expect(BudgetConsumption::query()->count())->toBe(0); $this->actingAs($this->validator); app(InvoiceWorkflow::class)->validate($this->validator, $invoice); app(InvoiceWorkflow::class)->validate($this->validator, $invoice); expect($invoice->refresh()->status)->toBe('validated') ->and((int) BudgetEntry::query()->sum('committed_minor'))->toBe(4000) ->and((int) BudgetConsumption::query()->sum('amount_minor'))->toBe(6000) ->and(BudgetConsumption::query()->count())->toBe(1); $this->get('/orcamentos')->assertOk()->assertInertia(fn ($page) => $page->where('budgets.0.available_minor', 10000)->where('budgets.0.consumed_minor', 6000)); }); test('author cannot validate own invoice', function () { $invoice = registerTestInvoice($this); $this->post('/facturas/'.$invoice->id.'/validar')->assertForbidden(); expect(BudgetConsumption::query()->count())->toBe(0); }); test('number normalized per supplier prevents duplicates', function () { registerTestInvoice($this); $this->data['number'] = ' ft test 01 '; expect(fn () => registerTestInvoice($this))->toThrow(ValidationException::class); expect(Invoice::query()->count())->toBe(1); }); test('differences in invoice values block registration', function (string $field) { if (in_array($field, ['unit_price', 'tax_rate', 'unit', 'quantity'])) { $this->data['items'][0][$field] = match ($field) { 'unit_price' => '101.00', 'tax_rate' => '16', 'unit' => 'un', default => '1.001' }; } else { $this->data[$field] = '61.00'; } expect(fn () => registerTestInvoice($this))->toThrow(ValidationException::class); expect(Invoice::query()->count())->toBe(0); })->with(['unit_price', 'tax_rate', 'unit', 'quantity', 'subtotal', 'tax', 'total']); test('pending overlapping invoices are rechecked at independent validation', function () { $first = registerTestInvoice($this); $this->data['number'] = 'FT TEST 02'; $second = registerTestInvoice($this); $this->actingAs($this->validator); app(InvoiceWorkflow::class)->validate($this->validator, $first); expect(fn () => app(InvoiceWorkflow::class)->validate($this->validator, $second))->toThrow(ValidationException::class); expect($second->refresh()->status)->toBe('pending')->and(BudgetConsumption::query()->count())->toBe(1); }); test('failed audit rolls back invoice validation and consumption', function () { $invoice = registerTestInvoice($this); $this->actingAs($this->validator); $this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->andThrow(new RuntimeException('audit failed')); expect(fn () => app(InvoiceWorkflow::class)->validate($this->validator, $invoice))->toThrow(RuntimeException::class); expect($invoice->refresh()->status)->toBe('pending')->and(BudgetConsumption::query()->count())->toBe(0) ->and((int) BudgetEntry::query()->sum('committed_minor'))->toBe(10000); }); test('foreign finance cannot read or validate invoice', function () { $invoice = registerTestInvoice($this); $other = receiptFixture(); $user = User::factory()->create(['tenant_id' => $other['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]); $this->actingAs($user)->post('/facturas/'.$invoice->id.'/validar')->assertNotFound(); $this->get('/facturas')->assertOk()->assertInertia(fn ($page) => $page->has('invoices.data', 0)); }); test('warehouse cannot access or register invoices', function () { $this->actingAs($this->f['receiver'])->get('/facturas')->assertForbidden(); $this->post('/ordens/'.$this->f['order']->id.'/facturas', $this->data)->assertForbidden(); }); test('registered documents cannot be rewritten or deleted', function () { $invoice = registerTestInvoice($this); expect(fn () => $invoice->update(['total_minor' => 1]))->toThrow(LogicException::class); expect(fn () => $invoice->items()->firstOrFail()->delete())->toThrow(LogicException::class); expect(fn () => $invoice->refresh()->delete())->toThrow(LogicException::class); }); test('complementary invoices consume the exact commitment without increasing available budget', function () { $first = registerTestInvoice($this); $this->actingAs($this->validator); app(InvoiceWorkflow::class)->validate($this->validator, $first); $this->data['number'] = 'FT TEST 02'; $this->data['items'][0]['quantity'] = '0.400'; $this->data['subtotal'] = $this->data['total'] = '40.00'; $second = registerTestInvoice($this); $this->actingAs($this->validator); app(InvoiceWorkflow::class)->validate($this->validator, $second); expect((int) BudgetEntry::query()->sum('committed_minor'))->toBe(0) ->and((int) BudgetConsumption::query()->sum('amount_minor'))->toBe(10000); $request = $this->f['r']->replicate(); $request->number = 'REQ-SECOND'; $request->save(); expect(fn () => DB::transaction(fn () => app(BudgetWorkflow::class)->record($this->validator, $request, 'reserve', 10001, 10001)))->toThrow(ValidationException::class); }); test('invoice without a committed budget remains pending', function () { $other = receiptFixture(); app(ReceiptWorkflow::class)->confirm($other['receiver'], $other['order'], receiptPayload($other, '1.000')); $author = User::factory()->create(['tenant_id' => $other['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]); $validator = User::factory()->create(['tenant_id' => $other['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]); $this->actingAs($author); $data = $this->data; $data['items'][0]['purchase_order_item_id'] = $other['line']->id; $invoice = app(InvoiceWorkflow::class)->register($author, $other['order'], $data); $this->actingAs($validator); expect(fn () => app(InvoiceWorkflow::class)->validate($validator, $invoice))->toThrow(ValidationException::class); expect($invoice->refresh()->status)->toBe('pending')->and(BudgetConsumption::query()->count())->toBe(0); }); test('future invoice date and foreign order line are rejected', function (string $case) { if ($case === 'future') { $this->data['issued_on'] = today()->addDay()->format('Y-m-d'); } else { $this->data['items'][0]['purchase_order_item_id'] = PHP_INT_MAX; } expect(fn () => registerTestInvoice($this))->toThrow(ValidationException::class); })->with(['future', 'foreign_line']); test('reception acceptance is required before invoicing services', function () { $other = receiptFixture('1.000', 'service'); $author = User::factory()->create(['tenant_id' => $other['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]); $this->actingAs($author); $data = $this->data; $data['items'][0]['purchase_order_item_id'] = $other['line']->id; expect(fn () => app(InvoiceWorkflow::class)->register($author, $other['order'], $data))->toThrow(ValidationException::class); }); test('partial invoices preserve cents and VAT across cumulative rounding', function () { // Configure only the isolated frozen-order fixture before registering any invoice. DB::table('purchase_order_items')->where('id', $this->f['line']->id)->update(['unit_price_minor' => 10001, 'tax_rate' => '16.00', 'subtotal_minor' => 10001, 'tax_minor' => 1600, 'total_minor' => 11601]); DB::table('purchase_orders')->where('id', $this->f['order']->id)->update(['subtotal_minor' => 10001, 'tax_minor' => 1600, 'total_minor' => 11601]); DB::table('budget_entries')->where('requisition_id', $this->f['r']->id)->where('kind', 'commit')->update(['committed_minor' => 11601]); foreach ([['0.333', '33.30', '5.33', '38.63'], ['0.333', '33.31', '5.33', '38.64'], ['0.334', '33.40', '5.34', '38.74']] as $index => [$quantity, $subtotal, $tax, $total]) { $this->data['number'] = 'FT-ROUND-'.$index; $this->data['items'][0]['quantity'] = $quantity; $this->data['items'][0]['unit_price'] = '100.01'; $this->data['items'][0]['tax_rate'] = '16'; $this->data['subtotal'] = $subtotal; $this->data['tax'] = $tax; $this->data['total'] = $total; $invoice = registerTestInvoice($this); $this->actingAs($this->validator); app(InvoiceWorkflow::class)->validate($this->validator, $invoice); } expect((int) Invoice::query()->sum('subtotal_minor'))->toBe(10001) ->and((int) Invoice::query()->sum('tax_minor'))->toBe(1600) ->and((int) BudgetConsumption::query()->sum('amount_minor'))->toBe(11601) ->and((int) BudgetEntry::query()->sum('committed_minor'))->toBe(0); }); test('public invoice example is available without exposing company invoices', function () { auth()->forgetGuards(); $this->get('/consulta/facturas')->assertOk()->assertInertia(fn ($page) => $page->where('initialScreen', 'invoices')->where('auth.user', null)->missing('invoices')->missing('orders')); });