f = paymentFixture(); config(['common-payables.enabled' => true, 'common-payables.history_enabled' => true, 'common-payables.source_instance' => 'history-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' => 'history-person-'.$user->id, 'action' => $action]); } } $this->actingAs($this->f['author']); }); function originalHistoryPayment(object $test, string $amount, string $suffix, string $status = 'pending') { $test->actingAs($test->f['author']); $workflow = app(ExternalPaymentWorkflow::class); $payment = $workflow->register($test->f['author'], $test->f['invoice'], [...$test->f['payment_data'], 'amount' => $amount, 'external_reference' => 'HISTORY-'.$suffix]); if ($status !== 'pending') { $test->actingAs($test->f['validator']); $workflow->review($test->f['validator'], $payment, $status, 'Decisão histórica sintética'); } return $payment; } function prepareOriginalHistory(object $test): array { $test->actingAs($test->f['author']); return app(SupplierPayables::class)->engine($test->f['author'])->command('prepare_history', $test->f['invoice']->id, 'Passagem sintética de histórico original', (string) Str::uuid()); } function businessHistoryFingerprint(): string { $rows = []; foreach (['invoices', 'invoice_items', 'external_payments', 'budget_entries', 'budget_consumptions', 'stock_movements'] as $table) { $rows[$table] = DB::table($table)->orderBy('id')->get()->map(fn ($row) => (array) $row)->all(); } return hash('sha256', Payables::canonical($rows)); } test('historical import keeps gross principal and subtracts confirmed payments exactly once', function () { originalHistoryPayment($this, '30.00', 'confirmed', 'confirmed'); originalHistoryPayment($this, '10.00', 'pending'); originalHistoryPayment($this, '5.00', 'rejected', 'rejected'); $before = businessHistoryFingerprint(); $proposal = prepareOriginalHistory($this); expect($proposal['state'])->toBe('prepared'); $service = app(SupplierPayables::class); expect(fn () => $service->engine($this->f['author'])->command('validate_history', $proposal['proposal_id'], 'Não validar proposta própria', (string) Str::uuid()))->toThrow(DomainException::class); $this->actingAs($this->f['validator']); $key = (string) Str::uuid(); $result = $service->engine($this->f['validator'])->command('validate_history', $proposal['proposal_id'], 'Validação distinta do histórico', $key); expect($result['state'])->toBe('recognised'); expect($service->engine($this->f['validator'])->command('validate_history', $proposal['proposal_id'], 'Validação distinta do histórico', $key)['replayed'])->toBeTrue(); $service->engine($this->f['validator'])->command('validate_history', $proposal['proposal_id'], 'Outra chave, mesma responsabilidade', (string) Str::uuid()); expect(DB::table('pa_obligations')->count())->toBe(1)->and(DB::table('pa_history_imports')->count())->toBe(1) ->and(DB::table('pa_imported_payments')->count())->toBe(3) ->and($service->principal($this->f['invoice']))->toBe(6000)->and($service->balance($this->f['invoice']))->toBe(3000) ->and(app(ExternalPaymentWorkflow::class)->outstanding($this->f['invoice']))->toBe(3000) ->and(businessHistoryFingerprint())->toBe($before); $this->getJson('/api/supplier-payables?invoice='.$this->f['invoice']->id)->assertOk() ->assertJsonPath('obligations.0.principal_minor', 6000)->assertJsonPath('obligations.0.balance_minor', 3000) ->assertJsonPath('obligations.0.pending_payments_minor', 1000)->assertJsonPath('obligations.0.rejected_payments_minor', 500) ->assertJsonPath('obligations.0.due_on', null)->assertJsonPath('obligations.0.history_imported', true); $this->get('/obrigacoes-fornecedores')->assertOk()->assertSee('Saldo por liquidar: 30.00 MZN', false)->assertSee('sem abatimento'); $snapshots = DB::table('pa_history_imports')->value('snapshot'); expect($snapshots)->not->toContain('account_identifier')->not->toContain('external_reference')->not->toContain('TEST-PAY-ACCOUNT'); $recovered = prepareOriginalHistory($this); expect($recovered['obligation_id'])->toBe($result['obligation_id'])->and(DB::table('pa_obligations')->count())->toBe(1); }); test('a decision between proposal and validation invalidates the captured history', function () { originalHistoryPayment($this, '30.00', 'confirmed', 'confirmed'); $pending = originalHistoryPayment($this, '10.00', 'pending'); $proposal = prepareOriginalHistory($this); $this->actingAs($this->f['validator']); app(ExternalPaymentWorkflow::class)->review($this->f['validator'], $pending, 'confirmed', 'Confirmado antes da passagem'); $service = app(SupplierPayables::class); $result = $service->engine($this->f['validator'])->command('validate_history', $proposal['proposal_id'], 'Rever fotografia anterior', (string) Str::uuid()); expect($result['state'])->toBe('conflict')->and(DB::table('pa_obligations')->count())->toBe(0) ->and(app(ExternalPaymentWorkflow::class)->outstanding($this->f['invoice']))->toBe(2000); $new = prepareOriginalHistory($this); $this->actingAs($this->f['validator']); $service->engine($this->f['validator'])->command('validate_history', $new['proposal_id'], 'Validar histórico actualizado', (string) Str::uuid()); expect($service->balance($this->f['invoice']))->toBe(2000); }); test('pending-only and fully paid histories retain the correct balances', function (string $amount, string $status, int $balance) { originalHistoryPayment($this, $amount, 'case', $status); $proposal = prepareOriginalHistory($this); $this->actingAs($this->f['validator']); $service = app(SupplierPayables::class); $service->engine($this->f['validator'])->command('validate_history', $proposal['proposal_id'], 'Conferência de saldo extremo', (string) Str::uuid()); expect($service->principal($this->f['invoice']))->toBe(6000)->and($service->balance($this->f['invoice']))->toBe($balance); })->with([['10.00', 'pending', 6000], ['60.00', 'confirmed', 0]]); test('import does not enable new payments or confirmations of pending history', function () { $pending = originalHistoryPayment($this, '10.00', 'pending'); $proposal = prepareOriginalHistory($this); $this->actingAs($this->f['validator']); app(SupplierPayables::class)->engine($this->f['validator'])->command('validate_history', $proposal['proposal_id'], 'Transferir sem executar pagamentos', (string) Str::uuid()); expect(fn () => app(ExternalPaymentWorkflow::class)->review($this->f['validator'], $pending, 'confirmed', 'Não confirmar depois da passagem'))->toThrow(HttpException::class); $this->actingAs($this->f['author']); expect(fn () => app(ExternalPaymentWorkflow::class)->register($this->f['author'], $this->f['invoice'], [...$this->f['payment_data'], 'external_reference' => 'NEW-PAYMENT']))->toThrow(HttpException::class); expect(DB::table('external_payments')->value('status'))->toBe('pending'); }); test('missing audit and unapproved endpoints cannot import history', function () { originalHistoryPayment($this, '10.00', 'pending'); DB::table('audit_logs')->where('action', 'payment.registered')->update(['action' => 'unverified.registration']); expect(fn () => prepareOriginalHistory($this))->toThrow(DomainException::class); config(['common-payables.history_enabled' => false]); $this->postJson('/api/supplier-payables/prepare_history', ['id' => $this->f['invoice']->id, 'reason' => 'Não activado'])->assertForbidden(); expect(DB::table('pa_obligations')->count())->toBe(0); }); test('failure during capture rolls back obligation and all imported facts', function () { originalHistoryPayment($this, '10.00', 'pending'); $proposal = prepareOriginalHistory($this); $fail = true; DB::listen(function (QueryExecuted $event) use (&$fail) { if ($fail && str_contains($event->sql, 'insert into "pa_imported_payments"')) { $fail = false; throw new RuntimeException('Injected historical link failure'); } }); $this->actingAs($this->f['validator']); expect(fn () => app(SupplierPayables::class)->engine($this->f['validator'])->command('validate_history', $proposal['proposal_id'], 'Teste de atomicidade', (string) Str::uuid()))->toThrow(RuntimeException::class); expect(DB::table('pa_obligations')->count())->toBe(0)->and(DB::table('pa_history_imports')->count())->toBe(0)->and(DB::table('pa_imported_payments')->count())->toBe(0); app(SupplierPayables::class)->engine($this->f['validator'])->command('validate_history', $proposal['proposal_id'], 'Retoma após falha', (string) Str::uuid()); expect(DB::table('pa_obligations')->count())->toBe(1); }); test('imported ledger is append-only and disabling the history cannot silently restore the old balance', function () { originalHistoryPayment($this, '30.00', 'confirmed', 'confirmed'); $proposal = prepareOriginalHistory($this); $this->actingAs($this->f['validator']); app(SupplierPayables::class)->engine($this->f['validator'])->command('validate_history', $proposal['proposal_id'], 'Preservar responsabilidade única', (string) Str::uuid()); expect(fn () => DB::table('pa_history_imports')->update(['confirmed_minor' => 0]))->toThrow(QueryException::class); expect(fn () => DB::table('pa_imported_payments')->delete())->toThrow(QueryException::class); config(['common-payables.history_enabled' => false]); expect(fn () => app(ExternalPaymentWorkflow::class)->outstanding($this->f['invoice']))->toThrow(HttpException::class); config(['common-payables.enabled' => false]); expect(fn () => app(ExternalPaymentWorkflow::class)->outstanding($this->f['invoice']))->toThrow(HttpException::class); }); test('normalizer refuses duplicated omitted or incompatible payment facts', function () { originalHistoryPayment($this, '30.00', 'confirmed', 'confirmed'); originalHistoryPayment($this, '10.00', 'pending'); $proposal = prepareOriginalHistory($this); $snapshot = json_decode(DB::table('pa_proposals')->where('id', $proposal['proposal_id'])->value('snapshot'), true); $source = app(SupplierPayables::class)->document($this->f['author'], $this->f['invoice']->id); $rows = $snapshot['_history_import']['payments']; expect(fn () => HistoricalPayments::capture($source, [$rows[0]]))->toThrow(DomainException::class); expect(fn () => HistoricalPayments::capture($source, [$rows[0], $rows[0]]))->toThrow(DomainException::class); foreach (['currency' => 'USD', 'document' => 999, 'amount_minor' => 7000, 'reviewed_by' => $rows[0]['created_by'], 'decision_audit_id' => null, 'status' => 'unknown'] as $field => $value) { $changed = $rows; $changed[0][$field] = $value; expect(fn () => HistoricalPayments::capture($source, $changed))->toThrow(DomainException::class); } }); test('two installation identities cannot recognise two obligations for the same local invoice', function () { originalHistoryPayment($this, '10.00', 'pending'); $first = prepareOriginalHistory($this); config(['common-payables.source_instance' => 'changed-before-recognition']); $second = prepareOriginalHistory($this); config(['common-payables.source_instance' => 'history-fixture']); $this->actingAs($this->f['validator']); app(SupplierPayables::class)->engine($this->f['validator'])->command('validate_history', $first['proposal_id'], 'Primeira identidade conferida', (string) Str::uuid()); config(['common-payables.source_instance' => 'changed-before-recognition']); expect(fn () => app(SupplierPayables::class)->engine($this->f['validator'])->command('validate_history', $second['proposal_id'], 'Não duplicar após mudança de configuração', (string) Str::uuid()))->toThrow(DomainException::class); expect(fn () => app(SupplierPayables::class)->engine($this->f['validator'])->positionForSource($this->f['invoice']->id))->toThrow(DomainException::class); expect(DB::table('pa_obligations')->count())->toBe(1)->and(DB::table('pa_imported_payments')->count())->toBe(1); }); test('the original 1160 example keeps 300 confirmed and 100 pending separate', function () { $source = ['instance' => 'example', 'company' => 1, 'document' => 1, 'currency' => 'MZN', 'total_minor' => 116000, 'payment_count' => 2]; $payment = ['id' => 1, 'company' => 1, 'document' => 1, 'amount_minor' => 30000, 'currency' => 'MZN', 'status' => 'confirmed', 'created_by' => 2, 'paid_on' => '2026-10-29', 'registration_audit_id' => 1, 'reviewed_by' => 3, 'reviewed_at' => '2026-10-29 12:00:00', 'decision_audit_id' => 2]; $pending = [...$payment, 'id' => 2, 'amount_minor' => 10000, 'status' => 'pending', 'reviewed_by' => null, 'reviewed_at' => null, 'decision_audit_id' => null]; $capture = HistoricalPayments::capture($source, [$payment, $pending]); expect($capture['confirmed_minor'])->toBe(30000)->and($capture['pending_minor'])->toBe(10000) ->and($source['total_minor'] - $capture['confirmed_minor'])->toBe(86000); });