Accept isolated invoice issuance and reproducible PDF delivery

This commit is contained in:
2026-10-27 08:56:13 +02:00
parent 9f480045fa
commit 4276aca5c2
25 changed files with 1072 additions and 1 deletions
+128
View File
@@ -0,0 +1,128 @@
<?php
namespace Tests\Feature;
use App\Modules\Services\ServiceInvoicing;
use App\Shared\Context\CompanyContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\Support\InvoiceFixture;
use Tests\Support\IdentityFixture;
use Tests\TestCase;
final class ServiceInvoiceTest extends TestCase
{
use RefreshDatabase;
private array $f; private string $token; private string $key;
protected function setUp(): void
{
parent::setUp(); $this->f=InvoiceFixture::create(); $this->key=(string)Str::uuid(); $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 review()
{
return $this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-invoices/drafts/'.$this->f['draft'].'/review',['series_id'=>$this->f['series'],'rule_id'=>$this->f['rule']]);
}
private function issue(string $hash,?string $key=null,array $changes=[])
{
return $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>$key ?? $this->key])->postJson('/api/service-invoices/drafts/'.$this->f['draft'].'/issue',array_replace(['series_id'=>$this->f['series'],'rule_id'=>$this->f['rule'],'review_hash'=>$hash],$changes));
}
public function test_atomic_issuance_pdf_snapshot_replay_and_correction_request(): void
{
$review=$this->review()->assertOk()->assertJsonPath('ready',true); $this->assertDatabaseCount('si_invoices',0);
$hash=$review->json('review_hash'); $id=$this->issue($hash)->assertOk()->assertJsonPath('number',1)->json('id');
$this->issue($hash)->assertOk()->assertJsonPath('id',$id)->assertJsonPath('replayed',true);
$this->issue(str_repeat('a',64))->assertStatus(409);
$this->issue($hash,(string)Str::uuid())->assertStatus(409);
$show=$this->getJson('/api/service-invoices/'.$id)->assertOk()->assertJsonPath('snapshot.notice',ServiceInvoicing::NOTICE)->assertJsonPath('snapshot.calculation.total_document_proposed','9106.00')->assertJsonPath('snapshot.assessment_period',substr($this->f['today'],0,7));
$this->assertSame($show->json('snapshot.document_lines'),$show->json('document_lines'));
$pdf=$this->get('/api/service-invoices/'.$id.'/pdf')->assertOk()->assertHeader('Content-Type','application/pdf')->getContent();
$this->assertStringStartsWith('%PDF-1.4',$pdf); $this->assertSame($show->json('pdf_sha256'),hash('sha256',$pdf));
$before=DB::table('si_invoices')->get()->toJson();
$this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>(string)Str::uuid()])->patchJson('/api/a/clients/'.$this->f['client'],['version'=>1,'name'=>'Changed after issue'])->assertOk();
$this->assertSame($pdf,$this->get('/api/service-invoices/'.$id.'/pdf')->assertOk()->getContent());
$this->getJson('/api/service-invoices/'.$id)->assertOk()->assertJsonPath('snapshot.document.client.name','Cliente sintético Horizonte');
$this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-invoices/'.$id.'/corrections',['reason'=>'Pedido sintético sem efeito monetário'])->assertOk()->assertJsonPath('state','pending_no_fiscal_effect');
$this->assertSame($before,DB::table('si_invoices')->get()->toJson());
$this->assertDatabaseCount('si_events',1); $this->assertDatabaseCount('si_outbox',1); $this->assertDatabaseCount('si_commands',1);
}
public function test_real_environment_cannot_issue_or_configure_even_with_grants(): void
{
$hash=$this->review()->assertOk()->json('review_hash');
$this->app->instance('env','local');
$this->withHeader('X-CSRF-TOKEN',session()->token());
$this->issue($hash)->assertForbidden(); $this->review()->assertForbidden();
$this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-invoices/proposals',[])->assertForbidden();
$this->getJson('/api/service-invoices')->assertOk()->assertJsonPath('real_blocked',true)->assertJsonPath('invoices',[]);
$this->assertDatabaseCount('si_invoices',0);
try { ServiceInvoicing::guard(); $this->fail('CLI bypass allowed'); } catch(\Symfony\Component\HttpKernel\Exception\HttpException $e) { $this->assertSame(403,$e->getStatusCode()); }
}
public function test_reader_separation_revocation_and_cross_company_access(): void
{
$hash=$this->review()->assertOk()->json('review_hash'); $id=$this->issue($hash)->assertOk()->json('id');
$this->login('reader'); $this->getJson('/api/service-invoices/'.$id)->assertOk(); $this->issue($hash)->assertForbidden();
$this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-invoices/'.$id.'/corrections',['reason'=>'No right'])->assertForbidden();
$this->login(); DB::table('permission_grants')->where('user_id',$this->f['manager']->id)->where('permission','service_invoices.issue')->delete(); $this->issue($hash)->assertForbidden();
$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['sibling'],'unit_id'=>$this->f['siblingUnit']])->assertOk();
$this->getJson('/api/service-invoices/'.$id)->assertForbidden();
}
public function test_stale_revision_date_changes_missing_data_and_pending_rule_refuse(): void
{
$hash=$this->review()->assertOk()->json('review_hash');
$this->issue($hash,null,['number'=>999])->assertUnprocessable();
$this->travel(1)->days(); $this->issue($hash)->assertStatus(409); $this->review()->assertOk()->assertJsonPath('ready',false); $this->travelBack();
$this->withHeader('X-SGF-Context',$this->token)->patchJson('/api/document-preparation/client/'.$this->f['client'],['version'=>1,'nuit'=>null])->assertOk();
$this->issue($hash)->assertStatus(409); $this->review()->assertOk()->assertJsonPath('ready',false);
$this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-invoices/drafts/'.$this->f['draft'].'/review',['series_id'=>$this->f['series'],'rule_id'=>$this->f['series']])->assertOk()->assertJsonPath('ready',false);
$this->assertDatabaseCount('si_invoices',0); $this->assertDatabaseCount('si_counters',0);
}
public function test_failure_during_outbox_rolls_back_number_invoice_and_audit(): void
{
$hash=$this->review()->assertOk()->json('review_hash');
DB::unprepared("CREATE OR REPLACE FUNCTION si_test_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'SYNTHETIC_FAILURE'; END; $$; CREATE TRIGGER si_failure BEFORE INSERT ON si_outbox FOR EACH ROW EXECUTE FUNCTION si_test_fail()");
$this->issue($hash)->assertStatus(500);
$this->assertDatabaseCount('si_invoices',0); $this->assertDatabaseCount('si_counters',0); $this->assertDatabaseCount('si_events',0); $this->assertDatabaseCount('si_commands',0);
$this->assertSame(0,DB::table('identity_audit')->where('action','service_invoice.test_issued')->count());
DB::unprepared('DROP TRIGGER si_failure ON si_outbox');
$this->issue($hash)->assertOk()->assertJsonPath('number',1);
}
public function test_database_immutability_and_b1_snapshot_are_preserved(): void
{
$id=$this->issue($this->review()->assertOk()->json('review_hash'))->assertOk()->json('id');
foreach(['si_invoices','si_proposals','si_decisions','si_events','si_outbox','si_commands'] as $table) {
try { DB::transaction(fn()=>DB::table($table)->delete()); $this->fail('History deleted'); }
catch(\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('SGF_',$e->getMessage()); }
}
try { DB::transaction(fn()=>DB::table('si_invoices')->where('id',$id)->update(['unit_id'=>$this->f['hidden']])); $this->fail('Mass transfer allowed'); }
catch(\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('SGF_',$e->getMessage()); }
$this->assertDatabaseCount('b_internal_confirmations',0);
}
public function test_b1_confirmed_draft_is_not_converted_or_changed(): void
{
$this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>(string)Str::uuid()])->postJson('/api/b1/billing-drafts/'.$this->f['draft'].'/confirm-internal',['version'=>1])->assertOk();
$before=DB::table('b_internal_confirmations')->get()->toJson();
$this->review()->assertStatus(409); $this->assertDatabaseCount('si_invoices',0);
$this->assertSame($before,DB::table('b_internal_confirmations')->get()->toJson());
}
public function test_closed_series_new_approval_changes_revision_and_has_no_counter_reset(): void
{
$old=$this->review()->assertOk()->json('review_hash');
$this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-invoices/proposals',['kind'=>'series','identifier'=>'TEST-SERIE-ISOLADA','previous_id'=>$this->f['series'],'data'=>['code'=>'TEST-SERIE-ISOLADA','first_number'=>1,'state'=>'closed','valid_from'=>$this->f['start'],'evidence'=>'Encerramento sintético aprovado.']])->assertOk();
$new=(int)DB::table('si_proposals')->max('id');
$this->login('reader'); $this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-invoices/proposals/'.$new.'/decision',['version'=>2,'decision'=>'approved','reason'=>'Encerrar sem apagar contador.'])->assertOk();
$this->login(); $this->issue($old)->assertStatus(409); $this->review()->assertOk()->assertJsonPath('ready',false);
$this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-invoices/proposals',['kind'=>'series','identifier'=>'TEST-SERIE-ISOLADA','previous_id'=>$new,'data'=>['code'=>'TEST-SERIE-ISOLADA','first_number'=>10,'state'=>'open','valid_from'=>$this->f['start'],'evidence'=>'Illegal reset attempt.']])->assertUnprocessable();
}
public function test_pdf_encoding_and_long_text_are_not_silently_lost(): void
{
$this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>(string)Str::uuid()])->patchJson('/api/a/clients/'.$this->f['client'],['version'=>1,'name'=>'测试'])->assertOk();
$this->review()->assertOk()->assertJsonPath('ready',false);
$this->assertDatabaseCount('si_invoices',0);
$pdf=new \App\Modules\Services\InvoicePdf();
// Encoding refusal must be explicit, rather than generating replacement glyphs.
$this->assertFalse(\App\Modules\Services\InvoicePdf::supports([['text','测试']]));
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace Tests\Support;
use App\Modules\Services\DocumentPreparation;
use App\Modules\Services\ServiceFiscal;
use App\Modules\Services\ServiceInvoicing;
use App\Modules\Services\ServiceWorkflow;
use App\Shared\Context\CompanyContext;
use App\Shared\Testing\TestingDatabaseGuard;
use Illuminate\Support\Str;
final class InvoiceFixture
{
public static function create(?array $f=null): array
{
TestingDatabaseGuard::assertSafe(); $f ??= DocumentPreviewFixture::create();
$actor=$f['manager']->id; $validator=$f['reader']->id;
foreach(['view','propose'] as $a) IdentityFixture::grant($actor,$f['tenant'],$f['company'],'service_fiscal.'.$a);
foreach(['view','approve'] as $a) IdentityFixture::grant($validator,$f['tenant'],$f['company'],'service_fiscal.'.$a);
IdentityFixture::grant($actor,$f['tenant'],$f['company'],'service_fiscal.calculate',$f['unit']);
foreach(['view','propose'] as $a) IdentityFixture::grant($actor,$f['tenant'],$f['company'],'invoice_series.'.$a);
foreach(['view','approve'] as $a) IdentityFixture::grant($validator,$f['tenant'],$f['company'],'invoice_series.'.$a);
foreach(['view','issue','correction_request'] as $a) IdentityFixture::grant($actor,$f['tenant'],$f['company'],'service_invoices.'.$a,$f['unit']);
IdentityFixture::grant($validator,$f['tenant'],$f['company'],'service_invoices.view',$f['unit']);
$select=function($id)use($f) { app(CompanyContext::class)->selectFor($id,$f['tenant'],$f['company'],$f['unit']); };
$select($actor); $w=app(ServiceWorkflow::class); $prepare=app(DocumentPreparation::class); $fiscal=app(ServiceFiscal::class); $invoices=app(ServiceInvoicing::class);
$client=$w->save('clients',null,$actor,['name'=>'Cliente sintético Horizonte'],(string)Str::uuid())['id'];
$service=$w->save('services',null,$actor,['name'=>'Alojamento mensal sintético'],(string)Str::uuid())['id'];
$today=now(ServiceInvoicing::ZONE)->format('Y-m-d'); $start=now(ServiceInvoicing::ZONE)->subDays(30)->format('Y-m-d'); $end=now(ServiceInvoicing::ZONE)->subDay()->format('Y-m-d');
$source=$w->save('service_drafts',null,$actor,['name'=>'Prestação SINTÉTICA para emissão de teste','client_id'=>$client,'currency'=>'MZN','lines'=>[['service_id'=>$service,'quantity'=>'1','unit_price'=>'7850.00','description'=>'Serviço fictício para demonstração isolada.']]],(string)Str::uuid())['id'];
$draft=$w->save('billing_drafts',null,$actor,['source_id'=>$source,'name'=>'Factura de TESTE — alojamento'],(string)Str::uuid())['id'];
$prepare->save('billing_drafts',$draft,$actor,['version'=>0,'draft_version'=>1,'currency'=>'MZN','price_mode'=>'added','period_start'=>$start,'period_end'=>$end,'units'=>['período'],'payment_terms'=>'Condição sintética: vencimento na data de emissão.','proposed_due_date'=>$today,'payment_instruction_ids'=>[]]);
foreach(['issuer'=>$f['company'],'client'=>$client] as $kind=>$id) $prepare->save($kind,$id,$actor,['version'=>0,'nuit'=>$kind==='issuer'?'999999991':'999999992','address'=>'Rua de demonstração, 1','locality'=>'Localidade sintética','country'=>'Moçambique']);
$profile=$fiscal->propose($actor,['kind'=>'profile','identifier'=>'TEST-PROFILE-HOSTING','data'=>['service_id'=>$service,'service_version'=>1,'currency'=>'MZN','price_mode'=>'added','rate_percent'=>'16','valid_from'=>$start,'treatment'=>'domestic_normal','conditions'=>'Hipótese sintética interna, sem aplicação à empresa real.','evidence'=>'Fixture de teste; pressupostos fictícios, não prova de conformidade.']])['id'];
$select($validator); $fiscal->decide($profile,$validator,['version'=>1,'decision'=>'approved','reason'=>'Aprovação sintética do perfil.']); $fiscal->proofActivation($profile,$validator,1,true); $select($actor);
$temporal=$fiscal->propose($actor,['kind'=>'temporal','identifier'=>'TEST-CALC-PERIOD','data'=>['profile_proposal_id'=>$profile,'draft_id'=>$draft,'draft_version'=>1,'preparation_version'=>1,'reference_date'=>$start,'reference_basis'=>'Início explícito sintético para vigência, sem exigibilidade determinada.','period_start'=>$start,'period_end'=>$end,'event'=>'pending','evidence'=>'Pendência deliberada para provar que não autoriza emitir.']])['id'];
$select($validator); $fiscal->decide($temporal,$validator,['version'=>1,'decision'=>'approved','reason'=>'Somente cálculo condicionado.']); $select($actor);
$calculation=$fiscal->calculate($draft,$actor,['profile_proposal_id'=>$profile,'profile_version'=>1,'temporal_proposal_id'=>$temporal,'temporal_version'=>1,'draft_version'=>1,'preparation_version'=>1])['id'];
$series=$invoices->propose($actor,['kind'=>'series','identifier'=>'TEST-SERIE-ISOLADA','data'=>['code'=>'TEST-SERIE-ISOLADA','first_number'=>1,'state'=>'open','valid_from'=>$start,'evidence'=>'Série exclusivamente sintética, sem autorização de uso real.']])['id'];
$rule=$invoices->propose($actor,['kind'=>'temporal','identifier'=>'TEST-ISSUE-RULE','data'=>['valid_from'=>$start,'draft_id'=>$draft,'draft_version'=>1,'preparation_version'=>1,'profile_proposal_id'=>$profile,'period_start'=>$start,'period_end'=>$end,'issue_window_start'=>$today,'issue_window_end'=>$today,'event'=>'on_issue_within_deadline','readiness_basis'=>'Hipótese sintética: dados documentais, cobertura, representação e tempestividade validados apenas para testar o mecanismo.','evidence'=>'Janela fictícia de emissão validada, não regra legal ou calendário da Tempo.']])['id'];
$select($validator);
foreach([$series,$rule] as $id) $invoices->decide($id,$validator,['version'=>1,'decision'=>'approved','reason'=>'Validação sintética da versão e pressupostos de teste.']);
$select($actor);
return $f+compact('client','service','source','draft','profile','temporal','calculation','series','rule','today','start','end');
}
public static function additionalDraft(array $f): array
{
TestingDatabaseGuard::assertSafe(); $actor=$f['manager']->id; $validator=$f['reader']->id;
$select=function($id)use($f) { app(CompanyContext::class)->selectFor($id,$f['tenant'],$f['company'],$f['unit']); };
$select($actor); $w=app(ServiceWorkflow::class); $fiscal=app(ServiceFiscal::class); $invoices=app(ServiceInvoicing::class);
$source=$w->save('service_drafts',null,$actor,['name'=>'Prestação sintética concorrente','client_id'=>$f['client'],'currency'=>'MZN','lines'=>[['service_id'=>$f['service'],'quantity'=>'1','unit_price'=>'7850.00']]],(string)Str::uuid())['id'];
$draft=$w->save('billing_drafts',null,$actor,['source_id'=>$source,'name'=>'Documento sintético concorrente'],(string)Str::uuid())['id'];
app(DocumentPreparation::class)->save('billing_drafts',$draft,$actor,['version'=>0,'draft_version'=>1,'currency'=>'MZN','price_mode'=>'added','period_start'=>$f['start'],'period_end'=>$f['end'],'units'=>['período'],'payment_instruction_ids'=>[]]);
$temporal=$fiscal->propose($actor,['kind'=>'temporal','identifier'=>'TEST-CALC-'.$draft,'data'=>['profile_proposal_id'=>$f['profile'],'draft_id'=>$draft,'draft_version'=>1,'preparation_version'=>1,'reference_date'=>$f['start'],'reference_basis'=>'Referência explícita sintética.','period_start'=>$f['start'],'period_end'=>$f['end'],'event'=>'pending','evidence'=>'Prova concorrente isolada.']])['id'];
$select($validator); $fiscal->decide($temporal,$validator,['version'=>1,'decision'=>'approved','reason'=>'Cálculo sintético condicionado.']); $select($actor);
$calculation=$fiscal->calculate($draft,$actor,['profile_proposal_id'=>$f['profile'],'profile_version'=>1,'temporal_proposal_id'=>$temporal,'temporal_version'=>1,'draft_version'=>1,'preparation_version'=>1])['id'];
$rule=$invoices->propose($actor,['kind'=>'temporal','identifier'=>'TEST-ISSUE-'.$draft,'data'=>['valid_from'=>$f['start'],'draft_id'=>$draft,'draft_version'=>1,'preparation_version'=>1,'profile_proposal_id'=>$f['profile'],'period_start'=>$f['start'],'period_end'=>$f['end'],'issue_window_start'=>$f['today'],'issue_window_end'=>$f['today'],'event'=>'on_issue_within_deadline','readiness_basis'=>'Pressupostos sintéticos validados para prova de concorrência.','evidence'=>'Prova isolada, sem calendário legal presumido.']])['id'];
$select($validator); $invoices->decide($rule,$validator,['version'=>1,'decision'=>'approved','reason'=>'Validação sintética da regra.']); $select($actor);
return array_replace($f,compact('source','draft','temporal','calculation','rule'));
}
}