feat: reconcile L6 economic income and extend L8 credits
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-11-10 13:23:11 +02:00
parent c359364de3
commit ac67f9c13a
27 changed files with 1828 additions and 30 deletions
@@ -1,5 +1,32 @@
# Rastreabilidade, utilização e formação
## 10/11/2026 — crédito económico: partilha e associações em QA
Homologação mantém `c359364d`. Avanço posterior em QA, sem instalação: art64(3) distingue a parcela elegível do rendimento da partilha e o excesso com natureza de mais-valia; art64(4) distingue associação em participação e à quota, exigindo tributação efectiva documentada na participação. Reutiliza cálculo60%, prova do imposto subjacente, limites, prioridades, isolamento e writer privado existentes. Não calcula a mais-valia como crédito nem pressupõe inclusão L6 ou reconferência da colecta. Campos antigos e históricos são preservados; fontes incompletas ficam recuperáveis e indeterminadas.
Critério demonstrado: calcular → gravar/GET pela API existente no QA55441 com rollback → recuperar exactamente → HTML e relatório do dossier. Partilha sintética: valor atribuído200, entradas100 e rendimento documentado150 → elegível100/excesso50 → quota35,84/crédito12/saldo688 MZN. Associação documentada100/imposto20 → crédito12/saldo688 MZN, sem alterar L6. Provas `outputs/irpc-economic-liquidation-20261110/verification.json` e `outputs/irpc-economic-association-20261110/verification.json`. Não são actos fiscais nem consumo de saldos oficiais.
Conjunto de fontes compatíveis com quota e inclusão ordinária agrupado em `outputs/entrega-economica-especial-20261110.zip`, base c359364d, aplicação/reversão conferidas em índices temporários. Pacotes anteriores intactos. Inclusão/correcção especial em L6 também demonstrada em QA: reconcilia rendimento total da partilha (incluindo mais-valia) uma só vez, enquanto a quota usa apenas a componente elegível; associações reutilizam a qualificação própria. Prova outputs/irpc-economic-special-inclusion-20261110/verification.json: efeito162, base4562/colecta1459,84, quota35,84/saldo739,84 recuperados na API e relatórios. Casos transitórios, fracções de cêntimo, CDT, benefícios/obrigações e restantes resíduos L1–L7/L9–L16 continuam no saldo. Conferência visual pendente. Próximo trabalho funcional: correspondência CDT automática nos casos suportados pelas fontes existentes; eventual instalação deste conjunto é acção separada. Tipos/lint/Pint/build QA conferidos no conjunto final.
## 10/11/2026 — inclusão/correcção económica L6 em QA
Instalado: `c359364d`. Quota económica e inclusão/correcção L6 ordinária demonstradas em QA, sem instalação. Pacote anterior `outputs/entrega-economica-l6-l8-20261110.zip` preservado; novo delta posterior agrupado separadamente. Conferência visual pendente no registo existente.
Escolha explícita da origem L8, reconciliação de lucros/crédito já considerados, prévia da diferença e confirmação precedem o cálculo/gravação existentes. Repetição substitui a parcela; não acumula. Prova: +112 MZN leva lucro tributável a6812, base4512 e colecta1443,84. Montantes já incluídos100/12 dão efeito0; excesso150/20 corrige−58. Origem L8 anterior fica desactualizada; reconferida a nova colecta, quota35,84 e saldo723,84 mantêm-se na API/recuperação/HTML/dossier, com históricos preservados. Provas `outputs/irpc-economic-inclusion-20261110/verification.json` e `outputs/irpc-economic-quota-20261110/verification.json`. Tipos/lint/Pint/build QA aprovados.
Âmbito ordinário residente geral2026/MZN. Fracções de cêntimo exigem tratamento próprio, sem arredondamento presumido. Casos especiais, CDT e obrigações continuam no saldo. Resíduos L1–L7 e L9–L16 preservados; L6/L8 não integrais. Registos anteriores abaixo são históricos.
## 10/11/2026 — conjunto Educação/L8 instalado e residual económico avançado
**Instalado e conferido em c359364de3f65599f2248540a5b040604e8490cc**, por fast-forward normal em main a partir de ff726e57. Árvore058910eb26f566c0486a20c1e7fc12f06bbe06a5 igual ao pacote QA. Executor existente terminou às10:54:50UTC; revisão HTTPS/portal/login/upHTTP200 e14hashes de fontes conferidos às10:55:21UTC. Zero migrations/importações; configuração idêntica; release anterior preservada. Backup fresco em `/var/backups/investmoz-education-l8-20261110/backup`,190tabelas efectivamente restauradas/checksums iguais num contentor sem rede,369ficheiros/configuração restaurados e conferidos em pasta privada. Credencial não guardada; sessão SSH encerrada após conferência. Recibos `outputs/education-l8-delivery-20261110/{backup,restore,files-restore,verification}.json`. Pacotes selados anteriores preservados. Controlos do executor obrigatórios,sem nova ronda manual de provas aceites.
Ficam utilizáveis no destino os dois recortes: revisão educativa conserva preparação/envio incerto ao consultar histórico; crédito internacional oferece derivação ordinária da fracção nacional. Instalação destes recortes deixa de ser pendência;conferência visual continua separada. Outros critérios dos módulos não são encerrados por esta instalação.
**Próximo residual executável avançado após a instalação — QA,sem instalação nova:**quota proporcional do crédito económico ordinário,CIRPC64(1–2)após acréscimo53. Fonte aceite Lei34/2007,PDF18/p768-(72),reutilizada;60% do imposto correspondente e qualificação/exclusão40 já validados pelo mecanismo existente. Novo modo calcula `colecta L6 × (lucros incluídos + crédito nominal já acrescido) / matéria colectável L6`,com versão/origem/referência no resultado e relatório. Não altera a matéria colectável,não duplica o acréscimo,não calcula automaticamente a sua inclusão. Modo documentado antigo por omissão;limite anterior conservado,não utilizado no modo derivado. Fonte/base desactualizada,acréscimo incompleto,correspondência ausente e soma de componentes acima da base impedem o saldo dependente. Casos64(3/4),L7 e outras situações conservam o saldo próprio.
Quatro casos novos frontend,cálculo/recuperação/HTML/dossier;API privada existente,num novo caso/9asserções,DatabaseTransactionsrollback na mesma QAproactive_investment_20261027_test:55441,actor1021existente. Novo exemplo sintético:lucros100+acréscimo60,base4400/colecta1408→quota256/5=51,2MZN;saldo3244/5=648,8MZN.Sem saldo fiscal consumido. Prova `outputs/irpc-economic-quota-20261110/verification.json`. Tipos/lint ePint aprovados;buildQA em log próprio. Nenhuma prova anterior de Educação/fracção nacional reaberta. Estado é QA,ainda não instalado nem visualmente aceite. L8 permanece parcial;resíduosL1–L7 eL9–L16 mantidos. Próximo:inclusão/correcção económica na base com a correspondência própria e restantes ramos já identificados,sem repetir os recortes fechados.
## 10/11/2026 — L8: fracção nacional ordinária demonstrada até ao relatório em QA
Pendência funcional encerrada **em QA no recorte ordinário**: opção explícita de derivar a fracção nacional do crédito internacional da colecta L6 corrente e da parcela líquida estrangeira correspondida à matéria colectável. Fonte aceite reutilizada: CIRPC65(1)(a/b),65(2),Lei34/2007,PDF18/p768-(72),e ordem66/RIRPC21. Não se exige nova validação da fonte oficial. A fórmula técnica `colecta antes de créditos × parcela líquida / matéria colectável` fica limitada à colecta normal corrente, base positiva, inclusão documentada, mesmo titular/exercício/país e afectação líquida identificada. O portal não inventa nem reparte custos/perdas/deduções; valores acima da base ou parcelas por país que em conjunto excedam a base impedem o saldo dependente.
@@ -1,5 +1,25 @@
## 10/11/2026 — crédito económico: partilha e associações em QA
Homologação mantém `c359364d`. Avanço posterior em QA, sem instalação: art64(3) distingue a parcela elegível do rendimento da partilha e o excesso com natureza de mais-valia; art64(4) distingue associação em participação e à quota, exigindo tributação efectiva documentada na participação. Reutiliza cálculo60%, prova do imposto subjacente, limites, prioridades, isolamento e writer privado existentes. Não calcula a mais-valia como crédito nem pressupõe inclusão L6 ou reconferência da colecta. Campos antigos e históricos são preservados; fontes incompletas ficam recuperáveis e indeterminadas.
Critério demonstrado: calcular → gravar/GET pela API existente no QA55441 com rollback → recuperar exactamente → HTML e relatório do dossier. Partilha sintética: valor atribuído200, entradas100 e rendimento documentado150 → elegível100/excesso50 → quota35,84/crédito12/saldo688 MZN. Associação documentada100/imposto20 → crédito12/saldo688 MZN, sem alterar L6. Provas `outputs/irpc-economic-liquidation-20261110/verification.json` e `outputs/irpc-economic-association-20261110/verification.json`. Não são actos fiscais nem consumo de saldos oficiais.
Conjunto de fontes compatíveis com quota e inclusão ordinária agrupado em `outputs/entrega-economica-especial-20261110.zip`, base c359364d, aplicação/reversão conferidas em índices temporários. Pacotes anteriores intactos. Inclusão/correcção especial em L6 também demonstrada em QA: reconcilia rendimento total da partilha (incluindo mais-valia) uma só vez, enquanto a quota usa apenas a componente elegível; associações reutilizam a qualificação própria. Prova outputs/irpc-economic-special-inclusion-20261110/verification.json: efeito162, base4562/colecta1459,84, quota35,84/saldo739,84 recuperados na API e relatórios. Casos transitórios, fracções de cêntimo, CDT, benefícios/obrigações e restantes resíduos L1–L7/L9–L16 continuam no saldo. Conferência visual pendente. Próximo trabalho funcional: correspondência CDT automática nos casos suportados pelas fontes existentes; eventual instalação deste conjunto é acção separada. Tipos/lint/Pint/build QA conferidos no conjunto final.
## 10/11/2026 — inclusão/correcção económica L6 em QA
Instalado: `c359364d`. Quota económica e inclusão/correcção L6 ordinária demonstradas em QA, sem instalação. Pacote anterior `outputs/entrega-economica-l6-l8-20261110.zip` preservado; novo delta posterior agrupado separadamente. Conferência visual pendente no registo existente.
Escolha explícita da origem L8, reconciliação de lucros/crédito já considerados, prévia da diferença e confirmação precedem o cálculo/gravação existentes. Repetição substitui a parcela; não acumula. Prova: +112 MZN leva lucro tributável a6812, base4512 e colecta1443,84. Montantes já incluídos100/12 dão efeito0; excesso150/20 corrige−58. Origem L8 anterior fica desactualizada; reconferida a nova colecta, quota35,84 e saldo723,84 mantêm-se na API/recuperação/HTML/dossier, com históricos preservados. Provas `outputs/irpc-economic-inclusion-20261110/verification.json` e `outputs/irpc-economic-quota-20261110/verification.json`. Tipos/lint/Pint/build QA aprovados.
Âmbito ordinário residente geral2026/MZN. Fracções de cêntimo exigem tratamento próprio, sem arredondamento presumido. Casos especiais, CDT e obrigações continuam no saldo. Resíduos L1–L7 e L9–L16 preservados; L6/L8 não integrais. Registos anteriores abaixo são históricos.
## Continuação integral — saldo e execução em 10/11/2026
Estado actualizado: conjunto Educação/fracção nacional ordinária instalado e HTTPS verificado em `c359364d`,com backup/restauro190tabelas/369ficheiros,delta14hashes,.env inalterado e zero migrations/importações. Recibos `outputs/education-l8-delivery-20261110/`. As anotações de QA/instalação pendente abaixo ficam históricas neste recorte. Conferência visual permanece separada.
Após essa instalação,avançado o residual de quota económica ordinária após acréscimo53:novo modo deriva de L6 com origem/versionamento e preservação do limite manual,salva/recupera pela API existente e apresenta HTML/dossier. Quatro casos novos+um API revertido/9asserções;tipos/lint/Pint aprovados. Prova `outputs/irpc-economic-quota-20261110/verification.json`. Este novo delta é QA,ainda não instalado. Inclusão/correcção automática do acréscimo na base e casos especiais não incluídos;L8parcial,resíduosL1–L7 eL9–L16 continuam explícitos. Não reabrir a fracção nacional internacional ou Educação para executar esse saldo.
Actualização L8 desta passagem: recorte ordinário de derivação da fracção nacional implementado e demonstrado em QA até gravação/recuperação/relatório pelo writer privado existente. Base4400/colecta1408/parcela100 → fracção32 → saldo simulado676,com limites/provas próprios; caso exacto2/625 e correspondência em falta demonstrados. Nenhuma determinação automática de CDT,repartição de custos/perdas ou consumo oficial. Modo documentado e históricos preservados. Delta agrupável com Educação,ainda sem instalação. Fonte e percurso no início do manual; provas `outputs/irpc-national-fraction-20261110/recovered-reports.json`.
Correcção do saldo global: preservar explicitamente resíduos L1–L7 e L9–L16 (INSS,IRPS mensal/anual,Selo,direitos,ICE,SISA,IPRA),conforme tabela actualizada na visão existente. Transporte/crédito económico entregues em569a1cd7 não são trabalho ausente. Nenhum lote agregado é fechado por número de testes;fecho comunicado nesta passagem é apenas a fracção nacional ordinária em QA.
File diff suppressed because one or more lines are too long
@@ -1,5 +1,31 @@
## 10/11/2026 — crédito económico: partilha e associações em QA
Homologação mantém `c359364d`. Avanço posterior em QA, sem instalação: art64(3) distingue a parcela elegível do rendimento da partilha e o excesso com natureza de mais-valia; art64(4) distingue associação em participação e à quota, exigindo tributação efectiva documentada na participação. Reutiliza cálculo60%, prova do imposto subjacente, limites, prioridades, isolamento e writer privado existentes. Não calcula a mais-valia como crédito nem pressupõe inclusão L6 ou reconferência da colecta. Campos antigos e históricos são preservados; fontes incompletas ficam recuperáveis e indeterminadas.
Critério demonstrado: calcular → gravar/GET pela API existente no QA55441 com rollback → recuperar exactamente → HTML e relatório do dossier. Partilha sintética: valor atribuído200, entradas100 e rendimento documentado150 → elegível100/excesso50 → quota35,84/crédito12/saldo688 MZN. Associação documentada100/imposto20 → crédito12/saldo688 MZN, sem alterar L6. Provas `outputs/irpc-economic-liquidation-20261110/verification.json` e `outputs/irpc-economic-association-20261110/verification.json`. Não são actos fiscais nem consumo de saldos oficiais.
Conjunto de fontes compatíveis com quota e inclusão ordinária agrupado em `outputs/entrega-economica-especial-20261110.zip`, base c359364d, aplicação/reversão conferidas em índices temporários. Pacotes anteriores intactos. Inclusão/correcção especial em L6 também demonstrada em QA: reconcilia rendimento total da partilha (incluindo mais-valia) uma só vez, enquanto a quota usa apenas a componente elegível; associações reutilizam a qualificação própria. Prova outputs/irpc-economic-special-inclusion-20261110/verification.json: efeito162, base4562/colecta1459,84, quota35,84/saldo739,84 recuperados na API e relatórios. Casos transitórios, fracções de cêntimo, CDT, benefícios/obrigações e restantes resíduos L1–L7/L9–L16 continuam no saldo. Conferência visual pendente. Próximo trabalho funcional: correspondência CDT automática nos casos suportados pelas fontes existentes; eventual instalação deste conjunto é acção separada. Tipos/lint/Pint/build QA conferidos no conjunto final.
## 10/11/2026 — inclusão/correcção económica L6 em QA
Instalado: `c359364d`. Quota económica e inclusão/correcção L6 ordinária demonstradas em QA, sem instalação. Pacote anterior `outputs/entrega-economica-l6-l8-20261110.zip` preservado; novo delta posterior agrupado separadamente. Conferência visual pendente no registo existente.
Escolha explícita da origem L8, reconciliação de lucros/crédito já considerados, prévia da diferença e confirmação precedem o cálculo/gravação existentes. Repetição substitui a parcela; não acumula. Prova: +112 MZN leva lucro tributável a6812, base4512 e colecta1443,84. Montantes já incluídos100/12 dão efeito0; excesso150/20 corrige−58. Origem L8 anterior fica desactualizada; reconferida a nova colecta, quota35,84 e saldo723,84 mantêm-se na API/recuperação/HTML/dossier, com históricos preservados. Provas `outputs/irpc-economic-inclusion-20261110/verification.json` e `outputs/irpc-economic-quota-20261110/verification.json`. Tipos/lint/Pint/build QA aprovados.
Âmbito ordinário residente geral2026/MZN. Fracções de cêntimo exigem tratamento próprio, sem arredondamento presumido. Casos especiais, CDT e obrigações continuam no saldo. Resíduos L1–L7 e L9–L16 preservados; L6/L8 não integrais. Registos anteriores abaixo são históricos.
## Saldo operacional reconciliado — 10/11/2026, após `ff726e57`
**Estado mais recente:** conjunto Educação + fracção nacional ordinária instalado na homologação em `c359364de3f65599f2248540a5b040604e8490cc`, confirmado por HTTPS às 10:55:21 UTC (12:55 locais). Backup/restauro190tabelas e369ficheiros/configuração conferidos,14fontes do delta idênticas à revisão Git,zero migrations/importações,.env preservado e release ff726e57 mantida para reversão. Recibo `outputs/education-l8-delivery-20261110/verification.json`. As notas anteriores de conjunto «só QA» descrevem o estado anterior à instalação e não são nova pendência.
| Recorte | Estado actual |
| --- | --- |
| Educação — continuidade ao paginar histórico | Instalado c359364d; restantes jornadas/retoma durável e conferência visual conservadas. |
| L8 — fracção nacional ordinária do crédito internacional | Instalado c359364d; não reabrir cálculo/gravação/recuperação/relatórios aceites. |
| L8 — quota proporcional económica ordinária após acréscimo53 | Novo delta QA, ainda não instalado: cálculo, gravação/GET pela API existente, recuperação e relatórios demonstrados; tipos/lint aprovados. Inclusão/correcção automática do acréscimo na base não incluída. |
Novo residual avançado após a instalação: opção explícita de derivar a quota económica da colecta L6 × (lucros incluídos + crédito nominal já acrescido) / matéria colectável L6. CIRPC64(1–2)/53 e ordem já aceites,sem novo enquadramento jurídico genérico. Exige correspondência documentada à base corrente; origem alterada,acréscimo ausente ou componentes acima da base tornam o caso dependente indeterminado. Não reescreve L6,não acrescenta o crédito duas vezes,não altera o modo manual/históricos. Prova `outputs/irpc-economic-quota-20261110/verification.json`:quota256/5, saldo3244/5 no cenário sintético. L8 continua parcial,com inclusão/correcção económica,art64(3/4),CDT automática,obrigações e restantes casos ainda por implementar conforme saldo;L1–L7 eL9–L16 preservados.
### Actualização fiscal: recorte L8 demonstrado em QA; saldo L1–L16 preservado
**Pendência encerrada nesta passagem em QA:** derivação proporcional da fracção nacional do crédito internacional no caso L6 ordinário corrente, com parcela líquida já incluída e correspondida à matéria colectável. Percurso cálculo → gravação/recuperação pela API privada existente → recálculo → HTML/relatório do dossier demonstrado. Não é fecho integral de L8, determinação automática de CDT ou repartição de custos/perdas. Delta agrupável com a continuidade de Educação, ainda não instalado na homologação, que mantém `ff726e57`.
@@ -0,0 +1,148 @@
import { useState } from 'react';
import { annualBook } from '@/lib/irpc-annual-settlement';
import type { NormalBook } from '@/lib/irpc-normal';
import {
prepareEconomicInclusion,
calculateEconomicInclusions,
economicInclusionMoney,
} from '@/lib/irpc-normal-economic';
export function SimulationIrpcEconomicInclusion({
draft,
book,
save,
}: {
draft: Record<string, unknown>;
book: NormalBook;
save: (b: NormalBook) => void;
}) {
const [selected, setSelected] = useState('');
const [profit, setProfit] = useState('');
const [credit, setCredit] = useState('');
const [reference, setReference] = useState('');
const [error, setError] = useState('');
const [preview, setPreview] = useState<{
fingerprint: string;
book: NormalBook;
net: string;
} | null>(null);
const sources = annualBook(draft)?.economicCredits?.rows ?? [];
if (!sources.length) return null;
const fingerprint = JSON.stringify([
book,
sources,
selected,
profit,
credit,
reference,
]);
return (
<section
className="space-y-3 rounded border p-3"
aria-label="Incluir ou corrigir lucros e crédito económico em L6"
>
<h3 className="font-semibold">
Incluir ou corrigir lucros e crédito económico em L6
</h3>
<p>
Escolha a origem preparada em L8. Indique os montantes já
considerados na contabilidade e noutros ajustamentos, antes
deste mapa; zero deve ser indicado expressamente. A prévia
propõe apenas a diferença e conserva o resultado anterior.
Depois de confirmar, calcule e guarde L6 pelo percurso
existente; L8 exige nova conferência da colecta.
</p>
<label className="block">
Origem dos lucros
<select
value={selected}
onChange={(e) => setSelected(e.target.value)}
>
<option value="">Escolha a origem</option>
{sources.map((r) => (
<option key={r.id} value={r.id}>
{r.distributionDocument} — {r.distributionParcel}
</option>
))}
</select>
</label>
<label className="block">
Rendimentos já considerados — MZN (incluindo eventual mais-valia
da partilha)
<input
value={profit}
onChange={(e) => setProfit(e.target.value)}
/>
</label>
<label className="block">
Crédito nominal já acrescido — MZN
<input
value={credit}
onChange={(e) => setCredit(e.target.value)}
/>
</label>
<label className="block">
Suporte da reconciliação
<textarea
value={reference}
onChange={(e) => setReference(e.target.value)}
/>
</label>
{error && <p role="alert">{error}</p>}
<button
type="button"
onClick={() => {
try {
const source = sources.find((r) => r.id === selected);
if (!source)
throw Error('Escolha explicitamente uma origem.');
const next = prepareEconomicInclusion(book, {
source,
profitAlreadyIncluded: profit,
creditAlreadyIncluded: credit,
reference,
});
const model = calculateEconomicInclusions(
next.economicInclusions!,
next.base!.subjectNuit,
next.year,
);
setPreview({
fingerprint,
book: next,
net: economicInclusionMoney(model.net),
});
setError('');
} catch (e) {
setPreview(null);
setError(String(e));
}
}}
>
Pré-visualizar inclusão/correcção
</button>
{preview && preview.fingerprint === fingerprint && (
<div className="space-y-2 rounded border p-3">
<p>
Efeito total deste mapa no lucro tributável:{' '}
{preview.net} MZN. Substitui as parcelas da mesma
origem, sem as acumular.
</p>
<button
type="button"
onClick={() => {
if (preview.fingerprint !== fingerprint) return;
save(preview.book);
setPreview(null);
}}
>
Confirmar preparação em L6
</button>
<button type="button" onClick={() => setPreview(null)}>
Cancelar prévia
</button>
</div>
)}
</section>
);
}
@@ -110,7 +110,10 @@ export function SimulationIrpcEconomic({
rowChange({ ...r, ...v });
const prefix = 'Crédito económico ' + (i + 1) + ' — ';
const field = (
key: Exclude<keyof EconomicPosition, 'previousUse'>,
key: Exclude<
keyof EconomicPosition,
'previousUse' | 'liquidation' | 'association'
>,
label: string,
) => (
<label key={key} className="block">
@@ -145,9 +148,7 @@ export function SimulationIrpcEconomic({
</select>
</label>
);
const outside =
(!!r.kind && r.kind !== 'ordinary_profit') ||
r.article40 === 'yes';
const outside = r.article40 === 'yes';
const detail = (
<>
{(
@@ -226,7 +227,60 @@ export function SimulationIrpcEconomic({
'Suporte da quota proporcional da colecta',
],
] as const
).map(([key, label]) => field(key, label))}
)
.filter(
([key]) =>
r.nationalMethod !== 'proportional' ||
(key !== 'nationalLimit' &&
key !== 'nationalLimitReference'),
)
.map(([key, label]) => field(key, label))}
<label className="block">
Quota proporcional
<select
aria-label={prefix + 'Método da quota'}
value={r.nationalMethod ?? 'documented'}
onChange={(e) =>
patch({
nationalMethod: e.target
.value as EconomicPosition['nationalMethod'],
})
}
>
<option value="documented">
Limite documentado
</option>
<option value="proportional">
Derivar da colecta L6 ordinária
</option>
</select>
</label>
{r.nationalMethod === 'proportional' && (
<label className="block">
Correspondência dos lucros mais acréscimo53 na
base corrente
<textarea
aria-label={
prefix + 'Correspondência da quota'
}
value={r.nationalBasisReference ?? ''}
onChange={(e) =>
patch({
nationalBasisReference:
e.target.value,
})
}
/>
<span className="block">
Quota = colecta L6 × (lucros incluídos +
crédito nominal acrescido) / matéria
colectável L6. Não altera a base nem
acrescenta o crédito novamente. O limite
manual anterior fica conservado, mas não é
usado neste modo.
</span>
</label>
)}
{choice(
'incomeIncluded',
'Lucros ilíquidos e acréscimo incluídos na base corrente',
@@ -339,6 +393,155 @@ export function SimulationIrpcEconomic({
</option>
</select>
</label>
{r.kind === 'association' && (
<fieldset className="space-y-2 rounded border p-3">
<legend>Associação — art.64(4)</legend>
<label className="block">
Modalidade
<select
aria-label={
prefix + 'Modalidade da associação'
}
value={r.association?.kind ?? ''}
onChange={(e) =>
patch({
association: {
kind: e.target.value as
| ''
| 'participation'
| 'quota',
effectivelyTaxed:
r.association
?.effectivelyTaxed ??
'',
reference:
r.association
?.reference ?? '',
},
})
}
>
<option value="">
Escolha a modalidade
</option>
<option value="participation">
Em participação
</option>
<option value="quota">À quota</option>
</select>
</label>
{r.association?.kind === 'participation' && (
<label className="block">
Rendimentos distribuídos efectivamente
tributados
<select
aria-label={
prefix +
'Tributação efectiva da participação'
}
value={
r.association.effectivelyTaxed
}
onChange={(e) =>
patch({
association: {
...r.association!,
effectivelyTaxed: e
.target.value as
| ''
| 'yes'
| 'no',
},
})
}
>
<option value="">
Informação em falta
</option>
<option value="yes">
Sim — documentado
</option>
<option value="no">
Não — documentado
</option>
</select>
</label>
)}
<label className="block">
Suporte do contrato, rendimentos e adaptação
do crédito
<input
aria-label={
prefix + 'Suporte da associação'
}
value={r.association?.reference ?? ''}
onChange={(e) =>
patch({
association: {
kind: '',
effectivelyTaxed: '',
...r.association,
reference: e.target.value,
},
})
}
/>
</label>
</fieldset>
)}
{r.kind === 'liquidation' && (
<fieldset className="space-y-2 rounded border p-3">
<legend>
Partilha em liquidação — art.64(3)
</legend>
<p>
No rendimento abaixo indique a diferença
positiva classificada documentalmente como
aplicação de capitais. O crédito limita-se
também ao valor atribuído menos entradas
efectivas para capital. O excesso mantém
tratamento próprio de mais-valia; não
integra a quota económica. Confirme a
inclusão L6 e o IRPC correspondente apenas à
parcela elegível.
</p>
{(
[
[
'attributed',
'Valor atribuído na partilha — MZN',
],
[
'paidInCapital',
'Entradas efectivas para capital na contabilidade da liquidada — MZN',
],
[
'reference',
'Suporte da partilha e classificação do rendimento',
],
] as const
).map(([key, label]) => (
<label key={key} className="block">
{label}
<input
aria-label={prefix + label}
value={r.liquidation?.[key] ?? ''}
onChange={(e) =>
patch({
liquidation: {
attributed: '',
paidInCapital: '',
reference: '',
...r.liquidation,
[key]: e.target.value,
},
})
}
/>
</label>
))}
</fieldset>
)}
{residentConfirmed && r.recipientResident === 'yes' ? (
<p>
Residência do beneficiário reutilizada da
@@ -4,6 +4,7 @@ import { SimulationIrpcPeriodFx } from '@/components/simulation-irpc-period-fx';
import { SimulationIrpcDepreciation } from '@/components/simulation-irpc-depreciation';
import { SimulationIrpcCosts } from '@/components/simulation-irpc-costs';
import { SimulationIrpcSchedules } from '@/components/simulation-irpc-schedules';
import { SimulationIrpcEconomicInclusion } from '@/components/simulation-irpc-economic-inclusion';
import { SimulationIrpcAssessment } from '@/components/simulation-irpc-assessment';
import { useState } from 'react';
import { SimulationIrpcTaxableBase } from '@/components/simulation-irpc-taxable-base';
@@ -438,6 +439,16 @@ export function SimulationIrpcNormal({
)}
<SimulationIrpcPeriodFx draft={draft} book={b} save={save} />
<SimulationIrpcSchedules book={b} save={save} />
<SimulationIrpcEconomicInclusion
key={JSON.stringify([
draft.context_opportunity,
b.base?.subjectNuit,
b.year,
])}
draft={draft}
book={b}
save={save}
/>
<SimulationIrpcDepreciation
draft={draft}
book={b}
+75 -2
View File
@@ -1193,6 +1193,13 @@ export function calculateAnnual(d: Record<string, unknown>, b: AnnualBook) {
b.assessmentKey,
colecta,
issues,
{
ordinary:
assessment?.kind === 'normal' &&
assessment.current &&
assessment.key === b.assessmentKey,
taxableBase: assessment?.record?.model.taxableBase ?? null,
},
);
pending.push(
...m.pending.map(
@@ -1202,6 +1209,22 @@ export function calculateAnnual(d: Record<string, unknown>, b: AnnualBook) {
return m;
}) ?? [];
if (b.economicCredits) {
if (
currentBase &&
cmp(
addFractions(
economicRows.flatMap((r) =>
r.nationalDerivation
? [r.nationalDerivation.included]
: [],
),
),
currentBase,
) > 0n
)
pending.push(
'As parcelas económicas com quotas derivadas excedem em conjunto a matéria colectável; reconcilie os lucros e acréscimos por origem.',
);
if (b.economicCredits.rows.length && assessment?.kind !== 'normal')
pending.push(
'Crédito económico delimitado à colecta normal corrente;efeitos L7 exigem correspondência própria.',
@@ -1390,7 +1413,7 @@ export function calculateAnnual(d: Record<string, unknown>, b: AnnualBook) {
limitations: [
...(b.economicCredits
? [
'Crédito económico ordinário:IRPC correspondente aos lucros,acréscimo53 já incluído e quota proporcional documentados. Sem cálculo automático da inclusão ou da exclusão40;liquidação64(3),associações64(4),benefícios e regras especiais separados. Sem reembolso ou transporte económico presumidos.',
'Crédito económico: IRPC correspondente, inclusão/acréscimo53 e quota proporcional reconciliados com a colecta corrente. Partilha64(3) separa rendimento elegível e excesso com natureza de mais-valia; associações64(4) têm modalidade/adaptação próprias. Preparação L6 é explícita; exclusão40, benefícios e restantes regras mantêm âmbito próprio. Sem reembolso ou transporte económico presumidos.',
]
: []),
...(b.creditCarry
@@ -1598,6 +1621,51 @@ export function annualReportRows(
r.input.grossUpIncluded +
' MZN;quota proporcional: ' +
show(r.nationalLimit) +
(r.nationalDerivation
? '; derivação L6: colecta ' +
show(r.nationalDerivation.colecta) +
' × lucros mais acréscimo53 ' +
show(
r.nationalDerivation.included,
) +
' / matéria colectável ' +
show(
r.nationalDerivation
.taxableBase,
) +
'; exacta ' +
r.nationalLimit?.numerator +
'/' +
r.nationalLimit?.denominator
: '') +
(r.liquidation
? '; partilha64(3): valor atribuído ' +
show(r.liquidation.attributed) +
'; entradas efectivas ' +
show(r.liquidation.paidInCapital) +
'; limite ' +
show(r.liquidation.ceiling) +
'; rendimento elegível ' +
show(r.liquidation.eligibleIncome) +
'; excesso com natureza de mais-valia ' +
show(
r.liquidation.excessCapitalGain,
) +
'; ' +
r.liquidation.version +
'; ' +
r.liquidation.reference
: '') +
(r.association
? '; ' +
r.association.version +
'; modalidade ' +
r.association.kind +
'; tributação efectiva ' +
r.association.effectivelyTaxed +
'; ' +
r.association.reference
: '') +
';elegível: ' +
show(r.eligible) +
';uso efectivo anterior: ' +
@@ -1616,7 +1684,12 @@ export function annualReportRows(
';53: ' +
r.input.grossUpReference +
';quota: ' +
r.input.nationalLimitReference +
(r.nationalDerivation
? r.nationalDerivation.version +
'; correspondência: ' +
r.nationalDerivation.reference +
'; limite manual anterior conservado, não utilizado'
: r.input.nationalLimitReference) +
';uso: ' +
r.input.previousUse.reference,
] as [string, string, string, string],
+154 -6
View File
@@ -51,6 +51,18 @@ export type EconomicPosition = {
grossUpReference: string;
nationalLimit: string;
nationalLimitReference: string;
association?: {
kind: '' | 'participation' | 'quota';
effectivelyTaxed: Choice;
reference: string;
};
liquidation?: {
attributed: string;
paidInCapital: string;
reference: string;
};
nationalMethod?: 'documented' | 'proportional';
nationalBasisReference?: string;
previousUse: CreditEvidence;
};
export type EconomicCredits = {
@@ -113,12 +125,37 @@ export function validateEconomic(c: EconomicCredits) {
throw Error(
'Crédito económico não recuperável; originais conservados.',
);
for (const r of c.rows)
if (
r?.liquidation !== undefined &&
(!r.liquidation ||
['attributed', 'paidInCapital', 'reference'].some(
(k) =>
typeof (
r.liquidation as unknown as Record<string, unknown>
)[k] !== 'string',
))
)
throw Error('Partilha não recuperável; originais conservados.');
for (const r of c.rows)
if (
r?.association !== undefined &&
(!r.association ||
!['', 'participation', 'quota'].includes(r.association.kind) ||
!['', 'yes', 'no'].includes(r.association.effectivelyTaxed) ||
typeof r.association.reference !== 'string')
)
throw Error('Associação não recuperável; originais conservados.');
const keys = Object.keys(newEconomicPosition('')).filter(
(k) => k !== 'previousUse',
);
for (const r of c.rows)
if (
!r ||
(r.nationalMethod !== undefined &&
!['documented', 'proportional'].includes(r.nationalMethod)) ||
(r.nationalBasisReference !== undefined &&
typeof r.nationalBasisReference !== 'string') ||
keys.some(
(k) =>
typeof (r as unknown as Record<string, unknown>)[k] !==
@@ -201,6 +238,57 @@ export function economicGroupPending(c: EconomicCredits) {
);
return pending;
}
export function economicIncome(r: EconomicPosition) {
const income = creditMoney(r.income);
if (r.kind === 'ordinary_profit') return { income };
if (r.kind === 'association') {
const a = r.association;
if (!a?.kind || !a.reference.trim())
throw Error(
'Identifique associação em participação ou à quota e o suporte da adaptação64(4).',
);
if (a.kind === 'participation' && a.effectivelyTaxed !== 'yes')
throw Error(
'Associação em participação exige rendimentos distribuídos efectivamente tributados; desconhecido não significa sim.',
);
return {
income,
association: {
version: 'IRPC-economic-association/1',
...structuredClone(a),
},
};
}
if (r.kind !== 'liquidation')
throw Error('Natureza económica por identificar.');
if (!r.liquidation?.reference.trim())
throw Error(
'Partilha64(3): identifique o valor atribuído, entradas efectivas para capital e classificação documental do rendimento.',
);
const attributed = creditMoney(r.liquidation.attributed),
paidInCapital = creditMoney(r.liquidation.paidInCapital);
if (creditCompare(income, attributed) > 0n)
throw Error(
'Rendimento da partilha não pode exceder o valor atribuído.',
);
const difference = subtractFractions(attributed, paidInCapital);
const ceiling =
creditCompare(difference, zero()) > 0n ? difference : zero();
const eligibleIncome = minimum(income, ceiling);
return {
income: eligibleIncome,
liquidation: {
version: 'IRPC-economic-liquidation/1',
attributed,
paidInCapital,
capitalIncome: income,
ceiling,
eligibleIncome,
excessCapitalGain: subtractFractions(income, eligibleIncome),
reference: r.liquidation.reference,
},
};
}
export function economicPosition(
r: EconomicPosition,
subjectNuit: string,
@@ -208,6 +296,7 @@ export function economicPosition(
assessmentKey: string,
colecta: FiscalFraction | null,
proofIssues: string[],
basis?: { ordinary: boolean; taxableBase: FiscalFraction | null },
) {
const pending = [...proofIssues];
let nominal: FiscalFraction | null = null,
@@ -216,6 +305,15 @@ export function economicPosition(
previousUse: FiscalFraction | null = null,
available: FiscalFraction | null = null,
included: FiscalFraction | null = null;
let liquidation: ReturnType<typeof economicIncome>['liquidation'];
let association: ReturnType<typeof economicIncome>['association'];
let nationalDerivation: {
version: string;
included: FiscalFraction;
taxableBase: FiscalFraction;
colecta: FiscalFraction;
reference: string;
} | null = null;
let status: 'qualified' | 'outside_scope' | 'indeterminate' =
'indeterminate';
try {
@@ -230,10 +328,12 @@ export function economicPosition(
throw Error(
'Titular, entidade distribuidora, exercício2026 e MZN por corresponder.',
);
if (r.kind !== 'ordinary_profit') {
if (
!['ordinary_profit', 'liquidation', 'association'].includes(r.kind)
) {
status = r.kind ? 'outside_scope' : 'indeterminate';
throw Error(
'Lucros ordinários64(1–2) por identificar; liquidação64(3) e associações64(4) exigem correspondência específica, sem converter a parcela em crédito internacional.',
'Identifique lucros ordinários64(1–2), partilha64(3) ou associação64(4), com correspondência própria; não converter a parcela em crédito internacional.',
);
}
if (
@@ -272,8 +372,11 @@ export function economicPosition(
throw Error(
'Confirme a inclusão dos lucros ilíquidos de retenção e a correspondência com a colecta corrente; a origem pode ter mudado.',
);
const qualified = economicIncome(r);
liquidation = qualified.liquidation;
association = qualified.association;
const tax = economicTaxProof(r),
income = creditMoney(r.income);
income = qualified.income;
nominal = fraction(
BigInt(tax.value.numerator) * 3n,
BigInt(tax.value.denominator) * 5n,
@@ -293,9 +396,41 @@ export function economicPosition(
'Acréscimo53(1)a incluído na base deve corresponder ao crédito nominal60%; reconcilie L6 sem alterar a colecta ou o histórico automaticamente.',
);
included = addFractions([income, nominal]);
nationalLimit = creditLimit(r.nationalLimit);
if (r.nationalMethod === 'proportional') {
if (
!basis?.ordinary ||
!basis.taxableBase ||
creditCompare(basis.taxableBase, zero()) <= 0n ||
!colecta ||
creditCompare(colecta, zero()) < 0n ||
!r.nationalBasisReference?.trim()
)
throw Error(
'Quota económica derivada exige colecta L6 ordinária corrente, matéria colectável positiva e correspondência dos lucros mais acréscimo53 já incluídos.',
);
if (creditCompare(included, basis.taxableBase) > 0n)
throw Error(
'Lucros mais acréscimo53 excedem a matéria colectável; reconcilie a inclusão antes de derivar a quota.',
);
nationalLimit = fraction(
BigInt(colecta.numerator) *
BigInt(included.numerator) *
BigInt(basis.taxableBase.denominator),
BigInt(colecta.denominator) *
BigInt(included.denominator) *
BigInt(basis.taxableBase.numerator),
);
nationalDerivation = {
version: 'IRPC-economic-quota/1',
included,
taxableBase: structuredClone(basis.taxableBase),
colecta: structuredClone(colecta),
reference: r.nationalBasisReference,
};
} else nationalLimit = creditLimit(r.nationalLimit);
if (
!r.nationalLimitReference.trim() ||
(r.nationalMethod !== 'proportional' &&
!r.nationalLimitReference.trim()) ||
!colecta ||
creditCompare(nationalLimit, colecta) > 0n ||
(creditCompare(included, zero()) === 0n &&
@@ -327,6 +462,9 @@ export function economicPosition(
id: r.id,
nominal,
nationalLimit,
...(nationalDerivation ? { nationalDerivation } : {}),
...(liquidation ? { liquidation } : {}),
...(association ? { association } : {}),
eligible,
previousUse,
available,
@@ -354,7 +492,17 @@ export function economicDeduction(
deducted,
afterCredit:
deducted && colecta ? subtractFractions(colecta, deducted) : null,
source: economicSource,
source:
economicSource +
(rows.some((r) => r.association)
? ' IRPC-economic-association/1:CIRPC64(4), associação em participação com tributação efectiva documentada ou associação à quota, com adaptação, inclusão L6 e imposto correspondente expressamente suportados.'
: '') +
(rows.some((r) => r.liquidation)
? ' IRPC-economic-liquidation/1:CIRPC64(3); rendimento de capitais documentado limitado à diferença positiva entre valor atribuído e entradas efectivas para capital. Excesso separado como mais-valia, sem crédito económico sobre essa parcela; inclusão L6 e imposto subjacente exigem correspondência expressa.'
: '') +
(rows.some((r) => r.nationalDerivation)
? ' IRPC-economic-quota/1:quota ordinária derivada da colecta e matéria colectável L6 com lucros mais acréscimo53 já correspondidos;sem alterar a base ou o acréscimo.'
: ''),
officialBalancesConsumed: false,
carryCreated: false,
};
+278
View File
@@ -0,0 +1,278 @@
import { canonicalParcel } from './fiscal-proof-parcel.ts';
import type { NormalBook } from './irpc-normal.ts';
import {
economicTaxProof,
economicDistributionIdentity,
economicIncome,
validateEconomic,
type EconomicPosition,
} from './irpc-economic-credit.ts';
import {
creditMoney,
creditLimit,
creditCompare,
} from './irpc-international-credit.ts';
import {
addFractions,
subtractFractions,
fraction,
type FiscalFraction,
} from './fiscal-rational.ts';
export type EconomicInclusion = {
source: EconomicPosition;
profitAlreadyIncluded: string;
creditAlreadyIncluded: string;
reference: string;
};
export type EconomicInclusions = { version: 1; rows: EconomicInclusion[] };
export const economicInclusionVersion = 'IRPC-economic-inclusion/1';
export function validateEconomicInclusions(input: EconomicInclusions) {
if (
input?.version !== 1 ||
!Array.isArray(input.rows) ||
input.rows.some(
(r) =>
!r ||
typeof r.profitAlreadyIncluded !== 'string' ||
typeof r.creditAlreadyIncluded !== 'string' ||
typeof r.reference !== 'string',
)
)
throw Error('Inclusões económicas não recuperáveis.');
validateEconomic({
version: 1,
state: 'known',
reference: economicInclusionVersion,
rows: input.rows.map((r) => r.source),
});
}
export function economicInclusionMoney(value: FiscalFraction): string {
const n = BigInt(value.numerator) * 100n,
d = BigInt(value.denominator);
if (n % d !== 0n)
throw Error(
'Correcção com fracção de cêntimo exige tratamento próprio; não arredondar por presunção.',
);
const cents = n / d,
absolute = cents < 0n ? -cents : cents;
return (
(cents < 0n ? '-' : '') +
absolute / 100n +
'.' +
(absolute % 100n).toString().padStart(2, '0')
);
}
export function calculateEconomicInclusions(
input: EconomicInclusions,
subject: string,
year: string,
) {
validateEconomicInclusions(input);
const pending: string[] = [];
const effects: {
id: string;
topic: string;
amount: FiscalFraction | null;
effect: FiscalFraction | null;
reason: string;
}[] = [];
const seen = new Set<string>();
for (const row of input.rows) {
const r = row.source;
try {
if (
!r ||
!['ordinary_profit', 'liquidation', 'association'].includes(
r.kind,
) ||
!r.id.trim() ||
!/^\d{9}$/.test(r.payerNuit) ||
r.subjectNuit !== subject ||
r.year !== year ||
year !== '2026' ||
r.recipientResident !== 'yes' ||
r.payerResident !== 'yes' ||
r.payerIrpc !== 'yes' ||
r.payerExempt !== 'no' ||
r.article40 !== 'no' ||
!r.qualificationReference?.trim() ||
!r.article40Reference?.trim() ||
!row.reference?.trim() ||
!r.distributionDocument?.trim() ||
!r.distributionParcel?.trim() ||
!/^2026-\d{2}-\d{2}$/.test(r.distributionDate) ||
new Date(r.distributionDate).toISOString().slice(0, 10) !==
r.distributionDate
)
throw Error(
'Falta qualificação, origem, titular/exercício ou correspondência da inclusão; art40 e outros âmbitos separados.',
);
const identity =
r.distributionDocument.trim().toUpperCase() +
'|' +
r.distributionParcel.trim().toUpperCase();
const taxIdentity = JSON.stringify([
canonicalParcel(r.taxIssuer),
canonicalParcel(r.taxDocument),
canonicalParcel(r.taxParcel),
]);
const distributionIdentity = economicDistributionIdentity(r);
if (
seen.has(distributionIdentity) ||
seen.has('tax|' + taxIdentity)
)
throw Error(
'Origem/parcela económica repetida; consolide antes de corrigir a base.',
);
seen.add(distributionIdentity);
seen.add('tax|' + taxIdentity);
const qualification = economicIncome(r);
const tax = economicTaxProof(r).value;
const nominal = fraction(
BigInt(tax.numerator) * 3n,
BigInt(tax.denominator) * 5n,
);
if (
creditCompare(qualification.income, fraction(0n)) === 0n &&
creditCompare(nominal, fraction(0n)) > 0n
)
throw Error(
'Imposto subjacente positivo sem rendimento elegível para crédito económico.',
);
const specialReference = qualification.liquidation
? `${qualification.liquidation.version}; rendimento elegível ${economicInclusionMoney(qualification.liquidation.eligibleIncome)}; excesso com natureza de mais-valia ${economicInclusionMoney(qualification.liquidation.excessCapitalGain)} incluído no rendimento total a reconciliar. ${qualification.liquidation.reference}`
: qualification.association
? `${qualification.association.version}; ${qualification.association.kind}; ${qualification.association.reference}`
: '';
const targets = [creditMoney(r.income), nominal];
const included = [
creditMoney(row.profitAlreadyIncluded),
creditMoney(row.creditAlreadyIncluded),
];
if (
r.incomeIncluded === 'yes' &&
creditCompare(included[0], targets[0]) < 0n
)
throw Error(
'A origem L8 declara os lucros já incluídos; o montante anterior indicado contradiz essa declaração. Compare e corrija a origem antes de acrescentar novamente.',
);
if (
r.grossUpIncluded.trim() &&
creditCompare(creditLimit(r.grossUpIncluded), included[1]) !==
0n
)
throw Error(
'O crédito já acrescido indicado não corresponde à origem L8; reconcilie a diferença antes de alterar a base.',
);
targets.forEach((target, index) => {
const effect = subtractFractions(target, included[index]);
economicInclusionMoney(effect);
effects.push({
id: identity + ':' + index,
topic: 'incomeCorrections',
amount: fraction(
BigInt(effect.numerator) < 0n
? -BigInt(effect.numerator)
: BigInt(effect.numerator),
BigInt(effect.denominator),
),
effect,
reason: `${economicInclusionVersion}; CIRPC64(1–2)/53(1)a; ${index ? 'acréscimo nominal' : 'lucros'}: alvo ${economicInclusionMoney(target)}; já considerado ${economicInclusionMoney(included[index])}; diferença ${economicInclusionMoney(effect)}. ${row.reference}${specialReference ? ' ' + specialReference : ''}`,
});
});
} catch (e) {
pending.push(String(e));
}
}
return {
version: economicInclusionVersion,
input: structuredClone(input),
effects,
pending,
net: addFractions(effects.flatMap((e) => (e.effect ? [e.effect] : []))),
};
}
export function prepareEconomicInclusion(
book: NormalBook,
row: EconomicInclusion,
): NormalBook {
if (
book.special ||
book.periodsFx ||
(book.schedules && book.schedules.period.kind !== 'calendar') ||
book.scope !== 'resident_general' ||
book.year !== '2026' ||
!book.base ||
!book.profit
)
throw Error(
'Prepare primeiro o lucro e a base L6 ordinária do sujeito/ano civil2026/MZN; períodos especiais e conversões têm correspondência própria.',
);
const old = book.economicInclusions ?? { version: 1 as const, rows: [] };
const rows = old.rows.filter(
(p) =>
economicDistributionIdentity(p.source) !==
economicDistributionIdentity(row.source),
);
const next: EconomicInclusions = {
version: 1,
rows: [...rows, structuredClone(row)],
};
const previous = calculateEconomicInclusions(
old,
book.base.subjectNuit,
book.year,
),
prepared = calculateEconomicInclusions(
next,
book.base.subjectNuit,
book.year,
);
if (previous.pending.length || prepared.pending.length)
throw Error([...previous.pending, ...prepared.pending].join(' '));
const topic = book.profit.topics.incomeCorrections;
if (
!topic.reference.trim() ||
!['known', 'absent'].includes(topic.state) ||
(topic.state === 'absent' && topic.amount.trim())
)
throw Error(
'Reconcilie as outras correcções aos proveitos antes de substituir parcelas.',
);
const current =
topic.state === 'absent' ? fraction(0n) : creditMoney(topic.amount);
const remaining = subtractFractions(
current,
addFractions(
previous.effects.flatMap((e) => (e.amount ? [e.amount] : [])),
),
);
if (BigInt(remaining.numerator) < 0n)
throw Error(
'Montante de correcções inferior às parcelas anteriores; compare antes de substituir.',
);
const amount = addFractions([
remaining,
addFractions(
prepared.effects.flatMap((e) => (e.amount ? [e.amount] : [])),
),
]);
return {
...book,
economicInclusions: next,
profit: {
...book.profit,
topics: {
...book.profit.topics,
incomeCorrections: {
state: 'known',
amount: economicInclusionMoney(amount),
reference: topic.reference.includes(row.reference)
? topic.reference
: topic.reference + '\n' + row.reference,
},
},
},
};
}
+15 -1
View File
@@ -155,6 +155,17 @@ export function closeNormalProfit(
schedules?: SchedulesModel,
costs?: NormalCostsModel,
depreciation?: NormalDepreciationModel,
economic?: {
effects: {
id: string;
topic: string;
amount: FiscalFraction | null;
effect: FiscalFraction | null;
reason: string;
}[];
net: FiscalFraction;
pending: string[];
},
): NormalProfitModel {
validateNormalProfit(input);
const pending: string[] = [],
@@ -291,9 +302,10 @@ export function closeNormalProfit(
const mapped = [
...(schedules?.effects ?? []),
...(costs?.effects ?? []),
...(economic?.effects ?? []),
].filter((e) => e.topic === key);
const covered =
(schedules || costs) &&
(schedules || costs || economic) &&
mapped.length > 0 &&
mapped.every((e) => e.effect !== null) &&
value !== null &&
@@ -324,6 +336,7 @@ export function closeNormalProfit(
}
if (schedules) pending.push(...schedules.pending);
if (costs) pending.push(...costs.pending);
if (economic) pending.push(...economic.pending);
if (depreciation && !schedules) pending.push(...depreciation.pending);
return {
input: structuredClone(input),
@@ -335,6 +348,7 @@ export function closeNormalProfit(
? addFractions([
accounting,
additions,
...(economic ? [economic.net] : []),
...(schedules ? [schedules.net] : []),
...(costs ? [costs.net] : []),
...(depreciation && !schedules && depreciation.adjustment
+41
View File
@@ -1,4 +1,9 @@
import { inputText } from './input-text.ts';
import {
calculateEconomicInclusions,
validateEconomicInclusions,
type EconomicInclusions,
} from './irpc-normal-economic.ts';
import { irpcSpecialCombinationsVersion } from './irpc-special-combinations.ts';
import {
irpcSpecialVersion,
@@ -104,6 +109,7 @@ export type NormalAdjustment = {
reference: string;
};
export type NormalBook = {
economicInclusions?: EconomicInclusions;
version: 1;
year: string;
mappingReference: string;
@@ -123,6 +129,7 @@ export type NormalBook = {
history?: NormalRecord[];
};
export type NormalModel = {
economicInclusions?: ReturnType<typeof calculateEconomicInclusions>;
special?: SpecialModel;
formPreparation?: NormalFormPreparationModel;
periodsFx?: NormalPeriodFxModel;
@@ -213,6 +220,8 @@ function store(d: Record<string, unknown>) {
return s;
}
export function validateNormal(b: NormalBook) {
if (b?.economicInclusions !== undefined)
validateEconomicInclusions(b.economicInclusions);
if (b?.special !== undefined) validateSpecial(b.special);
if (b?.forms !== undefined) validateNormalFormPreparation(b.forms);
if (b?.periodsFx !== undefined) validateNormalPeriodFx(b.periodsFx);
@@ -333,6 +342,7 @@ function key(d: Record<string, unknown>, b: NormalBook) {
b.scopeReference,
b.rows,
...(b.profit ? [b.profit] : []),
...(b.economicInclusions ? [b.economicInclusions] : []),
...(b.base ? [b.base] : []),
...(b.assessment ? [b.assessment] : []),
...(b.schedules ? [b.schedules] : []),
@@ -569,6 +579,23 @@ export function calculateNormal(
!!periodsFx?.short,
)
: undefined;
const economicInclusions = b.economicInclusions
? calculateEconomicInclusions(
b.economicInclusions,
b.base?.subjectNuit ?? '',
b.year,
)
: undefined;
if (
economicInclusions &&
(b.special ||
b.periodsFx ||
(b.schedules && b.schedules.period.kind !== 'calendar') ||
b.scope !== 'resident_general')
)
economicInclusions.pending.push(
'Inclusão económica automática delimitada à base ordinária residente/ano civil2026/MZN; este âmbito exige correspondência própria.',
);
const profit = b.profit
? closeNormalProfit(
b.profit,
@@ -580,6 +607,7 @@ export function calculateNormal(
schedules,
costs,
depreciation,
economicInclusions,
)
: undefined;
if (profit && periodsFx)
@@ -677,6 +705,7 @@ export function calculateNormal(
? addFractions([accountingProfit, additions])
: null,
taxableProfit: profit?.taxableProfit ?? null,
...(economicInclusions ? { economicInclusions } : {}),
taxableBase: base?.taxableBase ?? null,
tax: null,
rows,
@@ -774,6 +803,18 @@ export function normalReportRows(
if (!r) return [];
const m = r.model;
return [
...(m.economicInclusions?.effects.map(
(e) =>
[
'IRPC normal — L6',
'Inclusão/correcção económica — ' + e.id,
'Parcela reconciliada: ' +
money(e.amount) +
'; efeito com sinal: ' +
money(e.effect),
e.reason,
] as [string, string, string, string],
) ?? []),
[
'IRPC normal — L6',
'Ajustamentos ao resultado financeiro',
@@ -0,0 +1,36 @@
<?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 IrpcEconomicAssociationDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_economic_association_uses_the_existing_private_draft_writer(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('proactive_investment_20261027_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$this->actingAs(User::findOrFail(1021));
$d = json_decode(file_get_contents(base_path('outputs/irpc-economic-association-20261110/fixture.json')), true, flags: JSON_THROW_ON_ERROR);
$personal = ['person_type' => $d['person_type']];
unset($d['person_type']);
$id = $this->postJson('/api/v1/simulation-drafts', ['personal_snapshot' => $personal, 'economic_snapshot' => $d], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
$back = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk()->json('data.economic_snapshot');
$this->assertSame($d['modelo_a_snapshot'], $back['modelo_a_snapshot']);
$this->assertSame($d['business_plan_snapshot'], $back['business_plan_snapshot']);
$b = json_decode($back['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR)['irpcAnnual'][$d['context_opportunity']];
$this->assertSame('proportional', $b['economicCredits']['rows'][0]['nationalMethod']);
$this->assertSame(['numerator' => '896', 'denominator' => '25'], $b['result']['model']['economicCredit']['rows'][0]['nationalLimit']);
$this->assertSame(['numerator' => '688', 'denominator' => '1'], $b['result']['model']['arithmeticBalance']);
$this->assertSame('participation', $b['result']['model']['economicCredit']['rows'][0]['association']['kind']);
file_put_contents(base_path('outputs/irpc-economic-association-20261110/recovered.json'), json_encode($back, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
@@ -0,0 +1,36 @@
<?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 IrpcEconomicInclusionDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_economic_inclusion_uses_the_existing_private_draft_writer(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('proactive_investment_20261027_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$this->actingAs(User::findOrFail(1021));
$d = json_decode(file_get_contents(base_path('outputs/irpc-economic-inclusion-20261110/fixture.json')), true, flags: JSON_THROW_ON_ERROR);
$personal = ['person_type' => $d['person_type']];
unset($d['person_type']);
$id = $this->postJson('/api/v1/simulation-drafts', ['personal_snapshot' => $personal, 'economic_snapshot' => $d], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
$back = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk()->json('data.economic_snapshot');
$this->assertSame($d['modelo_a_snapshot'], $back['modelo_a_snapshot']);
$this->assertSame($d['business_plan_snapshot'], $back['business_plan_snapshot']);
$b = json_decode($back['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR)['irpcNormal'][$d['context_opportunity']];
$this->assertSame('IRPC-economic-inclusion/1', $b['result']['model']['economicInclusions']['version']);
$this->assertSame(['numerator' => '112', 'denominator' => '1'], $b['result']['model']['economicInclusions']['net']);
$this->assertSame(['numerator' => '36096', 'denominator' => '25'], $b['result']['model']['colecta']);
$this->assertNotEmpty($b['history']);
file_put_contents(base_path('outputs/irpc-economic-inclusion-20261110/recovered.json'), json_encode($back, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
@@ -0,0 +1,36 @@
<?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 IrpcEconomicLiquidationDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_economic_liquidation_uses_the_existing_private_draft_writer(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('proactive_investment_20261027_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$this->actingAs(User::findOrFail(1021));
$d = json_decode(file_get_contents(base_path('outputs/irpc-economic-liquidation-20261110/fixture.json')), true, flags: JSON_THROW_ON_ERROR);
$personal = ['person_type' => $d['person_type']];
unset($d['person_type']);
$id = $this->postJson('/api/v1/simulation-drafts', ['personal_snapshot' => $personal, 'economic_snapshot' => $d], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
$back = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk()->json('data.economic_snapshot');
$this->assertSame($d['modelo_a_snapshot'], $back['modelo_a_snapshot']);
$this->assertSame($d['business_plan_snapshot'], $back['business_plan_snapshot']);
$b = json_decode($back['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR)['irpcAnnual'][$d['context_opportunity']];
$this->assertSame('proportional', $b['economicCredits']['rows'][0]['nationalMethod']);
$this->assertSame(['numerator' => '896', 'denominator' => '25'], $b['result']['model']['economicCredit']['rows'][0]['nationalLimit']);
$this->assertSame(['numerator' => '688', 'denominator' => '1'], $b['result']['model']['arithmeticBalance']);
$this->assertSame(['numerator' => '50', 'denominator' => '1'], $b['result']['model']['economicCredit']['rows'][0]['liquidation']['excessCapitalGain']);
file_put_contents(base_path('outputs/irpc-economic-liquidation-20261110/recovered.json'), json_encode($back, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
@@ -0,0 +1,35 @@
<?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 IrpcEconomicQuotaDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_derived_economic_quota_uses_the_existing_private_draft_writer(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('proactive_investment_20261027_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$this->actingAs(User::findOrFail(1021));
$d = json_decode(file_get_contents(base_path('outputs/irpc-economic-quota-20261110/fixture.json')), true, flags: JSON_THROW_ON_ERROR);
$personal = ['person_type' => $d['person_type']];
unset($d['person_type']);
$id = $this->postJson('/api/v1/simulation-drafts', ['personal_snapshot' => $personal, 'economic_snapshot' => $d], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
$back = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk()->json('data.economic_snapshot');
$this->assertSame($d['modelo_a_snapshot'], $back['modelo_a_snapshot']);
$this->assertSame($d['business_plan_snapshot'], $back['business_plan_snapshot']);
$b = json_decode($back['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR)['irpcAnnual'][$d['context_opportunity']];
$this->assertSame('proportional', $b['economicCredits']['rows'][0]['nationalMethod']);
$this->assertSame(['numerator' => '256', 'denominator' => '5'], $b['result']['model']['economicCredit']['rows'][0]['nationalLimit']);
$this->assertSame(['numerator' => '3244', 'denominator' => '5'], $b['result']['model']['arithmeticBalance']);
file_put_contents(base_path('outputs/irpc-economic-quota-20261110/recovered.json'), json_encode($back, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
@@ -0,0 +1,36 @@
<?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 IrpcEconomicSpecialInclusionDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_economic_special_inclusion_uses_the_existing_private_draft_writer(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('proactive_investment_20261027_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$this->actingAs(User::findOrFail(1021));
$d = json_decode(file_get_contents(base_path('outputs/irpc-economic-special-inclusion-20261110/fixture.json')), true, flags: JSON_THROW_ON_ERROR);
$personal = ['person_type' => $d['person_type']];
unset($d['person_type']);
$id = $this->postJson('/api/v1/simulation-drafts', ['personal_snapshot' => $personal, 'economic_snapshot' => $d], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
$back = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk()->json('data.economic_snapshot');
$this->assertSame($d['modelo_a_snapshot'], $back['modelo_a_snapshot']);
$this->assertSame($d['business_plan_snapshot'], $back['business_plan_snapshot']);
$b = json_decode($back['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR)['irpcNormal'][$d['context_opportunity']];
$this->assertSame('IRPC-economic-inclusion/1', $b['result']['model']['economicInclusions']['version']);
$this->assertSame(['numerator' => '162', 'denominator' => '1'], $b['result']['model']['economicInclusions']['net']);
$this->assertSame(['numerator' => '36496', 'denominator' => '25'], $b['result']['model']['colecta']);
$this->assertNotEmpty($b['history']);
file_put_contents(base_path('outputs/irpc-economic-special-inclusion-20261110/recovered.json'), json_encode($back, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
@@ -0,0 +1,83 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { economicCase } from './helpers/irpc-economic-case.mjs';
import {
calculateAnnual,
recordAnnual,
saveAnnualBook,
annualBook,
annualCurrent,
annualHtml,
} from '../../resources/js/lib/irpc-annual-settlement.ts';
import { createProjectDossierReport } from '../../resources/js/lib/project-dossier-report.ts';
function associationCase(kind = 'participation', effectivelyTaxed = 'yes') {
const c = economicCase();
Object.assign(c.b.economicCredits.rows[0], {
kind: 'association',
association: {
kind,
effectivelyTaxed,
reference:
'SYNTHETIC QA contract and distributed income; documented adaptation64(4)',
},
nationalMethod: 'proportional',
nationalBasisReference:
'SYNTHETIC included100 plus nominal12 in current base4400',
});
return c;
}
void test('participation and quota use only the documented economic tax and preserve L6 and other deductions', () => {
for (const kind of ['participation', 'quota']) {
const c = associationCase(kind, kind === 'quota' ? '' : 'yes'),
before = c.d.modelo_a_snapshot,
m = calculateAnnual(c.d, c.b);
assert.deepEqual(m.pending, []);
assert.deepEqual(m.economicCredit.deducted, {
numerator: '12',
denominator: '1',
});
assert.deepEqual(m.arithmeticBalance, {
numerator: '688',
denominator: '1',
});
assert.equal(c.d.modelo_a_snapshot, before);
}
});
void test('participation cannot infer effective taxation and unknown contract stays recoverable but indeterminate', () => {
for (const patch of [
{ kind: 'participation', effectivelyTaxed: '' },
{ kind: 'participation', effectivelyTaxed: 'no' },
{ kind: '', effectivelyTaxed: 'yes' },
{ reference: '' },
]) {
const c = associationCase();
Object.assign(c.b.economicCredits.rows[0].association, patch);
assert.equal(calculateAnnual(c.d, c.b).arithmeticBalance, null);
}
});
void test('association adaptation survives recovery and both report consumers without resetting old inputs', () => {
const c = associationCase(),
b = recordAnnual(c.d, c.b, '2026-11-10T16:10:00+02:00'),
d = JSON.parse(
JSON.stringify({
...c.d,
modelo_a_snapshot: saveAnnualBook(c.d, b),
}),
),
back = annualBook(d);
assert.equal(annualCurrent(d, back), true);
assert.deepEqual(
calculateAnnual(d, back).economicCredit,
back.result.model.economicCredit,
);
assert.match(annualHtml(d), /IRPC-economic-association\/1/);
assert.match(
JSON.stringify(
createProjectDossierReport(d, {
selectedSections: new Set(['finance']),
generatedAt: '2026-11-10T16:10:00+02:00',
}),
),
/IRPC-economic-association\/1/,
);
});
+1 -5
View File
@@ -79,11 +79,7 @@ void test('article40 exclusion, payer exemption/foreign residence and special di
const m = pending(c);
assert.equal(m.internationalCredit.positions.length, 1);
assert.equal(number(m.internationalCredit.positions[0].eligible), 8);
if (
patch.article40 === 'yes' ||
patch.kind === 'liquidation' ||
patch.kind === 'association'
)
if (patch.article40 === 'yes')
assert.equal(m.economicCredit.rows[0].status, 'outside_scope');
}
});
@@ -0,0 +1,219 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { economicCase } from './helpers/irpc-economic-case.mjs';
import {
normalBook,
calculateNormal,
recordNormal,
saveNormalBook,
normalCurrent,
normalHtml,
} from '../../resources/js/lib/irpc-normal.ts';
import { calculateAnnual } from '../../resources/js/lib/irpc-annual-settlement.ts';
import {
prepareEconomicInclusion,
calculateEconomicInclusions,
} from '../../resources/js/lib/irpc-normal-economic.ts';
import { createProjectDossierReport } from '../../resources/js/lib/project-dossier-report.ts';
function caseInput() {
const c = economicCase();
const source = structuredClone(c.b.economicCredits.rows[0]);
Object.assign(source, {
incomeIncluded: 'no',
incomeReference: 'SYNTHETIC not yet included',
grossUpIncluded: '0',
grossUpReference: 'SYNTHETIC no gross-up yet',
});
return {
...c,
normal: normalBook(c.d),
inclusion: {
source,
profitAlreadyIncluded: '0',
creditAlreadyIncluded: '0',
reference:
'SYNTHETIC QA complete reconciliation before this map, not a real accounting assertion',
},
};
}
void test('economic inclusion adds only the missing profits and derived60percent to current L6', () => {
const c = caseInput(),
old = structuredClone(c.normal.result);
const b = prepareEconomicInclusion(c.normal, c.inclusion),
m = calculateNormal(c.d, b);
assert.deepEqual(m.economicInclusions.pending, []);
assert.deepEqual(m.economicInclusions.net, {
numerator: '112',
denominator: '1',
});
assert.deepEqual(m.taxableProfit, { numerator: '6812', denominator: '1' });
assert.deepEqual(m.taxableBase, { numerator: '4512', denominator: '1' });
assert.deepEqual(m.colecta, { numerator: '36096', denominator: '25' });
assert.deepEqual(c.normal.result, old);
});
void test('economic inclusion is repeatable and replaces the same parcel instead of accumulating corrections', () => {
const c = caseInput(),
once = prepareEconomicInclusion(c.normal, c.inclusion);
assert.deepEqual(prepareEconomicInclusion(once, c.inclusion), once);
const alias = structuredClone(c.inclusion);
alias.source.distributionDocument =
' ' + alias.source.distributionDocument.toLowerCase() + ' ';
assert.equal(
prepareEconomicInclusion(once, alias).economicInclusions.rows.length,
1,
);
const changed = structuredClone(c.inclusion);
changed.source.income = '200';
const next = prepareEconomicInclusion(once, changed);
assert.equal(next.economicInclusions.rows.length, 1);
assert.equal(next.profit.topics.incomeCorrections.amount, '212.00');
assert.deepEqual(calculateNormal(c.d, next).taxableProfit, {
numerator: '6912',
denominator: '1',
});
});
void test('economic inclusion does not add amounts already considered and can correct documented over-inclusion', () => {
const c = economicCase(),
source = c.b.economicCredits.rows[0],
b = normalBook(c.d);
const row = {
source,
profitAlreadyIncluded: '100',
creditAlreadyIncluded: '12',
reference: 'SYNTHETIC already included once',
};
assert.deepEqual(
calculateNormal(c.d, prepareEconomicInclusion(b, row)).colecta,
b.result.model.colecta,
);
const correction = structuredClone(row);
correction.profitAlreadyIncluded = '150';
correction.creditAlreadyIncluded = '20';
correction.source.grossUpIncluded = '20';
const m = calculateNormal(c.d, prepareEconomicInclusion(b, correction));
assert.deepEqual(m.economicInclusions.net, {
numerator: '-58',
denominator: '1',
});
assert.deepEqual(m.taxableProfit, { numerator: '6642', denominator: '1' });
});
void test('economic inclusion rejects contradictory prior inclusion, duplicate origins, different taxpayers and unsupported fractional cents', () => {
const c = caseInput();
const contradiction = structuredClone(c.inclusion);
contradiction.source.incomeIncluded = 'yes';
assert.throws(
() => prepareEconomicInclusion(c.normal, contradiction),
/declara os lucros/,
);
const duplicate = calculateEconomicInclusions(
{
version: 1,
rows: [
c.inclusion,
{
...c.inclusion,
source: { ...c.inclusion.source, id: 'another-id' },
},
],
},
c.normal.base.subjectNuit,
'2026',
);
assert.ok(duplicate.pending.some((e) => e.includes('repetida')));
const other = structuredClone(c.inclusion);
other.source.subjectNuit = '987654321';
assert.throws(() => prepareEconomicInclusion(c.normal, other));
const partial = structuredClone(c.inclusion);
partial.source.taxAmount = '20.01';
partial.source.proofAmount = '20.01';
assert.throws(() => prepareEconomicInclusion(c.normal, partial), /cêntimo/);
});
void test('economic inclusion keeps recovery, previous result and reports while making the old L8 origin stale', () => {
const c = caseInput(),
previous = structuredClone(c.normal.result);
const b = recordNormal(
c.d,
prepareEconomicInclusion(c.normal, c.inclusion),
'2026-11-10T15:00:00+02:00',
);
const d = JSON.parse(
JSON.stringify({ ...c.d, modelo_a_snapshot: saveNormalBook(c.d, b) }),
);
const recovered = normalBook(d);
assert.equal(normalCurrent(d, recovered), true);
assert.deepEqual(recovered.history.at(-1), previous);
assert.deepEqual(
calculateNormal(d, recovered).economicInclusions,
recovered.result.model.economicInclusions,
);
assert.match(normalHtml(d), /IRPC-economic-inclusion\/1/);
assert.match(
JSON.stringify(
createProjectDossierReport(d, {
selectedSections: new Set(['finance']),
generatedAt: '2026-11-10T15:00:00+02:00',
}),
),
/IRPC-economic-inclusion\/1/,
);
assert.equal(calculateAnnual(d, c.b).arithmeticBalance, null);
});
void test('special inclusion reconciles the whole taxable partilha once but keeps only its eligible component in the credit', () => {
const c = caseInput();
Object.assign(c.inclusion.source, {
kind: 'liquidation',
income: '150',
liquidation: {
attributed: '200',
paidInCapital: '100',
reference:
'SYNTHETIC capital component100 and gain50 included in taxable income150',
},
});
const b = prepareEconomicInclusion(c.normal, c.inclusion),
m = calculateNormal(c.d, b);
assert.deepEqual(m.economicInclusions.net, {
numerator: '162',
denominator: '1',
});
assert.deepEqual(m.taxableBase, { numerator: '4562', denominator: '1' });
assert.match(m.economicInclusions.effects[0].reason, /mais-valia 50.00/);
assert.deepEqual(prepareEconomicInclusion(b, c.inclusion), b);
const already = structuredClone(c.inclusion);
Object.assign(already, {
profitAlreadyIncluded: '150',
creditAlreadyIncluded: '12',
});
Object.assign(already.source, {
incomeIncluded: 'yes',
grossUpIncluded: '12',
});
assert.deepEqual(
calculateNormal(c.d, prepareEconomicInclusion(c.normal, already))
.colecta,
c.normal.result.model.colecta,
);
});
void test('association inclusion requires its specific evidence and does not infer effective taxation', () => {
const c = caseInput();
Object.assign(c.inclusion.source, {
kind: 'association',
association: {
kind: 'participation',
effectivelyTaxed: '',
reference: 'SYNTHETIC contract',
},
});
assert.throws(
() => prepareEconomicInclusion(c.normal, c.inclusion),
/efectivamente tributados/,
);
c.inclusion.source.association.effectivelyTaxed = 'yes';
assert.deepEqual(
calculateNormal(c.d, prepareEconomicInclusion(c.normal, c.inclusion))
.economicInclusions.net,
{ numerator: '112', denominator: '1' },
);
});
@@ -0,0 +1,114 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { economicCase } from './helpers/irpc-economic-case.mjs';
import { economicIncome } from '../../resources/js/lib/irpc-economic-credit.ts';
import {
calculateAnnual,
recordAnnual,
saveAnnualBook,
annualBook,
annualCurrent,
annualHtml,
} from '../../resources/js/lib/irpc-annual-settlement.ts';
import { createProjectDossierReport } from '../../resources/js/lib/project-dossier-report.ts';
export function liquidationCase() {
const c = economicCase();
Object.assign(c.b.economicCredits.rows[0], {
kind: 'liquidation',
income: '150',
liquidation: {
attributed: '200',
paidInCapital: '100',
reference:
'SYNTHETIC QA partilha: capital income150 documented; eligible100 and excess50 separately classified',
},
nationalMethod: 'proportional',
nationalBasisReference:
'SYNTHETIC eligible100 plus nominal12 in ordinary base4400',
incomeReference:
'SYNTHETIC150 included in current L6, eligible100 and capital gain50 distinguished',
});
return c;
}
void test('liquidation caps the economic-credit component and separates capital gain without changing L6', () => {
const c = liquidationCase(),
before = c.d.modelo_a_snapshot,
m = calculateAnnual(c.d, c.b),
r = m.economicCredit.rows[0];
assert.deepEqual(m.pending, []);
assert.deepEqual(r.liquidation.eligibleIncome, {
numerator: '100',
denominator: '1',
});
assert.deepEqual(r.liquidation.excessCapitalGain, {
numerator: '50',
denominator: '1',
});
assert.deepEqual(r.nationalLimit, { numerator: '896', denominator: '25' });
assert.deepEqual(r.nominal, { numerator: '12', denominator: '1' });
assert.equal(c.d.modelo_a_snapshot, before);
});
void test('liquidation handles documented lower capital income and nonpositive capital ceiling', () => {
const c = liquidationCase(),
r = c.b.economicCredits.rows[0];
r.income = '40';
assert.deepEqual(economicIncome(r).income, {
numerator: '40',
denominator: '1',
});
r.liquidation.paidInCapital = '250';
assert.deepEqual(economicIncome(r).income, {
numerator: '0',
denominator: '1',
});
assert.equal(calculateAnnual(c.d, c.b).arithmeticBalance, null);
});
void test('liquidation rejects unknown evidence, attribution contradictions and stale collection', () => {
for (const patch of [
{ liquidation: undefined },
{
liquidation: {
attributed: '',
paidInCapital: '0',
reference: 'QA',
},
},
{
liquidation: {
attributed: '100',
paidInCapital: '0',
reference: 'QA',
},
},
{ assessmentKey: 'stale' },
]) {
const c = liquidationCase();
Object.assign(c.b.economicCredits.rows[0], patch);
assert.equal(calculateAnnual(c.d, c.b).arithmeticBalance, null);
}
});
void test('liquidation recovers the exact cap and excess in both report consumers, retaining old history', () => {
const c = liquidationCase(),
book = recordAnnual(c.d, c.b, '2026-11-10T16:00:00+02:00');
const d = JSON.parse(
JSON.stringify({
...c.d,
modelo_a_snapshot: saveAnnualBook(c.d, book),
}),
),
b = annualBook(d);
assert.equal(annualCurrent(d, b), true);
assert.deepEqual(
calculateAnnual(d, b).economicCredit,
b.result.model.economicCredit,
);
assert.match(annualHtml(d), /IRPC-economic-liquidation\/1/);
const report = JSON.stringify(
createProjectDossierReport(d, {
selectedSections: new Set(['finance']),
generatedAt: '2026-11-10T16:00:00+02:00',
}),
);
assert.match(report, /IRPC-economic-liquidation\/1/);
assert.match(report, /mais-valia/);
});
@@ -0,0 +1,93 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { economicCase } from './helpers/irpc-economic-case.mjs';
import {
calculateAnnual,
recordAnnual,
saveAnnualBook,
annualBook,
annualCurrent,
annualHtml,
} from '../../resources/js/lib/irpc-annual-settlement.ts';
import { createProjectDossierReport } from '../../resources/js/lib/project-dossier-report.ts';
function quotaCase() {
const c = economicCase();
Object.assign(c.b.economicCredits.rows[0], {
nationalMethod: 'proportional',
nationalBasisReference:
'SYNTHETIC QA: profits100 plus nominal60 already included and reconciled in current ordinary base4400',
taxAmount: '100',
proofAmount: '100',
grossUpIncluded: '60',
grossUpReference:
'SYNTHETIC QA: nominal60 already included; no base change',
});
return c;
}
void test('economic quota derives from profits plus the existing gross-up, without changing L6', () => {
const c = quotaCase(),
before = c.d.modelo_a_snapshot;
const m = calculateAnnual(c.d, c.b);
assert.deepEqual(m.pending, []);
assert.deepEqual(m.economicCredit.rows[0].nationalLimit, {
numerator: '256',
denominator: '5',
});
assert.deepEqual(m.economicCredit.deducted, {
numerator: '256',
denominator: '5',
});
assert.deepEqual(m.payable, { numerator: '3244', denominator: '5' });
assert.equal(c.d.modelo_a_snapshot, before);
assert.equal(m.economicCredit.rows[0].input.nationalLimit, '32');
});
void test('economic quota survives saving and recovery into both report consumers with its origin', () => {
const c = quotaCase();
const book = recordAnnual(c.d, c.b, '2026-11-10T14:00:00+02:00');
const d = JSON.parse(
JSON.stringify({
...c.d,
modelo_a_snapshot: saveAnnualBook(c.d, book),
}),
);
const recovered = annualBook(d);
assert.equal(annualCurrent(d, recovered), true);
assert.deepEqual(
calculateAnnual(d, recovered).economicCredit,
recovered.result.model.economicCredit,
);
assert.match(annualHtml(d), /IRPC-economic-quota\/1/);
assert.match(
JSON.stringify(
createProjectDossierReport(d, {
selectedSections: new Set(['finance']),
generatedAt: '2026-11-10T14:00:00+02:00',
}),
),
/256\/5/,
);
});
void test('economic quota requires support, positive matched base and a still-current assessment', () => {
for (const patch of [
{ nationalBasisReference: '' },
{ income: '4500' },
{ assessmentKey: 'obsolete' },
{ grossUpIncluded: '0' },
]) {
const c = quotaCase();
Object.assign(c.b.economicCredits.rows[0], patch);
const m = calculateAnnual(c.d, c.b);
assert.equal(m.status, 'indeterminate');
assert.equal(m.arithmeticBalance, null);
}
});
void test('economic quota defaults to the accepted documented path for old books', () => {
const c = economicCase();
const m = calculateAnnual(c.d, c.b);
assert.equal(m.economicCredit.rows[0].nationalDerivation, undefined);
assert.deepEqual(m.economicCredit.rows[0].nationalLimit, {
numerator: '32',
denominator: '1',
});
});
@@ -0,0 +1,19 @@
import fs from 'node:fs';
import { economicCase } from '../tests/frontend/helpers/irpc-economic-case.mjs';
import { recordAnnual, saveAnnualBook, annualBook, calculateAnnual, annualHtml } from '../resources/js/lib/irpc-annual-settlement.ts';
import assert from 'node:assert/strict';
const out = new URL('../outputs/irpc-economic-association-20261110/', import.meta.url);
fs.mkdirSync(out, { recursive: true });
if (process.argv[2] === 'recovered') {
const d = JSON.parse(fs.readFileSync(new URL('recovered.json', out), 'utf8'));
const b = annualBook(d), m = b.result.model;
assert.deepEqual(calculateAnnual(d, b).economicCredit, m.economicCredit);
const html = annualHtml(d);assert.match(html, /IRPC-economic-association\/1/);
fs.writeFileSync(new URL('report.html', out), html);
fs.writeFileSync(new URL('verification.json', out), JSON.stringify({ synthetic: true, apiRecovered: true, currentRecalculation: true, html: true, association: m.economicCredit.rows[0].association, quota: m.economicCredit.rows[0].nationalLimit, balance: m.arithmeticBalance, activeChanged: false }, null, 2));
} else {
const c = economicCase();
Object.assign(c.b.economicCredits.rows[0], {kind:'association',association:{kind:'participation',effectivelyTaxed:'yes',reference:'SYNTHETIC QA contract and effective taxation of distributed income; documented adaptation64(4)'},nationalMethod:'proportional',nationalBasisReference:'SYNTHETIC current base includes100 plus nominal12'});
const b = recordAnnual(c.d, c.b, '2026-11-10T14:00:00+02:00');
fs.writeFileSync(new URL('fixture.json', out), JSON.stringify({ ...c.d, modelo_a_snapshot: saveAnnualBook(c.d, b) }));
}
@@ -0,0 +1,41 @@
import { reuseAnnualAssessment, calculateAnnual, recordAnnual, saveAnnualBook, annualBook, annualCurrent, annualHtml } from '../resources/js/lib/irpc-annual-settlement.ts';
import fs from 'node:fs';
import assert from 'node:assert/strict';
import { economicCase } from '../tests/frontend/helpers/irpc-economic-case.mjs';
import { normalBook, calculateNormal, recordNormal, saveNormalBook, normalCurrent, normalHtml } from '../resources/js/lib/irpc-normal.ts';
import { prepareEconomicInclusion } from '../resources/js/lib/irpc-normal-economic.ts';
import { createProjectDossierReport } from '../resources/js/lib/project-dossier-report.ts';
const special = process.argv.includes('special');
const out = new URL(special ? '../outputs/irpc-economic-special-inclusion-20261110/' : '../outputs/irpc-economic-inclusion-20261110/', import.meta.url);
fs.mkdirSync(out, { recursive: true });
if (process.argv[2] === 'recovered') {
const d = JSON.parse(fs.readFileSync(new URL('recovered.json', out), 'utf8'));
const b = normalBook(d), m = b.result.model;
assert.equal(normalCurrent(d,b),true);
assert.deepEqual(calculateNormal(d,b).economicInclusions,m.economicInclusions);
assert.equal(b.history.length > 0,true);
const html = normalHtml(d), report = createProjectDossierReport(d,{selectedSections:new Set(['finance']),generatedAt:'2026-11-10T15:00:00+02:00'});
assert.match(html,/IRPC-economic-inclusion\/1/);
assert.match(JSON.stringify(report),/IRPC-economic-inclusion\/1/);
const annual=annualBook(d), final=calculateAnnual(d,annual);
assert.equal(annualCurrent(d,annual),true);
assert.deepEqual(final.pending,[]);
assert.deepEqual(final.economicCredit,annual.result.model.economicCredit);
assert.match(annualHtml(d),/IRPC-economic-quota\/1/);
fs.writeFileSync(new URL('annual-report.html',out),annualHtml(d));
fs.writeFileSync(new URL('report.html',out),html);
fs.writeFileSync(new URL('verification.json',out),JSON.stringify({synthetic:true,apiRecovered:true,currentRecalculation:true,historyPreserved:true,html:true,dossierReport:true,effect:m.economicInclusions.net,taxableProfit:m.taxableProfit,taxableBase:m.taxableBase,colecta:m.colecta,annualContinued:true,quota:final.economicCredit.rows[0].nationalLimit,balance:final.arithmeticBalance,activeChanged:false},null,2));
} else {
const c=economicCase(), source=structuredClone(c.b.economicCredits.rows[0]);
Object.assign(source,{incomeIncluded:'no',incomeReference:'SYNTHETIC not yet included',grossUpIncluded:'0',grossUpReference:'SYNTHETIC no gross-up yet'});
if (special) Object.assign(source,{kind:'liquidation',income:'150',liquidation:{attributed:'200',paidInCapital:'100',reference:'SYNTHETIC QA capital100/gain50 in total taxable150; exact reconciliation'}});
const b=recordNormal(c.d,prepareEconomicInclusion(normalBook(c.d),{source,profitAlreadyIncluded:'0',creditAlreadyIncluded:'0',reference:'SYNTHETIC QA explicit reconciliation before this map'}),'2026-11-10T15:00:00+02:00');
const d={...c.d,modelo_a_snapshot:saveNormalBook(c.d,b)};
let annual=reuseAnnualAssessment(d,c.b);
annual.internationalCredits.positions.forEach(p=>{p.assessmentKey=annual.assessmentKey;p.incomeReference='SYNTHETIC reconfirmed foreign component in new L6';});
Object.assign(annual.economicCredits.rows[0],source,{assessmentKey:annual.assessmentKey,incomeIncluded:'yes',incomeReference:'SYNTHETIC reconciled inclusion map in new L6',grossUpIncluded:'12',grossUpReference:'SYNTHETIC reconciled nominal12 in new L6',nationalMethod:'proportional',nationalBasisReference:'SYNTHETIC same112 component in new base4512'});
assert.deepEqual(calculateAnnual(d,annual).pending,[]);
annual=recordAnnual(d,annual,'2026-11-10T15:01:00+02:00');
d.modelo_a_snapshot=saveAnnualBook(d,annual);
fs.writeFileSync(new URL('fixture.json',out),JSON.stringify(d));
}
@@ -0,0 +1,19 @@
import fs from 'node:fs';
import { economicCase } from '../tests/frontend/helpers/irpc-economic-case.mjs';
import { recordAnnual, saveAnnualBook, annualBook, calculateAnnual, annualHtml } from '../resources/js/lib/irpc-annual-settlement.ts';
import assert from 'node:assert/strict';
const out = new URL('../outputs/irpc-economic-liquidation-20261110/', import.meta.url);
fs.mkdirSync(out, { recursive: true });
if (process.argv[2] === 'recovered') {
const d = JSON.parse(fs.readFileSync(new URL('recovered.json', out), 'utf8'));
const b = annualBook(d), m = b.result.model;
assert.deepEqual(calculateAnnual(d, b).economicCredit, m.economicCredit);
const html = annualHtml(d);assert.match(html, /IRPC-economic-liquidation\/1/);
fs.writeFileSync(new URL('report.html', out), html);
fs.writeFileSync(new URL('verification.json', out), JSON.stringify({ synthetic: true, apiRecovered: true, currentRecalculation: true, html: true, liquidation: m.economicCredit.rows[0].liquidation, quota: m.economicCredit.rows[0].nationalLimit, balance: m.arithmeticBalance, activeChanged: false }, null, 2));
} else {
const c = economicCase();
Object.assign(c.b.economicCredits.rows[0], {kind:'liquidation',income:'150',liquidation:{attributed:'200',paidInCapital:'100',reference:'SYNTHETIC QA partilha; documented capital-income150, eligible100 and gain50; L6 already reconciled'},nationalMethod:'proportional',nationalBasisReference:'SYNTHETIC current base includes eligible100 plus nominal12'});
const b = recordAnnual(c.d, c.b, '2026-11-10T14:00:00+02:00');
fs.writeFileSync(new URL('fixture.json', out), JSON.stringify({ ...c.d, modelo_a_snapshot: saveAnnualBook(c.d, b) }));
}
+27
View File
@@ -0,0 +1,27 @@
<?php
$root = dirname(__DIR__);
if (($argv[1] ?? '') !== 'test' || basename($root) !== 'portal-investidor') {
throw new RuntimeException('Explicit local test operation required.');
}
foreach (['APP_ENV' => 'testing', 'APP_KEY' => 'base64:'.base64_encode(str_repeat('Q', 32)),
'APP_CONFIG_CACHE' => $root.'/outputs/irpc-national-fraction-20261110/no-config.php',
'DB_CONNECTION' => 'pgsql', 'DB_HOST' => '127.0.0.1', 'DB_PORT' => '55441',
'DB_DATABASE' => 'proactive_investment_20261027_test', 'DB_USERNAME' => 'investmoz_test', 'DB_PASSWORD' => '',
'DB_URL' => '', 'DATABASE_URL' => '', 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array',
'QUEUE_CONNECTION' => 'sync', 'MAIL_MAILER' => 'array', 'SUITECRM_ENABLED' => 'false'] as $key => $value) {
putenv($key.'='.$value);
$_ENV[$key] = $_SERVER[$key] = $value;
}
$pdo = new PDO('pgsql:host=127.0.0.1;port=55441;dbname=proactive_investment_20261027_test', 'investmoz_test', '');
$identity = $pdo->query('select current_database() as name,inet_server_port() as port')->fetch(PDO::FETCH_ASSOC);
if ($identity['name'] !== 'proactive_investment_20261027_test' || (int) $identity['port'] !== 55441) {
throw new RuntimeException('Isolated QA identity required.');
}
$case = ['quota' => 'IrpcEconomicQuotaDraftTest', 'inclusion' => 'IrpcEconomicInclusionDraftTest', 'liquidation' => 'IrpcEconomicLiquidationDraftTest', 'association' => 'IrpcEconomicAssociationDraftTest', 'special-inclusion' => 'IrpcEconomicSpecialInclusionDraftTest'][$argv[2] ?? ''] ?? throw new RuntimeException('Choose quota, inclusion, liquidation or association explicitly.');
$argv = ['phpunit', '-c', 'phpunit.investmoz.xml', '--do-not-cache-result', 'tests/Feature/'.$case.'.php'];
$argc = count($argv);
$_SERVER['argv'] = $argv;
$_SERVER['argc'] = $argc;
require $root.'/vendor/autoload.php';
require $root.'/vendor/phpunit/phpunit/phpunit';
+19
View File
@@ -0,0 +1,19 @@
import fs from 'node:fs';
import { economicCase } from '../tests/frontend/helpers/irpc-economic-case.mjs';
import { recordAnnual, saveAnnualBook, annualBook, calculateAnnual, annualHtml } from '../resources/js/lib/irpc-annual-settlement.ts';
import assert from 'node:assert/strict';
const out = new URL('../outputs/irpc-economic-quota-20261110/', import.meta.url);
fs.mkdirSync(out, { recursive: true });
if (process.argv[2] === 'recovered') {
const d = JSON.parse(fs.readFileSync(new URL('recovered.json', out), 'utf8'));
const b = annualBook(d), m = b.result.model;
assert.deepEqual(calculateAnnual(d, b).economicCredit, m.economicCredit);
const html = annualHtml(d);assert.match(html, /IRPC-economic-quota\/1/);
fs.writeFileSync(new URL('report.html', out), html);
fs.writeFileSync(new URL('verification.json', out), JSON.stringify({ synthetic: true, apiRecovered: true, currentRecalculation: true, html: true, quota: m.economicCredit.rows[0].nationalLimit, balance: m.arithmeticBalance, activeChanged: false }, null, 2));
} else {
const c = economicCase();
Object.assign(c.b.economicCredits.rows[0], { nationalMethod: 'proportional', nationalBasisReference: 'SYNTHETIC QA: profits100 plus nominal60 already reconciled in base4400', taxAmount: '100', proofAmount: '100', grossUpIncluded: '60', grossUpReference: 'SYNTHETIC QA: nominal60 already included, no automatic base change' });
const b = recordAnnual(c.d, c.b, '2026-11-10T14:00:00+02:00');
fs.writeFileSync(new URL('fixture.json', out), JSON.stringify({ ...c.d, modelo_a_snapshot: saveAnnualBook(c.d, b) }));
}