Files
Investidor-V2/resources/js/lib/irpc-withholdings.ts
T

1061 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { inputText } from './input-text.ts';
import {
validateMrfta,
mrftaReportRows,
type MrftaStore,
} from './irpc-mrfta.ts';
import {
irpcReliefVersion,
evaluateIrpcRelief,
irpcReliefEvidenceLabel,
type IrpcReliefPreparation,
} from './irpc-relief-preparation.ts';
import {
convertIrpcAmount,
irpcFxDependency,
irpcFxEvidenceLabel,
type IrpcFxAmount,
type IrpcFxEvidence,
} from './irpc-fx.ts';
import { acquisitionBook, acquisitionScope } from './iva-acquisitions.ts';
import { salesBook } from './iva-operations.ts';
import { closureFingerprint } from './iva-closure.ts';
import {
addFractions,
decimalFraction,
fraction,
fractionPresentation,
subtractFractions,
type FiscalFraction,
} from './fiscal-rational.ts';
export const irpcWithholdingVersion = 'irpc-L5/1';
export const irpcWithholdingSource =
'Lei12/2025,BR248/2025,PDF19/p2180(19):arts5(5/7),20(2)c,62(5)a/b e67(5/7);CIRPC Lei34/2007 arts2,62/67/68,PDF17–19/p768(71–73);Lei19/2013 PDF2/p682(22),62(3/4);Lei20/2022 BR252 p2731/PDF1,62(1–5). Art67(7)remete62(6),enquanto taxa nova consta62(5):divergência formal preservada,sem renumerar o original. MRFTA19/39AT,p1–2. Código2002 revogado não usado. Outros regimes/taxas dependem da correspondência específica,sem zero presumido.';
type Choice = '' | 'yes' | 'no';
export type IrpcKind =
| ''
| 'digital_goods'
| 'digital_services'
| 'emoney_commission'
| 'royalty'
| 'equipment'
| 'rent'
| 'capital'
| 'services'
| 'telecom'
| 'international_transport'
| 'connected_installation'
| 'aircraft_maintenance_charter'
| 'rural_public_electrification'
| 'maritime_fishing_cabotage'
| 'other';
export const irpcKinds: Array<[IrpcKind, string]> = [
['', 'Por identificar'],
['digital_goods', 'Transmissão de bens digitais'],
['digital_services', 'Prestação de serviços digitais'],
['emoney_commission', 'Comissão de agente de moeda electrónica'],
['royalty', 'Propriedade intelectual/industrial'],
['equipment', 'Uso de equipamento'],
['rent', 'Rendimento predial'],
['capital', 'Aplicação de capitais'],
['services', 'Outras prestações/intermediação'],
['telecom', 'Serviços de telecomunicações — artigo62(4)a'],
['international_transport', 'Transportes internacionais — artigo62(4)a'],
[
'connected_installation',
'Montagem/instalação de equipamentos conexos a telecomunicações/transportes —62(4)a',
],
[
'aircraft_maintenance_charter',
'Manutenção e fretamento de aeronaves —62(4)a',
],
[
'rural_public_electrification',
'Construção/reabilitação de infraestruturas de produção,transporte e distribuição de energia eléctrica,nas zonas rurais,em projectos públicos de electrificação rural —62(4)b',
],
[
'maritime_fishing_cabotage',
'Afretamento de embarcações marítimas para pesca/cabotagem —62(4)c',
],
['other', 'Outro enquadramento'],
];
export type IrpcEvent = {
kind:
| 'cost'
| 'payment'
| 'maturity'
| 'availability'
| 'liquidation'
| 'quantification';
date: string;
reference: string;
};
export type IrpcPayment = IrpcFxAmount & {
id: string;
kind: 'beneficiary' | 'remittance' | 'withholding_proof';
reference: string;
date: string;
amount: string;
currency: string;
};
export type IrpcOrdinaryFacts = {
nonDigital: Choice;
capitalType:
| ''
| 'unlisted_interest'
| 'unlisted_dividend'
| 'listed_debt_mz'
| 'listed_other_mz'
| 'liquidity_repo'
| 'other';
rentOwnPropertyManagement: Choice;
specialFacts: Choice;
capitalSeatAndManagement?: Choice;
reference: string;
};
export const emptyIrpcOrdinaryFacts = (): IrpcOrdinaryFacts => ({
nonDigital: '',
capitalType: '',
rentOwnPropertyManagement: '',
specialFacts: '',
capitalSeatAndManagement: '',
reference: '',
});
export type IrpcRow = IrpcFxAmount & {
reliefPreparation?: IrpcReliefPreparation;
ordinary?: IrpcOrdinaryFacts;
id: string;
direction: 'paid' | 'received';
counterparty: string;
documentRef: string;
description: string;
base: string;
currency: string;
origin: 'projected' | 'effective';
kind: IrpcKind;
recipientIrpc: Choice;
payerIrpc: Choice;
residence: '' | 'resident' | 'nonresident';
permanentEstablishment: Choice;
attributable: Choice;
mozambique: Choice;
qualificationRef: string;
specialRelief: '' | 'none' | 'claimed';
reliefRef: string;
eventsComplete: Choice;
events: IrpcEvent[];
settlement: '' | 'none' | 'complete';
settlementRef: string;
allocations: Array<{ paymentId: string; amount: string }>;
source?: { id: string; key: string; fingerprint: string };
};
export type IrpcResult = {
treatment?: 'dispensed' | 'treaty';
reliefRule?: string;
id: string;
status: 'calculated' | 'indeterminate' | 'outside_scope';
reason: string;
period: string;
eventDate: string;
nature: 'definitive' | 'on_account' | null;
rate: number | null;
base: FiscalFraction | null;
tax: FiscalFraction | null;
settled: FiscalFraction | null;
outstanding: FiscalFraction | null;
direction: IrpcRow['direction'];
conversion?: IrpcFxEvidence | null;
};
export type IrpcBook = MrftaStore & {
version: 1;
rows: IrpcRow[];
payments: IrpcPayment[];
result?: {
version: string;
key: string;
calculatedAt: string;
inputs: IrpcRow[];
payments: IrpcPayment[];
rows: IrpcResult[];
paymentConversions?: Record<string, IrpcFxEvidence | null>;
};
history?: Array<NonNullable<IrpcBook['result']>>;
};
export const newIrpcRow = (id: string): IrpcRow => ({
id,
direction: 'paid',
counterparty: '',
documentRef: '',
description: '',
base: '',
currency: 'MZN',
origin: 'projected',
kind: '',
recipientIrpc: '',
payerIrpc: '',
residence: '',
permanentEstablishment: '',
attributable: '',
mozambique: '',
qualificationRef: '',
specialRelief: '',
reliefRef: '',
eventsComplete: '',
events: [],
settlement: '',
settlementRef: '',
allocations: [],
});
function store(d: Record<string, unknown>) {
const s = JSON.parse(
inputText(d.modelo_a_snapshot || '{"version":1,"books":{}}'),
);
if (
s.version !== 1 ||
!s.books ||
typeof s.books !== 'object' ||
Array.isArray(s.books)
)
throw Error('Livros fiscais não recuperáveis;originais preservados.');
return s;
}
function map(d: Record<string, unknown>): Record<string, IrpcBook> {
const m = store(d).irpcWithholdings;
if (m === undefined) return {};
if (!m || typeof m !== 'object' || Array.isArray(m))
throw Error('Mapa IRPC não recuperável;originais preservados.');
return m;
}
function validate(b: IrpcBook) {
if (
!b ||
b.version !== 1 ||
!Array.isArray(b.rows) ||
!Array.isArray(b.payments) ||
(b.history !== undefined && !Array.isArray(b.history))
)
throw Error('Livro IRPC não recuperável.');
validateMrfta(b);
for (const r of b.rows) {
const template = newIrpcRow('');
if (
!r ||
Object.keys(template)
.filter((k) => typeof template[k as keyof IrpcRow] === 'string')
.some((k) => typeof r[k as keyof IrpcRow] !== 'string') ||
!['paid', 'received'].includes(r.direction) ||
!['projected', 'effective'].includes(r.origin) ||
!irpcKinds.some(([k]) => k === r.kind) ||
!['', 'resident', 'nonresident'].includes(r.residence) ||
!['', 'none', 'claimed'].includes(r.specialRelief) ||
!['', 'none', 'complete'].includes(r.settlement) ||
[
'recipientIrpc',
'payerIrpc',
'permanentEstablishment',
'attributable',
'mozambique',
'eventsComplete',
].some(
(k) =>
!['', 'yes', 'no'].includes(
r[k as keyof IrpcRow] as string,
),
) ||
!Array.isArray(r.events) ||
!Array.isArray(r.allocations) ||
r.events.some(
(e) =>
!e ||
![
'cost',
'payment',
'maturity',
'availability',
'liquidation',
'quantification',
].includes(e.kind) ||
typeof e.date !== 'string' ||
typeof e.reference !== 'string',
) ||
r.allocations.some(
(a) =>
!a ||
typeof a.paymentId !== 'string' ||
typeof a.amount !== 'string',
) ||
(r.source !== undefined &&
(!r.source ||
!['id', 'key', 'fingerprint'].every(
(k) =>
typeof r.source![
k as keyof NonNullable<IrpcRow['source']>
] === 'string',
)))
)
throw Error(
'Entradas IRPC não recuperáveis;sem apagar ou repor vazios.',
);
}
for (const r of b.rows)
if (
r.reliefPreparation &&
((r.reliefPreparation.rule !== undefined &&
!['', 'cirpc68d', 'cirpc68f', 'ptMz12'].includes(
r.reliefPreparation.rule,
)) ||
(r.reliefPreparation.facts !== undefined &&
(!r.reliefPreparation.facts ||
typeof r.reliefPreparation.facts !== 'object' ||
Array.isArray(r.reliefPreparation.facts) ||
Object.values(r.reliefPreparation.facts).some(
(v) => !['', 'yes', 'no'].includes(v),
))))
)
throw Error('Condições da dispensa/convenção não recuperáveis.');
for (const r of b.rows)
if (
r.reliefPreparation &&
(!['dispensation', 'exemption', 'treaty'].includes(
r.reliefPreparation.kind,
) ||
[
'instrument',
'article',
'country',
'residenceDocument',
'validFrom',
'validTo',
'conditions',
'evidence',
].some(
(k) =>
typeof r.reliefPreparation![
k as keyof IrpcReliefPreparation
] !== 'string',
))
)
throw Error(
'Preparação de dispensa/convenção não recuperável;original preservado.',
);
for (const r of b.rows)
if (
r.ordinary &&
(['nonDigital', 'rentOwnPropertyManagement', 'specialFacts'].some(
(k) =>
!['', 'yes', 'no'].includes(
r.ordinary![
k as
| 'nonDigital'
| 'rentOwnPropertyManagement'
| 'specialFacts'
],
),
) ||
typeof r.ordinary.reference !== 'string' ||
![
'',
'unlisted_interest',
'unlisted_dividend',
'listed_debt_mz',
'listed_other_mz',
'liquidity_repo',
'other',
].includes(r.ordinary.capitalType))
)
throw Error('Factos específicos IRPC não recuperáveis.');
for (const r of b.rows)
if (
r.ordinary?.capitalSeatAndManagement !== undefined &&
!['', 'yes', 'no'].includes(r.ordinary.capitalSeatAndManagement)
)
throw Error('Factos de sede/direcção não recuperáveis.');
for (const v of [...b.rows, ...b.payments])
if (
(v.fxSelection !== undefined &&
(!v.fxSelection ||
typeof v.fxSelection !== 'object' ||
Array.isArray(v.fxSelection))) ||
(v.fxLink !== undefined &&
(!v.fxLink ||
typeof v.fxLink.id !== 'string' ||
typeof v.fxLink.fingerprint !== 'string'))
)
throw Error(
'Vínculo/selecção cambial não recuperável;original preservado.',
);
for (const p of b.payments)
if (
!p ||
!['id', 'reference', 'date', 'amount', 'currency'].every(
(k) => typeof p[k as keyof IrpcPayment] === 'string',
) ||
!['beneficiary', 'remittance', 'withholding_proof'].includes(p.kind)
)
throw Error('Pagamento IRPC inválido;originais preservados.');
if (
b.result &&
(!Array.isArray(b.result.rows) ||
!Array.isArray(b.result.inputs) ||
typeof b.result.key !== 'string' ||
b.result.version !== irpcWithholdingVersion)
)
throw Error('Resultado IRPC não recuperável.');
if (
b.payments.some(
(p, i) =>
p.reference.trim() &&
b.payments.some(
(q, j) =>
i !== j &&
q.kind === p.kind &&
q.reference.trim() === p.reference.trim() &&
q.date === p.date &&
q.currency === p.currency,
),
)
)
throw Error(
'Pagamento/prova já registado:reutilize a referência existente em vez de duplicar.',
);
if (
new Set(b.rows.map((r) => r.id)).size !== b.rows.length ||
new Set(b.payments.map((p) => p.id)).size !== b.payments.length
)
throw Error('Identificadores IRPC repetidos.');
}
export function irpcBook(d: Record<string, unknown>): IrpcBook {
const b = map(d)[acquisitionScope(d)] ?? {
version: 1,
rows: [],
payments: [],
};
validate(b);
return b;
}
export function saveIrpcBook(d: Record<string, unknown>, b: IrpcBook): string {
validate(b);
return JSON.stringify({
...store(d),
irpcWithholdings: { ...map(d), [acquisitionScope(d)]: b },
});
}
export function irpcSources(d: Record<string, unknown>) {
return [
...acquisitionBook(d).rows.map((r) => ({
id: 'acquisition:' + r.id,
key: closureFingerprint(r),
counterparty: r.supplier,
documentRef:
r.documentRef +
(r.documentLine ? ' / linha ' + r.documentLine : ''),
description: r.description,
amount: r.net,
currency: acquisitionBook(d).currency,
direction: 'paid' as const,
origin: r.origin,
})),
...salesBook(d).rows.map((r) => ({
id: 'sale:' + r.id,
key: closureFingerprint(r),
counterparty: '',
documentRef: r.evidence,
description: r.description,
amount: r.basis === 'net' ? r.amount : '',
currency: r.sourceCurrency,
direction: 'received' as const,
origin: 'projected' as const,
})),
];
}
export function reuseIrpcSources(
d: Record<string, unknown>,
b: IrpcBook,
): IrpcBook {
validate(b);
const rows = [...b.rows];
for (const s of irpcSources(d))
if (!rows.some((r) => r.source?.id === s.id)) {
const r = {
...newIrpcRow('irpc:' + s.id),
counterparty: s.counterparty,
documentRef: s.documentRef,
description: s.description,
base: s.amount,
currency: s.currency,
direction: s.direction,
origin: s.origin,
};
rows.push({
...r,
source: {
id: s.id,
key: s.key,
fingerprint: closureFingerprint(r),
},
});
}
return { ...b, rows };
}
function sourceIssue(d: Record<string, unknown>, r: IrpcRow) {
if (!r.source) return '';
const s = irpcSources(d).find((s) => s.id === r.source!.id);
return !s
? 'Operação de origem não está disponível neste contexto;vínculo e edições conservados.'
: s.key !== r.source.key
? 'Documento/operação de origem alterado;compare o histórico e reconcilie a divergência antes de calcular.'
: '';
}
const date = (s: string) =>
/^20\d{2}-(0[1-9]|1[0-2])-\d{2}$/.test(s) &&
!Number.isNaN(Date.parse(s)) &&
new Date(s).toISOString().slice(0, 10) === s;
const positive = (f: FiscalFraction) => BigInt(f.numerator) > 0n;
export function evaluateIrpc(
d: Record<string, unknown>,
r: IrpcRow,
): IrpcResult {
const v: IrpcResult = {
id: r.id,
status: 'indeterminate',
reason: 'Identifique o rendimento e os intervenientes.',
period: '',
eventDate: '',
nature: null,
rate: null,
base: null,
tax: null,
settled: null,
outstanding: null,
direction: r.direction,
};
const issue = sourceIssue(d, r) || irpcFxDependency(d, r);
if (issue) return { ...v, reason: issue };
if (r.recipientIrpc === 'no')
return {
...v,
status: 'outside_scope',
reason: 'Titular fora de IRPC;sem converter em IRPS ou em isenção.',
};
if (
!r.counterparty.trim() ||
!r.documentRef.trim() ||
r.recipientIrpc !== 'yes' ||
r.payerIrpc !== 'yes' ||
!r.residence ||
!r.kind ||
!r.qualificationRef.trim()
)
return {
...v,
reason: 'Faltam contraparte,documento,qualificação do rendimento,titular IRPC,devedor IRPC ou residência. A via devedor IRPS exige âmbito próprio.',
};
if (r.mozambique !== 'yes')
return {
...v,
reason: 'Falta correspondência territorial aos arts5/67;ser estrangeiro não resolve incidência nem isenção.',
};
if (r.specialRelief !== 'none' && r.specialRelief !== 'claimed')
return {
...v,
reason: 'Convenção,isenção ou dispensa invocada/desconhecida:identificar artigo,período,condições e prova;sem taxa reduzida ou zero automáticos.',
};
if (
!r.eventsComplete ||
r.eventsComplete !== 'yes' ||
!r.events.length ||
r.events.some((e) => !date(e.date) || !e.reference.trim())
)
return {
...v,
reason: 'Identifique os eventos aplicáveis67(5),incluindo reconhecimento do custo,e confirme a sua completude com suporte;uma factura ou pagamento isolado não fixa o momento.',
};
v.eventDate = r.events.map((e) => e.date).sort()[0];
v.period = v.eventDate.slice(0, 7);
if (!v.eventDate.startsWith('2026-'))
return {
...v,
reason: 'Primeiro evento aplicável fora de2026;corresponder versão/período próprio,sem deslocar para o pagamento posterior.',
};
try {
const converted = convertIrpcAmount(d, r.base, r);
v.base = converted.value;
v.conversion = converted.evidence;
} catch (e) {
return {
...v,
reason: 'Base do rendimento/conversão por resolver: ' + String(e),
};
}
if (r.specialRelief === 'claimed') {
const relief = evaluateIrpcRelief(r, v.eventDate);
if (!relief.applied) return { ...v, reason: relief.reason };
v.treatment = relief.treatment;
v.reliefRule = relief.rule;
v.rate = relief.rate;
v.nature = relief.treatment === 'treaty' ? 'definitive' : 'on_account';
v.reason = relief.reason;
v.tax = fraction(
BigInt(v.base.numerator) * BigInt(relief.rate),
BigInt(v.base.denominator) * 100n,
);
v.status = 'calculated';
return v;
}
const digital = [
'digital_goods',
'digital_services',
'emoney_commission',
].includes(r.kind);
if (digital) {
if (
(r.residence === 'nonresident' &&
r.permanentEstablishment === 'no') ||
(r.residence === 'nonresident' &&
r.permanentEstablishment === 'yes' &&
r.attributable === 'no')
)
v.nature = 'definitive';
else
return {
...v,
reason: 'Para residente ou rendimento imputável aEP,corresponder concretamente a taxa62(5) com as dispensas68,em especial68(d),antes da liquidação. EP desconhecido permanece indeterminado;90dias/serviço digital não criam ausência deEP automática.',
};
v.rate = 10;
v.reason =
'Taxa10%62(5) no âmbito digital/moeda electrónica demonstrado;retenção definitiva67(3). Procedimento67(7)/remissão62(6) e CER permanecem separados.';
} else {
const f = r.ordinary;
if (!f || f.nonDigital !== 'yes' || !f.reference.trim())
return {
...v,
reason: 'Classifique o rendimento não abrangido pelo ramo digital/moeda electrónica e identifique os factos específicos. Cotejo62/67:Lei34/2007,Lei19/2013 eLei20/2022 agora disponível;sem taxa genérica.',
};
const nonresident = r.residence === 'nonresident';
if (
nonresident &&
r.permanentEstablishment !== 'no' &&
!(
r.permanentEstablishment === 'yes' &&
['yes', 'no'].includes(r.attributable)
)
)
return {
...v,
reason: 'Falta identificar EP e imputação;ausência física ou nacionalidade não resolvem este facto.',
};
const attributable =
!nonresident ||
(r.permanentEstablishment === 'yes' && r.attributable === 'yes');
v.nature =
r.kind === 'rent' || attributable ? 'on_account' : 'definitive';
if (
['equipment', 'services'].includes(r.kind) &&
v.nature === 'on_account'
)
return {
...v,
reason: 'Rendimento67(1)b/g com imposto por conta:dispensa68(d) identificada,por corresponder no ramo de dispensas;não reter20% nem declarar isenção do rendimento.',
};
if (r.kind === 'rent' && f.rentOwnPropertyManagement !== 'no')
return {
...v,
reason: 'Rendimento predial mantém natureza por conta67(3). Identifique a gestão de imóveis próprios e eventual dispensa68(f)/transparência;apenas este caso depende dessa correspondência.',
};
if (r.kind === 'capital') {
if (!f.capitalType || f.capitalType === 'other')
return {
...v,
reason: 'Identifique juros/dividendos não cotados,títulos cotadosBM ou permutas de liquidez;62(3/4) não permite taxa única para capitais.',
};
if (
r.residence === 'resident' &&
['listed_debt_mz', 'liquidity_repo'].includes(f.capitalType)
) {
if (f.capitalSeatAndManagement !== 'yes')
return {
...v,
reason: 'Art62(3):identifique sede e direcção efectiva nacionais do titular,além da categoria exacta dos títulos/permuta;residência genérica não substitui estes factos.',
};
v.nature = 'definitive';
}
if (v.nature === 'on_account' && f.capitalType !== 'listed_debt_mz')
return {
...v,
reason: 'Capitais com imposto por conta:correspondência específica das dispensas68(a/b/c/g),incluindo titular e operação,permanece separada;sem bloquear capitais definitivos independentes.',
};
if (
f.capitalType === 'liquidity_repo' &&
r.residence !== 'resident'
)
return {
...v,
reason: 'Permutas de liquidez62(3)b exigem entidade com sede/direcção efectiva nacional;caso não residente mantém correspondência própria.',
};
}
const special =
[
'telecom',
'international_transport',
'connected_installation',
'aircraft_maintenance_charter',
'rural_public_electrification',
'maritime_fishing_cabotage',
].includes(r.kind) ||
(r.kind === 'capital' && f.capitalType === 'listed_other_mz');
if (special) {
if (!nonresident || r.permanentEstablishment !== 'no')
return {
...v,
reason: 'Taxa especial62(4) refere entidades62(2),não residentes semEP. O caso residente/EP tem correspondência própria;sem copiar10%.',
};
if (f.specialFacts !== 'yes')
return {
...v,
reason: 'Confirme os factos exactos62(4)a–d:conexão dos equipamentos,projecto público rural,pesca/cabotagem ou título cotadoBM que não é dívida/bilhete62(3)a,conforme categoria. Sem taxa especial por descrição genérica.',
};
v.rate = 10;
v.reason =
'Taxa especial10%62(4),redacçãoLei19/2013,conservada emLei20/2022 eLei12/2025;entidade não residente semEP e categoria específica identificadas. Não é a taxa digital62(5).';
} else {
if (
![
'royalty',
'equipment',
'rent',
'capital',
'services',
].includes(r.kind)
)
return {
...v,
reason: 'Natureza fora dos ramos agrupados;identificar artigo e condições próprios,sem taxa por defeito.',
};
v.rate = 20;
v.reason =
(r.kind === 'capital' &&
r.residence === 'resident' &&
['listed_debt_mz', 'liquidity_repo'].includes(f.capitalType)
? 'Retenção liberatória20%62(3)a/b:juros de dívida/bilhetes cotados ou permuta de liquidez qualificados,com sede e direcção efectiva nacionais. '
: '') +
'Taxa20%62(1),com62(2/3) quando aplicável;incidência67(1),natureza67(3). Lei20/2022,p2731,confrontada:as medidas agrícolas terminam31/12/2025 e não são transportadas para2026. Sem aplicar10% digital a rendimentos não digitais.';
}
}
v.tax = fraction(
BigInt(v.base.numerator) * BigInt(v.rate!),
BigInt(v.base.denominator) * 100n,
);
v.status = 'calculated';
v.reason +=
' Base separada do IVA;entrega/prova e liquidação anual mantêm reconciliação própria.';
return v;
}
function sharedPayments(d: Record<string, unknown>, b: IrpcBook) {
const used = b.payments.filter((p) =>
b.rows.some((r) => r.allocations.some((a) => a.paymentId === p.id)),
);
return Object.entries(map(d))
.filter(([ctx]) => ctx !== acquisitionScope(d))
.flatMap(([ctx, other]) => {
validate(other);
const payments = other.payments.filter((q) =>
used.some(
(p) =>
p.reference.trim() === q.reference.trim() &&
p.date === q.date &&
p.currency === q.currency &&
p.kind === q.kind,
),
);
return payments.length
? [
[
ctx,
payments,
other.rows.flatMap((r) =>
r.allocations.filter((a) =>
payments.some((p) => p.id === a.paymentId),
),
),
],
]
: [];
});
}
function key(d: Record<string, unknown>, b: IrpcBook) {
const fx = [...b.rows, ...b.payments]
.filter((r) => r.currency !== 'MZN' && r.fxLink)
.map((r) => irpcFxDependency(d, r));
return JSON.stringify([
irpcWithholdingVersion,
acquisitionScope(d),
b.rows,
b.payments,
b.rows.map((r) => sourceIssue(d, r)),
sharedPayments(d, b),
...(fx.length ? [fx] : []),
...(b.rows.some((r) => r.specialRelief === 'claimed')
? [irpcReliefVersion]
: []),
]);
}
export function irpcCurrent(d: Record<string, unknown>, b: IrpcBook) {
return !!b.result && b.result.key === key(d, b);
}
export function recordIrpc(
d: Record<string, unknown>,
b: IrpcBook,
now = new Date().toISOString(),
): IrpcBook {
validate(b);
const rows = b.rows.map((r) => evaluateIrpc(d, r));
const paymentConversions: Record<string, IrpcFxEvidence | null> = {};
const allocations = new Map<string, FiscalFraction[]>();
const fail = (i: number, reason: string) => {
rows[i] = {
...rows[i],
status: 'indeterminate',
reason,
settled: null,
outstanding: null,
};
};
b.rows.forEach((r, i) => {
if (rows[i].status !== 'calculated') return;
if (rows[i].treatment === 'dispensed') {
if (r.allocations.length) {
fail(
i,
'Dispensa com entrega/prova associada: reconcilie sem apagar o pagamento nem gerar crédito automático.',
);
return;
}
rows[i].settled = fraction(0n, 1n);
rows[i].outstanding = fraction(0n, 1n);
return;
}
if (r.settlement === 'none' && r.allocations.length) {
fail(
i,
'Ausência de entrega/prova contradiz pagamentos associados;conserve e reconcilie.',
);
return;
}
if (!r.settlement || !r.settlementRef.trim()) {
rows[i].reason +=
' Entrega à AT/retenção sofrida ainda não reconciliada;não presumir zero pago.';
return;
}
const parts: FiscalFraction[] = [];
const seen = new Set<string>();
for (const a of r.allocations) {
const p = b.payments.find((p) => p.id === a.paymentId);
try {
if (
!p ||
seen.has(p.id) ||
(p.kind === 'remittance' && p.currency !== 'MZN') ||
!date(p.date) ||
p.date < rows[i].eventDate ||
!p.reference.trim() ||
p.kind !==
(r.direction === 'paid'
? 'remittance'
: 'withholding_proof')
)
throw Error();
seen.add(p.id);
const amount = decimalFraction(a.amount);
paymentConversions[p.id] = convertIrpcAmount(
d,
p.amount,
p,
).evidence;
parts.push(amount);
allocations.set(p.id, [
...(allocations.get(p.id) ?? []),
amount,
]);
} catch (e) {
fail(
i,
'Pagamento/prova ausente,incoerente,repetido ou de outra natureza:pagamento ao fornecedor e imposto IVA não são entrega IRPC. ' +
String(e),
);
return;
}
}
const settled = addFractions(parts);
if (positive(subtractFractions(settled, rows[i].tax!))) {
fail(
i,
'Valor associado excede a retenção calculada;reconcilie excesso sem gerar crédito automático.',
);
return;
}
rows[i].settled = settled;
rows[i].outstanding = subtractFractions(rows[i].tax!, settled);
});
for (const [id, parts] of allocations) {
const p = b.payments.find((p) => p.id === id)!;
let excess = false;
try {
const elsewhere: FiscalFraction[] = [];
for (const [ctx, other] of Object.entries(map(d))) {
if (ctx === acquisitionScope(d)) continue;
validate(other);
for (const q of other.payments.filter(
(q) =>
q.reference.trim() === p.reference.trim() &&
q.date === p.date &&
q.kind === p.kind &&
q.currency === p.currency,
)) {
if (
q.amount !== p.amount ||
(q.currency !== 'MZN' &&
JSON.stringify(q.fxSelection) !==
JSON.stringify(p.fxSelection))
)
throw Error('Montantes/conversões divergentes');
for (const r of other.rows)
for (const a of r.allocations.filter(
(a) => a.paymentId === q.id,
))
elsewhere.push(decimalFraction(a.amount));
}
}
excess = positive(
subtractFractions(
addFractions([...parts, ...elsewhere]),
convertIrpcAmount(d, p.amount, p).value,
),
);
} catch {
excess = true;
}
if (excess)
b.rows.forEach((r, i) => {
if (r.allocations.some((a) => a.paymentId === id))
fail(
i,
'Mesmo pagamento utilizado além do montante disponível;reparta sem duplicação.',
);
});
}
// An identical source document/income cannot become a second tax just by adding a row.
b.rows.forEach((r, i) => {
if (
b.rows.some(
(s, j) =>
j !== i &&
((r.source && s.source?.id === r.source.id) ||
(r.documentRef.trim() &&
s.documentRef.trim() === r.documentRef.trim() &&
s.counterparty.trim() === r.counterparty.trim() &&
s.direction === r.direction)),
)
) {
fail(
i,
'Documento/rendimento repetido neste livro;identifique parcelas próprias antes de somar,sem duplicação.',
);
}
});
return {
...b,
history: b.result
? [...(b.history ?? []), structuredClone(b.result)]
: b.history,
result: {
version: irpcWithholdingVersion,
key: key(d, b),
calculatedAt: now,
inputs: structuredClone(b.rows),
payments: structuredClone(b.payments),
rows,
paymentConversions,
},
};
}
const amount = (v: FiscalFraction | null) => {
if (!v) return 'Indeterminado';
const p = fractionPresentation(v);
return (p.approximate ? '≈' : '') + p.decimal + ' MZN';
};
export function irpcReportRows(
d: Record<string, unknown>,
): Array<[string, string, string, string]> {
try {
const b = irpcBook(d),
r = b.result;
if (!r) return mrftaReportRows(b, false);
return [
...r.rows.map((v): [string, string, string, string] => {
const i = r.inputs.find((i) => i.id === v.id)!;
return [
'IRPC — retenções',
`${i.documentRef} — ${i.counterparty}`,
`${{ calculated: 'Calculado', indeterminate: 'Indeterminado', outside_scope: 'Fora deste âmbito' }[v.status]};tratamento:${v.treatment === 'dispensed' ? 'Dispensa de retenção' : v.treatment === 'treaty' ? 'Limite convencional' : 'Retenção interna'};enquadramento: ${irpcKinds.find(([k]) => k === i.kind)?.[1] ?? i.kind};${i.direction === 'paid' ? 'retida a entregar' : 'sofrida'};base:${amount(v.base)};taxa:${v.treatment === 'dispensed' ? 'Dispensa (sem retenção)' : (v.rate ?? 'por corresponder') + '%'};retenção:${amount(v.tax)};entrega/prova:${amount(v.settled)};saldo:${amount(v.outstanding)};${v.reason}`,
`${acquisitionScope(d)};${i.origin};${v.period};evento:${v.eventDate};${v.nature === 'on_account' ? 'Imposto por conta (sem crédito anual automático)' : v.nature === 'definitive' ? 'Retenção definitiva' : 'Natureza por determinar'};${irpcCurrent(d, b) ? 'Entradas correspondentes' : 'Histórico;entradas/origem divergentes'};dispensa/convenção:${irpcReliefEvidenceLabel(i)};reconciliação:${i.settlementRef};comprovativos:${i.allocations
.map((a) => {
const p = r.payments.find(
(p) => p.id === a.paymentId,
);
return p
? `${p.reference}/${p.date}:parcela${a.amount}MZN de${p.amount}${p.currency};${r.paymentConversions?.[p.id] ? irpcFxEvidenceLabel(r.paymentConversions[p.id]!) : 'sem conversão usada'}`
: 'referência por resolver';
})
.join(
' | ',
)};${r.calculatedAt};${v.conversion ? 'Câmbio:' + irpcFxEvidenceLabel(v.conversion) + ';' : ''}${irpcWithholdingSource}`,
];
}),
...mrftaReportRows(b, irpcCurrent(d, b)),
];
} catch (e) {
return [
[
'IRPC — retenções',
'Livro não recuperável',
String(e),
'Originais e histórico preservados.',
],
];
}
}
export function irpcPreparationHtml(d: Record<string, unknown>): string {
const b = irpcBook(d);
if (!b.result || !irpcCurrent(d, b))
throw Error('Calcule e guarde as entradas actuais antes de preparar.');
const esc = (v: unknown) =>
String(v).replace(
/[&<>"']/g,
(c) =>
({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[c]!,
);
return (
'<!doctype html><html lang="pt"><meta charset="utf-8"><title>IRPC — preparação das retenções</title><body><h1>Preparação IRPC — L5</h1><p>Mapa de trabalho;sem submissão,recepção ou aceitação AT. MRFTA19/39:quadro1 retenção;quadro2 mês/ano único por declaração;quadro7 base/taxa/imposto. CER e correspondência das rubricas digitais/moeda electrónica por esclarecer;67(7)remete62(6),mas taxa nova em62(5). Não preencher assinatura ou uso dos serviços. Não consolidar períodos diferentes nem transformar retenções sofridas em imposto a entregar.</p>' +
irpcReportRows(d)
.map(
(r) =>
'<section><h2>' +
esc(r[1]) +
'</h2><p>' +
esc(r[2]) +
'</p><small>' +
esc(r[3]) +
'</small></section>',
)
.join('') +
'<p>Instruções do exemplar:prazo nominal dia20 do mês seguinte à dedução,sem ajuste de calendário/prorrogação ou procedimento2026 presumidos. Juros não calculados. GuiaM/39 anterior preservada,sem actualizar remissões do original.</p></body></html>'
);
}