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

221 lines
13 KiB
PHP

<?php
use App\Enums\PurchasingRole;
use App\Models\AuditLog;
use App\Models\BudgetConsumption;
use App\Models\BudgetEntry;
use App\Models\ExternalPayment;
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;
require_once __DIR__.'/../Support/PaymentFixture.php';
beforeEach(function () {
$this->f = paymentFixture();
$this->data = $this->f['payment_data'];
$this->actingAs($this->f['author']);
});
function registerTestPayment(object $test): ExternalPayment
{
$test->actingAs($test->f['author']);
return app(ExternalPaymentWorkflow::class)->register($test->f['author'], $test->f['invoice'], $test->data);
}
function confirmTestPayment(object $test, ExternalPayment $payment, string $decision = 'confirmed'): void
{
$test->actingAs($test->f['validator']);
app(ExternalPaymentWorkflow::class)->review($test->f['validator'], $payment, $decision, 'Conferência independente de teste');
}
test('external payment reduces invoice debt only after independent confirmation without consuming budget twice', function () {
$before = (int) BudgetConsumption::query()->sum('amount_minor');
$commitment = (int) BudgetEntry::query()->sum('committed_minor');
$payment = registerTestPayment($this);
expect(app(ExternalPaymentWorkflow::class)->outstanding($this->f['invoice']))->toBe(6000);
confirmTestPayment($this, $payment);
confirmTestPayment($this, $payment);
expect(app(ExternalPaymentWorkflow::class)->outstanding($this->f['invoice']))->toBe(2000)
->and((int) BudgetConsumption::query()->sum('amount_minor'))->toBe($before)
->and((int) BudgetEntry::query()->sum('committed_minor'))->toBe($commitment)
->and(ExternalPayment::query()->where('status', 'confirmed')->count())->toBe(1);
});
test('external reference retries are idempotent but altered data are refused', function () {
$first = registerTestPayment($this);
$this->data['external_reference'] = ' test-ext-01 ';
expect(registerTestPayment($this)->id)->toBe($first->id);
$this->data['amount'] = '30.00';
expect(fn () => registerTestPayment($this))->toThrow(ValidationException::class);
expect(ExternalPayment::query()->count())->toBe(1);
});
test('payment author cannot confirm own record', function () {
$payment = registerTestPayment($this);
$this->post('/pagamentos/'.$payment->id.'/conferir', ['decision' => 'confirmed', 'reason' => 'Teste', 'independently_verified' => true])->assertForbidden();
});
test('invalid dates amounts and bank versions cannot register a payment', function (string $case) {
match ($case) {
'over' => $this->data['amount'] = '60.01',
'zero' => $this->data['amount'] = '0.00',
'negative' => $this->data['amount'] = '-1.00',
'future' => $this->data['paid_on'] = today()->addDay()->format('Y-m-d'),
'before_invoice' => $this->data['paid_on'] = today()->subDay()->format('Y-m-d'),
'old_year' => $this->data['paid_on'] = today()->subYear()->format('Y-m-d'),
'bank' => $this->data['supplier_bank_detail_id'] = PHP_INT_MAX,
'reference' => $this->data['external_reference'] = ' ',
'evidence' => $this->data['evidence_reference'] = ' ',
};
expect(fn () => registerTestPayment($this))->toThrow(ValidationException::class);
expect(ExternalPayment::query()->count())->toBe(0);
})->with(['over', 'zero', 'negative', 'future', 'before_invoice', 'old_year', 'bank', 'reference', 'evidence']);
test('competing pending payments cannot overpay an invoice when confirmed', function () {
$first = registerTestPayment($this);
$this->data['external_reference'] = 'TEST-EXT-02';
$second = registerTestPayment($this);
confirmTestPayment($this, $first);
expect(fn () => confirmTestPayment($this, $second))->toThrow(ValidationException::class);
expect($second->refresh()->status)->toBe('pending')
->and(app(ExternalPaymentWorkflow::class)->outstanding($this->f['invoice']))->toBe(2000);
});
test('complementary payments settle invoice exactly', function () {
confirmTestPayment($this, registerTestPayment($this));
$this->data['external_reference'] = 'TEST-EXT-02';
$this->data['amount'] = '20.00';
confirmTestPayment($this, registerTestPayment($this));
expect(app(ExternalPaymentWorkflow::class)->outstanding($this->f['invoice']))->toBe(0);
});
test('bank revision changed after registration blocks confirmation and preserves encrypted snapshot', function () {
$payment = registerTestPayment($this);
$this->actingAs($this->f['issuer']);
app(SupplierBankWorkflow::class)->propose($this->f['issuer'], $this->f['supplier'], [...$this->f['bank_data'], 'previous_id' => $this->f['bank']->id, 'account_identifier' => 'TEST-NEW-ACCOUNT']);
expect(fn () => confirmTestPayment($this, $payment))->toThrow(ValidationException::class);
expect($payment->refresh()->bank_snapshot['account_identifier'])->toBe('TEST-PAY-ACCOUNT');
});
test('loss of reviewer eligibility or supplier eligibility blocks confirmation', function (string $case) {
$payment = registerTestPayment($this);
if ($case === 'reviewer') {
$this->f['author']->forceFill(['purchasing_role' => PurchasingRole::Warehouse])->save();
} else {
$this->f['supplier']->update(['status' => 'suspended']);
}
expect(fn () => confirmTestPayment($this, $payment))->toThrow(ValidationException::class);
})->with(['reviewer', 'supplier']);
test('rejected evidence leaves invoice outstanding and decision cannot be changed', function () {
$payment = registerTestPayment($this);
confirmTestPayment($this, $payment, 'rejected');
expect(app(ExternalPaymentWorkflow::class)->outstanding($this->f['invoice']))->toBe(6000);
expect(fn () => confirmTestPayment($this, $payment))->toThrow(ValidationException::class);
});
test('payment audit failure rolls back registration and confirmation', function () {
$payment = registerTestPayment($this);
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->andThrow(new RuntimeException('audit failed'));
expect(fn () => confirmTestPayment($this, $payment))->toThrow(RuntimeException::class);
expect($payment->refresh()->status)->toBe('pending');
$this->data['external_reference'] = 'TEST-EXT-02';
expect(fn () => registerTestPayment($this))->toThrow(RuntimeException::class);
expect(ExternalPayment::query()->count())->toBe(1);
});
test('payment references and bank snapshot are encrypted hidden and absent from audit', function () {
$payment = registerTestPayment($this);
expect(DB::table('external_payments')->where('id', $payment->id)->value('external_reference'))->not->toBe($this->data['external_reference']);
expect($payment->toArray())->not->toHaveKey('bank_snapshot')->not->toHaveKey('external_reference');
$audit = json_encode(AuditLog::query()->get()->toArray());
expect($audit)->not->toContain('TEST-PAY-ACCOUNT')->not->toContain('TEST-EXT-01');
expect(fn () => $payment->update(['amount_minor' => 1]))->toThrow(LogicException::class);
expect(fn () => $payment->refresh()->delete())->toThrow(LogicException::class);
});
test('foreign finance cannot inspect or confirm payment', function () {
$payment = registerTestPayment($this);
$other = receiptFixture();
$user = User::factory()->create(['tenant_id' => $other['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
$this->actingAs($user)->post('/pagamentos/'.$payment->id.'/conferir', ['decision' => 'confirmed', 'reason' => 'Teste', 'independently_verified' => true])->assertNotFound();
$this->get('/pagamentos')->assertOk()->assertInertia(fn ($page) => $page->has('payments.data', 0));
});
test('non finance roles cannot register payments', function (PurchasingRole $role) {
$user = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => $role]);
$this->actingAs($user)->post('/facturas/'.$this->f['invoice']->id.'/pagamentos', $this->data)->assertForbidden();
})->with([PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement, PurchasingRole::Requester]);
test('administration sees payment metadata without external evidence or banking snapshots', function () {
registerTestPayment($this);
$admin = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => PurchasingRole::Admin]);
$this->actingAs($admin)->get('/pagamentos')->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn ($page) => $page->where('payments.data.0.external_reference', null)->where('payments.data.0.evidence_reference', null)->missing('payments.data.0.bank_snapshot'));
});
test('invalid submission does not flash external references', function () {
$this->post('/facturas/'.$this->f['invoice']->id.'/pagamentos', [...$this->data, 'paid_on' => 'invalid'])->assertSessionHasErrors('paid_on');
expect(session()->getOldInput('external_reference'))->toBeNull()->and(session()->getOldInput('evidence_reference'))->toBeNull();
});
test('payment review requires explicit independent verification', function () {
$payment = registerTestPayment($this);
$this->actingAs($this->f['validator'])->post('/pagamentos/'.$payment->id.'/conferir', ['decision' => 'confirmed', 'reason' => 'Teste'])->assertSessionHasErrors('independently_verified');
expect($payment->refresh()->status)->toBe('pending');
});
test('conflict declared after payment registration blocks confirmation', function () {
$payment = registerTestPayment($this);
$this->post('/conflitos', ['scope' => 'supplier', 'supplier_id' => $this->f['supplier']->id, 'has_conflict' => true, 'known_since' => today()->format('Y-m-d'), 'reason' => 'Conflito fictício posterior ao registo externo.'])->assertSessionHasNoErrors();
expect(fn () => confirmTestPayment($this, $payment))->toThrow(ValidationException::class);
expect($payment->refresh()->status)->toBe('pending');
});
test('bank account in another currency does not imply permission to convert money', function () {
$this->actingAs($this->f['issuer']);
$bank = app(SupplierBankWorkflow::class)->propose($this->f['issuer'], $this->f['supplier'], [...$this->f['bank_data'], 'previous_id' => $this->f['bank']->id, 'currency' => 'USD']);
foreach ([$this->f['author'], $this->f['validator']] as $user) {
$this->actingAs($user);
app(SupplierBankWorkflow::class)->review($user, $bank, 'approved', 'Conferência de teste');
}
$this->data['supplier_bank_detail_id'] = $bank->id;
expect(fn () => registerTestPayment($this))->toThrow(ValidationException::class);
});
test('browser cannot forge a confirmed payment or its encrypted banking snapshot', function () {
$this->post('/facturas/'.$this->f['invoice']->id.'/pagamentos', [...$this->data, 'status' => 'confirmed', 'reviewed_by' => $this->f['author']->id, 'tenant_id' => PHP_INT_MAX, 'bank_snapshot' => ['account_identifier' => 'FORGED']])->assertRedirect('/pagamentos');
$payment = ExternalPayment::query()->firstOrFail();
expect($payment->status)->toBe('pending')->and($payment->tenant_id)->toBe($this->f['tenant']->id)
->and($payment->bank_snapshot['account_identifier'])->toBe('TEST-PAY-ACCOUNT');
});
test('public payment example exposes no company payment data', function () {
auth()->forgetGuards();
$this->get('/consulta/pagamentos')->assertOk()->assertInertia(fn ($page) => $page->where('initialScreen', 'payments')->where('auth.user', null)->missing('payments')->missing('invoices'));
});
test('financial role is reloaded after the company lock rather than trusting a stale session user', function () {
DB::table('users')->where('id', $this->f['author']->id)->update(['purchasing_role' => 'warehouse']);
$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);
});