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