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";