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); }); test('task inbox delegates imported payment authority without exposing or deciding the ledger', function () { $before = [DB::table('pa_payment_decisions')->count(), DB::table('audit_logs')->count(), $this->frozen]; $this->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 2)); config(['common-payables.history_decisions_enabled' => false]); $this->get('/requisicoes/tarefas?module=finance')->assertOk()->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); config(['common-payables.history_decisions_enabled' => true]); DB::table('pa_access')->where('user_id', $this->f['validator']->id)->where('action', 'validate')->delete(); $this->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); config(['common-payables.source_instance' => 'different-installation']); $this->get('/requisicoes/tarefas?module=finance')->assertOk()->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); expect([DB::table('pa_payment_decisions')->count(), DB::table('audit_logs')->count(), Payables::canonical(['imports' => DB::table('pa_history_imports')->get()->toArray(), 'payments' => DB::table('pa_imported_payments')->get()->toArray()])])->toEqual($before); });