Files
compras/tests/Feature/PayableHistoryImportTest.php
T
2026-11-02 20:27:11 +02:00

201 lines
14 KiB
PHP

<?php
use App\Services\ExternalPaymentWorkflow;
use App\Services\SupplierPayables;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tempo\Finance\Payables\HistoricalPayments;
use Tempo\Finance\Payables\Payables;
require_once __DIR__.'/../Support/PaymentFixture.php';
beforeEach(function () {
$this->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::transaction(fn () => DB::table('pa_history_imports')->update(['confirmed_minor' => 0])))->toThrow(QueryException::class);
expect(fn () => DB::transaction(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);
});