Files
SGF/app/Modules/Services/FiscalObligations.php
2026-11-11 09:00:17 +02:00

89 lines
18 KiB
PHP

<?php
namespace App\Modules\Services;
use App\Modules\Identity\Services\{Audit,Permissions};
use App\Shared\Context\{CompanyContext,ModuleRecords};
use App\Shared\Installation\CompositionRuntime;
use Illuminate\Support\Facades\{Crypt,DB,Validator};
use Tempo\Finance\Payables\Payables;
/** Administrative QA deadlines/evidence only. No legal-calendar or financial writer. */
final class FiscalObligations
{
public const POLICY='SGF-FISCAL-OBLIGATIONS-QA-v1';
public function __construct(private CompanyContext $context,private Permissions $permissions,private FiscalAssessment $forms){}
private function json(array $a):string{return Payables::canonical($a);}
private function hash(array $a):string{return hash('sha256',$this->json($a));}
private function exact(array $a,array $rules):array{abort_if(array_diff(array_keys($a),array_keys($rules)),422);return Validator::make($a,$rules)->validate();}
public function scope(int $actor,string $action='view'):array
{
abort_unless(config('fiscal_obligations.enabled')===true,404);CompositionRuntime::assert();$p=CompositionRuntime::physical();$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($actor===$this->context->userId()&&$s['unit_id']&&ModuleRecords::active()==='accounting',403);$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);
foreach(array_unique(['view',$action])as$right)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'fiscal_obligations.'.$right,$s['unit_id']);
if($action!=='view')abort_unless($actor===($action==='prepare'?13:14),403);$meta=DB::table('fo_meta')->where('policy',self::POLICY)->first();abort_unless($meta&&$meta->composition===$p['composition']&&hash_equals($meta->source_hash,(string)config('fiscal_obligations.schema_sha256')),503,'Schema de obrigações não conferido.');return$s;
}
private function identity(int $actor,array $s):string
{
$u=DB::table('users')->where('id',$actor)->first(['id','email','name']);abort_unless($u&&DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->exists(),403);return$this->hash((array)$u);
}
private function responsible(int $actor,array $s):void
{
abort_unless(in_array($actor,[13,14,15,16,17],true),403,'Responsável fora dos perfis QA aprovados.');$this->identity($actor,$s);$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'fiscal_obligations.view',$s['unit_id']);$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'fiscal_forms.view',$s['unit_id']);
}
private function lock(int $actor,array $s):void
{
DB::statement("SET LOCAL lock_timeout='5s'");DB::statement("SET LOCAL statement_timeout='20s'");DB::table('companies')->where(['tenant_id'=>$s['tenant_id'],'id'=>$s['company_id']])->lockForUpdate()->firstOrFail();DB::table('users')->where('id',$actor)->lockForUpdate()->firstOrFail();DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->lockForUpdate()->first();DB::table('permission_grants')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->orderBy('id')->lockForUpdate()->get();
}
private function source(int $actor,array $s):array
{
$state=$this->forms->listing($actor,['kind'=>'form','per_page'=>25]);abort_unless($state['form_current_review']===true&&$state['form'],409,'Mapa interno revisto deixou de ser actual.');$form=$state['form'];$row=DB::table('fx_versions')->where($s+['id'=>$form['id'],'kind'=>'form','state'=>'reviewed'])->first();abort_unless($row,409);$data=json_decode($row->data,true,64,JSON_THROW_ON_ERROR);$bytes=$data['artifact_csv']??null;$sha=$data['artifact_sha256']??null;abort_unless(is_string($bytes)&&is_string($sha)&&hash_equals($sha,hash('sha256',$bytes)),409,'Exportação interna alterada.');
return ['form_id'=>$row->id,'form_version'=>$row->version,'period'=>$state['period'],'export_sha256'=>$sha,'document_reference'=>$state['source']['document_reference'],'document_sha256'=>$state['source']['document_sha256'],'dependencies'=>json_decode($row->dependencies,true),'source_basis'=>$this->hash(['row'=>(array)$row,'source'=>$state['source'],'binding'=>$this->forms->binding()]),'synthetic_only'=>true,'tax_payable'=>null,'official_submission'=>false];
}
private function rows(array $s){return DB::table('fo_versions')->where($s);}
private function row(?object $r):?array{if(!$r)return null;$a=(array)$r;foreach(['data','impact']as$key)$a[$key]=json_decode($a[$key],true,64,JSON_THROW_ON_ERROR);return$a;}
private function latest(array $s,string $key,bool $approved=false):?array{$q=$this->rows($s)->where('record_key',$key);if($approved)$q->where('state','approved');return$this->row($q->orderByDesc('version')->first());}
private function basis(int $actor,array $s,string $key):array{return ['source'=>$this->source($actor,$s),'last'=>$this->latest($s,$key),'effective'=>$this->latest($s,$key,true),'actor_identity'=>$this->identity($actor,$s)];}
private function input(array $v):array
{
if(isset($v['reason'])&&is_string($v['reason']))$v['reason']=trim($v['reason']);$d=$this->exact($v,['action'=>'required|in:prepare,review','record_key'=>'required|uuid','version'=>'required|integer|min:0','reason'=>'required|string|min:10|max:2000','data'=>'present|array']);$d['record_key']=strtolower($d['record_key']);$d['version']=(int)$d['version'];
if($d['action']==='prepare'){$d['data']=$this->exact($d['data'],['operation'=>'required|in:establish,amend,cancel,delivery','form_id'=>'required|integer|min:1','period'=>'required|date_format:Y-m','responsible_id'=>'required|integer|min:1','due_on'=>'present|nullable|date_format:Y-m-d|after_or_equal:2000-01-01|before_or_equal:2100-12-31','evidence'=>'required|string|min:10|max:2000','export_sha256'=>'present|nullable|regex:/^[a-f0-9]{64}$/D']);foreach(['form_id','responsible_id']as$field)$d['data'][$field]=(int)$d['data'][$field];}else$d['data']=$this->exact($d['data'],['decision'=>'required|in:approved,refused']);return$d;
}
private function duplicate(array $s,string $key,int $form):bool
{
// Effective approved record, or a first unresolved preparation; no universe in PHP.
$q=$this->rows($s)->where('record_key','<>',$key)->whereRaw("(data->>'form_id')::bigint=?",[$form]);
return$q->where(function($q){$q->where(function($q){$q->where('state','approved')->whereRaw("data->>'administrative_status'<>'cancelled'")->whereNotExists(function($n){$n->selectRaw('1')->from('fo_versions as newer')->whereColumn('newer.record_key','fo_versions.record_key')->whereColumn('newer.tenant_id','fo_versions.tenant_id')->whereColumn('newer.company_id','fo_versions.company_id')->whereColumn('newer.unit_id','fo_versions.unit_id')->where('newer.state','approved')->whereColumn('newer.version','>','fo_versions.version');});})->orWhere(function($q){$q->where('state','prepared')->whereNotExists(function($n){$n->selectRaw('1')->from('fo_versions as anyapproved')->whereColumn('anyapproved.record_key','fo_versions.record_key')->whereColumn('anyapproved.tenant_id','fo_versions.tenant_id')->whereColumn('anyapproved.company_id','fo_versions.company_id')->whereColumn('anyapproved.unit_id','fo_versions.unit_id')->where('anyapproved.state','approved');})->whereNotExists(function($n){$n->selectRaw('1')->from('fo_versions as next')->whereColumn('next.record_key','fo_versions.record_key')->whereColumn('next.tenant_id','fo_versions.tenant_id')->whereColumn('next.company_id','fo_versions.company_id')->whereColumn('next.unit_id','fo_versions.unit_id')->whereColumn('next.version','>','fo_versions.version');});});})->exists();
}
private function execute(int $actor,array $s,array $d,array $b):array
{
$last=$b['last'];$effective=$b['effective'];abort_unless(($last['version']??0)===$d['version'],409,'Versão alterada; confira novamente.');$source=$b['source'];
if($d['action']==='prepare'){$data=$d['data'];$prepared=$actor;$identity=$this->identity($actor,$s);}
else{abort_unless($last&&$last['state']==='prepared'&&$last['prepared_by']!==$actor,403,'Preparação alheia actual obrigatória.');$prepared=$last['prepared_by'];$identity=$last['preparer_identity'];abort_unless(hash_equals($identity,$this->identity($prepared,$s)),409,'Identidade do preparador alterada.');$this->permissions->require($prepared,$s['tenant_id'],$s['company_id'],'fiscal_obligations.prepare',$s['unit_id']);abort_unless(hash_equals($last['source_basis'],$source['source_basis']),409,'Origem mudou desde a preparação.');$data=$last['data'];}
abort_unless($data['form_id']===$source['form_id']&&$data['period']===$source['period'],409,'Escolha o mapa/período revistos actuais.');$this->responsible($data['responsible_id'],$s);$op=$data['operation'];$old=$effective['data']??null;
if($op==='establish'){abort_unless(!$effective,409,'Já existe condição aprovada neste registo.');abort_if($this->duplicate($s,$d['record_key'],$data['form_id']),409,'Já existe obrigação ou preparação deste mapa. Não duplicar.');}
else{abort_unless($old&&$old['administrative_status']!=='cancelled'&&$old['form_id']===$data['form_id']&&$old['period']===$data['period'],409,'Operação exige obrigação activa da mesma origem.');if(in_array($op,['cancel','delivery'],true))foreach(['due_on','responsible_id']as$field)abort_unless($data[$field]===$old[$field],409,'Esta operação conserva as condições aprovadas.');if($op==='delivery')abort_unless($old['administrative_status']!=='delivery_simulated',409,'Entrega simulada já registada.');}
if($op==='delivery')abort_unless(is_string($data['export_sha256'])&&hash_equals($source['export_sha256'],$data['export_sha256']),409,'SHA da exportação interna não corresponde.');else abort_unless($data['export_sha256']===null,422,'SHA de entrega só na declaração simulada.');
$data['administrative_status']=$op==='cancel'?'cancelled':($op==='delivery'?'delivery_simulated':($old['administrative_status']??'active'));$data['delivery_evidence']=$op==='delivery'?['export_sha256'=>$data['export_sha256'],'reference'=>$data['evidence'],'simulated_only'=>true]:($old['delivery_evidence']??null);$data['source']=$source;
$state=$d['action']==='prepare'?'prepared':$d['data']['decision'];$version=($last['version']??0)+1;$impact=['before'=>['version'=>$last['version']??0,'state'=>$last['state']??null,'effective'=>$old],'after'=>['version'=>$version,'state'=>$state,'proposal'=>$data,'effective'=>$state==='approved'?$data:$old],'tax_payable'=>null,'new_debt'=>false,'payments'=>0,'journals'=>0,'official_submission'=>false];
$id=DB::table('fo_versions')->insertGetId($s+['record_key'=>$d['record_key'],'version'=>$version,'previous_id'=>$last['id']??null,'action'=>$d['action'],'state'=>$state,'prepared_by'=>$prepared,'actor_id'=>$actor,'preparer_identity'=>$identity,'data'=>$this->json($data),'source_basis'=>$source['source_basis'],'impact'=>$this->json($impact),'reason'=>$d['reason'],'created_at'=>now()->utc()]);return['id'=>$id,'record_key'=>$d['record_key'],'version'=>$version,'state'=>$state,'impact'=>$impact];
}
public function preview(int $actor,array $input,string $key):array
{
$d=$this->input($input);$this->exact(['key'=>$key],['key'=>'required|uuid']);$s=$this->scope($actor,$d['action']);return DB::transaction(function()use($actor,$s,$d,$key){$this->lock($actor,$s);$this->scope($actor,$d['action']);$b=$this->basis($actor,$s,$d['record_key']);$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$out=$this->execute($actor,$s,$d,$b);}finally{while($db->transactionLevel()>$level)$db->rollBack();}$cut=['actor'=>$actor,'scope'=>$s,'generation'=>request()->session()->get('company_context.token'),'input'=>$d,'key'=>$key,'basis'=>$this->hash($b),'impact'=>$out['impact'],'observed'=>time()];return['preview'=>Crypt::encryptString($this->json($cut)),'impact'=>$out['impact']];});
}
public function confirm(int $actor,string $preview):array
{
try{$c=json_decode(Crypt::decryptString($preview),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(is_array($c)&&is_array($c['input']??null),409);$d=$this->input($c['input']);$s=$this->scope($actor,$d['action']);return DB::transaction(function()use($actor,$s,$d,$c){$this->lock($actor,$s);$this->scope($actor,$d['action']);abort_unless($c['actor']===$actor&&$this->hash($c['scope'])===$this->hash($s)&&$c['generation']===request()->session()->get('company_context.token')&&$c['observed']>=time()-600&&$c['observed']<=time(),409,'Contexto/prévia alterados.');$b=$this->basis($actor,$s,$d['record_key']);$q=$s+['actor_id'=>$actor,'command_key'=>$c['key']];$old=DB::table('fo_commands')->where($q)->first();if($old){abort_unless(hash_equals($old->request_hash,$this->hash($d)),409,'Chave usada com outros dados.');return json_decode($old->result,true)+['replayed'=>true];}abort_unless(hash_equals($c['basis'],$this->hash($b)),409,'Origem, pessoa ou versão mudaram. Confira novamente.');$out=$this->execute($actor,$s,$d,$b);abort_unless($this->hash($out['impact'])===$this->hash($c['impact']),409,'Impacto alterado.');DB::table('fo_commands')->insert($q+['request_hash'=>$this->hash($d),'result'=>$this->json($out),'input'=>$this->json($d),'created_at'=>now()->utc()]);Audit::record('fiscal_obligations.'.$d['action'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$out['id'],'record_key'=>$d['record_key'],'version'=>$out['version'],'reason'=>$d['reason'],'impact'=>$out['impact']]);return$out+['replayed'=>false];});
}
public function receipt(int $actor,string $key):array
{
$this->exact(['key'=>$key],['key'=>'required|uuid']);$s=$this->scope($actor);$this->forms->scope($actor,'form');$old=DB::table('fo_commands')->where($s+['actor_id'=>$actor,'command_key'=>$key])->first();abort_unless($old,404);return['result'=>json_decode($old->result,true),'created_at'=>$old->created_at,'reason'=>json_decode($old->input,true)['reason'],'historical_receipt_only'=>true,'operation_executed'=>false];
}
public function listing(int $actor,array $input):array
{
$s=$this->scope($actor);$this->forms->scope($actor,'form');$d=$this->exact($input,['reference_on'=>'required|date_format:Y-m-d','period'=>'sometimes|date_format:Y-m','status'=>'sometimes|in:active,delivery_simulated,cancelled,unapproved','record_key'=>'sometimes|uuid','due_from'=>'required_with:due_to|date_format:Y-m-d','due_to'=>'required_with:due_from|date_format:Y-m-d|after_or_equal:due_from','due_state'=>'sometimes|in:unknown,overdue,not_overdue','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);
return DB::transaction(function()use($actor,$s,$d){if(DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$source=null;$blocked=null;try{$source=$this->source($actor,$s);}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface$e){if($e->getStatusCode()!==409)throw$e;$blocked=$e->getMessage();}$q=$this->rows($s);if(isset($d['record_key'])){$q->where('record_key',$d['record_key']);abort_unless((clone$q)->exists(),403,'Histórico fora do âmbito.');}else$q->whereNotExists(function($n){$n->selectRaw('1')->from('fo_versions as next');foreach(['tenant_id','company_id','unit_id','record_key']as$key)$n->whereColumn('next.'.$key,'fo_versions.'.$key);$n->whereColumn('next.version','>','fo_versions.version');});
$effective="(SELECT e.data->>'administrative_status' FROM fo_versions e WHERE e.tenant_id=fo_versions.tenant_id AND e.company_id=fo_versions.company_id AND e.unit_id=fo_versions.unit_id AND e.record_key=fo_versions.record_key AND e.state='approved' ORDER BY e.version DESC LIMIT 1)";$effectiveDue="(SELECT e.data->>'due_on' FROM fo_versions e WHERE e.tenant_id=fo_versions.tenant_id AND e.company_id=fo_versions.company_id AND e.unit_id=fo_versions.unit_id AND e.record_key=fo_versions.record_key AND e.state='approved' ORDER BY e.version DESC LIMIT 1)";if(isset($d['due_from']))$q->whereRaw("($effectiveDue)>=? AND ($effectiveDue)<=?",[$d['due_from'],$d['due_to']]);if(isset($d['due_state'])){if($d['due_state']==='unknown')$q->whereRaw("($effectiveDue) IS NULL");else$q->whereRaw("($effectiveDue)".($d['due_state']==='overdue'?'<':'>=').'?',[$d['reference_on']]);}if(isset($d['status']))$q->whereRaw("coalesce($effective,'unapproved')=?",[$d['status']]);if(isset($d['period']))$q->whereRaw("data->>'period'=?",[$d['period']]);$page=$q->orderByDesc('id')->paginate($d['per_page']??25,['*'],'page',$d['page']??1);$keys=$page->pluck('record_key')->unique()->all();$approved=$this->rows($s)->whereIn('record_key',$keys)->where('state','approved')->whereNotExists(function($n){$n->selectRaw('1')->from('fo_versions as later');foreach(['tenant_id','company_id','unit_id','record_key']as$key)$n->whereColumn('later.'.$key,'fo_versions.'.$key);$n->where('later.state','approved')->whereColumn('later.version','>','fo_versions.version');})->get()->keyBy('record_key');
$items=[];foreach($page->items()as$r){$row=$this->row($r);$a=$approved->get($r->record_key);$terms=$a?json_decode($a->data,true):null;$due=$terms['due_on']??null;$days=$due===null?null:max(0,(int)(new \DateTimeImmutable($due))->diff(new \DateTimeImmutable($d['reference_on']))->format('%r%a'));$row['effective']=$terms;$row['days_overdue']=$days;$row['due_status']=$due===null?'unknown':($due<$d['reference_on']?'overdue':'not_overdue');$row['current_source_matches']=$source!==null&&hash_equals($r->source_basis,$source['source_basis']);$items[]=$row;}$page->setCollection(collect($items));$can=fn($right,$expected)=>$actor===$expected&&$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'fiscal_obligations.'.$right,$s['unit_id']);return['policy'=>self::POLICY,'synthetic_only'=>true,'reference_on'=>$d['reference_on'],'source'=>$source,'blocked'=>$blocked,'records'=>$page->toArray(),'capabilities'=>['prepare'=>$can('prepare',13),'review'=>$can('review',14)],'official_submission'=>false,'tax_payable'=>null,'notice'=>'Prazo administrativo artificial; entrega apenas simulada, sem transmissão à AT ou pagamento.'];});
}
}