Files
SGF/scripts/ledger-cases.php
T
2026-11-04 09:37:20 +02:00

110 lines
15 KiB
PHP

<?php
$checks=0;$cases=[];
$assert=function(bool $ok,string $why)use(&$checks):void{$checks++;if(!$ok)throw new RuntimeException($why);};
$reject=function(callable $run,string $class='DomainException',?int $status=null)use($assert):void{
try{$run();}catch(Throwable $e){if(!$e instanceof $class)throw $e;if($status!==null&&(!$e instanceof Symfony\Component\HttpKernel\Exception\HttpExceptionInterface||$e->getStatusCode()!==$status))throw $e;$assert(true,'Refused');return;}throw new RuntimeException('Expected refusal absent');
};
$case=function(string $name,callable $run)use(&$cases,&$checks,$financialFingerprint,$g,$enter):void{
$n=$checks;DB::beginTransaction();try{$enter('prepare');$run();$cases[]=['name'=>$name,'assertions'=>$checks-$n,'passed'=>true];}
finally{while(DB::transactionLevel()>0)DB::rollBack();$enter('read');}
if($financialFingerprint()!==$g['financial_fingerprint']||DB::table('gl_journals')->count()!==0)throw new RuntimeException('Case left writes outside its rollback frame');
echo 'Passed ledger case: '.$name."\n";
};
$prepare=fn(array $overrides=[])=>$command('prepare','prepare',array_replace($g['body'],$overrides));
$review=fn(array $p,string $decision='approved')=>$command('validate','validate',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'decision'=>$decision,'reason'=>'Independent exact synthetic journal review.']);
$postBody=fn(array $p)=>['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'reason'=>'Apply the reviewed synthetic impact once.'];
$case('prepare-independent-review-post-and-source-reconciliation',function()use($prepare,$review,$postBody,$command,$ledger,$enter,$g,$assert):void{
$p=$prepare();$assert(DB::table('gl_journals')->count()===0,'Preparation does not post');
$snapshot=json_decode(DB::table('gl_proposals')->where('id',$p['proposal_id'])->value('snapshot'),true);
$assert($snapshot['source']['principal_minor']===6000&&$snapshot['source']['due_on']===null,'Principal and unknown due preserved');
$assert($snapshot['lines'][0]['debit_minor']===6000&&$snapshot['lines'][1]['credit_minor']===6000&&!$snapshot['impact']['financial_balance_changed'],'Visible exact impact');
$review($p);$assert(DB::table('gl_journals')->count()===0,'Approval still does not post');
$posted=$command('prepare','post',$postBody($p));$assert($posted['state']==='posted'&&DB::table('gl_journals')->count()===1&&DB::table('gl_lines')->count()===2,'One header and two lines');
DB::select('SELECT gl_assert_balanced(?)',[$posted['journal_id']]);
$all=$ledger->listing($enter('read'),['period_key'=>'QA-2026-10']);$debit=$ledger->listing($enter('read'),['account_id'=>$g['debit_account']]);$credit=$ledger->listing($enter('read'),['account_id'=>$g['credit_account']]);
$assert($all['totals']===['debit_minor'=>6000,'credit_minor'=>6000,'net_minor'=>0],'Period reconciles');
$assert($debit['totals']['net_minor']===6000&&$credit['totals']['net_minor']===-6000,'Both account extracts reconcile');
$assert(DB::table('gl_events')->count()===3&&DB::table('gl_events')->whereNull('reason')->count()===0,'Author date reason before/after history');
$assert(DB::table('pa_obligations')->where('id',$g['obligation_id'])->value('principal_minor')===6000,'No second debt or change of principal');
});
$case('repetition-conflicting-key-and-recovery-after-period-version',function()use($prepare,$review,$postBody,$command,$g,$assert,$reject):void{
$key=(string)Illuminate\Support\Str::uuid();$p=$command('prepare','prepare',$g['body'],$key);$again=$command('prepare','prepare',$g['body'],$key);
$assert($again['replayed']&&$again['proposal_id']===$p['proposal_id'],'Same request recovers proposal');
$other=$prepare();$assert($other['proposal_id']===$p['proposal_id']&&DB::table('gl_proposals')->count()===1,'Other key same preparation does not duplicate');
$reject(fn()=>$command('prepare','prepare',array_replace($g['body'],['reason'=>'Changed key content.']),$key));
$review($p);$body=$postBody($p);$postKey=(string)Illuminate\Support\Str::uuid();$j=$command('prepare','post',$body,$postKey);
$period=(array)DB::table('gl_periods')->first();unset($period['id']);$period['version']=2;$period['status']='closed';DB::table('gl_periods')->insert($period);
$a=$command('prepare','post',$body,$postKey);$b=$command('prepare','post',$body);
$assert($a['journal_id']===$j['journal_id']&&$b['journal_id']===$j['journal_id']&&$a['replayed'],'Existing journal recovered after period closes, no new write');
$assert(DB::table('gl_journals')->count()===1&&DB::table('gl_events')->where('kind','journal.posted')->count()===1,'One posting event');
$reject(fn()=>$command('prepare','post',array_replace($body,['reason'=>'Different posting content.'])));
});
$case('rights-revoked-actor-person-and-no-inheritance',function()use($ledger,$enter,$command,$g,$scope,$f,$assert,$reject):void{
$reject(fn()=>$command('read','prepare',$g['body']),Symfony\Component\HttpKernel\Exception\HttpExceptionInterface::class,403);
$engine=$ledger->engine($enter('prepare'));DB::table('permission_grants')->where($scope)->where(['user_id'=>$f['actors']['prepare'],'permission'=>'general_ledger.prepare'])->delete();
$reject(fn()=>$engine->command('prepare',$g['body'],(string)Illuminate\Support\Str::uuid()),Symfony\Component\HttpKernel\Exception\HttpExceptionInterface::class,403);
$engine=$ledger->engine($enter('validate'));DB::table('pa_access')->where($scope)->where(['user_id'=>$f['actors']['validate'],'action'=>'view'])->update(['person'=>'QA_CHANGED_PERSON']);
$reject(fn()=>$engine->command('validate',['proposal_id'=>1,'revision'=>str_repeat('a',64),'decision'=>'approved','reason'=>'Person changed.'],(string)Illuminate\Support\Str::uuid()),Symfony\Component\HttpKernel\Exception\HttpExceptionInterface::class,409);
config(['general_ledger.synthetic_enabled'=>false]);try{$reject(fn()=>$ledger->listing($enter('read')),Symfony\Component\HttpKernel\Exception\HttpExceptionInterface::class,403);}finally{config(['general_ledger.synthetic_enabled'=>true]);}
});
$case('independence-rejection-and-preparer-ownership',function()use($prepare,$review,$postBody,$command,$g,$scope,$f,$reject,$assert):void{
$p=$prepare();DB::table('permission_grants')->insert($scope+['user_id'=>$f['actors']['prepare'],'permission'=>'general_ledger.validate','created_at'=>now(),'updated_at'=>now()]);
$reject(fn()=>$command('prepare','validate',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Self review forbidden.']));
$person=DB::table('pa_access')->where($scope)->where(['user_id'=>$f['actors']['prepare'],'action'=>'view'])->value('person');$old=DB::table('pa_access')->where($scope)->where(['user_id'=>$f['actors']['validate'],'action'=>'view'])->value('person');
DB::table('pa_access')->where($scope)->where(['user_id'=>$f['actors']['validate'],'action'=>'view'])->update(['person'=>$person]);$reject(fn()=>$review($p));DB::table('pa_access')->where($scope)->where(['user_id'=>$f['actors']['validate'],'action'=>'view'])->update(['person'=>$old]);
$review($p,'rejected');$reject(fn()=>$command('prepare','post',$postBody($p)));$assert(DB::table('gl_journals')->count()===0,'Rejection does not post');
$p2=$prepare(['reason'=>'New independent proposal after refusal.']);$review($p2);
DB::table('permission_grants')->insert($scope+['user_id'=>$f['actors']['validate'],'permission'=>'general_ledger.post','created_at'=>now(),'updated_at'=>now()]);$reject(fn()=>$command('validate','post',$postBody($p2)));
$command('prepare','post',$postBody($p2));$assert(DB::table('gl_reviews')->count()===2,'Original refusal preserved');
});
$case('source-rule-period-latest-capture-and-revision-changes',function()use($prepare,$review,$postBody,$command,$scope,$g,$reject,$assert):void{
foreach(['source','rule','period','latest','revision'] as $change){DB::beginTransaction();try{
$p=$prepare();$review($p);
if($change==='source'){$row=DB::table('pa_imported_sources')->where('id',$g['local_source_id'])->first();$doc=json_decode($row->payload,true);$doc['number']='QA_CHANGED_AFTER_REVIEW';$payload=Tempo\Finance\Payables\Payables::canonical($doc);DB::table('pa_imported_sources')->where('id',$row->id)->update(['payload'=>$payload,'receipt_hash'=>hash('sha256',$payload)]);}
if($change==='rule'){$r=(array)DB::table('gl_rules')->first();unset($r['id']);$r['version']=2;DB::table('gl_rules')->insert($r);}
if($change==='period'){$r=(array)DB::table('gl_periods')->first();unset($r['id']);$r['version']=2;$r['status']='closed';DB::table('gl_periods')->insert($r);}
if($change==='latest')$prepare(['reason'=>'Later proposal supersedes the earlier capture.']);
$body=$postBody($p);if($change==='revision')$body['revision']=str_repeat('f',64);
$reject(fn()=>$command('prepare','post',$body));$assert(DB::table('gl_journals')->count()===0,'Changed state makes no journal');
}finally{DB::rollBack();}}
});
$case('invalid-period-accounts-currency-foreign-scopes-and-extra-fields',function()use($prepare,$command,$scope,$g,$reject,$assert):void{
$reject(fn()=>$prepare(['journal_on'=>'2026-11-01']));$reject(fn()=>$prepare(['journal_on'=>'2026-02-30']),InvalidArgumentException::class);
$reject(fn()=>$command('prepare','prepare',$g['body']+['company_id'=>999]),InvalidArgumentException::class);$reject(fn()=>$prepare(['obligation_id'=>PHP_INT_MAX]));
$p=$prepare();$row=(array)DB::table('gl_proposals')->where('id',$p['proposal_id'])->first();unset($row['id']);
foreach(['tenant_id','company_id','unit_id'] as $key){$foreign=DB::table('gl_proposals')->insertGetId(array_replace($row,[$key=>$scope[$key]+10000]));$reject(fn()=>$command('prepare','post',['proposal_id'=>$foreign,'revision'=>$p['revision'],'reason'=>'Foreign proposal cannot post.']));}
DB::beginTransaction();try{$rule=(array)DB::table('gl_rules')->first();unset($rule['id']);$rule['version']=2;$rule['currency']='USD';DB::table('gl_rules')->insert($rule);$reject(fn()=>$prepare());}finally{DB::rollBack();}
DB::beginTransaction();try{$account=DB::table('gl_accounts')->insertGetId($scope+['code'=>'QA-AGGREGATOR','label'=>'Not a movement account','is_movement'=>false,'currency'=>'MZN']);$rule=(array)DB::table('gl_rules')->first();unset($rule['id']);$rule['version']=2;$rule['debit_account_id']=$account;DB::table('gl_rules')->insert($rule);$reject(fn()=>$prepare());}finally{DB::rollBack();}
$assert(DB::table('gl_journals')->count()===0,'No invalid effect posted');
});
$case('failure-at-every-posting-boundary-and-idempotent-recovery',function()use($prepare,$review,$postBody,$command,$assert):void{
foreach(['after_header','after_lines','after_event','after_receipt'] as $point){DB::beginTransaction();try{
$p=$prepare();$review($p);$key=(string)Illuminate\Support\Str::uuid();$failed=false;
try{$command('prepare','post',$postBody($p),$key,function($name)use($point){if($name===$point)throw new RuntimeException('QA_FAIL_'.$point);});}catch(RuntimeException $e){$failed=$e->getMessage()==='QA_FAIL_'.$point;}
$assert($failed&&DB::table('gl_journals')->count()===0&&DB::table('gl_lines')->count()===0,'Atomic rollback at '.$point);
$assert(DB::table('gl_events')->where('kind','journal.posted')->count()===0&&DB::table('gl_commands')->where('command_key',$key)->count()===0,'No false event or command receipt');
$posted=$command('prepare','post',$postBody($p),$key);$again=$command('prepare','post',$postBody($p),$key);
$assert($again['replayed']&&$again['journal_id']===$posted['journal_id']&&DB::table('gl_journals')->count()===1,'Recovery creates one effect');
}finally{DB::rollBack();}}
});
$case('database-balance-guards-and-append-only-history',function()use($prepare,$review,$postBody,$command,$scope,$enter,$assert):void{
$p=$prepare();$review($p);
$proposal=DB::table('gl_proposals')->where('id',$p['proposal_id'])->first();$snapshot=json_decode($proposal->snapshot,true);$reviewId=DB::table('gl_reviews')->where('proposal_id',$p['proposal_id'])->value('id');
foreach(['unequal_header','wrong_principal','missing_line','foreign_account'] as $variant){
$blocked=false;try{DB::transaction(function()use($variant,$scope,$proposal,$snapshot,$reviewId){
$id=DB::table('gl_journals')->insertGetId($scope+['effect_key'=>$proposal->effect_key,'obligation_id'=>$proposal->obligation_id,'proposal_id'=>$proposal->id,'review_id'=>$reviewId,'source_revision'=>$snapshot['source']['recognition_revision'],'journal_on'=>$snapshot['journal_on'],'currency'=>'MZN','debit_minor'=>$variant==='wrong_principal'?6001:6000,'credit_minor'=>$variant==='unequal_header'?5999:($variant==='wrong_principal'?6001:6000),'snapshot'=>$proposal->snapshot,'posted_by'=>$proposal->prepared_by,'posted_person'=>$proposal->prepared_person,'reason'=>'Direct SQL must retain journal invariants.','created_at'=>now()]);
foreach($snapshot['lines'] as $n=>$line){if($variant==='missing_line'&&$n===1)continue;$account=$line['account_id'];if($variant==='foreign_account'&&$n===0)$account=DB::table('gl_accounts')->insertGetId(array_replace($scope,['unit_id'=>$scope['unit_id']+10000])+['code'=>'QA-FOREIGN','label'=>'Foreign account','is_movement'=>true,'currency'=>'MZN']);
DB::table('gl_lines')->insert($scope+['journal_id'=>$id,'line_no'=>$n+1,'account_id'=>$account,'debit_minor'=>$line['debit_minor'],'credit_minor'=>$line['credit_minor']]);}
DB::select('SELECT gl_assert_balanced(?)',[$id]);
});}catch(Illuminate\Database\QueryException $e){$blocked=in_array($e->errorInfo[0]??'',['23514','23503'],true);}
$assert($blocked&&DB::table('gl_journals')->count()===0,'Database refuses '.$variant);
}
$posted=$command('prepare','post',$postBody($p));
foreach(Tempo\Finance\GeneralLedger\LedgerSchema::TABLES as $table){$blocked=false;try{DB::transaction(fn()=>DB::table($table)->where('id',DB::table($table)->value('id'))->update(['id'=>DB::raw('id')]));}catch(Illuminate\Database\QueryException $e){$blocked=str_contains($e->getMessage(),'GL_APPEND_ONLY');}$assert($blocked,'Append-only '.$table);}
$assert((int)DB::selectOne('SELECT sum(debit_minor) AS d,sum(credit_minor) AS c FROM gl_lines')->d===6000,'Line sum is exact');
$bad=false;try{DB::transaction(function()use($scope,$posted){$line=(array)DB::table('gl_lines')->where('journal_id',$posted['journal_id'])->first();unset($line['id']);$line['line_no']=3;DB::table('gl_lines')->insert($line);});}catch(Illuminate\Database\QueryException $e){$bad=($e->errorInfo[0]??'')==='23514';}$assert($bad,'Third line cannot change a posted journal');
$assert(DB::table('gl_lines')->count()===2,'Original lines preserved after direct SQL refusal');
});
$assert($financialFingerprint()===$g['financial_fingerprint'],'Final financial baseline unchanged');
file_put_contents($s['root'].'/ledger-cases.json',json_encode(['passed'=>true,'cases'=>$cases,'assertions'=>$checks,'financial_fingerprint_equal'=>true,'all_test_journals_rolled_back'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo json_encode(['cases'=>count($cases),'assertions'=>$checks,'passed'=>true])."\n";