110 lines
14 KiB
JavaScript
110 lines
14 KiB
JavaScript
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"'),'Fully allocated payment must not be offered again');
|
|
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(process.env.SETTLEMENTS_PROOF_OUTPUT??'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.');
|