Files
SGF/scripts/settlements-probe.php
T
2026-11-02 08:06:45 +02:00

163 lines
16 KiB
PHP

<?php
// Adversarial operations exclusively in disposable proof databases, never in team demos.
$mode = $argv[1] ?? ''; $job = $argv[2] ?? '';
if (! in_array($mode, ['compras', 'sgf'], true)) throw new LogicException('Unknown proof host.');
$private = 'C:/laragon/data/sgf-tempo/secrets/';
$s = json_decode(file_get_contents($private.'payables-proof-'.$mode.'.json'), true, 32, JSON_THROW_ON_ERROR);
$team = json_decode(file_get_contents($private.'payables-demo-'.$mode.'.json'), true, 32, JSON_THROW_ON_ERROR);
if ($s['DB_DATABASE'] === $team['DB_DATABASE']) throw new LogicException('Team demonstration protected.');
foreach ($s as $k => $v) { putenv($k.'='.$v); $_ENV[$k] = $_SERVER[$k] = $v; }
require $s['HOST_ROOT'].'/vendor/autoload.php'; $app = require $s['HOST_ROOT'].'/bootstrap/app.php'; $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use Illuminate\Support\Facades\DB;
use Symfony\Component\Process\Process;
use Tempo\Finance\Payables\{Payables, SupplierSettlements, SettlementSchema};
$db = DB::connection();
if (! preg_match('/^(compras_receipts_test_|payable_sgf_)[a-f0-9]{12}$/D', $db->getDatabaseName()) || $db->selectOne('select current_database() as d')->d !== $s['DB_DATABASE']) throw new LogicException('QA target refused.');
$f = json_decode(file_get_contents($s['PAYABLE_ACCESS_FILE']), true, 32, JSON_THROW_ON_ERROR);
$input = json_decode(stream_get_contents(STDIN) ?: '{}', true, 64, JSON_THROW_ON_ERROR);
$class = $mode === 'compras' ? \App\Services\SupplierPayables::class : \App\Modules\Purchasing\SupplierPayables::class;
$select = function (string $role) use ($f, $mode) {
$u = \App\Models\User::findOrFail($f['accounts'][$role]['id']); auth()->guard()->setUser($u);
if ($mode === 'sgf') app(\App\Shared\Context\CompanyContext::class)->selectFor($u->id, $f['scope']['tenant_id'], $f['scope']['company_id'], $f['scope']['unit_id']);
return $u;
};
if ($job === 'inspect') {
$counts = []; foreach (array_merge(SettlementSchema::TABLES, \Tempo\Finance\Payables\ReversalSchema::TABLES) as $table) if ($db->getSchemaBuilder()->hasTable($table)) $counts[$table] = $db->table($table)->count();
$out = ['counts' => $counts, 'allocation_events' => $db->table('ps_events')->where('kind', 'supplier_allocation.confirmed')->count(),
'core_sha256' => hash_file('sha256', __DIR__.'/../packages/payables/src/SupplierSettlements.php'),
'original_hash' => hash('sha256', Payables::canonical([$db->table('ps_payments')->orderBy('id')->get()->all(), $db->table('ps_payment_decisions')->orderBy('id')->get()->all(), $db->table('ps_allocations')->orderBy('id')->first(), $db->table('pa_obligations')->orderBy('id')->get()->all()]))];
} elseif ($job === 'worker') {
$u = $select($input['role']); $db->select('select set_config(?, ?, false)', ['application_name', $input['label']]);
try { $out = ['status' => 200, 'result' => app($class)->engine($u)->settlements()->command($input['action'], $input['body'], $input['key'])]; }
catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { $out = ['status' => $e->getStatusCode()]; }
catch (\DomainException $e) { $out = ['status' => 409, 'message' => $e->getMessage()]; }
catch (\InvalidArgumentException $e) { $out = ['status' => 422]; }
} elseif ($job === 'race') {
$db->beginTransaction(); $db->table($mode === 'compras' ? 'tenants' : 'companies')->where('id', $f['scope'][$mode === 'compras' ? 'tenant_id' : 'company_id'])->lockForUpdate()->firstOrFail();
$processes = [];
try {
foreach ([0, 1] as $i) {
$command = $input['commands'][$i] ?? $input;
$p = new Process([PHP_BINARY, __FILE__, $mode, 'worker'], $s['HOST_ROOT']);
$p->setInput(json_encode($command + ['key' => \Illuminate\Support\Str::uuid()->toString(), 'label' => 'ps_proof_'.$i], JSON_THROW_ON_ERROR));
$p->setTimeout(35); $p->start(); $processes[] = $p;
}
config(['database.connections.ps_monitor' => $db->getConfig()]); $monitor = DB::connection('ps_monitor'); $deadline = microtime(true) + 18; $waits = 0;
do {
$waits = (int) $monitor->selectOne("select count(*) as n from pg_stat_activity where datname=? and application_name in ('ps_proof_0','ps_proof_1') and wait_event_type='Lock'", [$db->getDatabaseName()])->n;
if ($waits === 2) break;
foreach ($processes as $p) if (! $p->isRunning()) throw new RuntimeException('Worker ended before overlap: '.$p->getOutput().$p->getErrorOutput());
usleep(20000);
} while (microtime(true) < $deadline);
if ($waits !== 2) throw new RuntimeException('Two lock waits required.');
if (isset($input['revoke'])) $db->table('pa_access')->where($f['scope'])->where(['user_id' => $f['accounts']['validacao']['id'], 'action' => $input['revoke']])->delete();
$db->commit(); $results = [];
foreach ($processes as $p) { $p->wait(); if (! $p->isSuccessful()) throw new RuntimeException($p->getOutput().$p->getErrorOutput()); $results[] = json_decode($p->getOutput(), true, 32, JSON_THROW_ON_ERROR); }
$out = ['waits' => $waits, 'results' => $results];
} finally { if ($db->transactionLevel()) $db->rollBack(); foreach ($processes as $p) if ($p->isRunning()) $p->stop(); }
} elseif ($job === 'fault') {
$kind = $input['kind'] ?? 'supplier_allocation.confirmed';
if (! in_array($kind, ['supplier_allocation.confirmed', 'supplier_allocation.reversed'], true)) throw new LogicException('Invalid proof fault kind.');
if ($input['enabled']) $db->unprepared("CREATE FUNCTION ps_qa_fault() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.kind='{$kind}' THEN RAISE EXCEPTION 'PS_QA_FAULT'; END IF; RETURN NEW; END $$; CREATE TRIGGER ps_qa_fault BEFORE INSERT ON ps_events FOR EACH ROW EXECUTE FUNCTION ps_qa_fault()");
else $db->unprepared('DROP TRIGGER ps_qa_fault ON ps_events; DROP FUNCTION ps_qa_fault()');
$out = ['configured' => true];
} elseif ($job === 'grant') {
if (! in_array($input['action'], ['payments_view', 'payment_prepare', 'payment_confirm', 'allocation_prepare', 'allocation_confirm', 'reversal_prepare', 'reversal_confirm'], true)) throw new LogicException('Invalid QA action.');
$role = $input['role']; $match = $f['scope'] + ['user_id' => $f['accounts'][$role]['id'], 'action' => $input['action']];
if ($input['enabled']) $db->table('pa_access')->insertOrIgnore($match + ['person' => 'synthetic-person-'.$role]); else $db->table('pa_access')->where($match)->delete();
$out = ['changed' => true];
} elseif ($job === 'person') {
$db->table('pa_access')->where($f['scope'])->where(['user_id' => $f['accounts']['validacao']['id'], 'action' => 'view'])->update(['person' => $input['same'] ? 'synthetic-person-preparacao' : 'synthetic-person-validacao']);
$out = ['changed' => true];
} elseif ($job === 'reversal-immutable') {
$refused = 0;
foreach (\Tempo\Finance\Payables\ReversalSchema::TABLES as $table) {
foreach (['update', 'delete'] as $operation) {
try { DB::transaction(function () use ($db, $table, $operation): void {
$q = $db->table($table)->where('id', $db->table($table)->min('id'));
if ($operation === 'delete') $q->delete(); else $q->update(['reason' => 'Forbidden alteration']);
}); } catch (\Illuminate\Database\QueryException) { $refused++; }
}
}
$row = (array) $db->table('ps_reversals')->first(); unset($row['id']);
$proposal = $db->table('ps_reversal_proposals')->where('id', $row['proposal_id'])->first();
$invalid = 0;
foreach ([['amount_minor' => (int) $row['amount_minor'] - 1], ['actor_id' => $proposal->prepared_by, 'person' => $proposal->prepared_person], ['unit_id' => 999999]] as $override) {
try { DB::transaction(fn () => $db->table('ps_reversals')->insert(array_replace($row, $override))); }
catch (\Illuminate\Database\QueryException $e) {
if (! str_contains($e->getMessage(), 'Invalid full allocation compensation')) throw $e;
$invalid++;
}
}
$out = ['immutable_operations_refused' => $refused, 'invalid_compensations_refused' => $invalid];
} elseif ($job === 'immutable') {
$refused = 0;
foreach (['ps_payments', 'ps_payment_decisions', 'ps_allocations'] as $table) {
try { DB::transaction(fn () => $db->table($table)->where('id', $db->table($table)->min('id'))->delete()); }
catch (\Illuminate\Database\QueryException) { $refused++; }
}
$out = ['refused' => $refused];
} elseif ($job === 'foreign') {
$now = now(); $tenant = $db->table('tenants')->insertGetId(['name' => 'Outro contexto sintético', 'created_at' => $now, 'updated_at' => $now]);
if ($mode === 'compras') {
$u = \App\Models\User::factory()->create(['tenant_id' => $tenant, 'purchasing_role' => 'finance']); $scope = ['tenant_id' => $tenant, 'company_id' => 0, 'unit_id' => 0];
} else {
$company = $db->table('companies')->insertGetId(['tenant_id' => $tenant, 'name' => 'Outra empresa sintética', 'created_at' => $now, 'updated_at' => $now]);
$unit = $db->table('units')->insertGetId(['tenant_id' => $tenant, 'company_id' => $company, 'name' => 'Outra unidade', 'created_at' => $now, 'updated_at' => $now]);
$scope = ['tenant_id' => $tenant, 'company_id' => $company, 'unit_id' => $unit];
$u = \App\Models\User::create(['name' => 'Outro actor sintético', 'email' => 'foreign-settlement@example.test', 'password' => bin2hex(random_bytes(20))]);
$db->table('company_memberships')->insert(['user_id' => $u->id, 'tenant_id' => $tenant, 'company_id' => $company]);
foreach (['company.view' => null, 'unit.view' => $unit, 'supplier_payables.view' => $unit] as $permission => $which) $db->table('permission_grants')->insert(['tenant_id' => $tenant, 'company_id' => $company, 'unit_id' => $which, 'permission' => $permission, 'user_id' => $u->id, 'created_at' => $now, 'updated_at' => $now]);
}
foreach (['view', 'payments_view', 'payment_confirm', 'allocation_confirm', 'reversal_prepare', 'reversal_confirm'] as $action) $db->table('pa_access')->insert($scope + ['user_id' => $u->id, 'action' => $action, 'person' => 'foreign-synthetic-person']);
auth()->guard()->setUser($u); if ($mode === 'sgf') app(\App\Shared\Context\CompanyContext::class)->selectFor($u->id, $tenant, $company, $unit);
try { app($class)->engine($u)->settlements()->command($input['action'] ?? 'payment-decide', $input['body'], \Illuminate\Support\Str::uuid()->toString()); $out = ['refused' => false]; }
catch (\DomainException) { $out = ['refused' => true]; }
} elseif ($job === 'multi') {
// Core contract test with explicit synthetic source adapter, distinct from the real host source proof.
$base = json_decode(file_get_contents($s['PAYABLE_BUNDLE']), true, 64, JSON_THROW_ON_ERROR);
$scope = ['tenant_id' => 900001, 'company_id' => 900002, 'unit_id' => 900003]; $sources = [];
foreach ([1 => 1160000, 2 => 500000, 3 => 100000] as $id => $minor) {
$sources[$id] = array_replace($base, ['instance' => 'synthetic-core-multi', 'company' => 900002, 'document' => $id, 'local_id' => $id,
'number' => 'SIM-CORE-'.$id, 'subtotal_minor' => $minor, 'tax_minor' => 0, 'total_minor' => $minor, 'supplier_id' => $id === 3 ? 999 : $base['supplier_id'],
'lines' => [['id' => $id, 'order_line' => $id, 'quantity_milli' => 1000, 'subtotal_minor' => $minor, 'tax_minor' => 0, 'snapshot' => ['synthetic_core_fixture' => true]]]]);
}
$sources[4] = array_replace($sources[1], ['document' => 4, 'local_id' => 4, 'number' => 'SIM-CORE-UNIT-4']);
$engine = static function ($actor, ?array $otherScope = null) use ($db, $scope, $sources): Payables {
return new Payables($db, $actor, 'core-person-'.$actor, $otherScope ?? $scope, static function ($action) use ($actor) {
$allowed = $actor === 1 ? ['view', 'prepare', 'payments_view', 'payment_prepare', 'allocation_prepare'] : ['view', 'validate', 'payments_view', 'payment_confirm', 'allocation_confirm'];
if (! in_array($action, $allowed, true)) throw new DomainException('Core role refused.');
}, static fn ($id) => $sources[$id] ?? throw new DomainException('Unknown core source.'));
};
$p = $engine(1); $v = $engine(2); $uuid = static fn () => \Illuminate\Support\Str::uuid()->toString();
$obs = [];
foreach ([1, 2, 3] as $id) { $proposal = $p->command('prepare', $id, 'Core synthetic source', $uuid()); $obs[$id] = $v->command('validate', $proposal['proposal_id'], 'Independent core recognition', $uuid())['obligation_id']; }
$pay = static function ($ref, $amount) use ($p, $v, $obs, $sources, $uuid) {
$r = $p->settlements()->command('payment-prepare', ['obligation_id' => $obs[1], 'source_revision' => Payables::revision($sources[1]), 'amount' => $amount, 'currency' => 'MZN', 'paid_on' => date('Y-m-d'), 'fact_reference' => $ref, 'evidence_reference' => $ref.'-EVID', 'reason' => 'Core synthetic payment'], $uuid());
$v->settlements()->command('payment-decide', ['payment_id' => $r['payment_id'], 'decision' => 'confirmed', 'evidence_reference' => $ref.'-CONF', 'reason' => 'Independent core confirmation'], $uuid()); return $r['payment_id'];
};
$payment = $pay('SIM-MULTI-001', '14000.00');
$plan = ['payment_id' => $payment, 'reference' => 'SIM-MULTI-ALLOC-001', 'reason' => 'Explicit multi-document allocation', 'allocations' => [
['obligation_id' => $obs[1], 'source_revision' => Payables::revision($sources[1]), 'amount' => '11600.00'],
['obligation_id' => $obs[2], 'source_revision' => Payables::revision($sources[2]), 'amount' => '2400.00']]];
$proposal = $p->settlements()->command('allocation-prepare', $plan, $uuid());
$v->settlements()->command('allocation-decide', ['proposal_id' => $proposal['proposal_id'], 'decision' => 'confirmed', 'reason' => 'Independent multi allocation'], $uuid());
$payment2 = $pay('SIM-MULTI-002', '3000.00');
$proposal2 = $p->settlements()->command('allocation-prepare', ['payment_id' => $payment2, 'reference' => 'SIM-MULTI-ALLOC-002', 'reason' => 'Second payment allocation', 'allocations' => [['obligation_id' => $obs[2], 'source_revision' => Payables::revision($sources[2]), 'amount' => '2600.00']]], $uuid());
$v->settlements()->command('allocation-decide', ['proposal_id' => $proposal2['proposal_id'], 'decision' => 'confirmed', 'reason' => 'Independent second allocation'], $uuid());
$foreign = false;
try { $p->settlements()->command('allocation-prepare', ['payment_id' => $payment2, 'reference' => 'SIM-MULTI-FOREIGN', 'reason' => 'Foreign supplier refusal', 'allocations' => [['obligation_id' => $obs[3], 'source_revision' => Payables::revision($sources[3]), 'amount' => '100.00']]], $uuid()); } catch (DomainException) { $foreign = true; }
$positions = $p->listing([1, 2, 3])['obligations']; $payments = $p->settlements()->listing()['payments'];
$otherScope = array_replace($scope, ['unit_id' => 900004]); $op = $engine(1, $otherScope); $ov = $engine(2, $otherScope);
$otherProposal = $op->command('prepare', 4, 'Other unit source', $uuid());
$otherObligation = $ov->command('validate', $otherProposal['proposal_id'], 'Independent other unit recognition', $uuid())['obligation_id'];
$otherUnitRefused = false;
try { $op->settlements()->command('payment-prepare', ['obligation_id' => $otherObligation, 'source_revision' => Payables::revision($sources[4]), 'amount' => '14000.00', 'currency' => 'MZN', 'paid_on' => date('Y-m-d'), 'fact_reference' => 'SIM-MULTI-001', 'evidence_reference' => 'SIM-OTHER-UNIT-EVID', 'reason' => 'Duplicate through another unit'], $uuid()); }
catch (DomainException) { $otherUnitRefused = true; }
$out = ['balances' => array_column($positions, 'balance_minor'), 'unallocated_minor' => $payments[1]['unallocated_minor'], 'foreign_supplier_refused' => $foreign,
'other_unit_reference_refused' => $otherUnitRefused,
'kind' => 'core explicit synthetic source adapter; not another host invoice workflow'];
} else throw new LogicException('Unknown probe job.');
echo json_encode($out, JSON_THROW_ON_ERROR).PHP_EOL;