This commit is contained in:
2026-10-30 08:13:34 +02:00
parent 53ff46b0a2
commit b45c1a816e
31 changed files with 1726 additions and 32 deletions
+11 -1
View File
@@ -26,7 +26,17 @@ try {
assert(!reader.html.includes('<h2>A validar</h2>'));assert(!reader.html.includes('<h2>A preparar e acompanhar</h2>'));
assert(reader.html.includes('não agrega nem conta tarefas pendentes'));
const allGroups=navigationGroups(()=>true,7),topics=navigationTopics(allGroups);
assert.deepEqual(topics.map(t=>t.label),['Início','Comercial','Compras','Stock','Oficinas','Financeiro','Configuração']);
assert.deepEqual(topics.map(t=>t.label),['Início','Comercial','Compras','Stock','Oficinas','Financeiro','Contabilidade','Configuração']);
// Independent inventory: comparing the generated menu with itself cannot detect a removed module.
assert.deepEqual(allGroups.map(g=>g.id).sort(),['start','purchasing','stock','services','billing','billing_support','receivables','workshop','costs','accounting','admin'].sort());
const accountingTopic=topics.find(t=>t.id==='accounting');
assert.deepEqual(accountingTopic.sections.map(g=>g.id),['accounting']);
assert.deepEqual(accountingTopic.sections[0].items.map(i=>i.id),['accounting_panel','accounting_daily','accounting_review','accounting_analysis','accounting_closing','accounting_configuration','catalogue_accounting','service_fiscal']);
for(const grants of [[['company.view',null]],[['catalogue_accounting.view',null]],[['service_fiscal.view',null]]]) {
const authorised=navigationTopics(check(grants).groups);
assert(authorised.some(t=>t.id==='accounting'&&t.label==='Contabilidade'));
}
assert(!navigationTopics(noAccess.groups).some(t=>t.id==='accounting'));
const before=allGroups.flatMap(g=>g.items.map(i=>i.id)).sort(),after=topics.flatMap(t=>t.sections.flatMap(g=>g.items.map(i=>i.id))).sort();
assert.deepEqual(after,before);assert.equal(new Set(after).size,after.length);
const navHtml=renderToStaticMarkup(React.createElement(Navigation,{groups:allGroups,page:'billing_drafts',navigate:()=>{},user:{name:'Avaliador sintético',email:'consulta@example.test'},busy:false,logout:()=>{}}));
+50
View File
@@ -0,0 +1,50 @@
// Existing synthetic host only: entry/session/logout and served assets; no business commands.
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import React from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
import {createServer} from 'vite';
const base='http://127.0.0.1:8002';
const vite=await createServer({server:{middlewareMode:true},appType:'custom'});
try{
const {navigationGroups,navigationTopics,default:Navigation}=await vite.ssrLoadModule('/resources/js/WorkspaceNavigation.jsx');
const {serviceAreas}=await vite.ssrLoadModule('/resources/js/ServicesWorkspace.jsx');
const registry=new Set(Object.keys(serviceAreas));
const source=await fs.readFile('resources/js/app.jsx','utf8');
for(const m of source.matchAll(/page\s*===\s*['"]([^'"]+)['"]/g))registry.add(m[1]);
for(const [file,field] of [['billing-menu.js','billingSections'],['workshop-menu.js','workshopSections'],['stock-menu.js','stockSections'],['accounting-menu.js','accountingSections']]){
const module=await vite.ssrLoadModule('/resources/js/'+file);for(const item of module[field])registry.add(item.id);
}
const manifest=JSON.parse(await fs.readFile('public/build/manifest.json','utf8'))['resources/js/app.jsx'];
const root=await fetch(base+'/',{signal:AbortSignal.timeout(15000),headers:{Connection:'close'}});assert.equal(root.status,200);
assert((await root.text()).includes(manifest.file));
const asset=await fetch(base+'/build/'+manifest.file,{signal:AbortSignal.timeout(15000),headers:{Connection:'close'}});assert.equal(asset.status,200);
assert((await asset.text()).includes('purchasing_home'));
const results=[];
for(const role of ['proprietario','preparador','validador','tecnico','verificador']){
const cookies=new Map();
async function request(path,method='GET',body){
const r=await fetch(base+path,{signal:AbortSignal.timeout(15000),method,redirect:'manual',headers:{Connection:'close',Cookie:[...cookies].map(([k,v])=>`${k}=${v}`).join('; '),...(body?{'Content-Type':'application/x-www-form-urlencoded'}:{})},...(body?{body:new URLSearchParams(body)}:{})});
for(const c of r.headers.getSetCookie()){const pair=c.split(';',1)[0],i=pair.indexOf('=');cookies.set(pair.slice(0,i),pair.slice(i+1));}
return {status:r.status,text:await r.text()};
}
const landing=await request('/avaliar');assert.equal(landing.status,200);
const csrf=landing.text.match(/name="_token" value="([^"]+)"/)?.[1];assert(csrf);
assert.equal((await request('/avaliar/entrar','POST',{role,_token:csrf})).status,302,role+' entry');
const session=JSON.parse((await request('/api/session')).text);assert(session.user&&session.context);
const can=(p,u=null)=>session.context.grants.some(g=>g.permission===p&&g.unit_id===u);
const groups=navigationGroups(can,session.context.unit?.id??null),topics=navigationTopics(groups);
const destinations=groups.flatMap(g=>g.items.map(i=>i.id));
for(const id of destinations)assert(registry.has(id),role+' unregistered destination '+id);
assert.equal(new Set(destinations).size,destinations.length);
assert(topics.some(t=>t.id==='purchasing'),role+' purchasing visible');
assert(topics.some(t=>t.id==='stock'),role+' stock separately visible');
assert(topics.some(t=>t.id==='accounting'&&t.label==='Contabilidade'),role+' accounting separately visible');
const html=renderToStaticMarkup(React.createElement(Navigation,{groups,page:'purchasing_home',navigate:()=>{},user:{name:'Conta sintética',email:'avaliacao@example.test'},busy:false,logout:()=>{}}));
assert(html.includes('topic-purchasing'));assert(html.includes('topic-stock'));assert(html.includes('topic-accounting'));assert(html.includes('Área de Compras'));
const logout=await fetch(base+'/api/logout',{signal:AbortSignal.timeout(15000),method:'POST',headers:{Connection:'close',Accept:'application/json',Cookie:[...cookies].map(([k,v])=>`${k}=${v}`).join('; '),'X-CSRF-TOKEN':session.csrf}});assert.equal(logout.status,200);
results.push({role,topics:topics.map(t=>t.label),destinations:destinations.length,purchasing_visible:true,stock_separate:true,accounting_separate:true,all_destinations_registered:true});
}
await fs.writeFile('docs/proofs/navigation-live.json',JSON.stringify({checked_at:new Date().toISOString(),host:base,current_bundle_served:true,results,business_commands:0,accounts_created:0,permissions_changed:0,type:'HTTP/session plus static render; not interactive browser review',visual_review:false},null,2)+'\n');
console.log('Live navigation passed for five existing synthetic roles: Compras and Stock separate, all destinations registered, current bundle served. No business commands or access changes.');
}finally{await vite.close();}
+2 -2
View File
@@ -1,4 +1,4 @@
param([ValidateSet('start','stop','status','proof')][string]$Action='status',[switch]$HistoryOnly)
param([ValidateSet('start','stop','status','proof')][string]$Action='status',[switch]$HistoryOnly,[switch]$Settlements)
$ErrorActionPreference='Stop'
$payRoot=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
if($payRoot -ne 'C:\laragon\www\sgf-tempo'){throw 'Unexpected project path'}
@@ -31,7 +31,7 @@ try{
if(!$payProof){@{pid=$payProcess.Id;started=$payProcess.StartTime.ToUniversalTime().ToString('o');executable=$payWebPhp;port=$payPort}|ConvertTo-Json|Set-Content -LiteralPath "$payRun\payables-$payMode.json" -Encoding UTF8}
$payReady=$false;for($payAttempt=0;$payAttempt -lt 30;$payAttempt++){if(Get-NetTCPConnection -LocalPort $payPort -State Listen -ErrorAction SilentlyContinue){$payReady=$true;break};Start-Sleep -Milliseconds 100};if(!$payReady){throw 'Payables server failed to start'}
}
if($payProof){Push-Location $payRoot;try{if($HistoryOnly){& 'C:\laragon\tools\sgf-tempo\node-v24.22.1-win-arm64\node.exe' scripts/payables-http-proof.mjs --history-only}else{& 'C:\laragon\tools\sgf-tempo\node-v24.22.1-win-arm64\node.exe' scripts/payables-http-proof.mjs};if($LASTEXITCODE){throw 'Payables HTTP proof failed'}}finally{Pop-Location}}
if($payProof){Push-Location $payRoot;try{if($Settlements){foreach($settlementMode in @('compras','sgf')){& $payPhp scripts/settlements-install.php $settlementMode proof;if($LASTEXITCODE){throw 'Settlement schema failed'}};& 'C:\laragon\tools\sgf-tempo\node-v24.22.1-win-arm64\node.exe' scripts/settlements-http-proof.mjs}elseif($HistoryOnly){& 'C:\laragon\tools\sgf-tempo\node-v24.22.1-win-arm64\node.exe' scripts/payables-http-proof.mjs --history-only}else{& 'C:\laragon\tools\sgf-tempo\node-v24.22.1-win-arm64\node.exe' scripts/payables-http-proof.mjs};if($LASTEXITCODE){throw 'Payables HTTP proof failed'}}finally{Pop-Location}}
else{Write-Output 'Synthetic payables: Compras http://127.0.0.1:8005/obrigacoes-fornecedores ; SGF http://127.0.0.1:8006. Budget demonstration preserved.'}
}finally{
Remove-Item Env:PAYABLE_SETTINGS_FILE -ErrorAction SilentlyContinue
+20
View File
@@ -0,0 +1,20 @@
import fs from 'node:fs/promises';
import assert from 'node:assert/strict';
const source=await fs.readFile('scripts/settlements-http-proof.mjs','utf8');
const definition=source.slice(source.indexOf(' function client()'),source.indexOf(' const p=client()'));
const results=[];
for(const mode of ['compras','sgf']) {
const base=`http://127.0.0.1:${mode==='compras'?8005:8006}`;
const client=eval('('+definition.trim().replace(/^function client/, 'function')+')');
for(const role of ['preparacao','validacao','consulta']) {
const c=client();await c.login(role);
const html=await c.request('/supplier-payments');assert.equal(html.status,200);
const data=await c.list();assert.equal(data.payments.length,0);
const old=await c.request(mode==='compras'?'/obrigacoes-fornecedores':'/supplier-payables');assert.equal(old.status,200);assert.ok(old.text.includes('/supplier-payments'));
results.push({host:mode,role,status:html.status,payments:data.payments.length});
await c.request('/logout','POST',{});
}
}
await fs.writeFile('docs/proofs/supplier-settlements-demo-access.json',JSON.stringify({checked_at:new Date().toISOString(),kind:'HTTP and HTML only; no visual review or monetary commands',results},null,2)+'\n');
console.log('Both existing demonstrations: all three roles, payments page/API and obligation link passed; no monetary commands.');
+50
View File
@@ -0,0 +1,50 @@
<?php
// Hash-only preservation check of existing synthetic demonstration data. No credential columns.
$phase = $argv[1] ?? '';
if (! in_array($phase, ['before', 'after'], true)) throw new LogicException('Choose before/after.');
if (! isset($argv[2])) {
foreach (['compras', 'sgf'] as $mode) {
$php = $mode === 'compras' ? 'C:/Users/macbook/.config/investmoz/php-8.5.11/php.exe' : 'C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe';
passthru(escapeshellarg($php).' '.escapeshellarg(__FILE__).' '.$phase.' '.$mode, $status);
if ($status) exit($status);
}
exit;
}
$mode = $argv[2]; if (! in_array($mode, ['compras', 'sgf'], true)) throw new LogicException('Unknown synthetic host.');
$s = json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/payables-demo-'.$mode.'.json'), true, 32, JSON_THROW_ON_ERROR);
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 Tempo\Finance\Payables\Payables;
$db = DB::connection();
if (! preg_match('/^(compras_receipts_test_|payable_sgf_)[a-f0-9]{12}$/D', $db->getDatabaseName()) || $db->getConfig('host') !== '127.0.0.1'
|| $db->selectOne('select current_database() as d')->d !== $s['DB_DATABASE']) throw new LogicException('Synthetic database guard.');
$newActions = ['payments_view', 'payment_prepare', 'payment_confirm', 'allocation_prepare', 'allocation_confirm'];
$tables = ['pa_sources', 'pa_proposals', 'pa_obligations', 'pa_events', 'pa_commands', 'pa_outbox', 'pa_history_imports', 'pa_imported_payments', 'pa_payment_decisions', 'pa_imported_sources', 'permission_grants'];
if ($mode === 'compras') $tables = [...$tables, 'invoices', 'invoice_items', 'purchase_orders', 'goods_receipts', 'goods_receipt_items', 'stock_movements', 'budget_entries', 'budget_consumptions', 'external_payments'];
$hashes = []; $counts = [];
DB::transaction(function () use ($db, $tables, $newActions, &$hashes, &$counts): void {
$db->statement('SET TRANSACTION READ ONLY');
foreach ($tables as $table) {
if (! $db->getSchemaBuilder()->hasTable($table)) { $hashes[$table] = null; continue; }
$rows = $db->table($table)->orderBy($table === 'pa_outbox' ? 'event_id' : 'id')->get()->map(fn ($r) => (array) $r)->all();
$hashes[$table] = hash('sha256', Payables::canonical($rows)); $counts[$table] = count($rows);
}
$users = $db->table('users')->orderBy('id')->get(['id', 'name', 'email'])->map(fn ($r) => (array) $r)->all();
$hashes['accounts_without_credentials'] = hash('sha256', Payables::canonical($users)); $counts['users'] = count($users);
$access = $db->table('pa_access')->whereNotIn('action', $newActions)->orderBy('id')->get()->map(fn ($r) => (array) $r)->all();
$hashes['existing_access'] = hash('sha256', Payables::canonical($access));
});
$path = __DIR__.'/../docs/proofs/supplier-settlements-demo-'.$mode.'.json';
if ($phase === 'before') {
file_put_contents($path, json_encode(['host' => $mode, 'hashes_before' => $hashes, 'counts_before' => $counts, 'checked_at' => gmdate('c')], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL);
echo $mode." synthetic baseline recorded.\n";
} else {
$before = json_decode(file_get_contents($path), true, 32, JSON_THROW_ON_ERROR);
if ($before['hashes_before'] !== $hashes || $before['counts_before'] !== $counts) throw new LogicException('Existing synthetic data changed; investigate without reset.');
$ps = []; foreach (\Tempo\Finance\Payables\SettlementSchema::TABLES as $t) $ps[$t] = $db->table($t)->count();
$result = $before + ['preserved' => true, 'schema_counts' => $ps, 'checked_after' => gmdate('c')];
file_put_contents($path, json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL);
echo $mode." original documents, history, accounts and access preserved; synthetic settlements installed.\n";
}
+109
View File
@@ -0,0 +1,109 @@
import fs from 'node:fs/promises';
import assert from 'node:assert/strict';
import {randomUUID} from 'node:crypto';
import {spawn} from 'node:child_process';
async function probe(mode,job,input={},legacy=false){
return new Promise((resolve,reject)=>{
const php=mode==='compras'?'C:/Users/macbook/.config/investmoz/php-8.5.11/php.exe':'C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe';
const p=spawn(php,[legacy?'scripts/payables-probe.php':'scripts/settlements-probe.php',mode,job],{cwd:process.cwd(),windowsHide:true});let out='',err='';
p.stdout.on('data',d=>out+=d);p.stderr.on('data',d=>err+=d);p.on('error',reject);p.on('close',code=>{try{if(code)throw Error('Synthetic probe failed '+job+': '+out+err);resolve(JSON.parse(out));}catch(e){reject(e);}});p.stdin.end(JSON.stringify(input));
});
}
const hosts=[];
for(const mode of ['compras','sgf']){
console.log('Checking supplier settlements in '+mode+'…');
const fixture=JSON.parse(await fs.readFile(`C:/laragon/data/sgf-tempo/secrets/payables-proof-${mode}-access.json`,'utf8'));
const base=`http://127.0.0.1:${mode==='compras'?8007:8008}`;
function client(){const cookies=new Map();let csrf='',context='';return {
async request(path,method='GET',body,opts={}){
const r=await fetch(base+path,{signal:AbortSignal.timeout(20000),method,redirect:'manual',headers:{Connection:'close',Accept:'application/json','Content-Type':opts.form?'application/x-www-form-urlencoded':'application/json',Cookie:[...cookies].map(([k,v])=>`${k}=${v}`).join('; '),'X-CSRF-TOKEN':opts.noCsrf?'':csrf,'X-XSRF-TOKEN':opts.noCsrf?'':decodeURIComponent(cookies.get('XSRF-TOKEN')??''),'X-SGF-Context':opts.stale?'stale':context,...(opts.key?{'Idempotency-Key':opts.key}:{})},...(body===undefined?{}:{body:opts.form?new URLSearchParams(body):JSON.stringify(body)})});
for(const c of r.headers.getSetCookie()){const pair=c.split(';',1)[0],i=pair.indexOf('=');cookies.set(pair.slice(0,i),pair.slice(i+1));}
const text=await r.text();let data={};try{data=JSON.parse(text);}catch{}
csrf=text.match(/name="_token" value="([^"]+)"/)?.[1]??data.csrf??csrf;if(data.context?.token)context=data.context.token;
return {status:r.status,data,text};
},
async login(role){assert.equal((await this.request('/demo')).status,200);assert.equal((await this.request('/demo/entrar','POST',{role,_token:csrf},{form:true})).status,302);await this.request('/demo');if(mode==='sgf')await this.request('/api/session');},
async list(){const r=await this.request('/api/supplier-payments');assert.equal(r.status,200,r.text.slice(0,200));return r.data;},
async send(action,body,status=200,key=randomUUID(),extra={}){const r=await this.request('/api/supplier-payments/'+action,'POST',body,{key,...extra});assert.equal(r.status,status,mode+' '+action+' '+r.text.slice(0,250));return r.data;},
};}
const p=client(),v=client(),q=client();await p.login('preparacao');await v.login('validacao');await q.login('consulta');
assert.equal((await client().request('/api/supplier-payments')).status,401);
const before=await probe(mode,'inspect',{},true);
const proposal=await p.request('/api/supplier-payables/prepare','POST',{id:fixture.source_id,reason:'Synthetic payable source'},{key:randomUUID()});assert.equal(proposal.status,200);
const recognised=await v.request('/api/supplier-payables/validate','POST',{id:proposal.data.proposal_id,reason:'Independent synthetic recognition'},{key:randomUUID()});assert.equal(recognised.status,200);
let data=await q.list();const o=data.obligations[0];assert.equal(o.principal_minor,1160000);assert.equal(o.balance_minor,1160000);
const origin=JSON.parse((await q.request('/api/supplier-payables')).text).documents[0];assert.equal(origin.subtotal_minor,1000000);assert.equal(origin.tax_minor,160000);assert.equal(origin.total_minor,1160000);
const today=new Date().toISOString().slice(0,10);
const paymentBody={obligation_id:o.id,source_revision:o.source_revision,amount:'4000.00',currency:'MZN',paid_on:today,fact_reference:'SIM-PAY-001',evidence_reference:'SIM-EVID-001',reason:'Synthetic payment fact; no bank execution'};
const key=randomUUID(),first=await p.send('payment-prepare',paymentBody,200,key);
assert.equal((await p.send('payment-prepare',paymentBody,200,key)).replayed,true);
assert.equal((await p.send('payment-prepare',paymentBody)).payment_id,first.payment_id);
await p.send('payment-prepare',{...paymentBody,amount:'4000.01'},409);
await p.send('payment-prepare',{...paymentBody,fact_reference:'SIM-OTHER-REF'},409);
await p.send('payment-prepare',{...paymentBody,reason:'Changed key content'},409,key);
await p.send('payment-prepare',{...paymentBody,fact_reference:'SIM-ZERO',evidence_reference:'SIM-ZERO-EVID',amount:'0.00'},422);
await p.send('payment-prepare',{...paymentBody,amount:'4000.001'},422);
await p.send('payment-prepare',{...paymentBody,currency:'USD'},409);
const incomplete={...paymentBody};delete incomplete.paid_on;await p.send('payment-prepare',incomplete,422);
await q.send('payment-prepare',paymentBody,403);
const decision={payment_id:first.payment_id,decision:'confirmed',evidence_reference:'SIM-CONF-001',reason:'Independent synthetic evidence review'};
await probe(mode,'grant',{role:'preparacao',action:'payment_confirm',enabled:true});await p.send('payment-decide',decision,409);await probe(mode,'grant',{role:'preparacao',action:'payment_confirm',enabled:false});
await probe(mode,'person',{same:true});await v.send('payment-decide',decision,409);await probe(mode,'person',{same:false});
const revoked=await probe(mode,'race',{role:'validacao',action:'payment-decide',body:decision,revoke:'payment_confirm'});assert.equal(revoked.waits,2);assert.deepEqual(revoked.results.map(r=>r.status),[403,403]);
await probe(mode,'grant',{role:'validacao',action:'payment_confirm',enabled:true});
const paymentRace=await probe(mode,'race',{role:'validacao',action:'payment-decide',body:decision});assert.deepEqual(paymentRace.results.map(r=>r.status),[200,200]);assert.equal(paymentRace.results[0].result.decision_id,paymentRace.results[1].result.decision_id);
data=await q.list();assert.equal(data.obligations[0].balance_minor,1160000);assert.equal(data.payments[0].state,'confirmed');assert.equal(data.payments[0].unallocated_minor,400000);
await v.send('payment-decide',{...decision,decision:'rejected'},409);
const plan={payment_id:first.payment_id,reference:'SIM-ALLOC-001',reason:'Explicit partial allocation',allocations:[{obligation_id:o.id,source_revision:o.source_revision,amount:'4000.00'}]};
const planRace=await probe(mode,'race',{role:'preparacao',action:'allocation-prepare',body:plan});assert.deepEqual(planRace.results.map(r=>r.status),[200,200]);const proposalId=planRace.results[0].result.proposal_id;assert.equal(planRace.results[1].result.proposal_id,proposalId);
await q.send('allocation-prepare',plan,403);
const allocationDecision={proposal_id:proposalId,decision:'confirmed',reason:'Independent partial allocation review'};
const allocationRevoke=await probe(mode,'race',{role:'validacao',action:'allocation-decide',body:allocationDecision,revoke:'allocation_confirm'});assert.deepEqual(allocationRevoke.results.map(r=>r.status),[403,403]);
await probe(mode,'grant',{role:'validacao',action:'allocation_confirm',enabled:true});
const failedKey=randomUUID(),rollbackBefore=await probe(mode,'inspect');
await probe(mode,'fault',{enabled:true});await v.send('allocation-decide',allocationDecision,500,failedKey);await probe(mode,'fault',{enabled:false});
const rollbackAfter=await probe(mode,'inspect');assert.deepEqual(rollbackAfter.counts,rollbackBefore.counts);
const allocationRace=await probe(mode,'race',{role:'validacao',action:'allocation-decide',body:allocationDecision});assert.deepEqual(allocationRace.results.map(r=>r.status),[200,200]);assert.equal(allocationRace.results[0].result.decision_id,allocationRace.results[1].result.decision_id);
await v.send('allocation-decide',allocationDecision,200,failedKey);assert.equal((await v.send('allocation-decide',allocationDecision,200,failedKey)).replayed,true);
assert.equal((await p.send('allocation-prepare',{...plan,reference:'SIM-ALIAS-001'})).proposal_id,proposalId);
await p.send('allocation-prepare',{...plan,reference:'SIM-ALIAS-001',allocations:[{...plan.allocations[0],amount:'3999.00'}]},409);
data=await q.list();assert.equal(data.allocations.length,1);assert.equal(data.obligations[0].balance_minor,760000);assert.equal(data.obligations[0].principal_minor,1160000);assert.equal(data.obligations[0].payment_state,'partial');assert.equal(data.payments[0].unallocated_minor,0);
assert.equal(data.obligations[0].due_on,null);assert.equal(data.obligations[0].days_overdue,null);
const afterFirst=await probe(mode,'inspect',{},true);assert.deepEqual(afterFirst.domain_hashes,before.domain_hashes);if(mode==='compras')assert.equal(afterFirst.legacy_balance,760000);
const html=await q.request('/supplier-payments');assert.equal(html.status,200);assert(html.text.includes('7 600.00 MZN'));assert(!html.text.includes('<form data-action='));assert(html.text.includes('Dias de atraso: sem valor'));
const prepHtml=await p.request('/supplier-payments');assert(prepHtml.text.includes('data-action="payment-prepare"'));assert(prepHtml.text.includes('data-action="allocation-prepare"'));
assert.equal((await probe(mode,'foreign',{body:decision})).refused,true);
const immutable=await probe(mode,'immutable');assert.equal(immutable.refused,3);
const more=[];
for(const [ref,amount] of [['SIM-PAY-002','9000.00'],['SIM-PAY-003','5000.00']]){
const prepared=await p.send('payment-prepare',{...paymentBody,fact_reference:ref,evidence_reference:ref+'-EVID',amount});more.push(prepared.payment_id);
await v.send('payment-decide',{payment_id:prepared.payment_id,decision:'confirmed',evidence_reference:ref+'-CONF',reason:'Independent synthetic confirmation'});
}
await p.send('allocation-prepare',{...plan,payment_id:more[0],reference:'SIM-EXCESS-PLAN',allocations:[{...plan.allocations[0],amount:'8000.00'}]},409);
const competing=[];
for(let i=0;i<2;i++){const r=await p.send('allocation-prepare',{...plan,payment_id:more[i],reference:'SIM-COMPETING-'+i,allocations:[{...plan.allocations[0],amount:'5000.00'}]});competing.push({role:'validacao',action:'allocation-decide',body:{proposal_id:r.proposal_id,decision:'confirmed',reason:'Concurrent limited balance'}});}
const overRace=await probe(mode,'race',{commands:competing});assert.equal(overRace.waits,2);assert.deepEqual(overRace.results.map(r=>r.status).sort(),[200,409]);
data=await q.list();assert.equal(data.obligations[0].balance_minor,260000);assert.equal(data.allocations.length,2);assert(data.payments.some(r=>r.unallocated_minor>0));
const losing=overRace.results.findIndex(r=>r.status===409);
const rejectedPlan={...competing[losing].body,decision:'rejected',reason:'Balance changed; reject the unapplied proposal'};
await v.send('allocation-decide',rejectedPlan);await v.send('allocation-decide',competing[losing].body,409);
const rejectedPayment=await p.send('payment-prepare',{...paymentBody,fact_reference:'SIM-PAY-REJECT',evidence_reference:'SIM-PAY-REJECT-EVID',amount:'100.00'});
const reject={payment_id:rejectedPayment.payment_id,decision:'rejected',evidence_reference:'SIM-REJECT-CONF',reason:'Evidence does not sustain confirmation'};
const rejection=await v.send('payment-decide',reject);assert.equal((await v.send('payment-decide',reject)).decision_id,rejection.decision_id);
await p.send('allocation-prepare',{...plan,payment_id:rejectedPayment.payment_id,reference:'SIM-REJECTED-ALLOC'},409);
data=await q.list();assert.equal(data.obligations[0].balance_minor,260000);assert.equal(data.payments.find(r=>r.id===rejectedPayment.payment_id).unallocated_minor,null);
await probe(mode,'source',{override:{total_minor:1160001}},true);await v.send('payment-decide',decision,409);assert.equal((await q.list()).obligations[0].balance_minor,null);await probe(mode,'source',{},true);
const multi=await probe(mode,'multi');assert.deepEqual(multi.balances,[0,0,100000]);assert.equal(multi.unallocated_minor,40000);assert.equal(multi.foreign_supplier_refused,true);assert.equal(multi.other_unit_reference_refused,true);
const counts=await probe(mode,'inspect');assert.equal(counts.counts.ps_events,counts.counts.ps_outbox);
const effectsBeforeHistory=await probe(mode,'inspect');await probe(mode,'history',{},true);
await p.send('payment-prepare',{...paymentBody,fact_reference:'SIM-HISTORY-REFUSED',evidence_reference:'SIM-HISTORY-EVID'},409);
assert.deepEqual((await probe(mode,'inspect')).counts,effectsBeforeHistory.counts);
await v.send('payment-decide',decision,419,randomUUID(),{noCsrf:true});if(mode==='sgf')await v.send('payment-decide',decision,409,randomUUID(),{stale:true});
const fresh=client();await fresh.login('consulta');const recovered=await fresh.list();assert.equal(recovered.payments.length,4);assert.equal(recovered.allocations.length,2);assert.equal(recovered.obligations[0].balance_minor,null);
hosts.push({host:mode,passed:true,core_sha256:counts.core_sha256,principal_minor:1160000,balance_after_first_allocation_minor:760000,due_on:null,days_overdue:null,multi_document_core:multi,
races:[revoked,paymentRace,planRace,allocationRevoke,allocationRace,overRace],checks:['payment_confirmation_does_not_reduce_debt','explicit_allocations_only','new_request_keys_recover_same_fact','duplicate_evidence_conflicts','partial_and_excess','overallocation_refused','rejected_payment_or_allocation_has_no_effect','separate_actor_and_person','revocation_while_waiting','atomic_fault_rollback_and_retry','source_revision_conflict','cross_company_refused','cross_supplier_refused','history_not_ignored','immutable_confirmed_facts','legacy_balance_uses_common_position','unchanged_stock_budget_invoice_legacy_payments','csrf_and_context','logout_reentry_history','durable_outbox'],visual_review:false});
}
assert.equal(hosts[0].core_sha256,hosts[1].core_sha256);
await fs.writeFile('docs/proofs/supplier-settlements.json',JSON.stringify({version:'COMMON-SUPPLIER-SETTLEMENT-SIM-v1',checked_at:new Date().toISOString(),hosts,bank_execution:false,accounting:false,real_migration:false},null,2)+'\n');
console.log('Both hosts passed: explicit supplier settlements, canonical balance, duplicate/concurrent requests, failure recovery and preserved source/stock/budget/history.');
+33
View File
@@ -0,0 +1,33 @@
<?php
// Explicit additive installation into the two named synthetic hosts; no real settings changed.
$mode = $argv[1] ?? ''; $tag = $argv[2] ?? '';
if (! in_array($mode, ['compras', 'sgf'], true) || ! in_array($tag, ['proof', 'demo'], true)) throw new LogicException('Named synthetic host required.');
$settings = json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/payables-'.$tag.'-'.$mode.'.json'), true, 32, JSON_THROW_ON_ERROR);
foreach ($settings as $k => $v) { putenv($k.'='.$v); $_ENV[$k] = $_SERVER[$k] = $v; }
require $settings['HOST_ROOT'].'/vendor/autoload.php';
$app = require $settings['HOST_ROOT'].'/bootstrap/app.php'; $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use Illuminate\Support\Facades\DB;
use Tempo\Finance\Payables\SettlementSchema;
$db = DB::connection();
if (! preg_match('/^(compras_receipts_test_|payable_sgf_)[a-f0-9]{12}$/D', $db->getDatabaseName()) || $db->getConfig('host') !== '127.0.0.1'
|| $db->selectOne('select current_database() as d')->d !== $settings['DB_DATABASE'] || ! app()->environment('payable-testing')) throw new LogicException('Synthetic database guard refused.');
$role = $db->selectOne('select rolsuper,rolcreatedb,rolcreaterole from pg_roles where rolname=current_user');
if ($role->rolsuper || $role->rolcreatedb || $role->rolcreaterole) throw new LogicException('Restricted QA role required.');
$existing = array_filter(SettlementSchema::TABLES, fn ($t) => $db->getSchemaBuilder()->hasTable($t));
if ($existing && count($existing) !== count(SettlementSchema::TABLES)) throw new LogicException('Incomplete schema, nothing repaired.');
if (! $existing) SettlementSchema::install();
$fixture = json_decode(file_get_contents($settings['PAYABLE_ACCESS_FILE']), true, 32, JSON_THROW_ON_ERROR);
$grants = 0;
DB::transaction(function () use ($fixture, &$grants): void {
foreach (['preparacao' => ['payments_view', 'payment_prepare', 'allocation_prepare'], 'validacao' => ['payments_view', 'payment_confirm', 'allocation_confirm'], 'consulta' => ['payments_view']] as $role => $actions) {
$account = $fixture['accounts'][$role];
$user = \App\Models\User::findOrFail($account['id']);
if ($user->email !== $role.'-obrigacoes@example.test') throw new LogicException('Existing synthetic identity required; no accounts created.');
$person = DB::table('pa_access')->where($fixture['scope'])->where(['user_id' => $user->id, 'action' => 'view'])->value('person');
if ($person !== 'synthetic-person-'.$role) throw new LogicException('Synthetic person mapping differs.');
foreach ($actions as $action) $grants += DB::table('pa_access')->insertOrIgnore($fixture['scope'] + ['user_id' => $user->id, 'person' => $person, 'action' => $action]);
}
});
echo json_encode(['host' => $mode, 'isolated' => true, 'schema_tables' => count(SettlementSchema::TABLES), 'synthetic_grants_added' => $grants,
'accounts_created' => 0, 'documents_changed' => false, 'payments_created' => 0], JSON_THROW_ON_ERROR).PHP_EOL;
+138
View File
@@ -0,0 +1,138 @@
<?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 (SettlementSchema::TABLES as $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')];
} 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') {
if ($input['enabled']) $db->unprepared("CREATE FUNCTION ps_qa_fault() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.kind='supplier_allocation.confirmed' 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'], 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 === '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'] 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('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;