1061 lines
40 KiB
TypeScript
1061 lines
40 KiB
TypeScript
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) =>
|
||
({
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": ''',
|
||
})[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>'
|
||
);
|
||
}
|