f=ReceivablesFixture::create(); $this->login(); } private function login(string $who='manager'): void { $this->postJson('/api/login',['email'=>$this->f[$who]->email,'password'=>$this->f['password']])->assertOk(); $this->token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['unit']])->assertOk()->json('context.token'); } private function commandPost(string $path,array $data,?string $key=null) { return $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>$key??(string)Str::uuid()])->postJson('/api/service-receivables'.$path,$data); } private function rev(): int { return isset($this->ob)?DB::table('sr_effects')->where('obligation_id',$this->ob)->count():0; } private function proposal(string $kind,array $extra=[],?string $key=null) { return $this->commandPost('/proposals',array_replace(['kind'=>$kind,'invoice_id'=>$this->f['invoice'],'currency'=>'MZN','source'=>'TEST-MANUAL','external_id'=>(string)Str::uuid(),'revision'=>$this->rev()],isset($this->ob)?['obligation_id'=>$this->ob]:[],$extra),$key); } private function decide(int $id,?string $key=null,array $extra=[]) { return $this->commandPost('/'.$id.'/decision',array_replace(['version'=>1,'revision'=>$this->rev(),'decision'=>'approved','reason'=>'Confirmação sintética explícita.'],$extra),$key); } private function open(?string $due=null): void { $this->ob=$this->proposal('obligation',['due_date'=>$due])->assertOk()->json('id'); $this->login('reader'); $this->decide($this->ob)->assertOk(); $this->login(); } private function receipt(string $amount): int { $id=$this->proposal('receipt',['amount'=>$amount,'date'=>now('Africa/Johannesburg')->format('Y-m-d'),'evidence'=>'Origem artificial sem dinheiro.'])->assertOk()->json('id'); $this->login('reader'); $this->decide($id)->assertOk(); $this->login(); return $id; } private function allocate(int $id,string $amount,?string $key=null,?int $rev=null) { return $this->commandPost('/'.$id.'/allocate',['amount'=>$amount,'version'=>1,'revision'=>$rev??$this->rev()],$key); } private function ledgerView() { return $this->getJson('/api/service-receivables')->assertOk(); } private function correction(string $action,array $data=[]): int { return $this->proposal('correction',['action'=>$action,'reason'=>'Correcção sintética justificada.']+$data)->assertOk()->json('id'); } private function observation(int $receipt,string $amount): int { return $this->proposal('observation',['receipt_id'=>$receipt,'amount'=>$amount,'date'=>now('Africa/Johannesburg')->format('Y-m-d'),'evidence'=>'Observação artificial independente.'])->assertOk()->json('id'); } public function test_partial_receipts_exact_allocation_idempotency_and_independent_reconciliation(): void { $before=DB::table('si_invoices')->get()->toJson(); $this->open(); $a=$this->receipt('400.00'); $this->ledgerView()->assertJsonPath('obligations.0.balance','1000.00')->assertJsonPath('obligations.0.available','400.00')->assertJsonPath('obligations.0.due_state','unknown'); $revision=$this->rev(); $key=(string)Str::uuid(); $id=$this->allocate($a,'400.00',$key,$revision)->assertOk()->json('id'); $this->allocate($a,'400',$key,$revision)->assertOk()->assertJsonPath('id',$id)->assertJsonPath('replayed',true); $this->allocate($a,'399.99',$key,$revision)->assertStatus(409); $b=$this->receipt('600'); $this->allocate($b,'600')->assertOk(); $this->ledgerView()->assertJsonPath('obligations.0.balance','0.00')->assertJsonPath('obligations.0.received','1000.00')->assertJsonPath('obligations.0.allocated','1000.00'); $oa=$this->observation($a,'400'); $ob=$this->observation($b,'600'); $this->login('reader'); $this->decide($oa)->assertOk(); $this->decide($ob)->assertOk(); $v=$this->ledgerView(); $receipts=$v->json('obligations.0.receipts'); $this->assertTrue($receipts[$a]['reconciled']); $this->assertTrue($receipts[$b]['reconciled']); $this->assertSame($before,DB::table('si_invoices')->get()->toJson()); $this->assertSame(DB::table('sr_commands')->count(),DB::table('sr_events')->count()); $this->assertSame(DB::table('sr_events')->count(),DB::table('sr_outbox')->count()); $svc=app(ServiceReceivables::class); $event=DB::table('sr_events')->value('id'); $this->assertTrue($svc->deliver($event)); $this->assertFalse($svc->deliver($event)); } public function test_excess_divergence_no_tolerance_and_duplicate_source(): void { $this->open(); $a=$this->receipt('400'); $this->allocate($a,'400')->assertOk(); $b=$this->receipt('700'); $this->allocate($b,'700')->assertStatus(409); $this->ledgerView()->assertJsonPath('obligations.0.balance','600.00'); $this->allocate($b,'600')->assertOk(); $this->ledgerView()->assertJsonPath('obligations.0.available','100.00')->assertJsonPath('obligations.0.balance','0.00'); $o=$this->observation($a,'399.99'); $this->login('reader'); $this->decide($o)->assertOk()->assertJsonPath('event','divergence.simulation'); $this->assertSame('0.01',DB::table('sr_effects')->where('kind','divergence')->value('amount')); $this->login(); $r=DB::table('sr_records')->find($b); $this->proposal('receipt',['source'=>$r->source,'external_id'=>$r->external_id,'amount'=>'700','date'=>now()->format('Y-m-d'),'evidence'=>'Repeated source'])->assertStatus(409); } public function test_reduction_reversal_dependencies_and_preserved_original(): void { $this->open(); $a=$this->receipt('400'); $af=$this->allocate($a,'400')->assertOk()->json('id'); $c=$this->correction('reduction',['amount'=>'100','document_ref'=>'TEST-REDUCTION-100','line'=>'1']); $this->login('reader'); $this->decide($c)->assertOk(); $this->login(); $b=$this->receipt('500'); $bf=$this->allocate($b,'500')->assertOk()->json('id'); $this->ledgerView()->assertJsonPath('obligations.0.balance','0.00')->assertJsonPath('obligations.0.original','1000.00')->assertJsonPath('obligations.0.reduced','100.00'); $fail=$this->correction('reduction',['amount'=>'100','document_ref'=>'TEST-REDUCTION-200','line'=>'1']); $this->login('reader'); $this->decide($fail)->assertStatus(409); $this->login(); $fail=$this->correction('reverse_receipt',['receipt_id'=>$b]); $this->login('reader'); $this->decide($fail)->assertStatus(409); $this->login(); $undo=$this->correction('reverse_allocation',['target_effect_id'=>$bf]); $this->login('reader'); $this->decide($undo)->assertOk(); $this->login(); $this->ledgerView()->assertJsonPath('obligations.0.balance','500.00'); $undo=$this->correction('reverse_receipt',['receipt_id'=>$b]); $this->login('reader'); $this->decide($undo)->assertOk(); $this->ledgerView()->assertJsonPath('obligations.0.received','400.00')->assertJsonPath('obligations.0.available','0.00'); $this->assertDatabaseHas('sr_effects',['id'=>$bf,'kind'=>'allocate','amount'=>'500.00']); } public function test_permissions_revocation_self_approval_stale_versions_and_cross_scope(): void { $this->open(); $a=$this->receipt('400'); $this->login('reader'); $this->allocate($a,'400')->assertForbidden(); $this->proposal('receipt',[])->assertForbidden(); $this->login(); IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'service_receivables.confirm',$this->f['unit']); $r=$this->proposal('receipt',['amount'=>'100','date'=>now()->format('Y-m-d'),'evidence'=>'Synthetic'])->assertOk()->json('id'); $this->decide($r)->assertForbidden(); $this->allocate($a,'400',null,0)->assertStatus(409); $this->commandPost('/'.$a.'/allocate',['amount'=>'400','revision'=>$this->rev(),'version'=>2])->assertUnprocessable(); $c=$this->correction('due',['due_date'=>'2026-11-10']); $this->allocate($a,'1')->assertOk(); $this->login('reader'); $this->decide($c)->assertStatus(409); $this->login(); DB::table('permission_grants')->where('user_id',$this->f['manager']->id)->where('permission','service_receivables.allocate')->delete(); $this->allocate($a,'1')->assertForbidden(); $this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['sibling'],'unit_id'=>$this->f['siblingUnit']])->assertOk(); $this->commandPost('/'.$a.'/allocate',['amount'=>'1','version'=>1,'revision'=>0])->assertStatus(409); } public function test_invalid_money_environment_and_sql_immutability(): void { $this->open(); foreach(['0','-1','1.001','1e3','10000000000000000'] as $amount) $this->proposal('receipt',['amount'=>$amount,'date'=>now()->format('Y-m-d'),'evidence'=>'Synthetic'])->assertUnprocessable(); $this->proposal('receipt',['amount'=>'1','currency'=>'USD'])->assertUnprocessable(); foreach(['sr_records','sr_effects','sr_events','sr_outbox','sr_commands'] as $table) { try { DB::transaction(fn()=>DB::table($table)->delete()); $this->fail('Immutable deletion'); } catch(\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('SGF_',$e->getMessage()); } } $this->app->instance('env','local'); $this->withHeader('X-CSRF-TOKEN',session()->token()); $this->proposal('obligation')->assertForbidden(); $this->ledgerView()->assertJsonPath('blocked',true)->assertJsonPath('obligations',[]); try { ServiceReceivables::guard(); $this->fail('CLI bypass'); } catch(\Symfony\Component\HttpKernel\Exception\HttpException $e) { $this->assertSame(403,$e->getStatusCode()); } } public function test_outbox_failure_rolls_back_and_lost_response_replay_recovers(): void { $key=(string)Str::uuid(); $input=['kind'=>'obligation','invoice_id'=>$this->f['invoice'],'currency'=>'MZN','source'=>'TEST-FAIL','external_id'=>'failure-1','revision'=>0]; DB::unprepared("CREATE FUNCTION sr_test_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'TEST_FAILURE'; END; $$; CREATE TRIGGER sr_failure BEFORE INSERT ON sr_outbox FOR EACH ROW EXECUTE FUNCTION sr_test_fail()"); $this->commandPost('/proposals',$input,$key)->assertStatus(500); $this->assertDatabaseCount('sr_records',0); $this->assertDatabaseCount('sr_commands',0); $this->assertDatabaseCount('sr_events',0); DB::unprepared('DROP TRIGGER sr_failure ON sr_outbox'); $id=$this->commandPost('/proposals',$input,$key)->assertOk()->json('id'); $this->commandPost('/proposals',$input,$key)->assertOk()->assertJsonPath('id',$id)->assertJsonPath('replayed',true); $this->assertDatabaseCount('sr_records',1); } private function sqlDenied(callable $write): void { try { DB::transaction($write); $this->fail('SQL invariant bypass'); } catch(\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('SGF_',$e->getMessage()); } } public function test_sql_protects_linkage_scale_aggregate_and_history_without_eloquent(): void { $this->open(); $a=$this->receipt('700'); $this->allocate($a,'700')->assertOk(); $b=$this->receipt('400'); $row=(array)DB::table('sr_effects')->where('kind','allocate')->first(); unset($row['id']); $row['record_id']=$b; $row['receipt_id']=$b; $row['amount']='400.00'; $row['revision']=$this->rev(); $this->sqlDenied(fn()=>DB::table('sr_effects')->insert($row)); $row['amount']='0.001'; try { DB::transaction(fn()=>DB::table('sr_effects')->insert($row)); $this->fail('Scale silently rounded'); } catch(\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('check constraint',$e->getMessage()); } $row['amount']='1.00'; $row['obligation_id']=999999; $this->sqlDenied(fn()=>DB::table('sr_effects')->insert($row)); $r=(array)DB::table('sr_records')->find($a); unset($r['id']); $r['external_id']='sql-transfer'; $r['unit_id']=$this->f['hidden']; $this->sqlDenied(fn()=>DB::table('sr_records')->insert($r)); $this->sqlDenied(fn()=>DB::table('sr_records')->where('id',$a)->update(['client_id'=>$this->f['client']+1])); $this->ledgerView()->assertJsonPath('obligations.0.balance','300.00')->assertJsonPath('obligations.0.allocated','700.00'); } public function test_reconciliation_undo_reuse_and_post_settlement_reduction(): void { $this->open(); $a=$this->receipt('400'); $this->allocate($a,'400')->assertOk(); $b=$this->receipt('600'); $bf=$this->allocate($b,'600')->assertOk()->json('id'); $ob=$this->observation($b,'600'); $this->login('reader'); $cf=$this->decide($ob)->assertOk()->json('id'); $this->decide($ob)->assertStatus(409); $this->login(); $other=$this->observation($b,'600'); $this->login('reader'); $this->decide($other)->assertStatus(409); $this->login(); $reverse=$this->correction('reverse_reconcile',['target_effect_id'=>$cf]); $this->login('reader'); $this->decide($reverse)->assertOk(); $this->login(); $reverse=$this->correction('reverse_allocation',['target_effect_id'=>$bf]); $this->login('reader'); $key=(string)Str::uuid(); $rev=$this->rev(); $this->decide($reverse,$key)->assertOk(); $this->decide($reverse,$key,['revision'=>$rev])->assertOk()->assertJsonPath('replayed',true); $this->login(); $c=$this->correction('reduction',['amount'=>'100','document_ref'=>'TEST-REDUCTION-AFTER','line'=>'1']); $this->login('reader'); $this->decide($c)->assertOk(); $this->login(); $this->allocate($b,'500')->assertOk(); $this->ledgerView()->assertJsonPath('obligations.0.balance','0.00')->assertJsonPath('obligations.0.available','100.00'); $this->login('reader'); $this->decide($other)->assertOk(); $this->ledgerView()->assertJsonPath('obligations.0.balance','0.00'); } public function test_due_boundaries_missing_dates_and_consumer_failure_are_explicit(): void { $this->open('2026-11-10'); $this->travelTo(\Carbon\Carbon::parse('2026-11-10 12:00:00','Africa/Johannesburg')); $this->ledgerView()->assertJsonPath('obligations.0.due_state','not_due'); $this->travelTo(\Carbon\Carbon::parse('2026-11-11 12:00:00','Africa/Johannesburg')); $this->ledgerView()->assertJsonPath('obligations.0.due_state','overdue'); $this->travelBack(); $a=$this->receipt('1000'); $this->allocate($a,'1000')->assertOk(); $this->ledgerView()->assertJsonPath('obligations.0.due_state','settled'); $event=DB::table('sr_events')->value('id'); DB::unprepared("CREATE OR REPLACE FUNCTION sr_delivery_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'TEST_DELIVERY_FAILURE'; END; $$; CREATE TRIGGER sr_delivery_failure BEFORE INSERT ON sr_deliveries FOR EACH ROW EXECUTE FUNCTION sr_delivery_fail()"); try { app(ServiceReceivables::class)->deliver($event); $this->fail('Failure not injected'); } catch(\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('TEST_DELIVERY_FAILURE',$e->getMessage()); } $this->assertDatabaseCount('sr_deliveries',0); DB::unprepared('DROP TRIGGER sr_delivery_failure ON sr_deliveries'); $this->assertTrue(app(ServiceReceivables::class)->deliver($event)); $this->assertFalse(app(ServiceReceivables::class)->deliver($event)); } public function test_foreign_identifiers_missing_evidence_and_db_target_guard(): void { $this->open(); $this->proposal('receipt',['amount'=>'1'])->assertUnprocessable(); $this->proposal('receipt',['amount'=>'1','date'=>'2099-01-01','evidence'=>'Synthetic'])->assertUnprocessable(); $this->proposal('observation',['amount'=>'1','date'=>now()->format('Y-m-d'),'evidence'=>'Synthetic','receipt_id'=>999999])->assertForbidden(); $this->proposal('receipt',['amount'=>'1','date'=>now()->format('Y-m-d'),'evidence'=>'Synthetic','invoice_id'=>999999])->assertForbidden(); $this->proposal('receipt',['amount'=>'1','date'=>now()->format('Y-m-d'),'evidence'=>'Synthetic','client_id'=>999999])->assertUnprocessable(); $a=$this->receipt('100'); $target=$this->allocate($a,'100')->assertOk()->json('id'); $reverse=$this->correction('reverse_allocation',['target_effect_id'=>$target]); $this->login('reader'); $this->decide($reverse)->assertOk(); $this->login(); $again=$this->correction('reverse_allocation',['target_effect_id'=>$target]); $this->login('reader'); $this->decide($again)->assertStatus(409); $this->ledgerView()->assertJsonPath('obligations.0.balance','1000.00')->assertJsonPath('obligations.0.available','100.00'); } public function test_financial_effect_rollback_live_scope_and_wrong_database_guard(): void { $this->open(); $a=$this->receipt('400'); $revision=$this->rev(); $before=DB::table('sr_effects')->count(); $key=(string)Str::uuid(); DB::unprepared("CREATE OR REPLACE FUNCTION sr_test_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'TEST_FAILURE'; END; $$; CREATE TRIGGER sr_failure BEFORE INSERT ON sr_outbox FOR EACH ROW EXECUTE FUNCTION sr_test_fail()"); $this->allocate($a,'400',$key,$revision)->assertStatus(500); $this->assertDatabaseCount('sr_effects',$before); $this->ledgerView()->assertJsonPath('obligations.0.balance','1000.00')->assertJsonPath('obligations.0.available','400.00'); DB::unprepared('DROP TRIGGER sr_failure ON sr_outbox'); $this->allocate($a,'400',$key,$revision)->assertOk(); $this->allocate($a,'400',$key,$revision)->assertOk()->assertJsonPath('replayed',true); foreach(['view','allocate'] as $right) IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['sibling'],'service_receivables.'.$right,$this->f['siblingUnit']); $this->token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['sibling'],'unit_id'=>$this->f['siblingUnit']])->assertOk()->json('context.token'); $this->allocate($a,'1')->assertForbidden(); $this->ledgerView()->assertJsonPath('records',[]); $this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['hidden']])->assertForbidden(); config(['database.connections.pgsql.database'=>'sgf_tempo_dev']); try { ServiceReceivables::guard(); $this->fail('Wrong test destination allowed'); } catch(\RuntimeException $e) { $this->assertStringContainsString('SGF_TEST_DATABASE_REFUSED',$e->getMessage()); } } public function test_proposed_refused_obligation_is_not_open_and_original_identity_is_frozen(): void { $this->ob=$this->proposal('obligation')->assertOk()->json('id'); $this->ledgerView()->assertJsonPath('obligations.0.opened',false)->assertJsonPath('obligations.0.due_state','not_open'); $data=json_decode(DB::table('sr_records')->find($this->ob)->data,true); $this->assertSame(hash('sha256',DB::table('si_invoices')->find($this->f['invoice'])->snapshot),$data['origin']['snapshot_sha256']); $this->login('reader'); $this->decide($this->ob,null,['decision'=>'refused'])->assertOk(); $this->ledgerView()->assertJsonPath('obligations.0.state','refused')->assertJsonPath('obligations.0.opened',false); $this->login(); $this->proposal('receipt',['amount'=>'400','date'=>now()->format('Y-m-d'),'evidence'=>'No open obligation'])->assertStatus(409); } }