Files
compras/tests/Feature/PayablePaymentDecisionTest.php
T
nala 413c201d97
tests / ci (push) Canceled after 0s
commit5
2026-10-29 22:03:09 +02:00

110 lines
8.2 KiB
PHP

<?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);
});