context->identifiers(); $unit=$this->context->unitId(); abort_unless($actor===$this->context->userId() && $unit!==null,403,'Seleccione uma unidade autorizada.'); $this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'unit.view',$unit); $this->permissions->require($actor,$s['tenant_id'],$s['company_id'],$right,(str_starts_with($right,'invoice_series.') || str_starts_with($right,'service_fiscal.'))?null:$unit); return $s+['unit_id'=>$unit]; } private function exact(array $data,array $rules): array { abort_if(array_diff(array_keys($data),array_keys($rules))!==[],422,'Campo protegido ou fora do âmbito documental.'); return Validator::make($data,$rules)->validate(); } private function command(int $actor,string $right,callable $write): array { self::guard(); $s=$this->scope($actor,$right); return DB::transaction(function()use($actor,$right,$write,$s) { DB::table('companies')->where('id',$s['company_id'])->where('tenant_id',$s['tenant_id'])->lockForUpdate()->firstOrFail(); $this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']); $this->scope($actor,$right); return $write($s); }); } private function row(int $id,array $s): object { $r=DB::table('si_proposals')->where($s)->where('id',$id)->first(); abort_unless($r,403,'Proposta fora do âmbito.'); return $r; } private function data(object $r): array { return json_decode($r->data,true,32,JSON_THROW_ON_ERROR); } private function decision(int $id): ?object { return DB::table('si_decisions')->where('proposal_id',$id)->first(); } private function approved(object $p,array $s,string $date): bool { if($this->decision($p->id)?->decision!=='approved') return false; $d=$this->data($p); if(($d['valid_from'] ?? '')>$date || (!empty($d['valid_until']) && $date>$d['valid_until'])) return false; return !DB::table('si_proposals')->where($s)->where('kind',$p->kind)->where('identifier',$p->identifier)->where('version','>',$p->version)->whereRaw("data->>'valid_from' <= ?",[$date]) ->whereExists(fn($q)=>$q->selectRaw('1')->from('si_decisions')->whereColumn('si_decisions.proposal_id','si_proposals.id')->where('decision','approved'))->exists(); } public function propose(int $actor,array $input): array { $kind=$input['kind'] ?? ''; $right=$kind==='series'?'invoice_series.propose':'service_fiscal.propose'; return $this->command($actor,$right,function($s)use($actor,$input) { $in=$this->exact($input,['kind'=>'required|in:series,temporal','identifier'=>'required|string|max:100|regex:/^[A-Za-z0-9_-]+$/D','previous_id'=>'nullable|integer|min:1','data'=>'required|array']); $previous=isset($in['previous_id'])?$this->row((int)$in['previous_id'],$s):null; $last=DB::table('si_proposals')->where('company_id',$s['company_id'])->where('kind',$in['kind'])->where('identifier',$in['identifier'])->orderByDesc('version')->first(); abort_unless(($last?->id ?? null)===($previous?->id ?? null),409,'Versão anterior exacta necessária.'); $common=['valid_from'=>'required|date_format:Y-m-d','valid_until'=>'nullable|date_format:Y-m-d|after_or_equal:valid_from','evidence'=>'required|string|max:4000']; if($in['kind']==='series') { $d=$this->exact($in['data'],$common+['code'=>'required|string|max:40|regex:/^TEST-[A-Z0-9_-]+$/D','first_number'=>'required|integer|min:1|max:999999999','state'=>'required|in:open,closed']); abort_unless($d['code']===$in['identifier'],422,'Identidade da série deve coincidir com o código sintético.'); if($previous) { $old=$this->data($previous); abort_unless($old['code']===$d['code'] && (int)$old['first_number']===(int)$d['first_number'],422,'Não reiniciar ou transferir contador existente.'); } $d['first_number']=(int)$d['first_number']; } else { $d=$this->exact($in['data'],$common+['draft_id'=>'required|integer|min:1','draft_version'=>'required|integer|min:1','preparation_version'=>'required|integer|min:1','profile_proposal_id'=>'required|integer|min:1','period_start'=>'required|date_format:Y-m-d','period_end'=>'required|date_format:Y-m-d|after_or_equal:period_start','issue_window_start'=>'required|date_format:Y-m-d','issue_window_end'=>'required|date_format:Y-m-d|after_or_equal:issue_window_start','event'=>'required|in:on_issue_within_deadline','readiness_basis'=>'required|string|max:4000']); $view=$this->preview->captureDraftForIssuance((int)$d['draft_id'],$actor); abort_unless($view['draft_version']===(int)$d['draft_version'] && $view['preparation_version']===(int)$d['preparation_version'] && ($view['prepared']['period_start'] ?? null)===$d['period_start'] && ($view['prepared']['period_end'] ?? null)===$d['period_end'],409,'Período/versões não correspondem à preparação.'); $profile=DB::table('sf_proposals')->where($s)->where('id',$d['profile_proposal_id'])->where('kind','profile')->first(); abort_unless($profile && DB::table('sf_decisions')->where('proposal_id',$profile->id)->where('decision','approved')->exists(),422,'Perfil fiscal exacto aprovado necessário.'); abort_if(DB::table('si_proposals')->where($s)->where('kind','temporal')->whereRaw("data->>'draft_id' = ?",[(string)$d['draft_id']])->where('identifier','<>',$in['identifier'])->exists(),409,'Use a identidade da regra temporal existente.'); if($previous) abort_unless((int)$this->data($previous)['draft_id']===(int)$d['draft_id'],422,'Regra não pode trocar o rascunho.'); foreach(['draft_id','draft_version','preparation_version','profile_proposal_id'] as $f) $d[$f]=(int)$d[$f]; } $id=DB::table('si_proposals')->insertGetId($s+['kind'=>$in['kind'],'identifier'=>$in['identifier'],'version'=>(int)($last?->version ?? 0)+1,'previous_id'=>$previous?->id,'actor_id'=>$actor,'data'=>json_encode($d,JSON_THROW_ON_ERROR),'created_at'=>now()]); Audit::record('service_invoice.proposed',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'version'=>(int)($last?->version ?? 0)+1]); return ['id'=>$id,'version'=>(int)($last?->version ?? 0)+1]; }); } public function decide(int $id,int $actor,array $input): array { self::guard(); $scope=$this->context->identifiers()+['unit_id'=>$this->context->unitId()]; $p=$this->row($id,$scope); return $this->command($actor,$p->kind==='series'?'invoice_series.approve':'service_fiscal.approve',function($s)use($id,$actor,$input) { $d=$this->exact($input,['version'=>'required|integer|min:1','decision'=>'required|in:approved,refused','reason'=>'required|string|max:4000']); $p=$this->row($id,$s); abort_unless((int)$p->version===(int)$d['version'] && (int)$p->actor_id!==$actor,403,'Validador distinto e versão exacta obrigatórios.'); abort_if($this->decision($id),409,'Decisão imutável; correcção exige nova versão.'); DB::table('si_decisions')->insert(['proposal_id'=>$id,'actor_id'=>$actor,'decision'=>$d['decision'],'reason'=>$d['reason'],'created_at'=>now()]); Audit::record('service_invoice.decided',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'version'=>$p->version,'reason'=>$d['reason']]); return ['id'=>$id,'decision'=>$d['decision']]; }); } public function listing(int $actor): array { $s=$this->scope($actor,'service_invoices.view'); if(!ServiceCalculation::isolated()) return ['real_blocked'=>true,'test_enabled'=>false,'notice'=>'Emissão real bloqueada: conformidade ainda não demonstrada.','proposals'=>[],'invoices'=>[]]; TestingDatabaseGuard::assertSafe(); if(!Schema::hasTable('si_invoices')) return ['real_blocked'=>true,'notice'=>'Migração documental ainda não aplicada.','proposals'=>[],'invoices'=>[]]; return ['real_blocked'=>true,'test_enabled'=>true,'notice'=>self::NOTICE,'proposals'=>DB::table('si_proposals')->where($s)->orderByDesc('id')->get()->map(fn($p)=>(array)$p+['definition'=>$this->data($p),'decision'=>$this->decision($p->id)])->all(),'invoices'=>\App\Shared\Context\ModuleRecords::query('si_invoices')->where($s)->orderByDesc('id')->get(['id','series_code','number','draft_id','issued_at'])->all()]; } public function review(int $draft,int $series,int $rule,int $actor): array { return $this->command($actor,'service_invoices.issue',fn($s)=>$this->reviewInside($draft,$series,$rule,$actor,$s)); } private function reviewInside(int $draft,int $series,int $rule,int $actor,array $s): array { $view=$this->preview->captureDraftForIssuance($draft,$actor); $date=now(self::ZONE)->format('Y-m-d'); $blocks=[]; $p=$this->row($series,$s); $t=$this->row($rule,$s); $sd=$this->data($p); $td=$this->data($t); if($p->kind!=='series' || !$this->approved($p,$s,$date) || ($sd['state'] ?? null)!=='open') $blocks[]='Série sintética sem configuração aprovada/vigente e aberta.'; if($t->kind!=='temporal' || !$this->approved($t,$s,$date) || (int)($td['draft_id'] ?? 0)!==$draft || ($td['event'] ?? '')!=='on_issue_within_deadline') $blocks[]='Regra temporal prospectiva aprovada necessária; pendência de cálculo não autoriza emissão.'; if(($td['draft_version'] ?? null)!==$view['draft_version'] || ($td['preparation_version'] ?? null)!==$view['preparation_version'] || ($td['period_start'] ?? null)!==($view['prepared']['period_start'] ?? null) || ($td['period_end'] ?? null)!==($view['prepared']['period_end'] ?? null)) $blocks[]='Regra temporal/período desactualizado.'; if(empty($td['issue_window_start']) || empty($td['issue_window_end']) || $date<$td['issue_window_start'] || $date>$td['issue_window_end']) $blocks[]='Tempestividade da emissão não demonstrada para a data actual.'; foreach(['issuer'=>'Emitente','client'=>'Cliente'] as $party=>$label) foreach(['name','nuit','address','locality','country'] as $field) { $value=$field==='name'?($view[$party]['name'] ?? ''):($view[$party]['data'][$field] ?? ''); if(!is_string($value) || trim($value)==='') $blocks[]="$label: $field por completar no perfil técnico de teste."; } if(empty($view['prepared']['period_start']) || empty($view['prepared']['period_end']) || empty($view['prepared']['units'][0])) $blocks[]='Cobertura e unidade de medida precisam de estar identificadas neste recorte de teste.'; if(count($view['data']['lines'])!==1 || ($view['prepared']['price_mode'] ?? null)!=='added' || $view['data']['currency']!=='MZN') $blocks[]='Caso fora do recorte de uma linha, MZN e preço sem imposto.'; $current=array_values(array_filter($view['fiscal_history'],fn($r)=>$r['current'] && (int)$r['snapshot']['input']['profile_proposal_id']===(int)($td['profile_proposal_id'] ?? 0))); $entry=$current[0] ?? null; if(!$entry || $entry['snapshot']['result']['total_document_proposed']===null) $blocks[]='Cálculo actual compatível e representação monetária necessários.'; $profile=null; if($entry) { $in=$entry['snapshot']['input']; $profile=$in['profile']; if($date<($profile['valid_from'] ?? '9999') || (!empty($profile['valid_until']) && $date>$profile['valid_until'])) $blocks[]='Perfil não vigente na data prevista do acontecimento de emissão.'; } if(DB::table('si_invoices')->where('draft_id',$draft)->orWhere('source_id',$view['source_id'])->exists()) $blocks[]='Já existe documento para este rascunho ou prestação.'; unset($view['consulted_at'],$view['revision'],$view['calculation'],$view['fiscal_history']); $payload=['draft_id'=>$draft,'series_id'=>$series,'series_version'=>(int)$p->version,'series_code'=>$sd['code'] ?? '', 'series_definition'=>$sd,'rule_id'=>$rule,'rule_version'=>(int)$t->version,'rule'=>$td,'expected_date'=>$date,'document'=>$view,'calculation_entry'=>$entry,'profile'=>$profile,'blocks'=>$blocks]; if(!$blocks) { $sample=$this->snapshot($payload,0,now()->toIso8601String()); if(!InvoicePdf::supports(InvoicePdf::lines($sample))) $payload['blocks'][]='Texto incompatível com o PDF v1; rever representação sem substituir caracteres silenciosamente.'; } $hash=hash_hmac('sha256',json_encode($payload,JSON_THROW_ON_ERROR),(string)config('app.key')); return ['notice'=>self::NOTICE,'ready'=>$payload['blocks']===[],'review_hash'=>$hash,'payload'=>$payload]; } private function snapshot(array $p,int $number,string $instant): array { return ['notice'=>self::NOTICE,'renderer'=>InvoicePdf::VERSION,'draft_id'=>$p['draft_id'],'series_code'=>$p['series_code'],'number'=>$number,'issued_at'=>$instant,'issued_local'=>\Carbon\CarbonImmutable::parse($instant)->timezone(self::ZONE)->format('Y-m-d H:i:s'),'timezone'=>self::ZONE, 'series_id'=>$p['series_id'],'series_version'=>$p['series_version'],'series_definition'=>$p['series_definition'],'rule_id'=>$p['rule_id'],'rule_version'=>$p['rule_version'],'rule'=>$p['rule'],'document'=>$p['document'], 'calculation'=>array_replace($p['calculation_entry']['snapshot']['result'],['temporal_state'=>'issue_simulated','exigibility_date'=>$p['expected_date'],'assessment_period'=>substr($p['expected_date'],0,7),'pending'=>[]]),'prior_calculation_snapshot'=>$p['calculation_entry']['snapshot'],'calculation_id'=>$p['calculation_entry']['id'],'profile_identifier'=>$p['calculation_entry']['snapshot']['input']['profile_identifier'],'profile_version'=>$p['calculation_entry']['snapshot']['input']['profile_version'],'profile'=>$p['profile'],'rate_percent'=>$p['profile']['rate_percent'], 'exigibility_date'=>$p['expected_date'],'assessment_period'=>substr($p['expected_date'],0,7),'simulation_only'=>true]; } public function issue(int $draft,int $actor,array $input,string $key): array { return $this->command($actor,'service_invoices.issue',function($s)use($draft,$actor,$input,$key) { $in=$this->exact($input,['series_id'=>'required|integer|min:1','rule_id'=>'required|integer|min:1','review_hash'=>'required|string|regex:/^[a-f0-9]{64}$/D']); abort_unless((bool)preg_match('/^[A-Za-z0-9-]{8,100}$/D',$key),422,'Chave de repetição necessária.'); $requestHash=hash('sha256',json_encode([$draft,(int)$in['series_id'],(int)$in['rule_id'],$in['review_hash']],JSON_THROW_ON_ERROR)); $old=DB::table('si_commands')->where('company_id',$s['company_id'])->where('command_key',$key)->first(); if($old) { abort_unless((int)$old->actor_id===$actor && (int)$old->unit_id===$s['unit_id'] && hash_equals($old->request_hash,$requestHash),409,'Repetição com conteúdo/autor/âmbito diferente.'); return json_decode($old->result,true,32,JSON_THROW_ON_ERROR)+['replayed'=>true]; } $review=$this->reviewInside($draft,(int)$in['series_id'],(int)$in['rule_id'],$actor,$s); abort_unless(hash_equals($review['review_hash'],$in['review_hash']),409,'Revisão desactualizada; reveja os dados e a data antes de emitir.'); abort_unless($review['ready'],422,implode(' ',$review['payload']['blocks'])); $p=$review['payload']; $sd=$p['series_definition']; DB::table('si_counters')->insertOrIgnore($s+['code'=>$sd['code'],'last_number'=>$sd['first_number']-1]); $counter=DB::table('si_counters')->where($s)->where('code',$sd['code'])->lockForUpdate()->firstOrFail(); $number=(int)$counter->last_number+1; abort_if($number>999999999,422,'Limite de numeração atingido.'); DB::table('si_counters')->where('id',$counter->id)->update(['last_number'=>$number]); $instant=now()->toIso8601String(); abort_unless(\Carbon\CarbonImmutable::parse($instant)->timezone(self::ZONE)->format('Y-m-d')===$p['expected_date'],409,'Data mudou durante a confirmação; reveja antes de emitir.'); $snapshot=$this->snapshot($p,$number,$instant); $snapshot['document_lines']=InvoicePdf::lines($snapshot); // Freeze browser representation as well as PDF bytes. $bytes=$this->pdf->render($snapshot); $id=\App\Shared\Context\ModuleRecords::query('si_invoices')->insertGetId($s+['draft_id'=>$draft,'source_id'=>$p['document']['source_id'],'series_id'=>$p['series_id'],'rule_id'=>$p['rule_id'],'series_code'=>$sd['code'],'number'=>$number,'snapshot'=>json_encode($snapshot,JSON_THROW_ON_ERROR),'pdf_base64'=>base64_encode($bytes),'pdf_sha256'=>hash('sha256',$bytes),'actor_id'=>$actor,'issued_at'=>$instant]); $event=DB::table('si_events')->insertGetId(['invoice_id'=>$id,'type'=>'test_invoice.captured','payload'=>json_encode(['invoice_id'=>$id,'simulation_only'=>true],JSON_THROW_ON_ERROR),'created_at'=>now()]); DB::table('si_outbox')->insert(['event_id'=>$event,'state'=>'captured_no_processing']); Audit::record('service_invoice.test_issued',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'event_id'=>$event,'version'=>1]); $result=['id'=>$id,'series_code'=>$sd['code'],'number'=>$number,'notice'=>self::NOTICE]; DB::table('si_commands')->insert($s+['actor_id'=>$actor,'command_key'=>$key,'request_hash'=>$requestHash,'result'=>json_encode($result,JSON_THROW_ON_ERROR),'created_at'=>now()]); return $result+['replayed'=>false]; }); } public function show(int $id,int $actor): array { self::guard(); $s=$this->scope($actor,'service_invoices.view'); $r=\App\Shared\Context\ModuleRecords::query('si_invoices')->where($s)->where('id',$id)->first(); abort_unless($r,403,'Documento fora do âmbito.'); $snapshot=json_decode($r->snapshot,true,32,JSON_THROW_ON_ERROR); return ['id'=>$r->id,'snapshot'=>$snapshot,'document_lines'=>$snapshot['document_lines'] ?? InvoicePdf::lines($snapshot),'pdf_sha256'=>$r->pdf_sha256,'corrections'=>DB::table('si_correction_requests')->where('invoice_id',$id)->get(['id','reason','actor_id','created_at'])->all()]; } public function pdf(int $id,int $actor): string { $this->show($id,$actor); $r=\App\Shared\Context\ModuleRecords::query('si_invoices')->where('id',$id)->first(['pdf_base64','pdf_sha256']); $bytes=base64_decode($r->pdf_base64,true); abort_unless($bytes!==false && hash_equals($r->pdf_sha256,hash('sha256',$bytes)),409,'Integridade do PDF não confirmada.'); return $bytes; } public function requestCorrection(int $id,int $actor,array $input): array { return $this->command($actor,'service_invoices.correction_request',function($s)use($id,$actor,$input) { $d=$this->exact($input,['reason'=>'required|string|max:2000']); $this->show($id,$actor); $request=DB::table('si_correction_requests')->insertGetId(['invoice_id'=>$id,'actor_id'=>$actor,'reason'=>$d['reason'],'created_at'=>now()]); Audit::record('service_invoice.correction_requested',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'reason'=>$d['reason']]); return ['id'=>$request,'state'=>'pending_no_fiscal_effect']; }); } }