Files
compras/tests/Feature/InvoiceTest.php
T

196 lines
11 KiB
PHP

<?php
use App\Enums\PurchasingRole;
use App\Models\Budget;
use App\Models\BudgetConsumption;
use App\Models\BudgetEntry;
use App\Models\Invoice;
use App\Models\User;
use App\Services\BudgetWorkflow;
use App\Services\ConflictGuard;
use App\Services\InvoiceWorkflow;
use App\Services\ReceiptWorkflow;
use App\Services\RequisitionWorkflow;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
require_once __DIR__.'/../Support/ReceiptFixture.php';
beforeEach(function () {
$this->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'));
});