110 lines
8.2 KiB
PHP
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);
|
|
});
|