Implement scoped L3 A B credit carry and VAT closure with frozen L2 sources

This commit is contained in:
2026-10-25 14:01:33 +02:00
parent 508aeb5205
commit f7b778cdce
12 changed files with 496 additions and 2 deletions
@@ -26324,5 +26324,55 @@
"external_research": false,
"runtime_changes": false,
"active_writes": false
},
"l3_ab_closure_qa_20261025": {
"status": "L3-A/B concluído em QA no âmbito demonstrado;preparado para entrega conjunta,sem instalação",
"report": "docs/investmoz/simulations/l3-ab-closure-qa-20261025.json",
"manifest": "docs/investmoz/simulations/l3-ab-prepared-delivery-20261025.json",
"installed_revision": "7a380b76",
"L1": {
"delivered": 129,
"functional_residuals": [
"P058",
"P084",
"P085"
],
"documentary_residual": "P001 — exclusivamente05/06"
},
"L2_pending": [
"18(6)+afectação mista",
"regularizações sucessivas e plurianuais",
"regularizações por erros e incobráveis"
],
"L3_pending": [
"C — substituições",
"D — pedido/reembolso geral",
"E — restituição de bebidas"
],
"validation": {
"frontend": "18testes dirigidos passaram:9A/B novos e9E reutilizados;após ajustes finais,9A/B repetidos com preservação/histórico/relatório e todos passaram.",
"API": "1teste/103asserções;simulator_fiscal_delta_20261021_test,porta55441,DatabaseTransactions rollback. modelo_a_snapshot recuperado byte a byte;origensL2,créditos,documentos,pagamentos,contextos,históricos e todos os mapasL1/L2 anteriores conservados.",
"types": "npm.cmd run types:check passou",
"build": "npm.cmd run build passou",
"UI": "Componente real:alterar50→60 invalida Fevereiro/Março;adopção expressa em cada mês recupera continuidade. Reposição do exemploAPI,gravar→recuperar→relatório confirma150→70→0 e72calculado/30registado/42porliquidar.",
"proof": "outputs/iva-l3-ab-20261025/ui-recovered-closure.png",
"logs": [
"outputs/iva-l3-ab-20261025/frontend-tests-final.txt",
"outputs/iva-l3-ab-20261025/frontend-ab-final.txt",
"outputs/iva-l3-ab-20261025/types-final.txt",
"outputs/iva-l3-ab-20261025/build-final.txt"
]
},
"limits": [
"Âmbito demonstrado2026/MZN,primeira declaração em prazo e opção expressa de reporte integral. Prazo/data são declarados com referência,sem calendário legal automático.",
"Juros23:montante documentado/pressuposto ou inexistência expressa com motivo;taxa/prazo de juros não automatizados.",
"C:substituições21;D:pedidos de reembolso27;E:restituição de bebidas13(2) continuam pendentes na mesma matriz.27zero neste caso decorre da opção expressa de reporte integral,não da conclusãoD.",
"Declaração fora do prazo conserva origem/conta mas não fecha19–27 neste percurso;procedimento próprio pendente,sem apagar o crédito.",
"Controlo de duplicação sobre livros/mapas recuperados neste rascunho e âmbito declarado;não é conta corrente central da AT nem reconciliação automática entre rascunhos privados diferentes. Cenários projectados de oportunidades diferentes não são consumos fiscais efectivos.",
"Registo de pagamento é informação declarada,sem execução de pagamento,certificação de arrecadação ou nova comunicação20 por excedente. Não sincroniza caixa silenciosamente.",
"L2 mantém18(6)+afectação mista,regularizações sucessivas/plurianuais e erros/incobráveis.17/18 indeterminados bloqueiam só o respectivo fecho;outros casos independentes avançam.",
"L1 mantém129fechos+P058/P084/P085+P001=133;P001 exclusivamente05/06. Estatísticas eIAadiadas."
],
"active_writes": false
}
}
@@ -0,0 +1,116 @@
{
"status": "L3-A/B concluído em QA no âmbito demonstrado;preparado para entrega conjunta,sem instalação",
"matrix_anchor": "AUD-SIM-INT02",
"installed_revision": "7a380b76",
"installed_receipt": "acbd1f33",
"L1": {
"delivered": 129,
"functional_residuals": [
"P058",
"P084",
"P085"
],
"documentary_residual": "P001 — exclusivamente05/06"
},
"L2_pending": [
"18(6)+afectação mista",
"regularizações sucessivas e plurianuais",
"regularizações por erros e incobráveis"
],
"L3_pending": [
"C — substituições",
"D — pedido/reembolso geral",
"E — restituição de bebidas"
],
"source_cotejo": {
"accepted_exemplar": "VAT Return.pdf SHA2569c0b99a810c7db53eb75d63c22ee22fb86a50ba57bd5509041ed80ee10f75741,p1–2;DOCX62c198e9/63d45fa8 preservados",
"articles": "CIVA21(2/5),Lei13/2016 PDF15/p1426(273);23(1),PDF16/p1426(274);RCIVA12/15,Decreto7/2008 PDF3/p101. Quadro8 créditos e10/11 fecho. Lei10/2025 não fornece autorização para copiar remissãoart20 do formulário para reembolso geral;D separado.",
"conclusions": "Primeira declaração,excesso deduzido nos períodos seguintes;crédito comunicado exige referência/data da comunicação;utilização anterior apenas em prazo.25/26 não equivalem a recebimento. Fora do prazo não usar crédito por presumirzero;conservar conta e indicar procedimento.",
"divergences": "Cabeçalho do exemplar refere18/19,mas caixas19/20 identificadas;divergência visível,sem renumerar original nem reabrirP00105/06.",
"no_repeated_research": true
},
"implemented": {
"frozen_L2": "Reutiliza17/18 exactos de resultado congelado com oportunidade,período,moeda,localização,revisão,data e impressão de alterações;não recalcula L2. Acrescentar versãoL2 não invalida automaticamente uma origem congelada ainda disponível. Origem ausente/fabricada/indeterminada impede o fecho dependente.",
"credits": "19:saldo de abertura do mês anterior com documento/período e montante disponível,ou26 do fecho imediatamente anterior adoptado expressamente.20:comunicação recebida,referência,origem,data e montante. Cobertura de todos os créditos confirmada;lista vazia não viraausência silenciosa. Documento não duplicado como abertura e comunicação nem entre linhas/períodos do âmbito recuperado.",
"closure": "22=max(17−19−20,0);25=max(18+19+20−17,0);24=22+23;26=25 por opção expressa de reporte integral e27=0 por ausência expressa de pedido nessa opção.21não aplicável à primeira declaração,sem declararCimplementado. Valores desconhecidos não viramzero.",
"payments": "Documento,data e montante registados separados de24 e26;saldo por liquidar e excedente pago calculados separadamente. Pagamento não altera IVA/creditotransportado. Excedente não cria crédito20 sem comunicação. Referência repetida exige reconciliação.",
"continuity": "Três períodos encadeados,fracções exactas sem somar aproximações. Alteração de crédito original preserva versão anterior e exige adopção expressa em cada dependente;botão disponível mesmo mantendo o mesmo identificador do fecho anterior. Editar apenas pagamentos não altera o crédito26.",
"reuse": "Reutiliza livro/modelo_a_snapshot,ETag/API privada,aritmética e relatório de dossier existentes. Entidade fiscal única já indicada reutilizada ao preparar outro mês. Valores ocultos preservados,sem novos aprovadores/certificados.",
"history": "Cada resultado congela entradas completas e resultados;recalcular acrescenta o anterior ao histórico. Relatório histórico lê os seus próprios documentos/créditos/pagamentos,não as novas entradas editadas. Originais e mapasL1/L2 comparados integralmente."
},
"demonstration_MZN": [
{
"period": "2026-01",
"L2_18": "100",
"19_opening": "50",
"25": "150",
"26": "150",
"paid": "0"
},
{
"period": "2026-02",
"L2_17": "80",
"19_from_Jan": "150",
"25": "70",
"26": "70",
"paid": "0"
},
{
"period": "2026-03",
"L2_17": "160",
"19_from_Feb": "70",
"20_communicated": "20",
"22": "70",
"23": "2",
"24": "72",
"paid": "30",
"outstanding": "42",
"26": "0"
}
],
"exact_fraction_example": "ResultadoL2 deIVA500×1/3 menos160=20/3;maisabertura50=170/3 transportados;no mês seguinte80−170/3=70/3 a entregar. Sem transporte de decimal arredondado.",
"validation": {
"frontend": "18testes dirigidos passaram:9A/B novos e9E reutilizados;após ajustes finais,9A/B repetidos com preservação/histórico/relatório e todos passaram.",
"API": "1teste/103asserções;simulator_fiscal_delta_20261021_test,porta55441,DatabaseTransactions rollback. modelo_a_snapshot recuperado byte a byte;origensL2,créditos,documentos,pagamentos,contextos,históricos e todos os mapasL1/L2 anteriores conservados.",
"types": "npm.cmd run types:check passou",
"build": "npm.cmd run build passou",
"UI": "Componente real:alterar50→60 invalida Fevereiro/Março;adopção expressa em cada mês recupera continuidade. Reposição do exemploAPI,gravar→recuperar→relatório confirma150→70→0 e72calculado/30registado/42porliquidar.",
"proof": "outputs/iva-l3-ab-20261025/ui-recovered-closure.png",
"logs": [
"outputs/iva-l3-ab-20261025/frontend-tests-final.txt",
"outputs/iva-l3-ab-20261025/frontend-ab-final.txt",
"outputs/iva-l3-ab-20261025/types-final.txt",
"outputs/iva-l3-ab-20261025/build-final.txt"
]
},
"limits": [
"Âmbito demonstrado2026/MZN,primeira declaração em prazo e opção expressa de reporte integral. Prazo/data são declarados com referência,sem calendário legal automático.",
"Juros23:montante documentado/pressuposto ou inexistência expressa com motivo;taxa/prazo de juros não automatizados.",
"C:substituições21;D:pedidos de reembolso27;E:restituição de bebidas13(2) continuam pendentes na mesma matriz.27zero neste caso decorre da opção expressa de reporte integral,não da conclusãoD.",
"Declaração fora do prazo conserva origem/conta mas não fecha19–27 neste percurso;procedimento próprio pendente,sem apagar o crédito.",
"Controlo de duplicação sobre livros/mapas recuperados neste rascunho e âmbito declarado;não é conta corrente central da AT nem reconciliação automática entre rascunhos privados diferentes. Cenários projectados de oportunidades diferentes não são consumos fiscais efectivos.",
"Registo de pagamento é informação declarada,sem execução de pagamento,certificação de arrecadação ou nova comunicação20 por excedente. Não sincroniza caixa silenciosamente.",
"L2 mantém18(6)+afectação mista,regularizações sucessivas/plurianuais e erros/incobráveis.17/18 indeterminados bloqueiam só o respectivo fecho;outros casos independentes avançam.",
"L1 mantém129fechos+P058/P084/P085+P001=133;P001 exclusivamente05/06. Estatísticas eIAadiadas."
],
"schema_changes": false,
"active_writes": false,
"runtime_delta": [
{
"path": "resources/js/lib/iva-closure.ts",
"sha256_LF": "542e75fde957a8b2ebbee8d66d17ca8c5663183f89b85e7b7819d86253dd22ea"
},
{
"path": "resources/js/components/acquisition-closure.tsx",
"sha256_LF": "2f745bd54c8753215ef6386e52d9e3ab1785330310c6b725e67bbfaef29ffd06"
},
{
"path": "resources/js/components/simulation-acquisitions.tsx",
"sha256_LF": "b94ead08dc13a89ffa0a1b89472b39836b20df73fbfe028dfe4ffa697cd7e29c"
},
{
"path": "resources/js/lib/project-dossier.ts",
"sha256_LF": "233999eea4d7ca86df8af58ed46cb026a5c9fd9a3e507e56e43fdaa801436510"
}
]
}
@@ -0,0 +1,49 @@
{
"status": "prepared_for_grouped_L3_AB_delivery_not_installed",
"candidate": "commit que contém este manifesto",
"baseline_installed": "7a380b76",
"baseline_receipt": "acbd1f33",
"report": "docs/investmoz/simulations/l3-ab-closure-qa-20261025.json",
"coverage": "L3-A/B no âmbito demonstrado;C/D/E e residuaisL1/L2 permanecem na mesma matriz.",
"compatibility_at_preparation": {
"origin_main": "7a380b76904de50ba675f0b67e0aa949672dbc65",
"main_is_ancestor": true,
"checked_utc": "2026-10-25T12:00:29.072855+00:00"
},
"runtime_delta": [
{
"path": "resources/js/lib/iva-closure.ts",
"sha256_LF": "542e75fde957a8b2ebbee8d66d17ca8c5663183f89b85e7b7819d86253dd22ea"
},
{
"path": "resources/js/components/acquisition-closure.tsx",
"sha256_LF": "2f745bd54c8753215ef6386e52d9e3ab1785330310c6b725e67bbfaef29ffd06"
},
{
"path": "resources/js/components/simulation-acquisitions.tsx",
"sha256_LF": "b94ead08dc13a89ffa0a1b89472b39836b20df73fbfe028dfe4ffa697cd7e29c"
},
{
"path": "resources/js/lib/project-dossier.ts",
"sha256_LF": "233999eea4d7ca86df8af58ed46cb026a5c9fd9a3e507e56e43fdaa801436510"
}
],
"limits": [
"Âmbito demonstrado2026/MZN,primeira declaração em prazo e opção expressa de reporte integral. Prazo/data são declarados com referência,sem calendário legal automático.",
"Juros23:montante documentado/pressuposto ou inexistência expressa com motivo;taxa/prazo de juros não automatizados.",
"C:substituições21;D:pedidos de reembolso27;E:restituição de bebidas13(2) continuam pendentes na mesma matriz.27zero neste caso decorre da opção expressa de reporte integral,não da conclusãoD.",
"Declaração fora do prazo conserva origem/conta mas não fecha19–27 neste percurso;procedimento próprio pendente,sem apagar o crédito.",
"Controlo de duplicação sobre livros/mapas recuperados neste rascunho e âmbito declarado;não é conta corrente central da AT nem reconciliação automática entre rascunhos privados diferentes. Cenários projectados de oportunidades diferentes não são consumos fiscais efectivos.",
"Registo de pagamento é informação declarada,sem execução de pagamento,certificação de arrecadação ou nova comunicação20 por excedente. Não sincroniza caixa silenciosamente.",
"L2 mantém18(6)+afectação mista,regularizações sucessivas/plurianuais e erros/incobráveis.17/18 indeterminados bloqueiam só o respectivo fecho;outros casos independentes avançam.",
"L1 mantém129fechos+P058/P084/P085+P001=133;P001 exclusivamente05/06. Estatísticas eIAadiadas."
],
"required_at_delivery": [
"Fetchmain fresco e reconciliação de concorrência semreset/force",
"Autenticação de destino,backup novo e restauro isolado antes da instalação",
"Integração pelo processoGit existente,sem migração/importação",
"Conferir apenas delta instalado e preservação com mesmo contextoRLS;reutilizar provasQA aceites",
"Emitir recibo e conservarC/D/E e os residuais anteriores"
],
"active_writes": false
}
@@ -1,5 +1,31 @@
# Rastreabilidade, utilização e formação
## 25/10/2026 — L3-A/B concluído emQA no âmbito demonstrado;entrega conjunta preparada
Homologação permanece7a380b76,reciboacbd1f33.Sem instalação,migração/importação ou recálculo de dados activos.L1129fechos+P058/P084/P085+P001=133 preservados;P001 só05/06.L2 mantém18(6)+afectação mista,regularizações sucessivas/plurianuais,erros/incobráveis.Csubstituições,Dreembolso eErestituição de bebidas continuam pendentes;Estatísticas/IAadiadas.
No bloco existente de aquisições,abrir «L3-A/B — crédito,fecho e pagamentos por período». Preparar mês escolhendo resultadoL2 congelado:17/18 exactos,contexto,moeda,revisão/data ficam identificados;nenhuma compra é recalculada. Âmbito fiscal único já indicado reutiliza-se no novo mês. Oportunidades são cenários distintos,sem consolidação automática de dossiers/entidades.
**A:**identificar saldo de abertura disponível do mês imediatamente anterior,documento e montante;ou reutilizar26do fecho anterior por adopção expressa. Crédito20 exige comunicação recebida com referência,data,origem e montante. Confirmar cobertura de todos os créditos;lista vazia não viraausência sem confirmação. Evita repetir origem como abertura/comunicação ou noutro período. Origem anterior alterada/indeterminada/gap exige revisão,não transporte silencioso;usar «Adoptar versão actual deste reporte» e calcular novamente em cada dependente.
**B:**primeira declaração,dentro do prazo declarado com data/fundamento,2026/MZN,reporte integral expressamente escolhido.22=max(17−19−20,0);25=max(18+19+20−17,0);24=22+23;26=25. Juros23 são montante documentado/pressuposto ou inexistência expressa com motivo;sem calendário/taxa presumidos.21 é não aplicável à primeira declaração,sem concluirC.27 ézero pela escolha expressa de reporte integral,sem concluirD. Fora de prazo conserva dados/conta e pede procedimento próprio;nenhum crédito apagado ou reutilizado indevidamente.
**Pagamentos:**registar referência,data e valor;calcular montante registado,restante e excedente,separados de24 e26. Excedente pago não cria crédito20 sem comunicação. Sem pagamentos registados significa lista vazia,não certificado de inexistência de pagamentos externos. Documento/data repetidos ou informação em falta pedem reconciliação. Não executa pagamentos nem certifica cobrança/submissãoAT;em projecções,todos estes valores são pressupostos.
**Histórico e relatório:**guardarfecho,gravar rascunho privado,recuperar e abrir relatório. Cada versão conserva entradas e resultados completos;relatório histórico usa documentos/valores dessa versão. Alterarcrédito50→60 conserva a anterior,invalida os reportes dependentes e permite adopção expressa. Editar só pagamento não altera o crédito transportado. Frações permanecem exactas;20/3+50=170/3,seguido de80−170/3=70/3,sem transportar aproximação decimal. Acrescentar versãoL2 não força recálculo de origem congelada ainda disponível.
Exemplo por períodoMZN:Janeiro18=100+abertura19=50→25/26=150;Fevereiro17=80,19=150→25/26=70;Março17=160,19=70,20comunicado=20→22=70;juros23=2→24=72;pagamento registado30→restante42;26=0. Relatório recuperado conserva os três meses e oportunidade373,origens,referências e valores.
Cotejo limitado a A/B:quadros8/10/11 doVAT Return9c0b99a8p1–2,CIVA21(2/5)/23(1),RCIVA12/15. Cabeçalho18/19 versus caixas19/20 mantido visível,sem alterar original ou reabrirP001. Reembolso27 e restituição13(2) não herdam esta fórmula de reporte.
9testesA/B novos,9Edirigidos reutilizados(18passaram);9A/B repetidos após ajustes finais,todos passaram.API1/103asserções,baseQA55441 com guarda e rollback;modelo_a_snapshot recuperado exactamente,origensL2 e todos os mapas anteriores conservados.Tipos/build e componente real conferidos.Prova:outputs/iva-l3-ab-20261025/ui-recovered-closure.png.
Limites:controlo de duplicação no rascunho/livros recuperados,não conta corrente centralAT nem reconciliação automática de rascunhos privados independentes. Períodos/parcelas dependentes de residuaisL2 continuam indeterminados;casos independentes avançam. Não sincronizar caixa silenciosamente. Entrega conjunta preparada emdocs/investmoz/simulations/l3-ab-prepared-delivery-20261025.json;requer fetchmain fresco,preservação de concorrência,backup/restauro e conferência do delta instalado pelo processoGit existente,quando autorizada.Relatório:docs/investmoz/simulations/l3-ab-closure-qa-20261025.json.
## 25/10/2026 — inventário anterior à implementação/testesL3-A/B
Homologação7a380b76/acbd1f33 preservada;L1129fechos/quatroresiduais eL2três famílias pendentes conservados. Nenhuma perda confirmada. Reutiliza resultadosL2 congelados,fracções eAPI/relatórios existentes. Cotejo apenas A/B:quadros8/10/11 do exemplar,VAT Return9c0b99a8 p1–2,CIVA21(2/5)/23(1),RCIVA12/15. Primeira declaração em prazo;crédito,pagamento e reporte distintos. Juros apenas montante documentado/pressuposto ou inexistência expressa com motivo,sem taxa presumida. C/D/E permanecem próprios.
## 25/10/2026 — L3:delimitação do fecho do IVA,antes de implementar
L2 entregue no âmbito demonstrado7a380b76,reciboacbd1f33.L1 conserva129fechos+P058/P084/P085+P001=133;P001 só05/06. Nenhuma reabertura.L2 conserva trabalho por concluir:18(6)+afectação mista;regularizações sucessivas/plurianuais;erros/incobráveis.Só bloqueiam as parcelas/períodos dependentes,sem impedir casos independentes deL3.Estatísticas/IAadiadas.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,46 @@
import {useState} from 'react';
import {closureBook,closureSources,newClosure,newClosureCredit,saveClosureBook,recordClosure,adoptPreviousCredit,closureReportRows,type ClosureInput,type ClosureCredit,type ClosureBook} from '@/lib/iva-closure';
export function AcquisitionClosure({draft,onChange}:{draft:Record<string,unknown>;onChange:(field:string,value:string)=>void}){
const[error,setError]=useState('');let b:ClosureBook;try{b=closureBook(draft);}catch(e){return<p role="alert">{String(e)}</p>;}
const save=(next:ClosureBook)=>{try{onChange('modelo_a_snapshot',saveClosureBook(draft,next));setError('');}catch(e){setError(String(e));}};
const change=(id:string,p:Partial<ClosureInput>)=>save({...b,rows:b.rows.map(r=>r.id===id?{...r,...p}:r)});
const sources=closureSources(draft);
const entities=[...new Set(b.rows.map(r=>r.entityRef.trim()).filter(Boolean))];
const creditChange=(r:ClosureInput,id:string,p:Partial<ClosureCredit>)=>change(r.id,{credits:r.credits.map(c=>c.id===id?{...c,...p}:c)});
const choice=(r:ClosureInput,key:'firstDeclaration'|'creditsComplete',label:string)=><label className="block">{label}<select value={r[key]} onChange={e=>change(r.id,{[key]:e.target.value})}><option value="">Por confirmar</option><option value="yes">Sim</option><option value="no">Não</option></select></label>;
return<details className="rounded border p-3"><summary>L3-A/B — crédito,fecho e pagamentos por período</summary>
<p>Reutiliza resultados congelados de L2. Crédito anterior,saldo calculado,pagamento registado e transporte são distintos. Identifique o âmbito fiscal da entidade,sem consolidar oportunidades implicitamente.</p>
<p>Primeira declaração em prazo,2026/MZN e reporte integral. SubstituiçõesC,reembolsoD e restituição de bebidasE continuam próprios. Prazo/data são declarados,não calculados pelo portal. Uma simulação não submete declarações nem efectua pagamentos.</p>
{error&&<p role="alert">{error}</p>}
<button type="button" onClick={()=>save({...b,rows:[...b.rows,newClosure(crypto.randomUUID(),sources.length===1?sources[0]:null,entities.length===1?entities[0]:String(draft.fiscal_entity_ref??draft.organization_name??''))]})}>Preparar fecho de outro período</button>
{b.rows.map(r=><fieldset className="rounded border p-3" key={r.id}><legend>Fecho — {r.period||'período por escolher'}</legend>
<label>Resultado L2 congelado<select value={sources.findIndex(s=>JSON.stringify(s)===JSON.stringify(r.source))} onChange={e=>{const s=sources[Number(e.target.value)];if(s)change(r.id,{source:structuredClone(s),period:s.period});}}><option value={-1}>Escolha versão e período</option>{sources.map((s,i)=><option key={i} value={i}>{s.period} — revisão{s.revision} — {s.calculatedAt}</option>)}</select></label>
<label>Âmbito fiscal da entidade<input value={r.entityRef} onChange={e=>change(r.id,{entityRef:e.target.value})}/></label>
<label>Natureza do fecho<select value={r.origin} onChange={e=>change(r.id,{origin:e.target.value as ClosureInput['origin']})}><option value="projected">Pressuposto de simulação</option><option value="effective">Dados efectivos declarados</option></select></label>
{choice(r,'firstDeclaration','É a primeira declaração deste período (ou pressuposto explícito)')}
<label>Data de entrega ou prevista<input type="date" value={r.submissionDate} onChange={e=>change(r.id,{submissionDate:e.target.value})}/></label>
<label>Situação do prazo<select value={r.filing} onChange={e=>change(r.id,{filing:e.target.value as ClosureInput['filing']})}><option value="">Por identificar</option><option value="timely">Dentro do prazo declarado</option><option value="late">Fora do prazo — procedimento próprio</option></select></label>
<label>Fundamento/referência do prazo declarado<input value={r.filingRef} onChange={e=>change(r.id,{filingRef:e.target.value})}/></label>
<h5>Créditos de origem identificada</h5>
{r.credits.map(c=><fieldset className="rounded border p-2" key={c.id}><legend>{c.kind==='opening'?'19 — saldo de abertura':c.kind==='previous'?'19 — transporte anterior':'20 — crédito comunicado'}</legend>
{c.kind==='previous'?<label>Adoptar expressamente o reporte anterior<select value={c.previousId} onChange={e=>{try{const p=b.result?.rows.find(p=>p.id===e.target.value);if(p)creditChange(r,c.id,adoptPreviousCredit(c,p));}catch(err){setError(String(err));}}}><option value="">Escolha o fecho anterior</option>{b.result?.rows.filter(p=>p.id!==r.id&&p.period<r.period).map(p=><option key={p.id} value={p.id}>{p.period} — {p.entityRef} — {p.status==='determined'?'calculado':'indeterminado'}</option>)}</select></label>:<>
<label>Período de origem do crédito/saldo<input type="month" value={c.period} onChange={e=>creditChange(r,c.id,{period:e.target.value})}/></label>
<label>Documento/referência do crédito<input value={c.reference} onChange={e=>creditChange(r,c.id,{reference:e.target.value})}/></label>
<label>Montante disponível documentado (MZN)<input value={c.amount} onChange={e=>creditChange(r,c.id,{amount:e.target.value})}/></label>
{c.kind==='communication'&&<label>Data da comunicação recebida<input type="date" value={c.date} onChange={e=>creditChange(r,c.id,{date:e.target.value})}/></label>}</>}
{c.kind==='previous'&&<><p>Origem:{c.reference};{c.period};apresentação{c.amount}. Cálculo pela fracção exacta;origem alterada exige adopção expressa.</p><button type="button" onClick={()=>{try{const p=b.result?.rows.find(p=>p.id===c.previousId);if(!p)throw Error('Fecho anterior não recuperável.');creditChange(r,c.id,adoptPreviousCredit(c,p));}catch(e){setError(String(e));}}}>Adoptar versão actual deste reporte</button></>}
<button type="button" onClick={()=>change(r.id,{credits:r.credits.filter(v=>v.id!==c.id)})}>Remover vínculo de crédito desta preparação</button></fieldset>)}
{(['opening','communication','previous'] as const).map(kind=><button type="button" key={kind} onClick={()=>change(r.id,{credits:[...r.credits,newClosureCredit(crypto.randomUUID(),kind)]})}>{kind==='opening'?'Identificar saldo inicial':kind==='communication'?'Identificar crédito comunicado':'Reutilizar reporte anterior'}</button>)}
{choice(r,'creditsComplete','Reconciliei todos os créditos;lista vazia confirma expressamente ausência')}
<label>Juros23<select value={r.interest} onChange={e=>change(r.id,{interest:e.target.value as ClosureInput['interest']})}><option value="">Indeterminados</option><option value="none">Sem juros aplicáveis — motivo identificado</option><option value="documented">Montante identificado,sem cálculo de taxa pelo portal</option></select></label>
{r.interest==='documented'&&<label>Montante de juros (MZN)<input value={r.interestAmount} onChange={e=>change(r.id,{interestAmount:e.target.value})}/></label>}
{r.interest&&<label>Fonte/motivo dos juros ou da sua inexistência<input value={r.interestRef} onChange={e=>change(r.id,{interestRef:e.target.value})}/></label>}
<label>Destino do crédito25<select value={r.destination} onChange={e=>change(r.id,{destination:e.target.value as ClosureInput['destination']})}><option value="">Por escolher</option><option value="report">Reportar integralmente26;sem pedido27 neste percurso</option><option value="refund">Preparar reembolso — percursoD pendente</option></select></label>
<h5>Pagamentos registados,distintos do saldo calculado</h5>
{r.payments.map(p=><fieldset className="rounded border p-2" key={p.id}>{(['reference','date','amount'] as const).map((k,i)=><label key={k}>{['Referência do pagamento','Data do pagamento','Montante do pagamento (MZN)'][i]}<input type={k==='date'?'date':'text'} value={p[k]} onChange={e=>change(r.id,{payments:r.payments.map(v=>v.id===p.id?{...v,[k]:e.target.value}:v)})}/></label>)}<button type="button" onClick={()=>change(r.id,{payments:r.payments.filter(v=>v.id!==p.id)})}>Remover pagamento desta preparação</button></fieldset>)}
<button type="button" onClick={()=>change(r.id,{payments:[...r.payments,{id:crypto.randomUUID(),reference:'',date:'',amount:''}]})}>Registar referência de pagamento</button>
<button type="button" onClick={()=>save({...b,rows:b.rows.filter(v=>v.id!==r.id)})}>Remover fecho em preparação (histórico conservado)</button></fieldset>)}
<button type="button" onClick={()=>{try{save(recordClosure(draft,b));}catch(e){setError(String(e));}}}>Calcular e guardar fecho A/B</button>
{closureReportRows(draft).map(([,label,value,source],i)=><p key={i}><strong>{label}</strong>: {value}<small className="block">{source}</small></p>)}
</details>;
}
@@ -1,4 +1,5 @@
import { useState } from 'react';
import {AcquisitionClosure} from './acquisition-closure';
import {AcquisitionRegularizations} from './acquisition-regularizations';
import { AcquisitionOperationLink } from './acquisition-operation-link';
import { AcquisitionImportsExclusions } from './acquisition-imports-exclusions';
@@ -82,7 +83,8 @@ export function SimulationAcquisitions({draft,onChange}:{draft:Record<string,unk
{acquisitionExcludedFields.map(field=><label className="block" key={field}><input type="checkbox" checked={period.absent.includes(field)} onChange={e=>save({...book,periods:book.periods.map(p=>p.period===period.period?{...p,absent:e.target.checked?[...p.absent,field]:p.absent.filter(f=>f!==field)}:p)})}/> Sem operações no campo{field} — {labels[field]}</label>)}
</details>)}
<button type="button" disabled={!book.periods.length} className="rounded bg-emerald-800 p-2 text-white disabled:opacity-50" onClick={()=>run(()=>save(recordAcquisitions(draft,book)))}>Calcular e guardar versão do apuramento parcial</button>
<p>O preenchimento e o resultado seguem a gravação privada do simulador. Os campos19–27 e a submissão à AT permanecem fora deste lote; 17/18 são diferenças antes de créditos anteriores, sem ordem de pagamento ou promessa de reembolso.</p>
<p>O apuramento01–18 conserva o seu resultado antes dos créditos. O fechoA/B abaixo reutiliza a versão congelada,sem ordem de pagamento,submissãoAT ou promessa de reembolso.</p>
<AcquisitionClosure draft={draft} onChange={onChange}/>
{book.result&&<><p role="status">{acquisitionCurrent(draft,book)?'Resultado correspondente às entradas guardadas.':'Entradas ou origem alteradas: resultado histórico preservado; actualizar o cálculo.'}</p><dl>{acquisitionReportRows(draft).map(([,label,value,origin],i)=><div key={i}><dt className="font-semibold">{label}</dt><dd>{value}<small className="block">{origin}</small></dd></div>)}</dl></>}
</section>;
}
+105
View File
@@ -0,0 +1,105 @@
import type {AcquisitionCalculation} from './iva-acquisitions.ts';
import {addFractions,decimalFraction,fraction,subtractFractions,positiveFraction,fractionLabel,fractionPresentation,type FiscalFraction} from './fiscal-rational.ts';
export const closureVersion='iva-closure-ab/1';
export const closureSource='L3-A/B: CIVA21(2/5),Lei13/2016 PDF15/p1426(273),23(1),PDF16/p1426(274);RCIVA12/15,Decreto7/2008 PDF3/p101;ModeloA/VAT Return9c0b99a8,p1–2,quadros8/10/11. Crédito anterior/comunicado19/20;22=max(17−19−20,0);25=max(18+19+20−17,0);24=22+23;reporte integral26=25 quando expressamente escolhido. Sem substituição21,reembolso27 ou restituição13(2). Cabeçalho remete18/19 mas caixas são19/20;divergência conservada,distinta deP00105/06.';
type Choice=''|'yes'|'no';
export type ClosureSource={revision:number;calculatedAt:string;fingerprint:string;opportunity:string;currency:string;period:string;location:string;projected:boolean;debit:FiscalFraction|null;credit:FiscalFraction|null};
export type ClosureCredit={id:string;kind:'opening'|'communication'|'previous';period:string;reference:string;date:string;amount:string;previousId:string;carryKey:string};
export type ClosurePayment={id:string;reference:string;date:string;amount:string};
export type ClosureInput={id:string;period:string;entityRef:string;origin:'projected'|'effective';source:ClosureSource|null;firstDeclaration:Choice;filing:''|'timely'|'late';submissionDate:string;filingRef:string;creditsComplete:Choice;credits:ClosureCredit[];interest:''|'none'|'documented';interestAmount:string;interestRef:string;destination:''|'report'|'refund';payments:ClosurePayment[]};
export type ClosureResult={id:string;period:string;entityRef:string;origin:ClosureInput['origin'];status:'determined'|'indeterminate';reason:string;inputKey:string;financialKey:string;carryKey:string;fields:Record<string,FiscalFraction|null>;paid:FiscalFraction|null;outstanding:FiscalFraction|null;overpayment:FiscalFraction|null;source:string};
export type ClosureBook={version:1;rows:ClosureInput[];result?:{calculatedAt:string;key:string;inputs:ClosureInput[];rows:ClosureResult[]};history?:Array<NonNullable<ClosureBook['result']>>};
const scope=(d:Record<string,unknown>)=>String(d.context_opportunity||'general');
const zero=()=>fraction(0n);
export function closureFingerprint(v:unknown):string{const raw=JSON.stringify(v);let h=14695981039346656037n;for(let i=0;i<raw.length;i++)h=BigInt.asUintN(64,(h^BigInt(raw.charCodeAt(i)))*1099511628211n);return`${raw.length}:${h.toString(16)}`;}
function store(d:Record<string,unknown>):Record<string,unknown>{const s=JSON.parse(String(d.modelo_a_snapshot||'{"version":1,"books":{}}'));if(s.version!==1||!s.books)throw Error('Livros fiscais não recuperáveis;originais preservados.');return s;}
function maps(d:Record<string,unknown>):Record<string,ClosureBook>{const v=store(d).ivaClosures;if(v===undefined)return{};if(!v||typeof v!=='object'||Array.isArray(v))throw Error('Mapa de fechos não recuperável;originais preservados.');return v as Record<string,ClosureBook>;}
function validate(b:ClosureBook):void{
if(!b||b.version!==1||!Array.isArray(b.rows)||b.history!==undefined&&!Array.isArray(b.history)||b.result!==undefined&&(!Array.isArray(b.result.rows)||!Array.isArray(b.result.inputs)||typeof b.result.key!=='string'))throw Error('FechoL3 não recuperável;originais preservados.');
if(b.result?.rows.some(r=>!r||typeof r.id!=='string'||!r.fields||typeof r.fields!=='object'||!['determined','indeterminate'].includes(r.status)||Object.values(r.fields).some(v=>v!==null&&(!v||!/^\d{1,80}$/.test(v.numerator)||!/^\d{1,80}$/.test(v.denominator)||BigInt(v.denominator)===0n))))throw Error('ResultadoL3 não recuperável;originais preservados.');
for(const r of b.rows){if(!r||!['id','period','entityRef','submissionDate','filingRef','interestAmount','interestRef'].every(k=>typeof r[k as keyof ClosureInput]==='string')||!['projected','effective'].includes(r.origin)||!['','yes','no'].includes(r.firstDeclaration)||!['','yes','no'].includes(r.creditsComplete)||!['','timely','late'].includes(r.filing)||!['','none','documented'].includes(r.interest)||!['','report','refund'].includes(r.destination)||!Array.isArray(r.credits)||!Array.isArray(r.payments)||r.credits.some(c=>!c||!['opening','communication','previous'].includes(c.kind)||!['id','period','reference','date','amount','previousId','carryKey'].every(k=>typeof c[k as keyof ClosureCredit]==='string'))||r.payments.some(p=>!p||!['id','reference','date','amount'].every(k=>typeof p[k as keyof ClosurePayment]==='string'))||r.source!==null&&(!r.source||!Number.isInteger(r.source.revision)||!['calculatedAt','fingerprint','opportunity','currency','period','location'].every(k=>typeof r.source![k as keyof ClosureSource]==='string')))throw Error('EntradasL3 não recuperáveis;originais preservados,sem substituir por zero.');}
}
export function closureBook(d:Record<string,unknown>):ClosureBook{const b=maps(d)[scope(d)]??{version:1,rows:[]};validate(b);return b;}
export function saveClosureBook(d:Record<string,unknown>,b:ClosureBook):string{validate(b);const s=store(d);return JSON.stringify({...s,ivaClosures:{...maps(d),[scope(d)]:b}});}
export function closureSources(d:Record<string,unknown>):ClosureSource[]{const b=(store(d).books as Record<string,{result?:AcquisitionCalculation;history?:AcquisitionCalculation[]}>)[scope(d)];return b?[...(b.history??[]),...(b.result?[b.result]:[])].flatMap(calc=>calc.periods.map(p=>({revision:calc.revision,calculatedAt:calc.calculatedAt,fingerprint:closureFingerprint(calc),opportunity:calc.opportunity,currency:calc.currency,period:p.period,location:calc.location,projected:calc.inputs.length===0||calc.inputs.some(r=>r.origin==='projected'),debit:p.exactFields?.['17']??(p.fields['17']===null?null:decimalFraction(p.fields['17'])),credit:p.exactFields?.['18']??(p.fields['18']===null?null:decimalFraction(p.fields['18']))}))).filter((r,i,a)=>a.findIndex(s=>s.fingerprint===r.fingerprint&&s.period===r.period)===i):[];}
export function newClosure(id:string,source:ClosureSource|null,entityRef=''):ClosureInput{return{id,period:source?.period??'',entityRef,origin:'projected',source:source?structuredClone(source):null,firstDeclaration:'',filing:'',submissionDate:'',filingRef:'',creditsComplete:'',credits:[],interest:'',interestAmount:'',interestRef:'',destination:'',payments:[]};}
export function newClosureCredit(id:string,kind:ClosureCredit['kind']):ClosureCredit{return{id,kind,period:'',reference:'',date:'',amount:'',previousId:'',carryKey:''};}
const priorPeriod=(p:string)=>{const d=new Date(p+'-01T00:00:00Z');d.setUTCMonth(d.getUTCMonth()-1);return d.toISOString().slice(0,7);};
const validDate=(d:string)=>/^20\d{2}-(0[1-9]|1[0-2])-\d{2}$/.test(d)&&!Number.isNaN(Date.parse(d))&&new Date(d).toISOString().slice(0,10)===d;
const normalize=(s:string)=>s.trim().toLocaleLowerCase();
function creditIdentity(r:ClosureInput,c:ClosureCredit){return closureFingerprint([normalize(r.entityRef),r.origin,c.kind==='previous'?'carry':'document',c.kind==='previous'?c.previousId:normalize(c.reference),c.period]);}
function paymentIdentity(r:ClosureInput,p:ClosurePayment){return closureFingerprint([normalize(r.entityRef),r.origin,normalize(p.reference)]);}
function inputKey(r:ClosureInput){return closureFingerprint(r);}
function financialKey(r:ClosureInput){const{payments:_,...rest}=r;return closureFingerprint(rest);}
function otherUses(d:Record<string,unknown>,b:ClosureBook):ClosureInput[]{return[...b.rows,...Object.entries(maps(d)).filter(([id])=>id!==scope(d)).flatMap(([,book])=>{validate(book);return book.rows.filter(r=>r.origin==='effective');})];}
function duplicateCredits(d:Record<string,unknown>,b:ClosureBook):Set<string>{const uses=new Map<string,number>();for(const r of otherUses(d,b))for(const c of r.credits){const k=creditIdentity(r,c);uses.set(k,(uses.get(k)??0)+1);}return new Set([...uses].filter(([,n])=>n>1).map(([k])=>k));}
function bookKey(d:Record<string,unknown>,b:ClosureBook){const sources=closureSources(d);return closureFingerprint([b.rows,b.rows.map(r=>sources.some(s=>JSON.stringify(s)===JSON.stringify(r.source))),otherUses(d,b).map(r=>[normalize(r.entityRef),r.origin,r.period,r.credits.map(c=>creditIdentity(r,c)),r.payments.map(p=>paymentIdentity(r,p))])]);}
export function closureCurrent(d:Record<string,unknown>,b:ClosureBook):boolean{return !!b.result&&b.result.key===bookKey(d,b)&&b.result.rows.length===b.rows.length&&b.rows.every(r=>b.result!.rows.filter(x=>x.id===r.id&&x.inputKey===inputKey(r)).length===1);}
export function adoptPreviousCredit(c:ClosureCredit,previous:ClosureResult):ClosureCredit{if(previous.status!=='determined'||!previous.fields['26'])throw Error('Fecho anterior indeterminado;sem crédito presumido.');return{...c,kind:'previous',period:previous.period,reference:`Fecho ${previous.id}`,previousId:previous.id,carryKey:previous.carryKey,amount:fractionPresentation(previous.fields['26']).decimal,date:''};}
export function recordClosure(d:Record<string,unknown>,b:ClosureBook,now=new Date().toISOString()):ClosureBook{
validate(b);if(new Set(b.rows.map(r=>r.id)).size!==b.rows.length)throw Error('Identidade de fecho repetida.');
const contexts=b.rows.map(r=>JSON.stringify([normalize(r.entityRef),r.origin,r.period]));if(new Set(contexts).size!==contexts.length)throw Error('Mesmo âmbito/período repetido neste cenário;sem duplicar o fecho.');
const duplicate=duplicateCredits(d,b),puses=new Map<string,number>();for(const r of otherUses(d,b))for(const p of r.payments){const k=paymentIdentity(r,p);puses.set(k,(puses.get(k)??0)+1);}
const results:ClosureResult[]=[];
for(const row of [...b.rows].sort((a,z)=>a.period.localeCompare(z.period))){
const fields:Record<string,FiscalFraction|null>=Object.fromEntries(['17','18','19','20','21','22','23','24','25','26','27'].map(f=>[f,null]));
const r:ClosureResult={id:row.id,period:row.period,entityRef:row.entityRef,origin:row.origin,status:'indeterminate',reason:'',inputKey:inputKey(row),financialKey:financialKey(row),carryKey:'',fields,paid:null,outstanding:null,overpayment:null,source:closureSource};
const stop=(reason:string)=>{r.reason=reason;results.push(r);};
if(!/^2026-(0[1-9]|1[0-2])$/.test(row.period)||!row.entityRef.trim()||d.currency!=='MZN'){stop('Identifique âmbito fiscal e período2026/MZN;sem consolidar oportunidades automaticamente.');continue;}
if(row.origin==='effective'&&otherUses(d,b).some(other=>other!==row&&other.origin==='effective'&&normalize(other.entityRef)===normalize(row.entityRef)&&other.period===row.period)){stop('Mesmo âmbito fiscal/período efectivo reservado noutra oportunidade recuperada;reconcilie a declaração única,sem duplicar ou consolidar dossiers silenciosamente.');continue;}
const source=closureSources(d).find(s=>JSON.stringify(s)===JSON.stringify(row.source));
if(!source||source.period!==row.period||source.opportunity!==scope(d)||source.currency!=='MZN'||!source.debit||!source.credit){stop('ResultadoL2 congelado correspondente ausente ou17/18 indeterminados;sem recalcular nem presumir impostozero.');continue;}
fields['17']=source.debit;fields['18']=source.credit;
if(BigInt(source.debit.numerator)<0n||BigInt(source.credit.numerator)<0n||BigInt(source.debit.numerator)>0n&&BigInt(source.credit.numerator)>0n){stop('Sinais17/18 incompatíveis;resolver origemL2.');continue;}
if(row.origin==='effective'&&source.projected){stop('OrigemL2 contém pressupostos projectados;não os apresentar como declaração efectiva.');continue;}
if(row.firstDeclaration!=='yes'){stop('Este percursoA/B exige primeira declaração;substituição21 permaneceL3-C.');continue;}
if(!validDate(row.submissionDate)||row.submissionDate.slice(0,7)<row.period||!row.filingRef.trim()||row.filing!=='timely'){stop(row.filing==='late'?'Declaração fora do prazo:RCIVA15 e exemplar não permitem uso19/20 neste percurso;conservar conta de crédito e tratar procedimento próprio,sem o apagar.':'Indique data e fundamento da entrega em prazo,efectivos ou pressupostos;sem calendário/prazo presumido.');continue;}
if(row.creditsComplete!=='yes'){stop('Reconcilie todos os créditos aplicáveis;lista vazia só significa ausência quando expressamente confirmada.');continue;}
const credits19:FiscalFraction[]=[],credits20:FiscalFraction[]=[];let invalid='';
for(const c of row.credits){
if(!c.reference.trim()||!/^20\d{2}-(0[1-9]|1[0-2])$/.test(c.period)||c.period>row.period||c.kind!=='communication'&&c.period===row.period||duplicate.has(creditIdentity(row,c))){invalid='Crédito sem origem/período aplicável ou já utilizado/reservado noutra linha/âmbito recuperado;sem utilização duplicada.';break;}
if(c.kind==='previous'){
const p=results.find(p=>p.id===c.previousId);if(!p||p.status!=='determined'||p.period!==priorPeriod(row.period)||p.entityRef.trim().toLowerCase()!==row.entityRef.trim().toLowerCase()||p.origin!==row.origin||p.period!==c.period||p.carryKey!==c.carryKey||!p.fields['26']){invalid='Fecho anterior alterado,indeterminado,fora do mês imediatamente anterior ou de outro âmbito;não adoptar novo crédito silenciosamente.';break;}credits19.push(p.fields['26']);
}else{
let v:FiscalFraction;try{v=decimalFraction(c.amount);}catch{invalid='Montante de crédito em falta/inválido;não substituir porzero.';break;}
if(c.kind==='opening'&&c.period!==priorPeriod(row.period)){invalid='Saldo de abertura exige referência ao saldo disponível do mês imediatamente anterior;origem mais antiga deve ser reconciliada nesse saldo.';break;}
if(c.kind==='opening'&&b.rows.some(p=>p.id!==row.id&&p.entityRef.trim().toLowerCase()===row.entityRef.trim().toLowerCase()&&p.origin===row.origin&&p.period===priorPeriod(row.period))){invalid='Há fecho do período anterior neste livro;reutilize26 expressamente em vez de introduzir outra abertura.';break;}
if(c.kind==='communication'&&(!validDate(c.date)||c.date>row.submissionDate)){invalid='Crédito20 exige comunicação recebida,referência e data não posterior à entrega declarada;pedido não é comunicação.';break;}
(c.kind==='opening'?credits19:credits20).push(v);
}
}
if(invalid){stop(invalid);continue;}
fields['19']=addFractions(credits19);fields['20']=addFractions(credits20);
const balance=subtractFractions(addFractions([source.credit,fields['19'],fields['20']]),source.debit);
fields['22']=positiveFraction(fraction(-BigInt(balance.numerator),BigInt(balance.denominator)));fields['25']=positiveFraction(balance);
if(row.interest==='none'&&row.interestRef.trim())fields['23']=zero();
else if(row.interest==='documented'&&row.interestRef.trim()){try{fields['23']=decimalFraction(row.interestAmount);}catch{/* Preserve unknown. */}}
fields['24']=fields['23']?addFractions([fields['22'],fields['23']]):null;
if(row.destination==='report'){fields['26']=fields['25'];fields['27']=zero();}
if(row.destination!=='report'){stop('Escolha reporte integral para o percursoA/B;pedido de reembolso/alocação27 segueL3-D e não é presumido.');continue;}
if(!fields['24']){stop('Juros23 por identificar:indique montante documentado/pressuposto ou inexistência expressa com motivo;sem taxa presumida.');continue;}
r.status='determined';r.carryKey=closureFingerprint([r.id,r.financialKey,fields['26'],scope(d)]);
const payments:FiscalFraction[]=[];
for(const p of row.payments){if(!p.reference.trim()||!validDate(p.date)||(puses.get(paymentIdentity(row,p))??0)>1){invalid='Pagamento sem referência/data ou repetido noutro período/declaração;conserve o documento e reconcilie a imputação.';break;}try{payments.push(decimalFraction(p.amount));}catch{invalid='Montante de pagamento por identificar;sem baixa presumida.';break;}}
if(invalid){stop(invalid);continue;}
r.paid=addFractions(payments);r.outstanding=positiveFraction(subtractFractions(fields['24'],r.paid));r.overpayment=positiveFraction(subtractFractions(r.paid,fields['24']));
r.status='determined';r.carryKey=closureFingerprint([r.id,r.financialKey,fields['26'],scope(d)]);r.reason='Saldo calculado, pagamentos registados e crédito transportado distintos. Pagamento não altera22/25/26;excedente pago não cria crédito20 sem comunicação.21 não aplicável à primeira declaração;27zero pela opção expressa de reporte integral. '+(row.origin==='projected'?'Pressupostos de simulação,sem declaração ou pagamento efectuados pelo portal.':'Dados efectivos declarados;registo não certifica cobrança nem submissãoAT.');results.push(r);
}
const result={calculatedAt:now,key:bookKey(d,b),inputs:structuredClone(b.rows),rows:results};return{...b,...(b.result?{history:[...(b.history??[]),structuredClone(b.result)]}:{}),result};
}
export function closureReportRows(d:Record<string,unknown>):Array<[string,string,string,string]>{
try{
const b=closureBook(d);if(!b.rows.length)return[];
const out:Array<[string,string,string,string]>=[['IVA mensal','L3-A/B — fecho por período',closureCurrent(d,b)?'Resultado correspondente às entradas':'Histórico preservado;rever entradas/origens antes de utilizar',closureSource]];
for(const r of b.result?.rows??[]){
const input=b.result?.inputs.find(x=>x.id===r.id),source=input?.source;
const context=`${r.entityRef};${scope(d)};MZN;${r.origin==='projected'?'Pressuposto de simulação':'Dados efectivos declarados'};versão guardada${b.result!.calculatedAt}`;
out.push(['IVA mensal',`Fecho A/B — ${r.period}`,Object.entries(r.fields).map(([f,v])=>`${f}:${v?fractionPresentation(v).decimal:f==='21'&&input?.firstDeclaration==='yes'?'Não aplicável (primeira declaração)':'Indeterminado'}`).join('; '),`${context};${r.reason}`]);
const credits=(input?.credits??[]).map(c=>`${c.kind==='communication'?'Comunicação20':c.kind==='previous'?'Transporte19':'Abertura19'}: ${c.reference};origem${c.period};montante apresentado${c.amount}MZN${c.date?';recebida'+c.date:''}`).join(' | ')||'Sem créditos registados';
const payments=(input?.payments??[]).map(p=>`${p.reference};${p.date};${p.amount}MZN`).join(' | ')||'Sem pagamentos registados';
out.push(['IVA mensal',`Pagamentos e reporte — ${r.period}`,`Calculado24:${r.fields['24']?fractionLabel(r.fields['24']):'Indeterminado'};pagamentos registados:${r.paid?fractionLabel(r.paid):'Indeterminado'};por liquidar:${r.outstanding?fractionLabel(r.outstanding):'Indeterminado'};excedente pago:${r.overpayment?fractionLabel(r.overpayment):'Indeterminado'};transportado26:${r.fields['26']?fractionLabel(r.fields['26']):'Indeterminado'}`,`${context};OrigemL2:revisão${source?.revision??'por identificar'},${source?.period??''},${source?.location??''},${source?.calculatedAt??''};${credits};${payments};entrega${input?.submissionDate??''},${input?.filingRef??''};juros:${input?.interestRef??''}`]);
}
return out;
}catch{return[['IVA mensal','L3-A/B','Fecho guardado não recuperável','Originais preservados;sem crédito ou pagamento presumidos.']];}
}
+2
View File
@@ -15,6 +15,7 @@ import { investmentLandPreparation } from './investment-land-preparation.ts';
import { monthlyIvaInput, monthlyIvaKey, readMonthlyIvaRecord, readMonthlyIvaSettings } from './monthly-iva.ts';
import { modeloAPurchaseReferences } from './iva-modelo-a.ts';
import { acquisitionReportRows } from './iva-acquisitions.ts';
import {closureReportRows} from './iva-closure.ts';
import { importExemptionReportRows } from './iva-import-exemptions.ts';
import { publicBaseReportRows } from './iva-public-authority-base.ts';
import { commissionBaseReportRows } from './iva-commission-base.ts';
@@ -646,6 +647,7 @@ export function buildProjectDossierRows(
]);
}
rows.push(...acquisitionReportRows(draft));
rows.push(...closureReportRows(draft));
rows.push(...importExemptionReportRows(draft));
rows.push(...publicBaseReportRows(draft));
rows.push(...commissionBaseReportRows(draft));
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class IvaClosureDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_three_period_closure_recovers_exact_credits_payments_history_and_prior_L1_L2_maps(): void
{
$target=DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test',$target->name);$this->assertSame(55441,(int)$target->port);
config(['investor-path.enabled'=>true,'suitecrm.enabled'=>false]);$this->actingAs(User::factory()->create(['email_verified_at'=>now()]));
$draft=json_decode(file_get_contents(base_path('outputs/iva-l3-ab-20261025/calculated.json')),true,flags:JSON_THROW_ON_ERROR);
$id=$this->postJson('/api/v1/simulation-drafts',['personal_snapshot'=>[],'economic_snapshot'=>$draft],['Idempotency-Key'=>(string)Str::uuid()])->assertCreated()->json('data.id');
$recovered=$this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk()->json('data.economic_snapshot');
$this->assertSame($draft['modelo_a_snapshot'],$recovered['modelo_a_snapshot']);
$s=json_decode($recovered['modelo_a_snapshot'],true,flags:JSON_THROW_ON_ERROR);$b=$s['ivaClosures']['opportunity:373'];$this->assertCount(3,$b['rows']);$this->assertCount(2,$b['history']);
$this->assertSame(['determined','determined','determined'],array_column($b['result']['rows'],'status'));
foreach([0=>'150',1=>'70',2=>'0'] as $i=>$v)$this->assertSame(['numerator'=>$v,'denominator'=>'1'],$b['result']['rows'][$i]['fields']['26']);
$r=$b['result']['rows'][2];foreach(['19'=>'70','20'=>'20','22'=>'70','23'=>'2','24'=>'72','25'=>'0'] as $field=>$v)$this->assertSame(['numerator'=>$v,'denominator'=>'1'],$r['fields'][(string)$field]);
$this->assertSame(['numerator'=>'30','denominator'=>'1'],$r['paid']);$this->assertSame(['numerator'=>'42','denominator'=>'1'],$r['outstanding']);
$this->assertSame('2025-12',$b['result']['inputs'][0]['credits'][0]['period']);$this->assertSame('SYNTHETIC QA saldoabertura',$b['result']['inputs'][0]['credits'][0]['reference']);
$this->assertSame('2026-03-10',$b['result']['inputs'][2]['credits'][1]['date']);
$frozen=json_decode(file_get_contents(base_path('tests/frontend/fixtures/iva-l3ab-preserved.json')),true,flags:JSON_THROW_ON_ERROR);
foreach(json_decode($frozen['modelo_a_snapshot'],true,flags:JSON_THROW_ON_ERROR) as $key=>$map){if($key==='version')continue;foreach($map as $context=>$original)$this->assertSame($original,$s[$key][$context]);}
$this->assertSame('100.00',$s['books']['opportunity:373']['result']['periods'][0]['fields']['18']);
$this->assertSame('160.00',$s['books']['opportunity:373']['result']['periods'][2]['fields']['17']);
file_put_contents(base_path('outputs/iva-l3-ab-20261025/recovered.json'),json_encode($recovered,JSON_THROW_ON_ERROR|JSON_UNESCAPED_UNICODE));
}
}
File diff suppressed because one or more lines are too long
+64
View File
@@ -0,0 +1,64 @@
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';
import {recordSales,saveSalesBook} from '../../resources/js/lib/iva-operations.ts';
import {recordAcquisitions,saveAcquisitionBook,acquisitionBook,acquisitionCurrent} from '../../resources/js/lib/iva-acquisitions.ts';
import {closureBook,closureSources,newClosure,newClosureCredit,recordClosure,saveClosureBook,adoptPreviousCredit,closureCurrent,closureReportRows} from '../../resources/js/lib/iva-closure.ts';
const preserved=JSON.parse(fs.readFileSync(new URL('./fixtures/iva-l3ab-preserved.json',import.meta.url),'utf8'));
export function closureFixture(){
const draft={...structuredClone(preserved),context_opportunity:'opportunity:373',project_title:'SYNTHETIC QA — L3-A/B fecho do IVA'};const s=JSON.parse(draft.modelo_a_snapshot),base=s.books['opportunity:370'];
const sales=['2026-01','2026-02','2026-03'].map((period,i)=>({...structuredClone(s.salesOperations['opportunity:370'].rows[0]),id:'sale-'+i,period,amount:i===1?'500':'1000',sourcePeriod:period}));
draft.modelo_a_snapshot=saveSalesBook(draft,recordSales(draft,{currency:'MZN',rows:sales,periods:sales.map(r=>({period:r.period,complete:true}))}));
const purchase={...structuredClone(base.rows[0]),id:'purchase-Jan',period:'2026-01',documentDate:'2026-01-10',documentRef:'SYNTHETIC QA aquisiçãoJaneiro',net:'1625',vat:'260',gross:'1885',d:{...structuredClone(base.rows[0].d),numerator:'1',denominator:'1'}};
const acquisitions=recordAcquisitions(draft,{currency:'MZN',taxpayerRegistered:'yes',rows:[purchase],periods:sales.map(r=>({period:r.period,complete:true,importsComplete:true,absent:['12','13']}))});draft.modelo_a_snapshot=saveAcquisitionBook(draft,acquisitions);
const make=period=>({...newClosure('close-'+period,closureSources(draft).find(s=>s.period===period),'SYNTHETIC QA entidade373'),firstDeclaration:'yes',creditsComplete:'yes',filing:'timely',submissionDate:`2026-${String(Number(period.slice(5))+1).padStart(2,'0')}-15`,filingRef:'SYNTHETIC QA entrega em prazo assumida',interest:'none',interestRef:'SYNTHETIC QA pressuposto sem juros',destination:'report'});
const jan=make('2026-01');jan.credits=[{...newClosureCredit('opening','opening'),period:'2025-12',reference:'SYNTHETIC QA saldoabertura',amount:'50'}];
let book=recordClosure(draft,{version:1,rows:[jan]});const feb=make('2026-02');feb.credits=[adoptPreviousCredit(newClosureCredit('carry-Feb','previous'),book.result.rows[0])];book=recordClosure(draft,{...book,rows:[jan,feb]});
const mar=make('2026-03');mar.credits=[adoptPreviousCredit(newClosureCredit('carry-Mar','previous'),book.result.rows[1]),{...newClosureCredit('communication','communication'),period:'2026-02',reference:'SYNTHETIC QA comunicaçãoAT20',date:'2026-03-10',amount:'20'}];mar.interest='documented';mar.interestAmount='2';mar.interestRef='SYNTHETIC QA jurosidentificados';mar.payments=[{id:'payment1',reference:'SYNTHETIC QA reciboP1',date:'2026-04-16',amount:'30'}];
book=recordClosure(draft,{...book,rows:[jan,feb,mar]});return{draft,book,make,acquisitions};
}
const amount=(r,f)=>r.fields[f]&&`${r.fields[f].numerator}/${r.fields[f].denominator}`;
test('three periods reconcile opening credit,carry,communicated credit,interest and payment without double deduction',()=>{
const{draft,book}=closureFixture();assert.deepEqual(book.result.rows.map(r=>r.status),['determined','determined','determined']);assert.deepEqual(book.result.rows.map(r=>amount(r,'26')),['150/1','70/1','0/1']);const mar=book.result.rows[2];assert.equal(amount(mar,'19'),'70/1');assert.equal(amount(mar,'20'),'20/1');assert.equal(amount(mar,'22'),'70/1');assert.equal(amount(mar,'24'),'72/1');assert.deepEqual(mar.paid,{numerator:'30',denominator:'1'});assert.deepEqual(mar.outstanding,{numerator:'42',denominator:'1'});assert.equal(mar.fields['21'],null);assert.equal(amount(mar,'27'),'0/1');
assert.ok(acquisitionCurrent(draft,acquisitionBook(draft)));assert.equal(book.history.length,2);
});
test('origin,coverage,deadline and unsupported paths remain explicit;unknown never becomes zero',()=>{
const{draft,book}=closureFixture();for(const patch of [{creditsComplete:''},{filing:''},{filing:'late'},{firstDeclaration:'no'},{entityRef:''},{submissionDate:''},{destination:'refund'}]){const changed=structuredClone(book);changed.rows[0]={...changed.rows[0],...patch};const r=recordClosure(draft,changed).result.rows;assert.equal(r[0].status,'indeterminate',JSON.stringify(patch));assert.equal(r[1].status,'indeterminate');}
const interest=structuredClone(book);interest.rows[2].interest='';const r=recordClosure(draft,interest).result.rows[2];assert.equal(amount(r,'22'),'70/1');assert.equal(r.fields['24'],null);
const future=structuredClone(book);future.rows[2].credits[1].date='2026-05-01';assert.equal(recordClosure(draft,future).result.rows[2].fields['20'],null);
const effective=structuredClone(book);effective.rows[0].origin='effective';assert.match(recordClosure(draft,effective).result.rows[0].reason,/projectados/);
});
test('duplicate documents,opening versus communication and payment references cannot create duplicate use',()=>{
const{draft,book}=closureFixture();const b=structuredClone(book);b.rows[0].credits.push({...b.rows[0].credits[0],id:'copy'});assert.equal(recordClosure(draft,b).result.rows[0].fields['19'],null);
const cross=structuredClone(book);cross.rows[0].credits.push({...cross.rows[0].credits[0],id:'copy',kind:'communication',date:'2026-01-05'});assert.equal(recordClosure(draft,cross).result.rows[0].fields['19'],null);
const payments=structuredClone(book);payments.rows[2].payments.push({...payments.rows[2].payments[0],id:'copy'});const p=recordClosure(draft,payments).result.rows[2];assert.equal(p.paid,null);assert.equal(amount(p,'26'),'0/1');assert.equal(amount(p,'24'),'72/1');
const opening=structuredClone(book);opening.rows[1].credits=[{...opening.rows[0].credits[0],id:'new-opening',period:'2026-01'}];assert.match(recordClosure(draft,opening).result.rows[1].reason,/reutilize26/);
});
test('changing a predecessor preserves historical results and requires explicit carry adoption;payment edit does not change carry',()=>{
const{draft,book}=closureFixture();const first=structuredClone(book.result);let changed=structuredClone(book);changed.rows[0].credits[0].amount='60';assert.equal(closureCurrent(draft,changed),false);changed=recordClosure(draft,changed);assert.equal(amount(changed.result.rows[0],'26'),'160/1');assert.equal(changed.result.rows[1].status,'indeterminate');assert.deepEqual(changed.history.at(-1),first);
changed.rows[1].credits[0]=adoptPreviousCredit(changed.rows[1].credits[0],changed.result.rows[0]);changed=recordClosure(draft,changed);assert.equal(amount(changed.result.rows[1],'26'),'80/1');assert.equal(changed.result.rows[2].status,'indeterminate');
changed.rows[2].credits[0]=adoptPreviousCredit(changed.rows[2].credits[0],changed.result.rows[1]);changed=recordClosure(draft,changed);assert.equal(amount(changed.result.rows[2],'22'),'60/1');
const pay=structuredClone(book);pay.rows[0].payments=[{id:'unexpected',reference:'SYNTHETIC QA pagamentoanterior',date:'2026-02-16',amount:'1'}];const v=recordClosure(draft,pay);assert.equal(v.result.rows[0].carryKey,first.rows[0].carryKey);assert.equal(v.result.rows[1].status,'determined');
});
test('overpayment remains a payment fact and does not create credit20/25/26',()=>{
const{draft,book}=closureFixture();book.rows[2].payments[0].amount='100';const r=recordClosure(draft,book).result.rows[2];assert.deepEqual(r.overpayment,{numerator:'28',denominator:'1'});assert.equal(amount(r,'20'),'20/1');assert.equal(amount(r,'25'),'0/1');assert.equal(amount(r,'26'),'0/1');
});
test('carry between periods preserves exact fractions instead of adding rounded presentation',()=>{
const{draft,book}=closureFixture();let a=acquisitionBook(draft);a.rows[0].net='3125';a.rows[0].vat='500';a.rows[0].gross='3625';a.rows[0].d.denominator='3';a=recordAcquisitions(draft,a);draft.modelo_a_snapshot=saveAcquisitionBook(draft,a);book.rows[0].source=closureSources(draft).find(s=>s.revision===a.result.revision&&s.period==='2026-01');let b=recordClosure(draft,book);assert.deepEqual(b.result.rows[0].fields['26'],{numerator:'170',denominator:'3'});b.rows[1].credits[0]=adoptPreviousCredit(b.rows[1].credits[0],b.result.rows[0]);b=recordClosure(draft,b);assert.deepEqual(b.result.rows[1].fields['22'],{numerator:'70',denominator:'3'});
});
test('recovered opportunities keep scenarios separate and expose collisions of effective scope',()=>{
const{draft,book}=closureFixture();const other=structuredClone(book);const store=JSON.parse(draft.modelo_a_snapshot);store.ivaClosures={'opportunity:other':other};draft.modelo_a_snapshot=JSON.stringify(store);assert.equal(recordClosure(draft,book).result.rows[0].status,'determined','projected scenarios do not consume each other');
book.rows[0].origin='effective';other.rows[0].origin='effective';store.ivaClosures['opportunity:other']=other;draft.modelo_a_snapshot=JSON.stringify(store);assert.match(recordClosure(draft,book).result.rows[0].reason,/noutra oportunidade/);
});
test('frozen L2 sources are reused exactly and incomplete or fabricated sources cannot close balances',()=>{
const{draft,book}=closureFixture();const b=structuredClone(book);b.rows[0].source.credit={numerator:'999',denominator:'1'};assert.equal(recordClosure(draft,b).result.rows[0].status,'indeterminate');
const removed={...draft};const s=JSON.parse(draft.modelo_a_snapshot);delete s.books['opportunity:373'].result;removed.modelo_a_snapshot=JSON.stringify(s);assert.equal(recordClosure(removed,book).result.rows[0].fields['18'],null);
assert.deepEqual(acquisitionBook(draft).result.periods.map(p=>[p.fields['17'],p.fields['18']]),[['0.00','100.00'],['80.00','0.00'],['160.00','0.00']]);
const unchanged=recordAcquisitions(draft,acquisitionBook(draft));const extraVersion={...draft,modelo_a_snapshot:saveAcquisitionBook(draft,unchanged)};assert.ok(closureCurrent(extraVersion,book),'adding an unrelated L2 version does not rewrite or stale the selected frozen results');
});
test('save recovery and report preserve every prior L1/L2 map,values and closure histories',()=>{
let{draft,book}=closureFixture();const before=JSON.parse(preserved.modelo_a_snapshot);const acquired=structuredClone(acquisitionBook(draft));draft.modelo_a_snapshot=saveClosureBook(draft,book);const recovered=JSON.parse(JSON.stringify(draft));assert.deepEqual(closureBook(recovered),book);assert.ok(closureCurrent(recovered,book));assert.ok(acquisitionCurrent(recovered,acquisitionBook(recovered)));assert.deepEqual(acquisitionBook(recovered),acquired);
const after=JSON.parse(recovered.modelo_a_snapshot);for(const[k,map]of Object.entries(before)){if(k==='version')continue;for(const[id,v]of Object.entries(map))assert.deepEqual(after[k][id],v,k+id);}
const report=JSON.stringify(closureReportRows(recovered));for(const token of ['2026-01','2026-02','2026-03','150/1','70/1','72/1','30/1','42/1','saldoabertura','comunicaçãoAT20','reciboP1','opportunity:373'])assert.ok(report.includes(token),token);
const malformed=JSON.parse(recovered.modelo_a_snapshot);malformed.ivaClosures=null;assert.throws(()=>closureBook({...recovered,modelo_a_snapshot:JSON.stringify(malformed)}),/não recuperável/);
book.rows[0].credits[0].amount='999';const edited={...recovered,modelo_a_snapshot:saveClosureBook(recovered,book)};assert.equal(closureCurrent(edited,book),false);assert.match(JSON.stringify(closureReportRows(edited)),/50/);assert.equal(book.result.inputs[0].credits[0].amount,'50');
if(process.env.IVA_L3AB_CALCULATED)fs.writeFileSync(process.env.IVA_L3AB_CALCULATED,JSON.stringify(recovered));
});