Accept conditional service calculation with versioned fiscal profile

This commit is contained in:
2026-10-26 16:48:44 +02:00
parent bc2707bb65
commit 9f480045fa
25 changed files with 801 additions and 3 deletions
+2
View File
@@ -1,5 +1,7 @@
# SGF Tempo — instruções locais
- Em 26/10/2026, o utilizador aceitou o cálculo condicionado e autorizou o registo e checkpoint Git exclusivamente local, sem credenciais/dados locais. Ver `docs/CONDITIONAL_CALCULATION_ACCEPTANCE.md`. Perfil, concessões e activação local foram objecto de confirmações específicas; os detalhes nominativos permanecem na ficha local não versionada. Exigibilidade e período de apuramento permanecem pendentes; aprovação temporal não os determina. Preservar versões/âmbito exactos, separação de proponente/validador, B1 e aprovações contabilísticas. Calcular não altera decisão temporal validada. Sem avançar para emissão documental, recebimentos, lançamentos ou publicação.
- Em 26/10/2026, o utilizador aceitou expressamente o cálculo de ensaio demonstrado (base 1000,03; IVA exacto 160,0048; IVA proposto 160,00; diferença preservada 0,0048; total proposto 1160,03) e autorizou registo e checkpoint Git apenas local. Ver `docs/SERVICE_CALCULATION_SEPARATE_REVIEW.md`. Esta confirmação substitui apenas a pendência de aceitação do ensaio: a aplicabilidade fiscal real permanece separada e pendente, sem autorização para activar perfil fiscal real, emitir, contabilizar ou publicar.
- Em 26/10/2026, após informar que entrou e verificou, o utilizador confirmou a aceitação funcional do primeiro incremento de classificação contabilística e autorizou o encerramento e checkpoint Git exclusivamente local, sem credenciais ou dados locais. Preservar a entrega e os limites de `docs/CATALOGUE_ACCOUNTING_DELIVERY.md`. Esta aceitação não abrange a validação do cálculo fiscal, que continua separada e pendente; não autoriza lançamentos, publicação ou outro incremento.
+2
View File
@@ -89,6 +89,8 @@ Não existe criação de tenants ou empresas pela interface, auto-registo, opç
## Testes e ferramentas
O incremento de cálculo condicionado foi aceite; ver [entrega, verificações e limites](docs/CONDITIONAL_CALCULATION_ACCEPTANCE.md). Na empresa/unidade autorizadas, consultar **Perfil fiscal do serviço** e **Rascunhos de facturação → Pré-visualizar rascunho**. A activação é uma operação local especificamente autorizada, sem via no navegador. O cálculo exige decisão temporal aprovada e compatível; quando há pendência, não determina exigibilidade ou período de apuramento. Sem emissão, recebimentos ou lançamentos.
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 test
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 npm run build
+1
View File
@@ -105,6 +105,7 @@ final class DocumentPreview
'issuer' => ['name' => $issuer['name'], 'data' => $issuer['data']], 'client' => ['name' => $client['name'], 'data' => $client['data']],
'data' => $billing['data'], 'prepared' => $prepared['data'], 'base_amount' => $billing['base_amount'], 'pending' => $pending,
'calculation' => app(ServiceCalculation::class)->read($draft, $actor),
'fiscal_history' => $this->permissions->has($actor,$scope['tenant_id'],$scope['company_id'],'service_fiscal.view') ? app(ServiceFiscal::class)->history($draft,$actor) : [],
'payments' => $payments, 'logo' => $logo, 'contacts' => $model['data']['show_contacts'] ? $model['data']['contact_text'] : null,
'observations' => $model['data']['show_observations'] ? $model['data']['observations'] : null,
'versions' => ['issuer' => $issuer['version'], 'client' => $client['version'], 'company_name' => $issuer['name'], 'client_name' => $client['name'], 'instructions' => $paymentVersions, 'model' => $model['version']]];
@@ -0,0 +1,16 @@
<?php
namespace App\Modules\Services\Http;
use App\Http\Controllers\Controller;
use App\Modules\Services\ServiceFiscal;
use Illuminate\Http\Request;
final class ServiceFiscalController extends Controller
{
private function response(array $data) { return response()->json($data)->header('Cache-Control','no-store, private'); }
public function index(Request $r,ServiceFiscal $f) { return $this->response($f->listing($r->user()->id)); }
public function propose(Request $r,ServiceFiscal $f) { return $this->response($f->propose($r->user()->id,$r->all())); }
public function decide(Request $r,int $id,ServiceFiscal $f) { return $this->response($f->decide($id,$r->user()->id,$r->all())); }
public function calculate(Request $r,int $id,ServiceFiscal $f) { return $this->response($f->calculate($id,$r->user()->id,$r->all())); }
public function history(Request $r,int $id,ServiceFiscal $f) { return $this->response(['history'=>$f->history($id,$r->user()->id)]); }
}
@@ -0,0 +1,52 @@
<?php
namespace App\Modules\Services;
use App\Modules\Identity\Services\Audit;
use App\Modules\Identity\Services\LocalDatabaseGuard;
use App\Shared\Context\CompanyContext;
use App\Shared\Testing\TestingDatabaseGuard;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
/** Local operator command only. No HTTP route or standing activation grant. */
final class LocalFiscalActivation
{
public function execute(array $input,bool $apply=false): array
{
abort_unless(app()->runningInConsole(),403,'Operação exclusivamente no terminal local.');
if (ServiceCalculation::isolated()) TestingDatabaseGuard::assertSafe();
else abort_unless(LocalDatabaseGuard::allows(),403,'Base própria de desenvolvimento local obrigatória.');
$rules=['proposal_id'=>'required|integer|min:1','version'=>'required|integer|min:1','identifier'=>'required|string','tenant_id'=>'required|integer|min:1','company_id'=>'required|integer|min:1','unit_id'=>'required|integer|min:1','service_id'=>'required|integer|min:1','service_version'=>'required|integer|min:1','valid_from'=>'required|date_format:Y-m-d','valid_until'=>'nullable|date_format:Y-m-d|after_or_equal:valid_from','operator_id'=>'required|integer|min:1','definition_hash'=>($apply?'required':'nullable').'|string|regex:/^[a-f0-9]{64}$/D'];
abort_if(array_diff(array_keys($input),array_keys($rules))!==[],422);
$in=Validator::make($input,$rules)->validate();
return DB::transaction(function() use($in,$apply) {
$scope=array_intersect_key($in,array_flip(['tenant_id','company_id','unit_id']));
DB::table('companies')->where('id',$scope['company_id'])->where('tenant_id',$scope['tenant_id'])->lockForUpdate()->firstOrFail();
app(CompanyContext::class)->selectFor((int)$in['operator_id'],(int)$scope['tenant_id'],(int)$scope['company_id'],(int)$scope['unit_id']);
app(\App\Modules\Identity\Services\Permissions::class)->require((int)$in['operator_id'],(int)$scope['tenant_id'],(int)$scope['company_id'],'service_fiscal.view');
$p=DB::table('sf_proposals')->where($scope)->where('id',$in['proposal_id'])->where('kind','profile')->where('identifier',$in['identifier'])->where('version',$in['version'])->first();
abort_unless($p,409,'Perfil, versão ou âmbito diferente da operação autorizada.');
$d=json_decode($p->data,true,32,JSON_THROW_ON_ERROR);
foreach(['service_id','service_version'] as $key) abort_unless((int)$d[$key]===(int)$in[$key],409,'Serviço/versão diferente.');
abort_unless($d['valid_from']===$in['valid_from'] && ($d['valid_until'] ?? null)===($in['valid_until'] ?? null),409,'Vigência diferente.');
abort_unless($d['currency']==='MZN' && $d['price_mode']==='added' && $d['rate_percent']==='16' && $d['treatment']==='domestic_normal',409,'Tratamento diferente do recorte aprovado.');
abort_unless(DB::table('a_services')->where('id',$in['service_id'])->where('tenant_id',$scope['tenant_id'])->where('company_id',$scope['company_id'])->where('version',$in['service_version'])->exists(),409,'Serviço desactualizado.');
abort_unless(DB::table('sf_decisions')->where('proposal_id',$p->id)->where('decision','approved')->exists(),409,'Aprovação exacta em falta.');
abort_if(DB::table('sf_proposals')->where($scope)->where('kind','profile')->where('identifier',$p->identifier)->where('version','>',$p->version)->whereExists(fn($q)=>$q->selectRaw('1')->from('sf_decisions')->whereColumn('sf_decisions.proposal_id','sf_proposals.id')->where('decision','approved'))->exists(),409,'Outra versão já foi aprovada; reveja a operação.');
$profile=DB::table('service_calculation_profiles')->where($scope)->where('id',$p->profile_id)->where('identifier',$p->identifier)->where('version',$p->version)->first();
abort_unless($profile,409,'Perfil de cálculo incompatível.');
$definition=json_decode($profile->definition,true,32,JSON_THROW_ON_ERROR);
abort_unless(($definition['kind'] ?? null)==='real' && ($definition['policy'] ?? null)===ServiceCalculation::POLICY,409,'Política/perfil incompatível.');
foreach($d as $key=>$value) abort_unless(($definition[$key] ?? null)===$value,409,'Definição diverge da proposta aprovada.');
$hash=hash('sha256',json_encode([$p->id,(int)$p->version,$scope,$d,$definition],JSON_THROW_ON_ERROR));
if($apply) abort_unless(hash_equals($hash,$in['definition_hash']),409,'A definição não corresponde à operação revista.');
$last=DB::table('sf_activation_events')->where('proposal_id',$p->id)->orderByDesc('id')->first();
$active=(bool)($last?->active ?? false);
$result=['proposal_id'=>$p->id,'identifier'=>$p->identifier,'version'=>(int)$p->version,'scope'=>$scope,'service_id'=>(int)$d['service_id'],'service_version'=>(int)$d['service_version'],'valid_from'=>$d['valid_from'],'valid_until'=>$d['valid_until'] ?? null,'definition_hash'=>$hash,'active'=>$active,'applied'=>false];
if(!$apply || $active) return $result+['replayed'=>$apply && $active];
$event=DB::table('sf_activation_events')->insertGetId(['proposal_id'=>$p->id,'actor_id'=>$in['operator_id'],'active'=>true,'reason'=>'Operação local especificamente autorizada pelo utilizador para este perfil/versão/âmbito; apenas cálculo proposto, sem emissão, recebimentos ou lançamentos. Definição SHA256: '.$hash,'created_at'=>now()]);
Audit::record('service_fiscal.local_activated',(int)$in['operator_id'],(int)$scope['tenant_id'],(int)$scope['company_id'],(int)$scope['unit_id'],details:['record_id'=>$p->id,'version'=>(int)$p->version,'event_id'=>$event,'reason'=>'Activação local especificamente autorizada; sem poderes de emissão ou lançamentos.']);
return array_replace($result,['active'=>true,'applied'=>true,'event_id'=>$event,'replayed'=>false]);
});
}
}
+12 -2
View File
@@ -18,6 +18,15 @@ final class ServiceCalculation
public static function isolated(): bool { return app()->environment(['testing','browser-testing']); }
public static function evaluateReal(array $data, array $prepared, array $profile): array
{
abort_unless(($profile['kind'] ?? null)==='real',422,'Perfil real identificado obrigatório.');
// Shared decimal arithmetic; authority/applicability is checked by ServiceFiscal before calling.
$result=self::evaluate($data,$prepared,array_replace($profile,['kind'=>'synthetic']));
if ($result['state']!=='pending') $result['state']='real_proposed_conditioned';
return $result;
}
public static function evaluate(array $data, array $prepared, ?array $profile): array
{
$pending = [];
@@ -97,14 +106,15 @@ final class ServiceCalculation
public function read(int $draft, int $actor): array
{
$scope = $this->workflow->scope('billing_drafts',$actor,'view');
if (!self::isolated()) { return ['environment'=>'normal','history'=>[], 'pending'=>['Aplicabilidade à empresa, serviço/localização, base e acontecimento fiscal por demonstrar; nenhum perfil real activado.']]; }
if (!self::isolated()) { return ['environment'=>'normal','history'=>[], 'pending'=>['O cálculo proposto requer perfil aprovado, activo e compatível e decisão temporal aprovada. Consulte o resultado e as pendências abaixo, quando disponíveis.']]; }
TestingDatabaseGuard::assertSafe();
[$b,$p] = $this->sources($draft,$actor);
$profiles = DB::table('service_calculation_profiles')->where($scope)->orderByDesc('version')->get();
$profiles = DB::table('service_calculation_profiles')->where($scope)->whereRaw("definition->>'kind' = ?",['synthetic'])->orderByDesc('version')->get();
$latest = []; foreach ($profiles as $profile) { $latest[$profile->identifier] ??= $profile; }
$history = [];
foreach (DB::table('service_calculation_results')->where($scope)->where('billing_draft_id',$draft)->orderByDesc('id')->get() as $row) {
$snapshot = json_decode($row->snapshot,true,32,JSON_THROW_ON_ERROR);
if (isset($snapshot['input']['profile_proposal_id'])) continue;
$profile = $latest[$snapshot['input']['profile_identifier']] ?? null;
$current = $profile && hash_equals($row->fingerprint,$this->fingerprint($this->input($b,$p,$profile)));
$history[] = ['id'=>$row->id,'current'=>(bool)$current,'snapshot'=>$snapshot];
+181
View File
@@ -0,0 +1,181 @@
<?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\Validator;
/** Fiscal decisions are separate from arithmetic, catalogue accounting and emission. */
final class ServiceFiscal
{
public function __construct(private CompanyContext $context, private Permissions $permissions, private ServiceWorkflow $workflow, private DocumentPreparation $preparation) {}
private function scope(int $actor, string $action = 'view'): 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);
foreach (array_unique(['view',$action]) as $a) $this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'service_fiscal.'.$a,$a==='calculate' ? $unit : null);
return $s+['unit_id'=>$unit];
}
private function command(int $actor,string $action,callable $fn): array
{
$s=$this->scope($actor,$action);
return DB::transaction(function() use($actor,$action,$fn,$s) {
DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();
$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']); $this->scope($actor,$action);
return $fn($s);
});
}
private function row(int $id,array $s): object
{
$r=DB::table('sf_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('sf_decisions')->where('proposal_id',$id)->first(); }
private function approved(object $r): bool { return $this->decision($r->id)?->decision==='approved'; }
private function activated(object $r): bool { return (bool)(DB::table('sf_activation_events')->where('proposal_id',$r->id)->orderByDesc('id')->first()?->active ?? false); }
private function latest(object $r,array $s,?string $date=null): bool
{
// Pending/refused proposals do not supersede a currently approved version.
$q=DB::table('sf_proposals')->where($s)->where('kind',$r->kind)->where('identifier',$r->identifier)->where('version','>',$r->version)
->whereExists(fn($q)=>$q->selectRaw('1')->from('sf_decisions')->whereColumn('sf_decisions.proposal_id','sf_proposals.id')->where('decision','approved'));
if($r->kind==='profile' && $date!==null) $q->whereRaw("data->>'valid_from' <= ?",[$date]);
return !$q->exists();
}
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 fiscal.');
return Validator::make($data,$rules)->validate();
}
public function listing(int $actor): array
{
$s=$this->scope($actor);
if (!\Illuminate\Support\Facades\Schema::hasTable('sf_proposals')) return ['migration_pending'=>true,'proposals'=>[]];
$rows=DB::table('sf_proposals')->where($s)->orderByDesc('id')->get()->map(function($r) { return (array)$r+['definition'=>$this->data($r),'decision'=>$this->decision($r->id),'active'=>$r->kind==='profile' && $this->activated($r)]; })->all();
return ['proposals'=>$rows,'migration_pending'=>false,'notice'=>'RASCUNHO — SEM VALIDADE FISCAL; aprovação não activa o perfil.'];
}
public function propose(int $actor,array $input): array
{
return $this->command($actor,'propose',function($s) use($actor,$input) {
$in=$this->exact($input,['kind'=>'required|in:profile,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('sf_proposals')->where($s)->where('kind',$in['kind'])->where('identifier',$in['identifier'])->orderByDesc('version')->first();
abort_unless(($last?->id ?? null)===($previous?->id ?? null),409,'Indique a versão anterior exacta.');
$version=(int)($last?->version ?? 0)+1;
if ($in['kind']==='profile') {
$d=$this->exact($in['data'],['service_id'=>'required|integer|min:1','service_version'=>'required|integer|min:1','currency'=>'required|in:MZN','price_mode'=>'required|in:added','rate_percent'=>'required|in:16','valid_from'=>'required|date_format:Y-m-d','valid_until'=>'nullable|date_format:Y-m-d|after_or_equal:valid_from','treatment'=>'required|in:domestic_normal','conditions'=>'required|string|max:4000','evidence'=>'required|string|max:4000']);
$service=DB::table('a_services')->where('tenant_id',$s['tenant_id'])->where('company_id',$s['company_id'])->where('id',$d['service_id'])->first();
abort_unless($service && (int)$service->version===(int)$d['service_version'],409,'Serviço/versão incompatível.');
if ($previous) abort_unless((int)$this->data($previous)['service_id']===(int)$d['service_id'],422,'Nova versão não pode trocar a identidade do serviço.');
abort_if(DB::table('sf_proposals')->where($s)->where('kind','profile')->whereRaw("data->>'service_id' = ?",[(string)$d['service_id']])->where('identifier','<>',$in['identifier'])->exists(),409,'Use a identidade existente deste serviço e uma nova versão.');
$profile=DB::table('service_calculation_profiles')->insertGetId($s+['identifier'=>$in['identifier'],'version'=>$version,'definition'=>json_encode($d+['kind'=>'real','policy'=>ServiceCalculation::POLICY,'sources'=>ServiceCalculation::SOURCES+['fiscal_2026'=>'Lei10/2025:6,32;vigencia2026-01-01']],JSON_THROW_ON_ERROR),'created_at'=>now()]);
$draft=null;
} else {
$d=$this->exact($in['data'],['profile_proposal_id'=>'required|integer|min:1','draft_id'=>'required|integer|min:1','draft_version'=>'required|integer|min:1','preparation_version'=>'required|integer|min:0','reference_date'=>'required|date_format:Y-m-d','reference_basis'=>'required|string|max:2000','period_start'=>'required|date_format:Y-m-d','period_end'=>'nullable|date_format:Y-m-d|after_or_equal:period_start','next_billing'=>'nullable|date_format:Y-m-d','event'=>'required|in:pending,invoice_in_time,invoice_deadline','event_date'=>'nullable|required_unless:event,pending|date_format:Y-m-d','evidence'=>'required|string|max:4000']);
abort_if($d['event']==='pending' && !empty($d['event_date']),422,'Pendência temporal não tem data de exigibilidade.');
abort_if($d['event']!=='pending' && $d['reference_date']!==$d['event_date'],422,'A referência de vigência deve corresponder à exigibilidade fundamentada quando determinada.');
$p=$this->row((int)$d['profile_proposal_id'],$s); abort_unless($p->kind==='profile' && $this->approved($p),422,'Perfil exacto aprovado necessário.');
[$b,$prepared]=$this->draft((int)$d['draft_id'],$actor);
$this->versions($b,$prepared,$d);
abort_unless(($prepared['data']['period_start'] ?? null)===$d['period_start'] && ($prepared['data']['period_end'] ?? null)===($d['period_end'] ?? null),409,'Período deve corresponder à preparação revista.');
if ($previous) abort_unless((int)$previous->draft_id===(int)$d['draft_id'],422,'Decisão temporal pertence ao mesmo rascunho.');
abort_if(DB::table('sf_proposals')->where($s)->where('kind','temporal')->where('draft_id',$d['draft_id'])->where('identifier','<>',$in['identifier'])->exists(),409,'Use a identidade temporal existente e uma nova versão.');
$profile=$p->profile_id; $draft=$d['draft_id'];
}
$id=DB::table('sf_proposals')->insertGetId($s+['kind'=>$in['kind'],'identifier'=>$in['identifier'],'version'=>$version,'previous_id'=>$previous?->id,'profile_id'=>$profile,'draft_id'=>$draft,'data'=>json_encode($d,JSON_THROW_ON_ERROR),'actor_id'=>$actor,'created_at'=>now()]);
Audit::record('service_fiscal.proposed',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['proposal_id'=>$id,'version'=>$version,'kind'=>$in['kind']]);
return ['id'=>$id,'version'=>$version,'active'=>false];
});
}
public function decide(int $id,int $actor,array $input): array
{
return $this->command($actor,'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']);
$r=$this->row($id,$s); abort_unless((int)$r->version===(int)$d['version'] && (int)$r->actor_id!==$actor,403,'Versão exacta e validador distinto obrigatórios.');
abort_if($this->decision($id),409,'Decisão imutável; correcção exige nova versão.');
if ($r->kind==='temporal' && $d['decision']==='approved') {
$data=$this->data($r); [$b,$p]=$this->draft((int)$r->draft_id,$actor); $this->versions($b,$p,$data);
$profile=$this->row((int)$data['profile_proposal_id'],$s); $this->compatible($profile,$s,$data['reference_date'],$b,$p,false);
}
DB::table('sf_decisions')->insert(['proposal_id'=>$id,'actor_id'=>$actor,'decision'=>$d['decision'],'reason'=>$d['reason'],'created_at'=>now()]);
Audit::record('service_fiscal.decided',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['proposal_id'=>$id,'version'=>$r->version,'decision'=>$d['decision']]);
return ['id'=>$id,'decision'=>$d['decision'],'active'=>false];
});
}
/** No HTTP activation endpoint. Only isolated proofs may exercise active/revoked states in this increment. */
public function proofActivation(int $id,int $actor,int $version,bool $active): void
{
abort_unless(ServiceCalculation::isolated(),403,'Activação real não autorizada neste incremento.'); TestingDatabaseGuard::assertSafe();
$this->command($actor,'approve',function($s) use($id,$actor,$version,$active) {
$p=$this->row($id,$s); abort_unless($p->kind==='profile' && (int)$p->version===$version && $this->approved($p) && $this->latest($p,$s),409);
DB::table('sf_activation_events')->insert(['proposal_id'=>$id,'actor_id'=>$actor,'active'=>$active,'reason'=>'Prova sintética isolada; não é activação real.','created_at'=>now()]);
Audit::record('service_fiscal.proof_activation',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['proposal_id'=>$id,'version'=>$version,'active'=>$active]);
return [];
});
}
private function draft(int $id,int $actor): array
{
$b=$this->workflow->show('billing_drafts',$id,$actor); abort_if($b['internal_confirmation'],409,'B1 preservada.');
return [$b,$this->preparation->show('billing_drafts',$id,$actor)];
}
private function versions(array $b,array $p,array $d): void
{
abort_unless($b['version']===(int)$d['draft_version'] && $p['version']===(int)$d['preparation_version'] && ($p['data']['draft_version'] ?? null)===$b['version'],409,'Rascunho/preparação desactualizados.');
$current=DB::table('a_service_drafts')->where('id',$b['source_id'])->value('version');
abort_unless((int)$current===(int)$b['data']['source_version'],409,'Origem desactualizada.');
}
private function compatible(object $p,array $s,string $date,array $b,array $prepared,bool $active=true): array
{
$d=$this->data($p); $line=$b['data']['lines'][0] ?? [];
abort_unless($p->kind==='profile' && $this->approved($p) && (!$active || $this->activated($p)) && $this->latest($p,$s,$date),409,'Perfil sem aprovação/activação exacta ou substituído.');
abort_unless($date!=='' && $d['valid_from']<=$date && (empty($d['valid_until']) || $date<=$d['valid_until']),422,'Vigência não demonstrada.');
$service=DB::table('a_services')->where('tenant_id',$s['tenant_id'])->where('company_id',$s['company_id'])->where('id',$d['service_id'])->first();
abort_unless($service && (int)$service->version===(int)$d['service_version'] && count($b['data']['lines'])===1 && (int)($line['service_id'] ?? 0)===(int)$d['service_id'] && $b['data']['currency']===$d['currency'] && ($prepared['data']['price_mode'] ?? null)===$d['price_mode'],422,'Serviço, moeda ou modo incompatível.');
return $d;
}
public function calculate(int $draft,int $actor,array $input): array
{
return $this->command($actor,'calculate',function($s) use($draft,$actor,$input) {
$d=$this->exact($input,['profile_proposal_id'=>'required|integer|min:1','profile_version'=>'required|integer|min:1','temporal_proposal_id'=>'required|integer|min:1','temporal_version'=>'required|integer|min:1','draft_version'=>'required|integer|min:1','preparation_version'=>'required|integer|min:0']);
[$b,$prepared]=$this->draft($draft,$actor); $this->versions($b,$prepared,$d);
$p=$this->row((int)$d['profile_proposal_id'],$s); $t=$this->row((int)$d['temporal_proposal_id'],$s);
abort_unless((int)$p->version===(int)$d['profile_version'] && $t->kind==='temporal' && (int)$t->version===(int)$d['temporal_version'] && (int)$t->draft_id===$draft && (int)$t->profile_id===(int)$p->profile_id && $this->approved($t) && $this->latest($t,$s),409,'Decisão temporal exacta e aprovada necessária.');
$td=$this->data($t); $this->versions($b,$prepared,$td);
$definition=$this->compatible($p,$s,$td['reference_date'],$b,$prepared);
$profile=json_decode(DB::table('service_calculation_profiles')->where('id',$p->profile_id)->value('definition'),true,32,JSON_THROW_ON_ERROR);
$result=ServiceCalculation::evaluateReal($b['data'],$prepared['data'],$profile);
abort_if($result['state']==='pending',422,implode(' ',$result['pending']));
$pending=$td['event']==='pending';
$result['temporal_state']=$pending ? 'temporal_pending' : 'temporal_decision_validated';
$result['exigibility_date']=$pending ? null : $td['event_date'];
$result['assessment_period']=$pending ? null : substr($td['event_date'],0,7);
if ($pending) $result['pending'][]='Exigibilidade e período de apuramento não determinados; resultado condicionado.';
$input=['draft_id'=>$draft,'draft_version'=>$b['version'],'preparation_version'=>$prepared['version'],'profile_proposal_id'=>$p->id,'profile_id'=>$p->profile_id,'profile_version'=>(int)$p->version,'profile_identifier'=>$p->identifier,'profile'=>$profile,'temporal_proposal_id'=>$t->id,'temporal_version'=>(int)$t->version,'temporal'=>$td,'data'=>$b['data'],'prepared'=>$prepared['data'],'policy'=>ServiceCalculation::POLICY];
$hash=hash('sha256',json_encode($input,JSON_THROW_ON_ERROR));
$old=DB::table('service_calculation_results')->where('billing_draft_id',$draft)->where('fingerprint',$hash)->first();
if ($old) return ['id'=>$old->id,'replayed'=>true];
$id=DB::table('service_calculation_results')->insertGetId($s+['billing_draft_id'=>$draft,'profile_id'=>$p->profile_id,'actor_id'=>$actor,'fingerprint'=>$hash,'snapshot'=>json_encode(['input'=>$input,'result'=>$result],JSON_THROW_ON_ERROR),'created_at'=>now()]);
Audit::record('service_fiscal.calculated',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['result_id'=>$id,'profile_proposal_id'=>$p->id,'temporal_proposal_id'=>$t->id]);
return ['id'=>$id,'replayed'=>false];
});
}
public function history(int $draft,int $actor): array
{
$s=$this->scope($actor); [$b,$prepared]=$this->draft($draft,$actor); $history=[];
if (!\Illuminate\Support\Facades\Schema::hasTable('sf_proposals')) return [];
foreach(DB::table('service_calculation_results')->where($s)->where('billing_draft_id',$draft)->orderByDesc('id')->get() as $r) {
$snapshot=json_decode($r->snapshot,true,32,JSON_THROW_ON_ERROR); $in=$snapshot['input'];
if (!isset($in['profile_proposal_id'])) continue;
$current=true;
try { $this->versions($b,$prepared,$in); $p=$this->row($in['profile_proposal_id'],$s); $t=$this->row($in['temporal_proposal_id'],$s); abort_unless($this->approved($t) && $this->latest($t,$s),409); $this->compatible($p,$s,$in['temporal']['reference_date'],$b,$prepared); }
catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { $current=false; }
$history[]=['id'=>$r->id,'current'=>$current,'snapshot'=>$snapshot];
}
return $history;
}
}
@@ -0,0 +1,68 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
$existing=DB::selectOne("SELECT pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE conrelid='permission_grants'::regclass AND conname='permission_grants_scope_check'")->definition;
$expression=substr($existing,6); // retain every existing permission/scope branch
DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');
DB::statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK (($expression) OR (permission IN ('service_fiscal.view','service_fiscal.propose','service_fiscal.approve') AND unit_id IS NULL) OR (permission='service_fiscal.calculate' AND unit_id IS NOT NULL))");
Schema::create('sf_proposals', function (Blueprint $t) {
$t->id(); $t->unsignedBigInteger('tenant_id'); $t->unsignedBigInteger('company_id'); $t->unsignedBigInteger('unit_id');
$t->string('kind'); $t->string('identifier'); $t->unsignedInteger('version');
$t->foreignId('profile_id')->constrained('service_calculation_profiles')->restrictOnDelete();
$t->foreignId('draft_id')->nullable()->constrained('a_billing_drafts')->restrictOnDelete();
$t->foreignId('previous_id')->nullable()->constrained('sf_proposals')->restrictOnDelete();
$t->foreignId('actor_id')->constrained('users'); $t->jsonb('data'); $t->timestampTz('created_at');
$t->unique(['unit_id','kind','identifier','version']);
$t->foreign(['tenant_id','company_id','unit_id'])->references(['tenant_id','company_id','id'])->on('units')->restrictOnDelete();
});
Schema::create('sf_decisions', function (Blueprint $t) {
$t->id(); $t->foreignId('proposal_id')->unique()->constrained('sf_proposals')->restrictOnDelete();
$t->foreignId('actor_id')->constrained('users'); $t->string('decision'); $t->text('reason'); $t->timestampTz('created_at');
});
Schema::create('sf_activation_events', function (Blueprint $t) {
$t->id(); $t->foreignId('proposal_id')->constrained('sf_proposals')->restrictOnDelete();
$t->foreignId('actor_id')->constrained('users'); $t->boolean('active'); $t->text('reason'); $t->timestampTz('created_at');
});
DB::unprepared(<<<'SQL'
CREATE OR REPLACE FUNCTION sgf_fiscal_proposal_guard() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.kind NOT IN ('profile','temporal') OR NOT EXISTS(
SELECT 1 FROM service_calculation_profiles p WHERE p.id=NEW.profile_id AND p.tenant_id=NEW.tenant_id AND p.company_id=NEW.company_id AND p.unit_id=NEW.unit_id
) OR (NEW.kind='profile' AND NEW.draft_id IS NOT NULL) OR (NEW.kind='temporal' AND NEW.draft_id IS NULL)
OR (NEW.draft_id IS NOT NULL AND NOT EXISTS(SELECT 1 FROM a_billing_drafts b WHERE b.id=NEW.draft_id AND b.tenant_id=NEW.tenant_id AND b.company_id=NEW.company_id AND b.unit_id=NEW.unit_id))
OR (NEW.previous_id IS NOT NULL AND NOT EXISTS(SELECT 1 FROM sf_proposals p WHERE p.id=NEW.previous_id AND p.tenant_id=NEW.tenant_id AND p.company_id=NEW.company_id AND p.unit_id=NEW.unit_id AND p.kind=NEW.kind AND p.identifier=NEW.identifier AND p.version=NEW.version-1))
THEN RAISE EXCEPTION 'SGF_FISCAL_SCOPE'; END IF;
IF NEW.previous_id IS NULL AND NEW.version<>1 THEN RAISE EXCEPTION 'SGF_FISCAL_INITIAL_VERSION'; END IF;
IF NEW.kind='profile' AND NOT EXISTS(SELECT 1 FROM a_services s WHERE s.id=(NEW.data->>'service_id')::bigint AND s.tenant_id=NEW.tenant_id AND s.company_id=NEW.company_id)
THEN RAISE EXCEPTION 'SGF_FISCAL_SERVICE_SCOPE'; END IF;
RETURN NEW;
END; $$;
CREATE TRIGGER fiscal_proposal_scope BEFORE INSERT ON sf_proposals FOR EACH ROW EXECUTE FUNCTION sgf_fiscal_proposal_guard();
CREATE OR REPLACE FUNCTION sgf_fiscal_decision_guard() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.decision NOT IN ('approved','refused') OR EXISTS(SELECT 1 FROM sf_proposals WHERE id=NEW.proposal_id AND actor_id=NEW.actor_id)
THEN RAISE EXCEPTION 'SGF_FISCAL_DECISION'; END IF;
RETURN NEW;
END; $$;
CREATE TRIGGER fiscal_decision_guard BEFORE INSERT ON sf_decisions FOR EACH ROW EXECUTE FUNCTION sgf_fiscal_decision_guard();
CREATE OR REPLACE FUNCTION sgf_fiscal_activation_guard() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NOT EXISTS(SELECT 1 FROM sf_proposals p JOIN sf_decisions d ON d.proposal_id=p.id AND d.decision='approved' WHERE p.id=NEW.proposal_id AND p.kind='profile')
THEN RAISE EXCEPTION 'SGF_FISCAL_ACTIVATION'; END IF;
RETURN NEW;
END; $$;
CREATE TRIGGER fiscal_activation_guard BEFORE INSERT ON sf_activation_events FOR EACH ROW EXECUTE FUNCTION sgf_fiscal_activation_guard();
SQL);
foreach (['sf_proposals','sf_decisions','sf_activation_events'] as $table) {
DB::unprepared("CREATE TRIGGER {$table}_immutable BEFORE UPDATE OR DELETE ON {$table} FOR EACH ROW EXECUTE FUNCTION sgf_append_only_audit()");
}
}
public function down(): void { throw new RuntimeException('Fiscal history requires a reviewed preservation procedure.'); }
};
@@ -0,0 +1,39 @@
# Cálculo condicionado — entrega aceite
26/10/2026, Africa/Johannesburg. O utilizador declarou: **«Aceito esta entrega do cálculo condicionado. Regista a aceitação e cria o checkpoint Git local, excluindo credenciais e dados locais. Exigibilidade e período de apuramento permanecem pendentes. Não avances para emissão documental.»**
## Âmbito aceite e limites
Perfil fiscal versionado, proposta e validação por identidades distintas, activação por operação local especificamente autorizada e cálculo exacto sobre uma linha de serviço com preço sem imposto. A versão, empresa, unidade, serviço, vigência, fontes e condições são verificadas. O cálculo não altera decisões fiscais validadas. A classificação contabilística não determina IVA.
A decisão temporal aprovada pode manter o acontecimento pendente. Nesse caso o resultado permanece condicionado, com exigibilidade e período de apuramento nulos, sem presumir datas a partir do início do serviço ou da próxima facturação. O resultado é apresentado como **RASCUNHO — SEM VALIDADE FISCAL**. Não existe emissão, recebimento, apuramento líquido ou lançamento contabilístico neste incremento.
O caso local foi conferido no navegador: base 7 850,00, IVA exacto/proposto 1 256,00, diferença 0,00 e total proposto 9 106,00 MZN, com perfil/versões identificados. Os mesmos montantes constam das provas sintéticas; este registo não exporta cliente, documentos, decisões, identificadores ou movimentos da base local. A repetição do cálculo conservou um único resultado.
## Implementação e verificações
- `ServiceFiscal` e controlador: propostas/decisões imutáveis, autorização por âmbito e comando de cálculo condicionado; integração na pré-visualização existente.
- Migração aditiva `2026_10_26_050000_create_service_fiscal_lifecycle.php`: três tabelas, relações e triggers, ampliação do CHECK de permissões preservando os direitos anteriores. Migração e cinco concessões iniciais foram confirmadas especificamente e aplicadas; comparação verificou preservação de 28 tabelas preexistentes. As provas JSON conservam apenas resultados agregados dessas fases, não o estado actual de activação.
- `LocalFiscalActivation` / `scripts/service-fiscal-activate.php`: operação de console, base local própria, manifesto exacto e hash da definição revista. Sem rota HTTP ou concessão permanente de activação. Repetição não duplica eventos. A activação local posteriormente autorizada foi executada e auditada; não é executada automaticamente pelo checkout ou migração.
- Interface com nomes em português, resumo de taxa/moeda/preço/vigência, estados distintos, condições e fontes acessíveis, fundamento da decisão e indicação permanente dos limites do rascunho.
- Regressões dirigidas do circuito inicial: **52 testes / 1 171 asserções** em `sgf_tempo_test`. Após acrescentar a activação local: **17 testes / 423 asserções**, filtro `ServiceFiscalTest|ServiceCalculationTest|DatabaseSafetyTest`. Compilação Vite passou. Sem novas dependências ou versões.
- Provas de concorrência: dois processos PHP sobrepostos, uma inserção e um replay, preservando o histórico; [registo sintético](proofs/service-fiscal-concurrency.json). [Imagem sintética](proofs/service-fiscal-browser.png). Os testes destrutivos e demonstrações sintéticas usam exclusivamente a instância isolada e a guarda de `sgf_tempo_test`.
Não foram repetidos testes destrutivos para registar esta aceitação. As alterações posteriores às provas foram apresentação e correcção de uma mensagem obsoleta, sem mudança das fórmulas/autorizações.
## Utilização e reprodução técnica
Seleccionar empresa e unidade autorizadas → **Perfil fiscal do serviço** para consultar/propor/validar versões. Consultar **Rascunhos de facturação → registo autorizado → Pré-visualizar rascunho** para o resultado e pendências. Apenas o detentor da permissão unitária de cálculo pode executar o comando; a validação temporal requer direito próprio e identidade distinta de quem propôs.
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/sgf.ps1 test --stop-on-failure '--filter=ServiceFiscalTest|ServiceCalculationTest|DatabaseSafetyTest'
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/sgf.ps1 npm run build
```
Os scripts `service-fiscal-fixture.php`, `service-fiscal-worker.php` e `service-fiscal-concurrency.php` são para provas sintéticas guardadas. Parar a instância de prova antes de testes que recriam a base de testes. Nunca usar reset/fresh em desenvolvimento. Os scripts de concessão/activação não são instruções para repetir as operações reais sem autorização específica.
## Preservação do checkpoint
Checkpoint exclusivamente local, sem push. Contém código, migração, testes, documentação de aceitação e provas sintéticas/agregadas. Ficam fora: `.env*` privados, credenciais, manifestos, fingerprints locais, bases, logs, imagens da operação real e a ficha nominativa `REAL_SERVICE_FISCAL_ASSESSMENT.md`, que permanece no posto local. A ficha contém o histórico detalhado do caso e este registo não a substitui.
**Entrega encerrada no âmbito aceite.** Exigibilidade e período de apuramento permanecem pendentes. A emissão documental exige incremento separado; esta aceitação não o autoriza e não autoriza publicação.
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

@@ -0,0 +1,7 @@
{
"database": "sgf_tempo_test",
"overlapping_php_processes": 2,
"lock_overlap_observed": true,
"one_insert_one_replay": true,
"prior_history_preserved": true
}
@@ -0,0 +1,9 @@
{
"database": "sgf_tempo_dev",
"preexisting_tables_checked": 28,
"preexisting_records_unchanged": true,
"new_fiscal_tables_empty": true,
"real_profile_activated": false,
"confirmed_grants_verified": 5,
"prior_grants_and_audit_unchanged": true
}
@@ -0,0 +1,9 @@
{
"database": "sgf_tempo_dev",
"preexisting_tables_checked": 28,
"preexisting_records_unchanged": true,
"new_fiscal_tables_empty": true,
"real_profile_activated": false,
"confirmed_grants_verified": 0,
"prior_grants_and_audit_unchanged": true
}
+1
View File
@@ -9,3 +9,4 @@
.accounting-proposal dl { display: grid; gap: .5rem; }
.accounting-proposal dl > div { display: grid; grid-template-columns: minmax(8rem, 1fr) 2fr; gap: 1rem; }
.accounting-proposal dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
.fiscal-page-heading h1{margin:8px 0 12px}.fiscal-page-heading>p:last-child{color:#5d716d;margin:0 0 24px}.fiscal-notice{display:flex;flex-direction:column;gap:7px;background:#fff7e7;border:1px solid #ead7ad;border-left:4px solid #c5983e;border-radius:8px;padding:16px 20px;margin-bottom:24px;color:#745723;font-size:.85rem;line-height:1.6}.fiscal-notice strong{font-size:.75rem;letter-spacing:.04em}.fiscal-list-heading{display:flex;justify-content:space-between;align-items:center;gap:16px;margin:26px 0 16px}.fiscal-list-heading h2{margin:0}.fiscal-list-heading>span{font-size:.8rem;color:#64796f}.fiscal-proposal{background:#fff;border:1px solid #dce6e1;border-radius:12px;padding:28px;margin-bottom:24px;box-shadow:0 3px 12px #123f3505}.fiscal-card-heading{display:flex;justify-content:space-between;align-items:flex-start;gap:20px}.fiscal-card-heading h3{font-size:1.3rem;margin:8px 0;letter-spacing:-.02em}.fiscal-card-heading .eyebrow{margin-top:0}.fiscal-reference{color:#65786f;font-size:.78rem;margin:0;overflow-wrap:anywhere}.fiscal-statuses{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end}.fiscal-status{border-radius:20px;padding:6px 10px;white-space:nowrap;font-size:.72rem;font-weight:650;background:#edf2ef;color:#53665d}.fiscal-status.pending{background:#fff2d3;color:#80601c}.fiscal-status.approved{background:#e5f3e9;color:#286444}.fiscal-status.refused{background:#fbece8;color:#963f2d}.fiscal-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin:24px 0;background:#f3f7f4;border:1px solid #e0e9e3;border-radius:8px;padding:18px;gap:20px}.fiscal-summary dt,.fiscal-facts dt{color:#667b70;font-size:.75rem;margin-bottom:8px}.fiscal-summary dd{margin:0;font-weight:650;font-size:.95rem;line-height:1.5}.fiscal-facts{display:grid;grid-template-columns:1fr 1fr;gap:18px 30px;margin:0 0 24px}.fiscal-facts dd{margin:0;line-height:1.5;font-size:.9rem}.fiscal-conditions{border-top:1px solid #e5ece7;padding-top:18px}.fiscal-conditions h4,.fiscal-review h4{font-size:.95rem;margin:0 0 10px}.fiscal-conditions p,.fiscal-evidence p,.fiscal-recorded p{font-size:.88rem;line-height:1.85;white-space:pre-wrap;overflow-wrap:anywhere;margin:0 0 18px;max-width:75ch}.fiscal-evidence{border-top:1px solid #e5ece7;padding:16px 0}.fiscal-evidence summary,.fiscal-new>summary{cursor:pointer;color:#28634f;font-weight:650;font-size:.9rem}.fiscal-evidence p{margin:15px 0 0;color:#5d716d}.fiscal-review{background:#f5f8f6;border-top:1px solid #dce6e1;margin:8px -28px -28px;padding:24px 28px;border-radius:0 0 12px 12px}.fiscal-review>p{font-size:.85rem;color:#5d716d;margin:0 0 18px}.fiscal-review label{max-width:700px}.fiscal-review textarea{resize:vertical;min-height:110px}.fiscal-refuse{color:#923f2c;border-color:#dbb7ac}.fiscal-recorded{padding-top:16px;border-top:1px solid #e5ece7}.fiscal-recorded>strong{font-size:.85rem}.fiscal-new form{margin-top:24px}.fiscal-new textarea{resize:vertical}.fiscal-proposal dd{overflow-wrap:anywhere}@media(max-width:1000px){.fiscal-summary{grid-template-columns:1fr 1fr}.fiscal-card-heading{flex-direction:column}.fiscal-statuses{justify-content:flex-start}}@media(max-width:560px){.fiscal-proposal{padding:20px}.fiscal-review{margin:8px -20px -20px;padding:20px}.fiscal-list-heading{display:block}.fiscal-list-heading>span{display:block;margin-top:8px}.fiscal-facts{grid-template-columns:1fr}.fiscal-summary{gap:16px;padding:14px}.fiscal-card-heading h3{font-size:1.12rem}.fiscal-review .actions button{flex:1}}
+1
View File
@@ -52,6 +52,7 @@ export default function DocumentPreview({ id, historical, context, api, onClose
<p>Base aritmética exacta: <strong>{entry.snapshot.result.base_exact}</strong></p><p>IVA exacto: <strong>{entry.snapshot.result.vat_exact}</strong></p><p>IVA truncado proposto: <strong>{entry.snapshot.result.vat_document_proposed}</strong></p><p>Diferença descartada: <strong>{entry.snapshot.result.discarded_difference}</strong></p><p>Total documental proposto: <strong>{entry.snapshot.result.total_document_proposed ?? 'Pendente: representação da base/total'}</strong> {entry.snapshot.result.currency}</p>{entry.snapshot.result.pending.map((text,i)=><p key={i}>{text}</p>)}<p>Sem validação fiscal. {notice}</p></div>)}
</>}
</section>}
{!historical && view.fiscal_history?.length>0 && <section><h2>Cálculo proposto do serviço — condicionado</h2>{view.fiscal_history.map(entry=><div className="preview-values" key={entry.id}><h3>Resultado #{entry.id} · {entry.current?'Actual para as versões validadas':'Desactualizado — histórico'}</h3><p>Perfil {entry.snapshot.input.profile_identifier} v{entry.snapshot.input.profile_version} · decisão temporal v{entry.snapshot.input.temporal_version}</p><p>Base exacta: {entry.snapshot.result.base_exact} {entry.snapshot.result.currency}</p><p>IVA exacto: {entry.snapshot.result.vat_exact}</p><p>IVA truncado proposto: {entry.snapshot.result.vat_document_proposed}</p><p>Diferença preservada: {entry.snapshot.result.discarded_difference}</p><p>Total proposto: {entry.snapshot.result.total_document_proposed ?? 'Pendente'}</p><p>Exigibilidade: {entry.snapshot.result.exigibility_date ?? 'Não determinada'}</p><p>Período de apuramento: {entry.snapshot.result.assessment_period ?? 'Não determinado'}</p>{entry.snapshot.result.pending.map((p,i)=><p key={i}>{p}</p>)}<p>{notice} · Não representa emissão ou apuramento líquido do imposto.</p></div>)}</section>}
{!!view.payments.length && <section><h2>Instruções do emitente para facilitar o pagamento</h2><p className="help">Informação introduzida pelo utilizador, sem verificação bancária. Não regista recebimento nem inicia operação bancária.</p>{view.payments.map(payment => <div key={payment.id} className="preview-payment"><h3>{payment.data.label} · versão {payment.version}</h3>{Object.entries({ bank:'Banco', holder:'Titular', account_number:'Conta', nib:'NIB', iban:'IBAN', swift:'SWIFT', bank_code:'Código bancário', currency:'Moeda' }).filter(([key]) => payment.data[key]).map(([key,label]) => <p key={key}>{label}: <strong>{payment.data[key]}</strong></p>)}{payment.data.instructions && <p className="preview-text">{payment.data.instructions}</p>}</div>)}</section>}
{view.observations && <section><h2>Observações</h2><p className="preview-text">{view.observations}</p></section>}
<section className="preview-pending" aria-label="Pendências obrigatórias"><h2>Dados em falta e validações pendentes</h2><p>Preenchido não significa validado. Imposto e total definitivo continuam por apurar.</p><ul>{Object.entries(view.pending).map(([code,text]) => <li key={code}>{text}</li>)}</ul></section><p className="preview-end-notice">{notice}</p>
+52
View File
@@ -0,0 +1,52 @@
import React, { useEffect, useState } from 'react';
const fieldLabels = {currency:'Moeda',treatment:'Tratamento fiscal',price_mode:'Modo de preço',service_id:'Serviço',service_version:'Versão do serviço',valid_from:'Vigência desde',valid_until:'Vigência até',rate_percent:'Taxa de IVA',profile_proposal_id:'Proposta do perfil',draft_id:'Rascunho',draft_version:'Versão do rascunho',preparation_version:'Versão da preparação',reference_date:'Data de referência',reference_basis:'Fundamento da referência',period_start:'Início do período',period_end:'Fim do período',next_billing:'Próxima facturação prevista',event:'Acontecimento',event_date:'Data do acontecimento'};
const valueLabels = {domestic_normal:'Operação interna · regime normal',added:'Preço sem IVA · imposto acrescido',included:'Imposto incluído',pending:'Por determinar',invoice_in_time:'Emissão dentro do prazo fundamentado',invoice_deadline:'Termo do prazo de emissão fundamentado'};
function displayValue(key,value) {
if(value===null || value===undefined || value==='') return 'Não indicado';
if(['valid_from','valid_until','reference_date','period_start','period_end','next_billing','event_date'].includes(key) && /^\d{4}-\d{2}-\d{2}$/.test(value)) return value.split('-').reverse().join('/');
if(key==='rate_percent') return `${value}%`;
if(['service_id','draft_id','profile_proposal_id'].includes(key)) return `#${value}`;
return valueLabels[value] || String(value);
}
function FiscalProposal({p,can,busy,reasons,setReasons,perform,api,load,listing,context}) {
const d=p.definition, status=p.decision?.decision;
const stateLabel=status==='approved'?'Validada':status==='refused'?'Recusada':'Aguarda validação';
const summary=p.kind==='profile'?['rate_percent','currency','price_mode','valid_from']:['period_start','period_end','event','event_date'];
return <article className="fiscal-proposal">
<div className="fiscal-card-heading"><div><p className="eyebrow">{p.kind==='profile'?'PERFIL FISCAL':'DECISÃO TEMPORAL'} · PROPOSTA #{p.id}</p><h3>{p.kind==='profile'?`Enquadramento de IVA · serviço #${d.service_id}`:`Período e exigibilidade · rascunho #${d.draft_id}`}</h3><p className="fiscal-reference">{p.identifier} · Versão {p.version}</p></div><div className="fiscal-statuses"><span className={`fiscal-status ${status || 'pending'}`}>{stateLabel}</span>{p.kind==='profile' && <span className={`fiscal-status ${p.active?'approved':'inactive'}`}>{p.active?'Activo para cálculo proposto':'Sem activação'}</span>}</div></div>
<dl className="fiscal-summary">{summary.map(key=><div key={key}><dt>{fieldLabels[key]}</dt><dd>{displayValue(key,d[key])}</dd></div>)}</dl>
<dl className="fiscal-facts">{Object.entries(d).filter(([key])=>!summary.includes(key) && !['evidence','conditions','reference_basis'].includes(key)).map(([key,value])=><div key={key}><dt>{fieldLabels[key] || key}</dt><dd>{displayValue(key,value)}</dd></div>)}</dl>
{d.conditions && <section className="fiscal-conditions"><h4>Condições de aplicação</h4><p>{d.conditions}</p></section>}
{d.reference_basis && <section className="fiscal-conditions"><h4>Fundamento da data de referência</h4><p>{d.reference_basis}</p></section>}
{d.evidence && <details className="fiscal-evidence"><summary>Fontes e elementos de suporte</summary><p>{d.evidence}</p></details>}
{p.decision && <div className="fiscal-recorded"><strong>Fundamento da decisão</strong><p>{p.decision.reason}</p></div>}
{!p.decision && can('service_fiscal.approve') && <div className="fiscal-review"><h4>Validar esta versão</h4><p>A decisão refere-se à versão {p.version}, na unidade seleccionada. Validar não activa o perfil nem emite uma factura.</p><label htmlFor={`fiscal-reason-${p.id}`}>Fundamento da decisão<textarea id={`fiscal-reason-${p.id}`} rows={4} placeholder="Indique o fundamento da validação ou recusa." value={reasons[p.id] || ''} onChange={e=>setReasons({...reasons,[p.id]:e.target.value})}/></label><div className="actions">{['approved','refused'].map(decision=><button className={decision==='approved'?'primary':'fiscal-refuse'} key={decision} disabled={busy || !reasons[p.id]} onClick={()=>perform(async()=>{await api(`/api/service-fiscal/proposals/${p.id}/decision`,'POST',{version:p.version,decision,reason:reasons[p.id]});await load();},'Decisão registada para a versão exacta; sem activação.')}>{decision==='approved'?'Validar versão':'Recusar versão'}</button>)}</div></div>}
{p.kind==='temporal' && status==='approved' && can('service_fiscal.calculate',context.unit.id) && <div className="fiscal-review"><button disabled={busy} onClick={()=>perform(async()=>{const profile=listing.proposals.find(x=>x.id===d.profile_proposal_id); if(!profile) throw new Error('Perfil exacto não disponível; actualize a consulta.'); await api(`/api/service-fiscal/drafts/${d.draft_id}/calculate`,'POST',{profile_proposal_id:profile.id,profile_version:profile.version,temporal_proposal_id:p.id,temporal_version:p.version,draft_version:d.draft_version,preparation_version:d.preparation_version});},'Resultado proposto guardado; consulte a pré-visualização do rascunho.')}>Calcular com perfil activo e decisão exacta</button></div>}
</article>;
}
export default function ServiceFiscalWorkspace({ api, context, can, perform, busy }) {
const [listing,setListing]=useState(null), [error,setError]=useState(''), [kind,setKind]=useState('profile');
const [form,setForm]=useState({identifier:'',previous_id:'',service_id:'',service_version:'',valid_from:'',valid_until:'',conditions:'',evidence:'',profile_proposal_id:'',draft_id:'',draft_version:'',preparation_version:'',reference_date:'',reference_basis:'',period_start:'',period_end:'',next_billing:'',event:'pending',event_date:''});
const [reasons,setReasons]=useState({});
async function load() { setListing(await api('/api/service-fiscal')); }
useEffect(()=>{setListing(null);setError(''); if(context.unit) load().catch(e=>setError(e.message));},[context.token]);
if (!context.unit) return <section className="panel"><h1>Enquadramento fiscal do serviço</h1><p>Seleccione uma unidade autorizada.</p></section>;
const fields=kind==='profile' ? {service_id:'ID do serviço existente',service_version:'Versão do serviço',valid_from:'Vigência desde',valid_until:'Vigência até (opcional)',conditions:'Condições de aplicabilidade',evidence:'Referências de validação e fontes'} : {profile_proposal_id:'ID da proposta exacta do perfil',draft_id:'ID do rascunho',draft_version:'Versão do rascunho',preparation_version:'Versão da preparação',reference_date:'Data explícita para verificar vigência',reference_basis:'Fundamento dessa referência (não presumido)',period_start:'Início do período na preparação',period_end:'Fim do período (opcional)',next_billing:'Próxima facturação prevista (opcional)',event_date:'Data do acontecimento (apenas se determinado)',evidence:'Evidência e fundamento do acontecimento/pêndencia'};
const dates=['valid_from','valid_until','reference_date','period_start','period_end','next_billing','event_date'];
async function propose(e) { e.preventDefault(); await perform(async()=>{
const data={}; Object.keys(fields).forEach(key=>{ if(form[key]!=='' && !(key==='event_date' && form.event==='pending')) data[key]=key.endsWith('_id') || key.endsWith('_version') ? Number(form[key]) : form[key]; });
if(kind==='profile') Object.assign(data,{currency:'MZN',price_mode:'added',rate_percent:'16',treatment:'domestic_normal'}); else data.event=form.event;
await api('/api/service-fiscal/proposals','POST',{kind,identifier:form.identifier,...(form.previous_id ? {previous_id:Number(form.previous_id)} : {}),data}); await load();
},'Proposta guardada; não activa o perfil.'); }
return <section className="fiscal-workspace"><div className="fiscal-page-heading"><p className="eyebrow">ENQUADRAMENTO DO SERVIÇO</p><h1>Perfis e validação fiscal</h1><p>Consulte as condições propostas e acompanhe a validação de cada versão.</p></div><div className="fiscal-notice"><strong>RASCUNHO — SEM VALIDADE FISCAL</strong><span>Aprovar não activa o perfil. Este ecrã não emite facturas; calcular não altera uma decisão temporal validada.</span></div>{error && <p className="alert error" role="alert">{error}</p>}
{listing?.migration_pending ? <p>Preparação técnica pendente de migração autorizada; não há perfil activo.</p> : <>
{can('service_fiscal.propose') && <details className="fiscal-new panel"><summary>Preparar nova proposta</summary><form onSubmit={propose}><h2>Preparar proposta para validação</h2><label>Tipo<select value={kind} onChange={e=>setKind(e.target.value)}><option value="profile">Perfil deste serviço · MZN · IVA geral 16% · preço sem IVA</option><option value="temporal">Decisão temporal de um rascunho</option></select></label>
<label>Identificador da proposta<input value={form.identifier} required onChange={e=>setForm({...form,identifier:e.target.value})}/></label><label>ID da versão anterior (apenas correcção)<input type="number" min="1" value={form.previous_id} onChange={e=>setForm({...form,previous_id:e.target.value})}/></label>
{kind==='temporal' && <label>Acontecimento<select value={form.event} onChange={e=>setForm({...form,event:e.target.value,event_date:''})}><option value="pending">Pendente — não determinar exigibilidade</option><option value="invoice_in_time">Emissão dentro do prazo fundamentado</option><option value="invoice_deadline">Termo do prazo de emissão fundamentado</option></select><small>Adiantamentos e afectações parciais ficam fora deste primeiro caso.</small></label>}
{Object.entries(fields).filter(([key])=>key!=='event_date' || form.event!=='pending').map(([key,label])=><label key={key}>{label}{['conditions','evidence','reference_basis'].includes(key)?<textarea rows={4} value={form[key]} required onChange={e=>setForm({...form,[key]:e.target.value})}/>:<input type={dates.includes(key)?'date':'text'} value={form[key]} required={!['valid_until','period_end','next_billing'].includes(key)} onChange={e=>setForm({...form,[key]:e.target.value})}/>}</label>)}
<p className="help">Fontes e condições são referências de validação; não escreva segredos. Correcção material gera nova versão. Facturação prevista não é emissão efectiva.</p><button className="primary" disabled={busy}>Submeter proposta</button></form></details>}
<div className="fiscal-list-heading"><h2>Propostas na unidade</h2><span>{context.unit.name} · {listing?.proposals.length ?? '…'} proposta(s)</span></div>{listing?.proposals.map(p=><FiscalProposal key={p.id} {...{p,can,busy,reasons,setReasons,perform,api,load,listing,context}}/>)}{!listing && <p role="status">A carregar propostas…</p>}{listing && !listing.proposals.length && <div className="panel"><h3>Ainda não há propostas nesta unidade</h3><p>As propostas e respectivas decisões serão apresentadas aqui.</p></div>}</>}
</section>;
}
+3 -1
View File
@@ -5,6 +5,7 @@ import ServicesWorkspace, { serviceAreas } from './ServicesWorkspace.jsx';
import DocumentPreparationWorkspace from './DocumentPreparationWorkspace.jsx';
import { DocumentPreviewConfiguration } from './DocumentPreview.jsx';
import CatalogueAccountingWorkspace from './CatalogueAccountingWorkspace.jsx';
import ServiceFiscalWorkspace from './ServiceFiscalWorkspace.jsx';
const labels = { 'catalogue_accounting.view':'Consultar classificação contabilística', 'catalogue_accounting.propose':'Preparar classificação contabilística', 'catalogue_accounting.approve':'Validar ou recusar classificação contabilística', 'company.view': 'Consultar empresa', 'company.edit': 'Editar nome da empresa', 'unit.create': 'Criar unidades', 'access.manage': 'Gerir acessos', 'unit.view': 'Consultar unidade', 'unit.edit': 'Editar nome da unidade' };
@@ -49,7 +50,7 @@ function App() {
if (!state) return <main className="loading"><h1>SGF TEMPO</h1><p role="status">{error || 'A carregar…'}</p></main>;
if (state.setup_pending) return <main className="setup-pending"><p className="eyebrow">SGF TEMPO · PREPARAÇÃO LOCAL</p><h1>Falta concluir a primeira conta</h1><p>A aplicação está pronta, mas ainda não existe um utilizador. O login ficará disponível depois de definires a palavra-passe no procedimento local já autorizado.</p><section className="panel"><h2>Concluir no terminal do VS Code</h2><p>Executa este comando, confere o âmbito apresentado e define a palavra-passe nos dois pedidos de entrada oculta:</p><pre>powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\laragon\data\sgf-tempo\run\complete-first-admin.ps1</pre><p className="help">Não envies a palavra-passe pelo chat. Nenhuma conta é criada por este ecrã.</p><button className="primary" disabled={busy} onClick={() => perform(async () => {}, '')}>Já concluí — actualizar estado</button>{error && <p role="alert">{error}</p>}</section></main>;
if (!state.user) return <div className="login-layout"><aside><span className="brand">TEMPO<span>SGF</span></span><h1>O seu trabalho,<br />no contexto certo.</h1><p>Uma entrada para as empresas e unidades a que tem acesso.</p><small>Ambiente local · Primeiro percurso funcional</small></aside><main className="login-card"><p className="eyebrow">BEM-VINDO AO SGF TEMPO</p><h2>Entrar na aplicação</h2><p className="muted">Use a conta preparada pelo procedimento local autorizado.</p>{error && <div className="alert error" role="alert">{error}</div>}<Login busy={busy} onSubmit={(email, password) => perform(() => api('/api/login', 'POST', { email, password }), 'Sessão iniciada. Seleccione o contexto de trabalho.')} /><p className="help">Precisa de recuperar o acesso? Solicite recuperação local assistida. Não existe recuperação por email nesta etapa.</p></main></div>;
return <div className="shell"><aside className="sidebar"><a className="brand" href="/">TEMPO<span>SGF</span></a><p className="sidebar-label">ESPAÇO DE TRABALHO</p><nav>{[['home', 'Área inicial'], ['company', 'Empresa'], ['units', 'Unidades'], ...(can('access.manage') ? [['access', 'Acessos']] : []), ...((can('issuer_documents.view') || can('client_documents.view') || can('draft_documents.view', context?.unit?.id ?? null)) ? [['documents', 'Preparação documental'], ...(can('issuer_documents.view') ? [['preview_model', 'Apresentação do rascunho']] : [])] : []), ...(can('catalogue_accounting.view') ? [['catalogue_accounting', 'Classificação contabilística']] : []), ...Object.entries(serviceAreas).filter(([area]) => can(`${area}.view`, area.endsWith('_drafts') ? context?.unit?.id ?? null : null))].map(([id, text]) => <button key={id} className={page === id ? 'active' : ''} onClick={() => { setPage(id); setError(''); }}>{text}</button>)}</nav><div className="sidebar-bottom"><strong>{state.user.name}</strong><small>{state.user.email}</small><button disabled={busy} onClick={() => perform(() => api('/api/logout', 'POST'), 'Sessão terminada.')}>Sair da aplicação</button></div></aside><div className="workspace"><header><div><span className="eyebrow">CONTEXTO DE TRABALHO</span><strong>{context?.company.name || 'Seleccione uma empresa'}</strong><span className="muted">{context?.unit?.name || 'Sem unidade seleccionada'}</span></div><span className="local-badge">Ambiente local</span></header><main>
return <div className="shell"><aside className="sidebar"><a className="brand" href="/">TEMPO<span>SGF</span></a><p className="sidebar-label">ESPAÇO DE TRABALHO</p><nav>{[['home', 'Área inicial'], ['company', 'Empresa'], ['units', 'Unidades'], ...(can('access.manage') ? [['access', 'Acessos']] : []), ...((can('issuer_documents.view') || can('client_documents.view') || can('draft_documents.view', context?.unit?.id ?? null)) ? [['documents', 'Preparação documental'], ...(can('issuer_documents.view') ? [['preview_model', 'Apresentação do rascunho']] : [])] : []), ...(can('service_fiscal.view') ? [['service_fiscal', 'Perfil fiscal do servi\u00e7o']] : []), ...(can('catalogue_accounting.view') ? [['catalogue_accounting', 'Classificação contabilística']] : []), ...Object.entries(serviceAreas).filter(([area]) => can(`${area}.view`, area.endsWith('_drafts') ? context?.unit?.id ?? null : null))].map(([id, text]) => <button key={id} className={page === id ? 'active' : ''} onClick={() => { setPage(id); setError(''); }}>{text}</button>)}</nav><div className="sidebar-bottom"><strong>{state.user.name}</strong><small>{state.user.email}</small><button disabled={busy} onClick={() => perform(() => api('/api/logout', 'POST'), 'Sessão terminada.')}>Sair da aplicação</button></div></aside><div className="workspace"><header><div><span className="eyebrow">CONTEXTO DE TRABALHO</span><strong>{context?.company.name || 'Seleccione uma empresa'}</strong><span className="muted">{context?.unit?.name || 'Sem unidade seleccionada'}</span></div><span className="local-badge">Ambiente local</span></header><main>
{error && <div className="alert error" role="alert">{error}</div>}{notice && <div className="alert success" role="status">{notice}</div>}
<section className="context-card"><label>Empresa<select aria-label="Empresa activa" disabled={busy} value={context?.company.id || ''} onChange={e => select(e.target.value)}><option value="">Escolher empresa autorizada</option>{state.companies.map(c => <option value={c.id} key={c.id}>{c.name}</option>)}</select></label><label>Unidade<select aria-label="Unidade activa" disabled={busy || !context} value={context?.unit?.id || ''} onChange={e => select(context.company.id, e.target.value ? Number(e.target.value) : null)}><option value="">Sem unidade seleccionada</option>{context?.units.map(u => <option value={u.id} key={u.id}>{u.name}</option>)}</select></label><p>Apenas os contextos com acesso explícito são apresentados.</p></section>
{!state.companies.length ? <section className="panel"><h1>Sem empresas autorizadas</h1><p>A conta existe, mas ainda não tem acesso a uma empresa. Peça ao responsável uma concessão explícita.</p></section> : !context ? <section className="panel"><h1>Escolha onde vai trabalhar</h1><p>Seleccione uma empresa. A pertença a um cliente contratante não dá acesso automático às suas empresas ou unidades.</p></section> : <div key={context.token}>
@@ -59,6 +60,7 @@ function App() {
{serviceAreas[page] && <ServicesWorkspace key={page} area={page} context={context} can={can} api={api} perform={perform} busy={busy} initialId={aSelection?.area === page ? aSelection.id : null} navigate={(area,id) => { setASelection({area,id}); setPage(area); }} />}
{page === 'preview_model' && <DocumentPreviewConfiguration context={context} can={can} api={api} perform={perform} busy={busy} />}
{page === 'documents' && <DocumentPreparationWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
{page === 'service_fiscal' && <ServiceFiscalWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
{page === 'catalogue_accounting' && <CatalogueAccountingWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
{page === 'access' && (can('access.manage') ? <AccessPanel context={context} can={can} api={api} perform={perform} busy={busy} /> : <section className="panel"><h1>Acesso não autorizado</h1><p>A gestão de acessos exige uma concessão própria.</p></section>)}
</div>}
+5
View File
@@ -15,6 +15,11 @@ Route::middleware(['auth', RequireValidSession::class])->group(function (): void
Route::post('/api/logout', [IdentityController::class, 'logout']);
Route::post('/api/context', [IdentityController::class, 'select']);
Route::middleware(RequireCompanyContext::class)->group(function (): void {
Route::get('/api/service-fiscal', [\App\Modules\Services\Http\ServiceFiscalController::class,'index']);
Route::post('/api/service-fiscal/proposals', [\App\Modules\Services\Http\ServiceFiscalController::class,'propose']);
Route::post('/api/service-fiscal/proposals/{id}/decision', [\App\Modules\Services\Http\ServiceFiscalController::class,'decide'])->whereNumber('id');
Route::post('/api/service-fiscal/drafts/{id}/calculate', [\App\Modules\Services\Http\ServiceFiscalController::class,'calculate'])->whereNumber('id');
Route::get('/api/service-fiscal/drafts/{id}/history', [\App\Modules\Services\Http\ServiceFiscalController::class,'history'])->whereNumber('id');
Route::get('/api/catalogue-accounting', [\App\Modules\Services\Http\CatalogueAccountingController::class,'index']);
Route::post('/api/catalogue-accounting/proposals', [\App\Modules\Services\Http\CatalogueAccountingController::class,'propose']);
Route::post('/api/catalogue-accounting/proposals/{id}/submit', [\App\Modules\Services\Http\CatalogueAccountingController::class,'submit'])->whereNumber('id');
+35
View File
@@ -0,0 +1,35 @@
<?php
require __DIR__.'/../vendor/autoload.php';
$app=require __DIR__.'/../bootstrap/app.php'; $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
if (!\App\Modules\Identity\Services\LocalDatabaseGuard::allows()) throw new RuntimeException('Own local development database required.');
$args=array_values(array_filter(array_slice($argv,1),fn($v)=>$v!=='--apply'));
if(count($args)!==4 || array_filter($args,fn($v)=>!ctype_digit($v))) throw new RuntimeException('Usage: service-fiscal-access.php proposer validator company unit [--apply]; specific confirmation required.');
[$proposer,$validator,$companyId,$unitId]=array_map('intval',$args);
if($proposer===$validator) throw new RuntimeException('Distinct proposer and validator required.');
$db=\Illuminate\Support\Facades\DB::class;
$preview=function() use($proposer,$validator,$companyId,$unitId,$db) {
$company=$db::table('companies')->where('id',$companyId)->first(['id','tenant_id','name']);
if(!$company) throw new RuntimeException('Existing company required.');
$unit=$db::table('units')->where('id',$unitId)->where('company_id',$companyId)->where('tenant_id',$company->tenant_id)->first(['id','name']);
if(!$unit) throw new RuntimeException('Existing unit required.');
$accounts=$db::table('users')->whereIn('id',[$proposer,$validator])->orderBy('id')->get(['id','name','email']);
if($accounts->count()!==2) throw new RuntimeException('Two existing accounts required.');
$operations=[];
foreach($accounts as $a) {
if(!$db::table('company_memberships')->where('user_id',$a->id)->where('tenant_id',$company->tenant_id)->where('company_id',$companyId)->exists()) throw new RuntimeException('No membership inference allowed.');
foreach(['company.view','services.view'] as $right) app(\App\Modules\Identity\Services\Permissions::class)->require($a->id,$company->tenant_id,$companyId,$right);
app(\App\Modules\Identity\Services\Permissions::class)->require($a->id,$company->tenant_id,$companyId,'unit.view',$unitId);
foreach(['view',$a->id===$proposer?'propose':'approve'] as $action) $operations[]=['user_id'=>$a->id,'tenant_id'=>$company->tenant_id,'company_id'=>$companyId,'unit_id'=>null,'permission'=>'service_fiscal.'.$action];
if($a->id===$proposer) $operations[]=['user_id'=>$a->id,'tenant_id'=>$company->tenant_id,'company_id'=>$companyId,'unit_id'=>$unitId,'permission'=>'service_fiscal.calculate'];
}
return ['accounts'=>$accounts,'company'=>$company,'unit'=>$unit,'proposed_grants'=>$operations,'applied'=>false];
};
if(!in_array('--apply',$argv,true)) { echo json_encode($preview(),JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE).PHP_EOL; exit; }
$count=$db::transaction(function() use($db,$companyId,$preview) {
$db::table('companies')->where('id',$companyId)->lockForUpdate()->firstOrFail(); $p=$preview(); $count=0;
foreach($p['proposed_grants'] as $grant) if($db::table('permission_grants')->insertOrIgnore($grant+['created_at'=>now(),'updated_at'=>now()])) {
$count++; \App\Modules\Identity\Services\Audit::record('service_fiscal.initial_grant',null,$grant['tenant_id'],$grant['company_id'],$grant['unit_id'],subject:$grant['user_id'],details:['permission'=>$grant['permission'],'reason'=>'Initial grants require exact specific user confirmation; no activation or emission right.']);
}
return $count;
});
echo "Applied $count specifically confirmed fiscal grants; no profile activated.\n";
+9
View File
@@ -0,0 +1,9 @@
<?php
require __DIR__.'/../vendor/autoload.php';
$app=require __DIR__.'/../bootstrap/app.php'; $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
// Input is a reviewed operation manifest, not credentials. Never accepts rates/results or grants.
$options=getopt('',['manifest:','apply']);
if(!isset($options['manifest'])) throw new RuntimeException('Usage: service-fiscal-activate.php --manifest=path [--apply]; specific prior authorisation required.');
$input=json_decode(file_get_contents($options['manifest']),true,32,JSON_THROW_ON_ERROR);
$result=app(\App\Modules\Services\LocalFiscalActivation::class)->execute($input,array_key_exists('apply',$options));
echo json_encode($result,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE).PHP_EOL;
+29
View File
@@ -0,0 +1,29 @@
<?php
use Illuminate\Support\Facades\DB;
use Symfony\Component\Process\Process;
require __DIR__.'/../vendor/autoload.php';
$app=require __DIR__.'/../bootstrap/app.php'; $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
\App\Shared\Testing\TestingDatabaseGuard::assertSafe();
$f=json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/browser-fixture.json'),true,32,JSON_THROW_ON_ERROR);
$context=app(\App\Shared\Context\CompanyContext::class);
$old=DB::table('sf_proposals')->where('draft_id',$f['fiscal_draft'])->orderByDesc('version')->first();
if(!$old) throw new RuntimeException('Synthetic temporal fixture required.');
$data=json_decode($old->data,true,32,JSON_THROW_ON_ERROR); $data['evidence']='New synthetic temporal version for overlapping process proof.';
$context->selectFor($f['manager_id'],$f['tenant'],$f['company'],$f['unit']);
$temporal=app(\App\Modules\Services\ServiceFiscal::class)->propose($f['manager_id'],['kind'=>'temporal','identifier'=>$old->identifier,'previous_id'=>$old->id,'data'=>$data]);
$context->selectFor($f['reader_id'],$f['tenant'],$f['company'],$f['unit']);
app(\App\Modules\Services\ServiceFiscal::class)->decide($temporal['id'],$f['reader_id'],['version'=>$temporal['version'],'decision'=>'approved','reason'=>'Synthetic concurrent proof only.']);
$profile=DB::table('sf_proposals')->where('id',$f['fiscal_profile_proposal'])->first();
$input=['actor'=>$f['manager_id'],'tenant'=>$f['tenant'],'company'=>$f['company'],'unit'=>$f['unit'],'draft'=>$f['fiscal_draft'],'command'=>['profile_proposal_id'=>$profile->id,'profile_version'=>$profile->version,'temporal_proposal_id'=>$temporal['id'],'temporal_version'=>$temporal['version'],'draft_version'=>1,'preparation_version'=>1]];
$before=DB::table('service_calculation_results')->where('billing_draft_id',$input['draft'])->count();
$a=new Process([PHP_BINARY,'scripts/service-fiscal-worker.php','--env=testing'],base_path(),null,json_encode($input+['pause'=>true]));
$b=new Process([PHP_BINARY,'scripts/service-fiscal-worker.php','--env=testing'],base_path(),null,json_encode($input));
$a->start(); if(!$a->waitUntil(fn($type,$buffer)=>str_contains($buffer,'LOCK_HELD'))) throw new RuntimeException('No overlap established.');
$b->start(); $a->wait(); $b->wait(); $results=[];
foreach([$a,$b] as $worker) {
if(!$worker->isSuccessful()) throw new RuntimeException('Fiscal worker failed; no output containing input disclosed.');
$lines=array_values(array_filter(explode("\n",trim($worker->getOutput())),fn($s)=>str_starts_with($s,'{'))); $results[]=json_decode(end($lines),true,32,JSON_THROW_ON_ERROR);
}
if($results[0]['id']!==$results[1]['id'] || $results[0]['replayed'] || !$results[1]['replayed'] || DB::table('service_calculation_results')->where('billing_draft_id',$input['draft'])->count()!==$before+1) throw new RuntimeException('Concurrent duplicate or unexpected result.');
file_put_contents(base_path('docs/proofs/service-fiscal-concurrency.json'),json_encode(['database'=>'sgf_tempo_test','overlapping_php_processes'=>2,'lock_overlap_observed'=>true,'one_insert_one_replay'=>true,'prior_history_preserved'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
echo "Fiscal concurrency proof: two overlapping processes, one insertion and one replay; history preserved.\n";
+35
View File
@@ -0,0 +1,35 @@
<?php
// Synthetic, isolated browser proof. Never creates a Tempo account/profile/operation.
use App\Modules\Services\ServiceFiscal;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
require __DIR__.'/../vendor/autoload.php';
$app=require __DIR__.'/../bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
\App\Shared\Testing\TestingDatabaseGuard::assertSafe();
abort_unless(\App\Modules\Services\ServiceCalculation::isolated(),403);
$file='C:/laragon/data/sgf-tempo/secrets/browser-fixture.json';
$f=json_decode(file_get_contents($file),true,32,JSON_THROW_ON_ERROR);
abort_if(DB::table('a_services')->exists() || DB::table('sf_proposals')->exists(),409,'Fixture não vazia; nenhum dado apagado.');
$result=DB::transaction(function() use($f) {
foreach(['manager_id'=>['view','propose'],'reader_id'=>['view','approve']] as $who=>$actions) foreach($actions as $action) \Tests\Support\IdentityFixture::grant($f[$who],$f['tenant'],$f['company'],'service_fiscal.'.$action);
\Tests\Support\IdentityFixture::grant($f['manager_id'],$f['tenant'],$f['company'],'service_fiscal.calculate',$f['unit']);
$context=app(\App\Shared\Context\CompanyContext::class);
$select=function($actor) use($context,$f) { $context->selectFor($actor,$f['tenant'],$f['company'],$f['unit']); };
$select($f['manager_id']); $w=app(\App\Modules\Services\ServiceWorkflow::class); $actor=$f['manager_id'];
$client=$w->save('clients',null,$actor,['name'=>'Cliente de prova fiscal'],(string)Str::uuid())['id'];
$service=$w->save('services',null,$actor,['name'=>'Alojamento mensal — PROVA SINTÉTICA'],(string)Str::uuid())['id'];
$source=$w->save('service_drafts',null,$actor,['name'=>'Período de prova fiscal','client_id'=>$client,'currency'=>'MZN','lines'=>[['service_id'=>$service,'quantity'=>'1','unit_price'=>'7850.00']]],(string)Str::uuid())['id'];
$draft=$w->save('billing_drafts',null,$actor,['source_id'=>$source,'name'=>'Prova fiscal condicionada — 7850 sem IVA'],(string)Str::uuid())['id'];
app(\App\Modules\Services\DocumentPreparation::class)->save('billing_drafts',$draft,$actor,['version'=>0,'draft_version'=>1,'currency'=>'MZN','price_mode'=>'added','period_start'=>'2026-10-26','units'=>['PERIODO'],'payment_instruction_ids'=>[]]);
$fiscal=app(ServiceFiscal::class);
$profile=$fiscal->propose($actor,['kind'=>'profile','identifier'=>'PROVA-ALOJAMENTO-16','data'=>['service_id'=>$service,'service_version'=>1,'currency'=>'MZN','price_mode'=>'added','rate_percent'=>'16','valid_from'=>'2026-10-26','treatment'=>'domestic_normal','conditions'=>'EXCLUSIVAMENTE SINTÉTICO — operação interna fictícia.','evidence'=>'Fixture isolada de aceitação; referências da ficha v0.8, sem aplicação à Tempo.']])['id'];
$select($f['reader_id']); $fiscal->decide($profile,$f['reader_id'],['version'=>1,'decision'=>'approved','reason'=>'Validação sintética do perfil exacto.']);
$select($actor);
$temporal=$fiscal->propose($actor,['kind'=>'temporal','identifier'=>'PROVA-PERIODO','data'=>['profile_proposal_id'=>$profile,'draft_id'=>$draft,'draft_version'=>1,'preparation_version'=>1,'reference_date'=>'2026-10-26','reference_basis'=>'Referência contratual explícita para prova; não é exigibilidade.','period_start'=>'2026-10-26','next_billing'=>'2026-11-26','event'=>'pending','evidence'=>'Pendência temporal deliberada; não se presumem fim de cobertura ou emissão.']])['id'];
$select($f['reader_id']); $fiscal->decide($temporal,$f['reader_id'],['version'=>1,'decision'=>'approved','reason'=>'Aprovado resultado condicionado, sem exigibilidade determinada.']);
$fiscal->proofActivation($profile,$f['reader_id'],1,true);
return ['fiscal_draft'=>$draft,'fiscal_profile_proposal'=>$profile,'fiscal_temporal_proposal'=>$temporal];
});
file_put_contents($file,json_encode($f+$result,JSON_THROW_ON_ERROR));
echo "Synthetic fiscal proposals and draft prepared only in sgf_tempo_test; no calculation precomputed.\n";
+34
View File
@@ -0,0 +1,34 @@
<?php
require __DIR__.'/../vendor/autoload.php';
$app=require __DIR__.'/../bootstrap/app.php'; $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
if(!\App\Modules\Identity\Services\LocalDatabaseGuard::allows()) throw new RuntimeException('Own local development database required.');
$db=\Illuminate\Support\Facades\DB::class;
$file=storage_path('app/service-fiscal-preservation-before.json');
$before=in_array('--before',$argv,true); $grants=in_array('--after-grants',$argv,true);
$baseline=$before ? null : json_decode(file_get_contents($file),true,64,JSON_THROW_ON_ERROR);
$tables=$before ? array_map(fn($r)=>$r->tablename,$db::select("SELECT tablename FROM pg_tables WHERE schemaname='public' AND (tablename ~ '^(a_|b_|ca_|doc_|document_|service_calculation_)' OR tablename IN ('users','tenants','companies','units','company_memberships','permission_grants','identity_audit')) ORDER BY tablename")) : array_keys($baseline['tables']);
$summary=[];
foreach($tables as $table) {
if(!preg_match('/^[a-z_]+$/D',$table)) throw new RuntimeException('Unexpected table.');
$where='';
if($grants && in_array($table,['permission_grants','identity_audit'],true)) $where=' WHERE id <= '.(int)$baseline['max'][$table];
$r=$db::selectOne('SELECT count(*) AS n, md5(COALESCE(string_agg(row_to_json(t)::text, E\'\\n\' ORDER BY row_to_json(t)::text),\'\')) AS fingerprint FROM (SELECT * FROM "'.$table.'"'.$where.') t');
$summary[$table]=['count'=>(int)$r->n,'hash'=>$r->fingerprint];
}
if($before) {
file_put_contents($file,json_encode(['tables'=>$summary,'max'=>['permission_grants'=>(int)$db::table('permission_grants')->max('id'),'identity_audit'=>(int)$db::table('identity_audit')->max('id')]],JSON_THROW_ON_ERROR));
echo "Existing SGF development records fingerprinted in database; no record contents disclosed.\n"; exit;
}
if($summary!==$baseline['tables']) throw new RuntimeException('Existing SGF record fingerprint changed; review before proceeding.');
foreach(['sf_proposals','sf_decisions','sf_activation_events'] as $table) if($db::table($table)->count()!==0) throw new RuntimeException('Unexpected real fiscal lifecycle record.');
if($grants) {
$actual=$db::table('permission_grants')->where('permission','like','service_fiscal.%')->orderBy('user_id')->orderBy('permission')->get(['user_id','tenant_id','company_id','unit_id','permission'])->map(fn($r)=>(array)$r)->all();
$expected=[];
foreach([1=>['view','propose','calculate'],2=>['view','approve']] as $user=>$actions) foreach($actions as $a) $expected[]=['user_id'=>$user,'tenant_id'=>1,'company_id'=>1,'unit_id'=>$a==='calculate'?1:null,'permission'=>'service_fiscal.'.$a];
usort($expected,fn($a,$b)=>[$a['user_id'],$a['permission']]<=>[$b['user_id'],$b['permission']]);
if($actual!==$expected) throw new RuntimeException('Fiscal grants differ from specifically confirmed matrix.');
$new=$db::table('identity_audit')->where('id','>',$baseline['max']['identity_audit'])->where('action','service_fiscal.initial_grant')->count();
if($new!==5) throw new RuntimeException('Expected five audited initial grants.');
}
file_put_contents(base_path('docs/proofs/service-fiscal-dev-preservation'.($grants?'-grants':'').'.json'),json_encode(['database'=>'sgf_tempo_dev','preexisting_tables_checked'=>count($summary),'preexisting_records_unchanged'=>true,'new_fiscal_tables_empty'=>true,'real_profile_activated'=>false,'confirmed_grants_verified'=>$grants?5:0,'prior_grants_and_audit_unchanged'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
echo $grants ? "Exactly five confirmed fiscal grants and audit entries verified; prior records preserved; no real profile created or activated.\n" : "Migration preserved existing records and grants; all new fiscal tables empty.\n";
+11
View File
@@ -0,0 +1,11 @@
<?php
require __DIR__.'/../vendor/autoload.php';
$app=require __DIR__.'/../bootstrap/app.php'; $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
\App\Shared\Testing\TestingDatabaseGuard::assertSafe();
$in=json_decode(stream_get_contents(STDIN),true,32,JSON_THROW_ON_ERROR);
app(\App\Shared\Context\CompanyContext::class)->selectFor($in['actor'],$in['tenant'],$in['company'],$in['unit']);
if($in['pause'] ?? false) {
$paused=false;
\Illuminate\Support\Facades\DB::listen(function($q) use(&$paused) { if(!$paused && str_contains($q->sql,'"companies"') && str_contains($q->sql,'for update')) { $paused=true; echo "LOCK_HELD\n"; flush(); usleep(1500000); } });
}
echo json_encode(app(\App\Modules\Services\ServiceFiscal::class)->calculate($in['draft'],$in['actor'],$in['command']),JSON_THROW_ON_ERROR).PHP_EOL;
+188
View File
@@ -0,0 +1,188 @@
<?php
namespace Tests\Feature;
use App\Modules\Services\ServiceFiscal;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\Support\DocumentPreviewFixture;
use Tests\Support\IdentityFixture;
use Tests\TestCase;
final class ServiceFiscalTest extends TestCase
{
use RefreshDatabase;
private array $f; private string $token; private int $draft; private int $service;
protected function setUp(): void
{
parent::setUp(); $this->f=DocumentPreviewFixture::create();
foreach (['manager'=>['view','propose'],'reader'=>['view','approve']] as $user=>$actions) foreach($actions as $a) IdentityFixture::grant($this->f[$user]->id,$this->f['tenant'],$this->f['company'],'service_fiscal.'.$a);
IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'service_fiscal.calculate',$this->f['unit']);
$this->login();
$client=$this->write('clients',['name'=>'Cliente isolado'])->json('id');
$this->service=$this->write('services',['name'=>'Alojamento sintético mensal'])->json('id');
$source=$this->write('service_drafts',['client_id'=>$client,'currency'=>'MZN','lines'=>[['service_id'=>$this->service,'quantity'=>'1','unit_price'=>'7850.00']]])->json('id');
$this->draft=$this->write('billing_drafts',['source_id'=>$source])->json('id');
$this->withHeader('X-SGF-Context',$this->token)->patchJson('/api/document-preparation/billing_drafts/'.$this->draft,['version'=>0,'draft_version'=>1,'currency'=>'MZN','price_mode'=>'added','period_start'=>'2026-10-26','units'=>['PERIODO'],'payment_instruction_ids'=>[]])->assertOk();
}
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 write(string $area,array $data)
{
return $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>(string)Str::uuid()])->postJson('/api/a/'.$area,$data)->assertSuccessful();
}
private function profile(array $changes=[],?int $previous=null)
{
return $this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-fiscal/proposals',['kind'=>'profile','identifier'=>'ISOLATED-HOSTING','previous_id'=>$previous,'data'=>array_replace(['service_id'=>$this->service,'service_version'=>1,'currency'=>'MZN','price_mode'=>'added','rate_percent'=>'16','valid_from'=>'2026-10-26','treatment'=>'domestic_normal','conditions'=>'Prestação interna sintética; um período; sem outras componentes.','evidence'=>'Fixture sintética; CIVA7/8/15/17 e Lei10/2025; não é validação Tempo.'],$changes)]);
}
private function approve(int $id,int $version=1,string $decision='approved'): void
{
$this->login('reader');
$this->withHeader('X-SGF-Context',$this->token)->postJson("/api/service-fiscal/proposals/$id/decision",['version'=>$version,'decision'=>$decision,'reason'=>'Validação sintética da versão e âmbito exactos.'])->assertOk();
$this->login();
}
private function temporal(int $profile,array $changes=[],?int $previous=null)
{
return $this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-fiscal/proposals',['kind'=>'temporal','identifier'=>'ISOLATED-PERIOD','previous_id'=>$previous,'data'=>array_replace(['profile_proposal_id'=>$profile,'draft_id'=>$this->draft,'draft_version'=>1,'preparation_version'=>1,'reference_date'=>'2026-10-26','reference_basis'=>'Início contratual explícito usado apenas para verificar vigência; não é exigibilidade.','period_start'=>'2026-10-26','next_billing'=>'2026-11-26','event'=>'pending','evidence'=>'Período sintético; exigibilidade ainda pendente.'],$changes)]);
}
private function calculate(int $p,int $t,array $changes=[])
{
return $this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-fiscal/drafts/'.$this->draft.'/calculate',array_replace(['profile_proposal_id'=>$p,'profile_version'=>1,'temporal_proposal_id'=>$t,'temporal_version'=>1,'draft_version'=>1,'preparation_version'=>1],$changes));
}
private function activate(int $p,int $version=1,bool $active=true): void
{
$this->login('reader');
app(\App\Shared\Context\CompanyContext::class)->selectFor($this->f['reader']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);
app(ServiceFiscal::class)->proofActivation($p,$this->f['reader']->id,$version,$active); $this->login();
}
private function ready(): array
{
$p=$this->profile()->assertOk()->json('id'); $this->approve($p);
$response=$this->temporal($p); $this->assertSame(200,$response->status(),$response->getContent()); $t=$response->json('id'); $this->approve($t);
return [$p,$t];
}
public function test_pending_temporal_result_exact_values_repetition_history_and_revocation(): void
{
[$p,$t]=$this->ready(); $this->calculate($p,$t)->assertStatus(409); $this->activate($p);
$id=$this->calculate($p,$t)->assertOk()->assertJsonPath('replayed',false)->json('id');
$this->calculate($p,$t)->assertOk()->assertJsonPath('id',$id)->assertJsonPath('replayed',true);
$h=$this->getJson('/api/service-fiscal/drafts/'.$this->draft.'/history')->assertOk();
foreach(['base_exact'=>'7850','vat_exact'=>'1256','vat_document_proposed'=>'1256.00','discarded_difference'=>'0','total_document_proposed'=>'9106.00','temporal_state'=>'temporal_pending','exigibility_date'=>null,'assessment_period'=>null,'fiscally_validated'=>false] as $key=>$value) $h->assertJsonPath('history.0.snapshot.result.'.$key,$value);
$h->assertJsonPath('history.0.current',true);
$this->getJson('/api/document-preview/draft/'.$this->draft)->assertOk()->assertJsonPath('fiscal_history.0.id',$id);
$this->activate($p,1,false); $this->calculate($p,$t)->assertStatus(409);
$this->getJson('/api/service-fiscal/drafts/'.$this->draft.'/history')->assertOk()->assertJsonPath('history.0.current',false)->assertJsonPath('history.0.snapshot.result.total_document_proposed','9106.00');
}
public function test_separation_of_duties_and_calculator_cannot_edit_temporal_decision(): void
{
[$p,$t]=$this->ready(); $this->activate($p);
$this->withHeader('X-SGF-Context',$this->token)->postJson("/api/service-fiscal/proposals/$t/decision",['version'=>1,'decision'=>'approved','reason'=>'Calculator attempt'])->assertForbidden();
$this->calculate($p,$t,['event_date'=>'2026-10-26'])->assertUnprocessable();
$this->temporal($p,['event'=>'advance_payment','event_date'=>'2026-10-26'],$t)->assertUnprocessable();
$this->login('reader'); $this->profile()->assertForbidden(); $this->calculate($p,$t)->assertForbidden();
$this->withHeader('X-SGF-Context',$this->token)->postJson("/api/service-fiscal/proposals/$t/decision",['version'=>1,'decision'=>'refused','reason'=>'Overwrite'])->assertStatus(409);
}
public function test_versions_dates_compatibility_and_unknown_information_fail_closed(): void
{
[$p,$t]=$this->ready(); $this->activate($p);
$this->calculate($p,$t,['profile_version'=>2])->assertStatus(409);
$this->temporal($p,['reference_date'=>null],$t)->assertUnprocessable();
$this->temporal($p,['event_date'=>'2026-10-26'],$t)->assertUnprocessable();
$bad=$this->temporal($p,['reference_date'=>'2026-10-25'],$t)->assertOk()->json('id');
$this->login('reader'); $this->withHeader('X-SGF-Context',$this->token)->postJson("/api/service-fiscal/proposals/$bad/decision",['version'=>2,'decision'=>'approved','reason'=>'Invalid validity'])->assertUnprocessable(); $this->login();
$this->write('services',['name'=>'Other service']);
$this->calculate($p,$t)->assertOk();
$p2=$this->profile(['conditions'=>'Material change'],$p)->assertOk()->json('id');
$this->calculate($p,$t)->assertOk(); // merely proposed version does not replace approved one
$this->approve($p2,2); $this->calculate($p,$t)->assertStatus(409);
}
public function test_temporal_correction_is_versioned_and_determines_month_only_after_approval(): void
{
[$p,$t]=$this->ready(); $this->activate($p); $this->calculate($p,$t)->assertOk();
$t2=$this->temporal($p,['event'=>'invoice_in_time','reference_date'=>'2026-11-26','event_date'=>'2026-11-26','evidence'=>'Synthetic invoice date, deadline and timely issue reviewed; no invoice issued by application.'],$t)->assertOk()->json('id');
$this->calculate($p,$t,['temporal_proposal_id'=>$t2,'temporal_version'=>2])->assertStatus(409);
$this->approve($t2,2); $this->calculate($p,$t)->assertStatus(409);
$this->calculate($p,$t,['temporal_proposal_id'=>$t2,'temporal_version'=>2])->assertOk();
$this->getJson('/api/service-fiscal/drafts/'.$this->draft.'/history')->assertOk()->assertJsonPath('history.0.snapshot.result.assessment_period','2026-11')->assertJsonPath('history.1.current',false)->assertJsonPath('history.1.snapshot.result.exigibility_date',null);
}
public function test_mass_changes_cross_scope_revocation_b1_and_activation_are_protected(): void
{
[$p,$t]=$this->ready(); $this->activate($p); $this->calculate($p,$t)->assertOk();
foreach (['sf_proposals','sf_decisions','sf_activation_events'] as $table) {
try { DB::transaction(fn()=>DB::table($table)->delete()); $this->fail('History deleted'); }
catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('SGF_',$e->getMessage()); }
}
$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['hidden']])->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-fiscal')->assertForbidden(); $this->login();
DB::table('permission_grants')->where('user_id',$this->f['manager']->id)->where('permission','service_fiscal.calculate')->delete();
$this->calculate($p,$t)->assertForbidden();
$this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>(string)Str::uuid()])->postJson('/api/b1/billing-drafts/'.$this->draft.'/confirm-internal',['version'=>1])->assertOk();
$before=DB::table('b_internal_confirmations')->get()->toJson();
$this->getJson('/api/service-fiscal/drafts/'.$this->draft.'/history')->assertStatus(409);
$this->assertSame($before,DB::table('b_internal_confirmations')->get()->toJson());
$this->app->instance('env','local');
try { app(ServiceFiscal::class)->proofActivation($p,$this->f['manager']->id,1,true); $this->fail('Real activation allowed'); }
catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { $this->assertSame(403,$e->getStatusCode()); }
}
public function test_future_profile_does_not_replace_present_and_reference_cannot_be_invented(): void
{
[$p,$t]=$this->ready(); $this->activate($p);
$next=$this->profile(['valid_from'=>'2027-01-01','conditions'=>'Future material version'],$p)->assertOk()->json('id');
$this->approve($next,2); $this->calculate($p,$t)->assertOk();
$this->temporal($p,['event'=>'invoice_in_time','event_date'=>'2026-11-26'],$t)->assertUnprocessable();
$this->calculate($p,$t,['temporal_version'=>2])->assertStatus(409);
$this->login('reader');
$this->withHeader('X-SGF-Context',$this->token)->postJson("/api/service-fiscal/proposals/$next/decision",['version'=>1,'decision'=>'approved','reason'=>'Wrong exact version'])->assertForbidden();
}
public function test_stale_catalogue_and_draft_and_raw_cross_scope_inserts_are_rejected(): void
{
[$p,$t]=$this->ready(); $this->activate($p);
$row=(array)DB::table('sf_proposals')->where('id',$p)->first(); unset($row['id']);
$row['unit_id']=$this->f['hidden']; $row['identifier']='CROSS-SCOPE';
try { DB::transaction(fn()=>DB::table('sf_proposals')->insert($row)); $this->fail('Cross scope inserted'); }
catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('SGF_FISCAL_SCOPE',$e->getMessage()); }
$this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>(string)Str::uuid()])->patchJson('/api/a/services/'.$this->service,['version'=>1,'name'=>'Changed synthetic catalogue'])->assertOk();
$this->calculate($p,$t)->assertUnprocessable();
$this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>(string)Str::uuid()])->patchJson('/api/a/billing_drafts/'.$this->draft,['version'=>1,'lines'=>[['service_id'=>$this->service,'quantity'=>'1','unit_price'=>'7850.00']]])->assertOk();
$this->calculate($p,$t)->assertStatus(409);
}
public function test_combined_rights_do_not_allow_self_approval_or_bypass_database_immutability(): void
{
$p=$this->profile()->assertOk()->json('id');
IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'service_fiscal.approve');
$this->withHeader('X-SGF-Context',$this->token)->postJson("/api/service-fiscal/proposals/$p/decision",['version'=>1,'decision'=>'approved','reason'=>'Self approval attempt'])->assertForbidden();
foreach(['update','delete'] as $op) {
try { DB::transaction(fn()=> $op==='update' ? DB::table('sf_proposals')->where('id',$p)->update(['unit_id'=>$this->f['hidden']]) : DB::table('sf_proposals')->where('id',$p)->delete()); $this->fail('Immutable proposal changed'); }
catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('SGF_',$e->getMessage()); }
}
try { DB::transaction(fn()=>DB::table('sf_decisions')->insert(['proposal_id'=>$p,'actor_id'=>$this->f['manager']->id,'decision'=>'approved','reason'=>'Raw self approval','created_at'=>now()])); $this->fail('Raw self approval accepted'); }
catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('SGF_FISCAL_DECISION',$e->getMessage()); }
}
public function test_local_activation_requires_reviewed_exact_approved_manifest_and_is_idempotent(): void
{
$p=$this->profile()->assertOk()->json('id');
$manifest=['proposal_id'=>$p,'version'=>1,'identifier'=>'ISOLATED-HOSTING','tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['unit'],'service_id'=>$this->service,'service_version'=>1,'valid_from'=>'2026-10-26','operator_id'=>$this->f['manager']->id];
$activation=app(\App\Modules\Services\LocalFiscalActivation::class);
try { $activation->execute($manifest); $this->fail('Unapproved activation'); }
catch(\Symfony\Component\HttpKernel\Exception\HttpException $e) { $this->assertSame(409,$e->getStatusCode()); }
$this->approve($p);
$review=$activation->execute($manifest); $this->assertFalse($review['active']); $this->assertFalse($review['applied']);
foreach([['version'=>2],['service_id'=>$this->service+1],['unit_id'=>$this->f['hidden']],['valid_from'=>'2026-10-27'],['definition_hash'=>str_repeat('0',64)]] as $change) {
try { $activation->execute(array_replace($manifest,['definition_hash'=>$review['definition_hash']],$change),true); $this->fail('Different manifest accepted'); }
catch(\Symfony\Component\HttpKernel\Exception\HttpException $e) { $this->assertContains($e->getStatusCode(),[403,409]); }
}
$manifest['definition_hash']=$review['definition_hash'];
$result=$activation->execute($manifest,true); $this->assertTrue($result['active']); $this->assertTrue($result['applied']);
$replay=$activation->execute($manifest,true); $this->assertTrue($replay['replayed']); $this->assertFalse($replay['applied']);
$this->assertSame(1,DB::table('sf_activation_events')->where('proposal_id',$p)->count());
$audit=DB::table('identity_audit')->where('action','service_fiscal.local_activated')->first();
$this->assertSame($p,json_decode($audit->details,true)['record_id']);
$this->app->instance('env','production');
try { $activation->execute($manifest,true); $this->fail('Production allowed'); }
catch(\Symfony\Component\HttpKernel\Exception\HttpException $e) { $this->assertSame(403,$e->getStatusCode()); }
}
}