Files
SGF/app/Modules/Services/ServiceInvoicing.php
T
2026-11-02 08:06:45 +02:00

201 lines
20 KiB
PHP

<?php
namespace App\Modules\Services;
use App\Modules\Identity\Services\Audit;
use App\Modules\Identity\Services\Permissions;
use App\Shared\Context\CompanyContext;
use App\Shared\Testing\TestingDatabaseGuard;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
final class ServiceInvoicing
{
public const NOTICE='TESTE — SEM VALIDADE FISCAL';
public const ZONE='Africa/Johannesburg';
public function __construct(private CompanyContext $context,private Permissions $permissions,private DocumentPreview $preview,private InvoicePdf $pdf) {}
public static function guard(): void
{
// Deliberately unconditional outside the isolated environment: no enable flag or HTTP bypass.
abort_unless(ServiceCalculation::isolated(),403,'Emissão real bloqueada: conformidade ainda não demonstrada.');
TestingDatabaseGuard::assertSafe();
}
private function scope(int $actor,string $right): array
{
$s=$this->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'];
});
}
}