commit5
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-29 22:03:09 +02:00
parent 57f6b3cc36
commit 413c201d97
54 changed files with 2753 additions and 14 deletions
+14
View File
@@ -9,6 +9,7 @@ use App\Models\User;
use App\Services\ExternalPaymentWorkflow;
use App\Services\RequisitionWorkflow;
use App\Services\SupplierBankWorkflow;
use App\Services\SupplierPayables;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
@@ -204,3 +205,16 @@ test('financial role is reloaded after the company lock rather than trusting a s
$this->post('/facturas/'.$this->f['invoice']->id.'/pagamentos', $this->data)->assertForbidden();
expect(ExternalPayment::query()->count())->toBe(0);
});
test('payables trial stays unavailable outside its isolated database even with a source configured', function () {
config(['common-payables.source_instance' => 'synthetic-source']);
expect(SupplierPayables::enabled())->toBeFalse();
$this->get('/obrigacoes-fornecedores')->assertForbidden();
$this->getJson('/api/supplier-payables')->assertForbidden();
$this->postJson('/api/supplier-payables/prepare', ['id' => $this->f['invoice']->id, 'reason' => 'Ensaio isolado'])->assertForbidden();
$this->postJson('/api/supplier-payables/validate', ['id' => $this->f['invoice']->id, 'reason' => 'Ensaio isolado'])->assertForbidden();
confirmTestPayment($this, registerTestPayment($this));
expect(app(ExternalPaymentWorkflow::class)->outstanding($this->f['invoice']))->toBe(2000);
});
+200
View File
@@ -0,0 +1,200 @@
<?php
use App\Services\ExternalPaymentWorkflow;
use App\Services\SupplierPayables;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tempo\Finance\Payables\HistoricalPayments;
use Tempo\Finance\Payables\Payables;
require_once __DIR__.'/../Support/PaymentFixture.php';
beforeEach(function () {
$this->f = paymentFixture();
config(['common-payables.enabled' => true, 'common-payables.history_enabled' => true, 'common-payables.source_instance' => 'history-fixture']);
foreach ([$this->f['author'], $this->f['validator']] as $user) {
foreach (['view', 'prepare', 'validate'] as $action) {
DB::table('pa_access')->insert(['tenant_id' => $user->tenant_id, 'company_id' => 0, 'unit_id' => 0, 'user_id' => $user->id, 'person' => 'history-person-'.$user->id, 'action' => $action]);
}
}
$this->actingAs($this->f['author']);
});
function originalHistoryPayment(object $test, string $amount, string $suffix, string $status = 'pending')
{
$test->actingAs($test->f['author']);
$workflow = app(ExternalPaymentWorkflow::class);
$payment = $workflow->register($test->f['author'], $test->f['invoice'], [...$test->f['payment_data'], 'amount' => $amount, 'external_reference' => 'HISTORY-'.$suffix]);
if ($status !== 'pending') {
$test->actingAs($test->f['validator']);
$workflow->review($test->f['validator'], $payment, $status, 'Decisão histórica sintética');
}
return $payment;
}
function prepareOriginalHistory(object $test): array
{
$test->actingAs($test->f['author']);
return app(SupplierPayables::class)->engine($test->f['author'])->command('prepare_history', $test->f['invoice']->id, 'Passagem sintética de histórico original', (string) Str::uuid());
}
function businessHistoryFingerprint(): string
{
$rows = [];
foreach (['invoices', 'invoice_items', 'external_payments', 'budget_entries', 'budget_consumptions', 'stock_movements'] as $table) {
$rows[$table] = DB::table($table)->orderBy('id')->get()->map(fn ($row) => (array) $row)->all();
}
return hash('sha256', Payables::canonical($rows));
}
test('historical import keeps gross principal and subtracts confirmed payments exactly once', function () {
originalHistoryPayment($this, '30.00', 'confirmed', 'confirmed');
originalHistoryPayment($this, '10.00', 'pending');
originalHistoryPayment($this, '5.00', 'rejected', 'rejected');
$before = businessHistoryFingerprint();
$proposal = prepareOriginalHistory($this);
expect($proposal['state'])->toBe('prepared');
$service = app(SupplierPayables::class);
expect(fn () => $service->engine($this->f['author'])->command('validate_history', $proposal['proposal_id'], 'Não validar proposta própria', (string) Str::uuid()))->toThrow(DomainException::class);
$this->actingAs($this->f['validator']);
$key = (string) Str::uuid();
$result = $service->engine($this->f['validator'])->command('validate_history', $proposal['proposal_id'], 'Validação distinta do histórico', $key);
expect($result['state'])->toBe('recognised');
expect($service->engine($this->f['validator'])->command('validate_history', $proposal['proposal_id'], 'Validação distinta do histórico', $key)['replayed'])->toBeTrue();
$service->engine($this->f['validator'])->command('validate_history', $proposal['proposal_id'], 'Outra chave, mesma responsabilidade', (string) Str::uuid());
expect(DB::table('pa_obligations')->count())->toBe(1)->and(DB::table('pa_history_imports')->count())->toBe(1)
->and(DB::table('pa_imported_payments')->count())->toBe(3)
->and($service->principal($this->f['invoice']))->toBe(6000)->and($service->balance($this->f['invoice']))->toBe(3000)
->and(app(ExternalPaymentWorkflow::class)->outstanding($this->f['invoice']))->toBe(3000)
->and(businessHistoryFingerprint())->toBe($before);
$this->getJson('/api/supplier-payables?invoice='.$this->f['invoice']->id)->assertOk()
->assertJsonPath('obligations.0.principal_minor', 6000)->assertJsonPath('obligations.0.balance_minor', 3000)
->assertJsonPath('obligations.0.pending_payments_minor', 1000)->assertJsonPath('obligations.0.rejected_payments_minor', 500)
->assertJsonPath('obligations.0.due_on', null)->assertJsonPath('obligations.0.history_imported', true);
$this->get('/obrigacoes-fornecedores')->assertOk()->assertSee('Saldo por liquidar: 30.00 MZN', false)->assertSee('sem abatimento');
$snapshots = DB::table('pa_history_imports')->value('snapshot');
expect($snapshots)->not->toContain('account_identifier')->not->toContain('external_reference')->not->toContain('TEST-PAY-ACCOUNT');
$recovered = prepareOriginalHistory($this);
expect($recovered['obligation_id'])->toBe($result['obligation_id'])->and(DB::table('pa_obligations')->count())->toBe(1);
});
test('a decision between proposal and validation invalidates the captured history', function () {
originalHistoryPayment($this, '30.00', 'confirmed', 'confirmed');
$pending = originalHistoryPayment($this, '10.00', 'pending');
$proposal = prepareOriginalHistory($this);
$this->actingAs($this->f['validator']);
app(ExternalPaymentWorkflow::class)->review($this->f['validator'], $pending, 'confirmed', 'Confirmado antes da passagem');
$service = app(SupplierPayables::class);
$result = $service->engine($this->f['validator'])->command('validate_history', $proposal['proposal_id'], 'Rever fotografia anterior', (string) Str::uuid());
expect($result['state'])->toBe('conflict')->and(DB::table('pa_obligations')->count())->toBe(0)
->and(app(ExternalPaymentWorkflow::class)->outstanding($this->f['invoice']))->toBe(2000);
$new = prepareOriginalHistory($this);
$this->actingAs($this->f['validator']);
$service->engine($this->f['validator'])->command('validate_history', $new['proposal_id'], 'Validar histórico actualizado', (string) Str::uuid());
expect($service->balance($this->f['invoice']))->toBe(2000);
});
test('pending-only and fully paid histories retain the correct balances', function (string $amount, string $status, int $balance) {
originalHistoryPayment($this, $amount, 'case', $status);
$proposal = prepareOriginalHistory($this);
$this->actingAs($this->f['validator']);
$service = app(SupplierPayables::class);
$service->engine($this->f['validator'])->command('validate_history', $proposal['proposal_id'], 'Conferência de saldo extremo', (string) Str::uuid());
expect($service->principal($this->f['invoice']))->toBe(6000)->and($service->balance($this->f['invoice']))->toBe($balance);
})->with([['10.00', 'pending', 6000], ['60.00', 'confirmed', 0]]);
test('import does not enable new payments or confirmations of pending history', function () {
$pending = originalHistoryPayment($this, '10.00', 'pending');
$proposal = prepareOriginalHistory($this);
$this->actingAs($this->f['validator']);
app(SupplierPayables::class)->engine($this->f['validator'])->command('validate_history', $proposal['proposal_id'], 'Transferir sem executar pagamentos', (string) Str::uuid());
expect(fn () => app(ExternalPaymentWorkflow::class)->review($this->f['validator'], $pending, 'confirmed', 'Não confirmar depois da passagem'))->toThrow(HttpException::class);
$this->actingAs($this->f['author']);
expect(fn () => app(ExternalPaymentWorkflow::class)->register($this->f['author'], $this->f['invoice'], [...$this->f['payment_data'], 'external_reference' => 'NEW-PAYMENT']))->toThrow(HttpException::class);
expect(DB::table('external_payments')->value('status'))->toBe('pending');
});
test('missing audit and unapproved endpoints cannot import history', function () {
originalHistoryPayment($this, '10.00', 'pending');
DB::table('audit_logs')->where('action', 'payment.registered')->update(['action' => 'unverified.registration']);
expect(fn () => prepareOriginalHistory($this))->toThrow(DomainException::class);
config(['common-payables.history_enabled' => false]);
$this->postJson('/api/supplier-payables/prepare_history', ['id' => $this->f['invoice']->id, 'reason' => 'Não activado'])->assertForbidden();
expect(DB::table('pa_obligations')->count())->toBe(0);
});
test('failure during capture rolls back obligation and all imported facts', function () {
originalHistoryPayment($this, '10.00', 'pending');
$proposal = prepareOriginalHistory($this);
$fail = true;
DB::listen(function (QueryExecuted $event) use (&$fail) {
if ($fail && str_contains($event->sql, 'insert into "pa_imported_payments"')) {
$fail = false;
throw new RuntimeException('Injected historical link failure');
}
});
$this->actingAs($this->f['validator']);
expect(fn () => app(SupplierPayables::class)->engine($this->f['validator'])->command('validate_history', $proposal['proposal_id'], 'Teste de atomicidade', (string) Str::uuid()))->toThrow(RuntimeException::class);
expect(DB::table('pa_obligations')->count())->toBe(0)->and(DB::table('pa_history_imports')->count())->toBe(0)->and(DB::table('pa_imported_payments')->count())->toBe(0);
app(SupplierPayables::class)->engine($this->f['validator'])->command('validate_history', $proposal['proposal_id'], 'Retoma após falha', (string) Str::uuid());
expect(DB::table('pa_obligations')->count())->toBe(1);
});
test('imported ledger is append-only and disabling the history cannot silently restore the old balance', function () {
originalHistoryPayment($this, '30.00', 'confirmed', 'confirmed');
$proposal = prepareOriginalHistory($this);
$this->actingAs($this->f['validator']);
app(SupplierPayables::class)->engine($this->f['validator'])->command('validate_history', $proposal['proposal_id'], 'Preservar responsabilidade única', (string) Str::uuid());
expect(fn () => DB::table('pa_history_imports')->update(['confirmed_minor' => 0]))->toThrow(QueryException::class);
expect(fn () => DB::table('pa_imported_payments')->delete())->toThrow(QueryException::class);
config(['common-payables.history_enabled' => false]);
expect(fn () => app(ExternalPaymentWorkflow::class)->outstanding($this->f['invoice']))->toThrow(HttpException::class);
config(['common-payables.enabled' => false]);
expect(fn () => app(ExternalPaymentWorkflow::class)->outstanding($this->f['invoice']))->toThrow(HttpException::class);
});
test('normalizer refuses duplicated omitted or incompatible payment facts', function () {
originalHistoryPayment($this, '30.00', 'confirmed', 'confirmed');
originalHistoryPayment($this, '10.00', 'pending');
$proposal = prepareOriginalHistory($this);
$snapshot = json_decode(DB::table('pa_proposals')->where('id', $proposal['proposal_id'])->value('snapshot'), true);
$source = app(SupplierPayables::class)->document($this->f['author'], $this->f['invoice']->id);
$rows = $snapshot['_history_import']['payments'];
expect(fn () => HistoricalPayments::capture($source, [$rows[0]]))->toThrow(DomainException::class);
expect(fn () => HistoricalPayments::capture($source, [$rows[0], $rows[0]]))->toThrow(DomainException::class);
foreach (['currency' => 'USD', 'document' => 999, 'amount_minor' => 7000, 'reviewed_by' => $rows[0]['created_by'], 'decision_audit_id' => null, 'status' => 'unknown'] as $field => $value) {
$changed = $rows;
$changed[0][$field] = $value;
expect(fn () => HistoricalPayments::capture($source, $changed))->toThrow(DomainException::class);
}
});
test('two installation identities cannot recognise two obligations for the same local invoice', function () {
originalHistoryPayment($this, '10.00', 'pending');
$first = prepareOriginalHistory($this);
config(['common-payables.source_instance' => 'changed-before-recognition']);
$second = prepareOriginalHistory($this);
config(['common-payables.source_instance' => 'history-fixture']);
$this->actingAs($this->f['validator']);
app(SupplierPayables::class)->engine($this->f['validator'])->command('validate_history', $first['proposal_id'], 'Primeira identidade conferida', (string) Str::uuid());
config(['common-payables.source_instance' => 'changed-before-recognition']);
expect(fn () => app(SupplierPayables::class)->engine($this->f['validator'])->command('validate_history', $second['proposal_id'], 'Não duplicar após mudança de configuração', (string) Str::uuid()))->toThrow(DomainException::class);
expect(fn () => app(SupplierPayables::class)->engine($this->f['validator'])->positionForSource($this->f['invoice']->id))->toThrow(DomainException::class);
expect(DB::table('pa_obligations')->count())->toBe(1)->and(DB::table('pa_imported_payments')->count())->toBe(1);
});
test('the original 1160 example keeps 300 confirmed and 100 pending separate', function () {
$source = ['instance' => 'example', 'company' => 1, 'document' => 1, 'currency' => 'MZN', 'total_minor' => 116000, 'payment_count' => 2];
$payment = ['id' => 1, 'company' => 1, 'document' => 1, 'amount_minor' => 30000, 'currency' => 'MZN', 'status' => 'confirmed', 'created_by' => 2,
'paid_on' => '2026-10-29', 'registration_audit_id' => 1, 'reviewed_by' => 3, 'reviewed_at' => '2026-10-29 12:00:00', 'decision_audit_id' => 2];
$pending = [...$payment, 'id' => 2, 'amount_minor' => 10000, 'status' => 'pending', 'reviewed_by' => null, 'reviewed_at' => null, 'decision_audit_id' => null];
$capture = HistoricalPayments::capture($source, [$payment, $pending]);
expect($capture['confirmed_minor'])->toBe(30000)->and($capture['pending_minor'])->toBe(10000)
->and($source['total_minor'] - $capture['confirmed_minor'])->toBe(86000);
});
@@ -0,0 +1,109 @@
<?php
use App\Services\ExternalPaymentWorkflow;
use App\Services\SupplierPayables;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tempo\Finance\Payables\Payables;
require_once __DIR__.'/../Support/PaymentFixture.php';
beforeEach(function () {
$this->f = paymentFixture();
config(['common-payables.enabled' => true, 'common-payables.history_enabled' => true, 'common-payables.history_decisions_enabled' => true, 'common-payables.source_instance' => 'decision-fixture']);
foreach ([$this->f['author'], $this->f['validator']] as $user) {
foreach (['view', 'prepare', 'validate'] as $action) {
DB::table('pa_access')->insert(['tenant_id' => $user->tenant_id, 'company_id' => 0, 'unit_id' => 0, 'user_id' => $user->id, 'person' => 'decision-person-'.$user->id, 'action' => $action]);
}
}
$this->actingAs($this->f['author']);
$this->payment = app(ExternalPaymentWorkflow::class)->register($this->f['author'], $this->f['invoice'], [...$this->f['payment_data'], 'amount' => '10.00']);
$this->otherPayment = app(ExternalPaymentWorkflow::class)->register($this->f['author'], $this->f['invoice'], [...$this->f['payment_data'], 'amount' => '60.00', 'external_reference' => 'SECOND-PENDING']);
$service = app(SupplierPayables::class);
$proposal = $service->engine($this->f['author'])->command('prepare_history', $this->f['invoice']->id, 'Preparar histórico sintético', (string) Str::uuid());
$this->actingAs($this->f['validator']);
$service->engine($this->f['validator'])->command('validate_history', $proposal['proposal_id'], 'Validar histórico sintético', (string) Str::uuid());
$this->frozen = Payables::canonical(['imports' => DB::table('pa_history_imports')->get()->toArray(), 'payments' => DB::table('pa_imported_payments')->get()->toArray()]);
});
test('imported pending decisions change the balance once and preserve the import', function (string $decision, int $balance) {
$workflow = app(ExternalPaymentWorkflow::class);
$workflow->review($this->f['validator'], $this->payment, $decision, 'Conferência posterior à importação');
$workflow->review($this->f['validator'], $this->payment, $decision, 'Repetição sem novo efeito');
expect($this->payment->fresh()->status)->toBe($decision)
->and(app(SupplierPayables::class)->balance($this->f['invoice']))->toBe($balance)
->and($workflow->outstanding($this->f['invoice']))->toBe($balance)
->and(DB::table('pa_payment_decisions')->count())->toBe(1)
->and(DB::table('pa_events')->where('kind', 'payable.imported_payment_decided')->count())->toBe(1)
->and(Payables::canonical(['imports' => DB::table('pa_history_imports')->get()->toArray(), 'payments' => DB::table('pa_imported_payments')->get()->toArray()]))->toBe($this->frozen);
$this->getJson('/api/supplier-payables?invoice='.$this->f['invoice']->id)->assertOk()->assertJsonPath('obligations.0.balance_minor', $balance)->assertJsonPath('obligations.0.pending_payments_minor', 6000);
expect(fn () => $workflow->review($this->f['validator'], $this->payment, $decision === 'confirmed' ? 'rejected' : 'confirmed', 'Tentativa de reescrita'))->toThrow(ValidationException::class);
})->with([['confirmed', 5000], ['rejected', 6000]]);
test('decision failure rolls back original payment audit and ledger together', function () {
$audits = DB::table('audit_logs')->count();
$fail = true;
DB::listen(function (QueryExecuted $query) use (&$fail) {
if ($fail && str_contains($query->sql, 'insert into "pa_payment_decisions"')) {
$fail = false;
throw new RuntimeException('Synthetic decision failure');
}
});
expect(fn () => app(ExternalPaymentWorkflow::class)->review($this->f['validator'], $this->payment, 'confirmed', 'Teste de atomicidade'))->toThrow(RuntimeException::class);
expect($this->payment->fresh()->status)->toBe('pending')->and(DB::table('audit_logs')->count())->toBe($audits)
->and(DB::table('pa_payment_decisions')->count())->toBe(0)->and(app(SupplierPayables::class)->balance($this->f['invoice']))->toBe(6000);
app(ExternalPaymentWorkflow::class)->review($this->f['validator'], $this->payment->fresh(), 'confirmed', 'Recuperar após falha');
expect(app(SupplierPayables::class)->balance($this->f['invoice']))->toBe(5000);
});
test('activation and independent eligible reviewer remain mandatory', function () {
config(['common-payables.history_decisions_enabled' => false]);
expect(fn () => app(ExternalPaymentWorkflow::class)->review($this->f['validator'], $this->payment, 'confirmed', 'Desactivado'))->toThrow(HttpException::class);
config(['common-payables.history_decisions_enabled' => true]);
$this->actingAs($this->f['author']);
expect(fn () => app(ExternalPaymentWorkflow::class)->review($this->f['author'], $this->payment, 'confirmed', 'Não conferir próprio registo'))->toThrow(HttpException::class);
$this->actingAs($this->f['validator']);
DB::table('pa_access')->where('user_id', $this->f['validator']->id)->where('action', 'validate')->delete();
expect(fn () => app(ExternalPaymentWorkflow::class)->review($this->f['validator'], $this->payment, 'confirmed', 'Direito retirado'))->toThrow(HttpException::class);
expect($this->payment->fresh()->status)->toBe('pending')->and(DB::table('pa_payment_decisions')->count())->toBe(0);
});
test('deciding historical pending does not allow new payments', function () {
app(ExternalPaymentWorkflow::class)->review($this->f['validator'], $this->payment, 'confirmed', 'Conferir pendente');
config(['common-payables.history_decisions_enabled' => false]);
expect(app(SupplierPayables::class)->balance($this->f['invoice']))->toBe(5000);
$this->actingAs($this->f['author']);
expect(fn () => app(ExternalPaymentWorkflow::class)->register($this->f['author'], $this->f['invoice'], [...$this->f['payment_data'], 'external_reference' => 'NEW']))->toThrow(HttpException::class);
});
test('unauthorised original changes invalidate balance and prevent decisions', function () {
DB::table('external_payments')->where('id', $this->payment->id)->update(['amount_minor' => 2000]);
expect(fn () => app(ExternalPaymentWorkflow::class)->review($this->f['validator'], $this->payment, 'rejected', 'Não adoptar alteração'))->toThrow(HttpException::class);
expect(DB::table('pa_payment_decisions')->count())->toBe(0)->and($this->payment->fresh()->status)->toBe('pending');
});
test('decision facts cannot be updated or deleted', function () {
app(ExternalPaymentWorkflow::class)->review($this->f['validator'], $this->payment, 'confirmed', 'Conferir antes de testar imutabilidade');
expect(fn () => DB::table('pa_payment_decisions')->update(['actor_id' => 999]))->toThrow(QueryException::class);
expect(fn () => DB::table('pa_payment_decisions')->delete())->toThrow(QueryException::class);
});
test('confirmation rechecks available balance and rejection does not deduct', function () {
$workflow = app(ExternalPaymentWorkflow::class);
$workflow->review($this->f['validator'], $this->payment, 'confirmed', 'Primeiro pagamento confirmado');
expect(fn () => $workflow->review($this->f['validator'], $this->otherPayment, 'confirmed', 'Excede o saldo actualizado'))->toThrow(ValidationException::class);
expect($this->otherPayment->fresh()->status)->toBe('pending')->and(DB::table('pa_payment_decisions')->count())->toBe(1);
$workflow->review($this->f['validator'], $this->otherPayment, 'rejected', 'Rejeitar sem abater saldo');
expect(app(SupplierPayables::class)->balance($this->f['invoice']))->toBe(5000)->and(DB::table('pa_payment_decisions')->count())->toBe(2);
});
test('a full confirmation leaves zero balance and prevents another confirmation', function () {
$workflow = app(ExternalPaymentWorkflow::class);
$workflow->review($this->f['validator'], $this->otherPayment, 'confirmed', 'Conferência integral');
expect(app(SupplierPayables::class)->balance($this->f['invoice']))->toBe(0);
expect(fn () => $workflow->review($this->f['validator'], $this->payment, 'confirmed', 'Saldo já liquidado'))->toThrow(ValidationException::class);
});
+30
View File
@@ -24,6 +24,36 @@ function procurementPayload(int $center, string $price = '100.00', string $tax =
];
}
test('request navigation separates own requests from the complete authorised list', function () {
$this->actingAs($this->requester)->post('/requisicoes', procurementPayload($this->center->id))->assertRedirect();
$admin = procurementUser($this->tenant, $this->department, PurchasingRole::Admin);
$this->actingAs($admin)->post('/requisicoes', procurementPayload($this->center->id))->assertRedirect();
$own = Requisition::query()->where('requester_id', $admin->id)->sole();
$this->get('/requisicoes?view=all')->assertOk()->assertInertia(fn (Assert $page) => $page->where('requisitions.total', 2));
$this->get('/requisicoes?view=mine&status=draft')->assertOk()->assertInertia(fn (Assert $page) => $page->where('requisitions.total', 1)->where('requisitions.data.0.id', $own->id)->where('filters.view', 'mine'));
$foreign = Tenant::query()->create(['name' => 'Empresa externa']);
$department = Department::query()->create(['tenant_id' => $foreign->id, 'name' => 'Outro departamento']);
$outsider = procurementUser($foreign, $department, PurchasingRole::Admin);
$this->actingAs($outsider)->get('/requisicoes?view=all')->assertOk()->assertInertia(fn (Assert $page) => $page->where('requisitions.total', 0));
});
test('approval navigation uses eligibility and keeps personal decisions separate', function () {
$head = procurementUser($this->tenant, $this->department, PurchasingRole::DepartmentHead);
$this->actingAs($this->requester)->post('/requisicoes', procurementPayload($this->center->id))->assertRedirect();
$request = Requisition::query()->sole();
$this->post('/requisicoes/'.$request->id.'/submeter')->assertRedirect();
$this->actingAs($head)->post('/requisicoes', procurementPayload($this->center->id))->assertRedirect();
$own = Requisition::query()->where('requester_id', $head->id)->sole();
$this->post('/requisicoes/'.$own->id.'/submeter')->assertRedirect();
$this->get('/requisicoes?view=approvals')->assertOk()->assertInertia(fn (Assert $page) => $page->where('requisitions.total', 1)->where('requisitions.data.0.id', $request->id));
$this->get('/requisicoes?view=decided')->assertOk()->assertInertia(fn (Assert $page) => $page->where('requisitions.total', 0));
$this->post('/requisicoes/'.$request->id.'/decidir', ['decision' => 'approved'])->assertRedirect();
$this->get('/requisicoes?view=approvals')->assertOk()->assertInertia(fn (Assert $page) => $page->where('requisitions.total', 0));
$this->get('/requisicoes?view=decided')->assertOk()->assertInertia(fn (Assert $page) => $page->where('requisitions.total', 1)->where('requisitions.data.0.id', $request->id));
$this->actingAs($this->requester)->get('/requisicoes?view=decided')->assertOk()->assertInertia(fn (Assert $page) => $page->where('requisitions.total', 0));
$this->getJson('/requisicoes?view=unrestricted')->assertUnprocessable();
});
beforeEach(function () {
$this->tenant = Tenant::query()->create(['name' => 'Empresa A']);
$this->department = Department::query()->create(['tenant_id' => $this->tenant->id, 'name' => 'Operações']);
@@ -0,0 +1,81 @@
<?php
use App\Models\User;
use App\Services\ExternalPaymentWorkflow;
use App\Services\SupplierPayables;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tempo\Finance\Payables\PayablesSchema;
require_once __DIR__.'/../Support/PaymentFixture.php';
beforeEach(function () {
$this->f = paymentFixture();
config(['common-payables.enabled' => true, 'common-payables.source_instance' => 'integration-fixture-instance']);
foreach ([$this->f['author'], $this->f['validator']] as $user) {
foreach (['view', 'prepare', 'validate'] as $action) {
DB::table('pa_access')->insert(['tenant_id' => $user->tenant_id, 'company_id' => 0, 'unit_id' => 0,
'user_id' => $user->id, 'person' => 'fixture-person-'.$user->id, 'action' => $action]);
}
}
$this->actingAs($this->f['author']);
});
test('explicit activation recognises a confirmed invoice once on the normal application schema', function () {
expect(SupplierPayables::enabled())->toBeTrue();
$service = app(SupplierPayables::class);
$prepared = $service->engine($this->f['author'])->command('prepare', $this->f['invoice']->id, 'Conferência de integração sintética', (string) Str::uuid());
expect($prepared['state'])->toBe('prepared');
$this->actingAs($this->f['validator']);
$recognised = $service->engine($this->f['validator'])->command('validate', $prepared['proposal_id'], 'Validação independente de teste', (string) Str::uuid());
expect($recognised['state'])->toBe('recognised');
$service->engine($this->f['validator'])->command('validate', $prepared['proposal_id'], 'Validação repetida de teste', (string) Str::uuid());
expect(DB::table('pa_obligations')->count())->toBe(1)
->and(DB::table('pa_obligations')->value('principal_minor'))->toBe(6000)
->and(DB::table('pa_obligations')->value('due_on'))->toBeNull()
->and(app(ExternalPaymentWorkflow::class)->outstanding($this->f['invoice']))->toBe(6000);
$this->getJson('/api/supplier-payables?invoice='.$this->f['invoice']->id)->assertOk()->assertJsonCount(1, 'obligations');
config(['common-payables.source_instance' => 'different-installation']);
expect(fn () => $service->principal($this->f['invoice']))->toThrow(HttpException::class);
});
test('legacy payments keep their balance and prevent automatic import of a second debt', function () {
$payments = app(ExternalPaymentWorkflow::class);
$payment = $payments->register($this->f['author'], $this->f['invoice'], $this->f['payment_data']);
$this->actingAs($this->f['validator']);
$payments->review($this->f['validator'], $payment, 'confirmed', 'Conferência sintética do histórico');
expect($payments->outstanding($this->f['invoice']))->toBe(2000);
$this->actingAs($this->f['author']);
$prepared = app(SupplierPayables::class)->engine($this->f['author'])->command('prepare', $this->f['invoice']->id, 'Tentativa com pagamento anterior', (string) Str::uuid());
expect($prepared['state'])->toBe('pending_source');
$this->actingAs($this->f['validator']);
$result = app(SupplierPayables::class)->engine($this->f['validator'])->command('validate', $prepared['proposal_id'], 'Rever histórico sem nova dívida', (string) Str::uuid());
expect($result['state'])->toBe('conflict')->and(DB::table('pa_obligations')->count())->toBe(0)
->and($payments->outstanding($this->f['invoice']))->toBe(2000);
});
test('activation does not grant permissions and source queries remain tenant scoped', function () {
DB::table('pa_access')->where('user_id', $this->f['author']->id)->delete();
$this->getJson('/api/supplier-payables')->assertForbidden();
$foreign = User::factory()->create(['tenant_id' => null]);
$this->actingAs($foreign);
expect(fn () => app(SupplierPayables::class)->principal($this->f['invoice']))->toThrow(HttpException::class);
config(['common-payables.enabled' => false]);
expect(SupplierPayables::enabled())->toBeFalse();
});
test('the shared schema preserves histories and refuses repeated or partial installation', function () {
expect(fn () => PayablesSchema::install())->toThrow(LogicException::class);
$service = app(SupplierPayables::class);
$service->engine($this->f['author'])->command('prepare', $this->f['invoice']->id, 'Criar histórico imutável de teste', (string) Str::uuid());
expect(fn () => DB::table('pa_events')->delete())->toThrow(QueryException::class);
});
test('navigation exposes the workspace only while the current actor has explicit view access', function () {
$this->get('/dashboard')->assertOk()->assertInertia(fn ($page) => $page->where('payablesAvailable', true));
DB::table('pa_access')->where('user_id', $this->f['author']->id)->where('action', 'view')->delete();
$this->get('/dashboard')->assertOk()->assertInertia(fn ($page) => $page->where('payablesAvailable', false));
$this->get('/obrigacoes-fornecedores')->assertForbidden();
});