207 lines
12 KiB
PHP
207 lines
12 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 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);
|
|
});
|