961 lines
38 KiB
TypeScript
961 lines
38 KiB
TypeScript
import { inputText } from './input-text.ts';
|
||
import { acquisitionScope } from './iva-acquisitions.ts';
|
||
import {
|
||
ispcEligibilityBook,
|
||
ispcEligibilityCurrent,
|
||
} from './ispc-eligibility.ts';
|
||
import { ispcWithholdingBook } from './ispc-withholding.ts';
|
||
import { ispcQuarterBook, ispcQuarterCurrent } from './ispc-quarter.ts';
|
||
import {
|
||
addFractions,
|
||
decimalFraction,
|
||
fraction,
|
||
fractionPresentation,
|
||
subtractFractions,
|
||
type FiscalFraction,
|
||
} from './fiscal-rational.ts';
|
||
export type ResidualKind = 'occasional' | 'acquisitions' | 'formalities';
|
||
export type ResidualResult = {
|
||
key: string;
|
||
scope: string;
|
||
calculatedAt: string;
|
||
status:
|
||
| 'calculated'
|
||
| 'settled_by_withholding'
|
||
| 'partial'
|
||
| 'indeterminate'
|
||
| 'not_applicable';
|
||
reason: string;
|
||
missing: string[];
|
||
input: Record<string, string>;
|
||
amounts: Record<string, FiscalFraction | null>;
|
||
details: Record<string, string>;
|
||
source: string;
|
||
};
|
||
export type ResidualBook = {
|
||
version: 1;
|
||
kind: ResidualKind;
|
||
input: Record<string, string>;
|
||
result?: ResidualResult;
|
||
history?: ResidualResult[];
|
||
};
|
||
export const residualSources = {
|
||
occasional:
|
||
'ISPC Lei9/2025 arts3(3–4),4,5,8,9,10-A,12(3);BR248 PDF10–11. IRPS republicação Lei19/2017,BR202/7.ºsuplemento,art8(3h,4),9;PDF4/p2602(128). Lei11/2025 não altera estes artigos. Operação imprevisível/não reiterada, pessoa singular sem actividade habitual e famílias restritas; imposto próprio distinto de retenção liberatória documentada.',
|
||
acquisitions:
|
||
'ISPC12(3–4):limite global anual2.500.000MZN para factura por conta/retencão5%. Parcela acima do limite não adquire taxa por ultrapassagem. IRPS57(1,2k,4),republicação19/2017 PDF13/p2602(137),e65(1)naLei11/2025 PDF17:acto isolado qualificado e sem opçãoISPC aplicável,20%sobre rendimento ilíquido declarado; não confundir com20%do excesso de vendasISPC8(1)d.',
|
||
formalities:
|
||
'AT Comunicado9/2026,06/10/2026,p1:ModeloB–Geral enquanto se ajusta sistema;pagamentos trimestrais emJaneiro/Abril/Julho/Outubro. AT ModeloBGeral2p:quadros1/2/3/5/6/8/10. Modelo30 disponível contém75.000/3%antigos,não usado para calcular2026. Sem submissãoAT automática.',
|
||
};
|
||
const blank = (kind: ResidualKind): Record<string, string> =>
|
||
kind === 'occasional'
|
||
? {
|
||
personType: '',
|
||
currency: '',
|
||
origin: 'projected',
|
||
operationDate: '',
|
||
quarter: '',
|
||
noHabitual: '',
|
||
territory: '',
|
||
sporadic: '',
|
||
activity: '',
|
||
reference: '',
|
||
amount: '',
|
||
allQuarterOperations: '',
|
||
priorYearTurnover: '',
|
||
priorYearRef: '',
|
||
organizedRequired: '',
|
||
option: 'preparation',
|
||
optionRef: '',
|
||
optionDate: '',
|
||
settlement: '',
|
||
annual2026: '',
|
||
annualOrigin: '',
|
||
annualRef: '',
|
||
priorInYear: '',
|
||
buyerIspc: '',
|
||
unregistered: '',
|
||
buyerAnnualPurchases: '',
|
||
withheldActual: '',
|
||
withheldRef: '',
|
||
}
|
||
: kind === 'acquisitions'
|
||
? {
|
||
route: 'unknown',
|
||
noIspcOnExcess: '',
|
||
isolatedAct: '',
|
||
incomeInMoz: '',
|
||
grossExcess: '',
|
||
grossRef: '',
|
||
supplierOptionRef: '',
|
||
reference: '',
|
||
}
|
||
: {
|
||
sourceKind: 'quarter',
|
||
name: '',
|
||
nuit: '',
|
||
fiscalArea: '',
|
||
address: '',
|
||
declaration: 'initial',
|
||
formMonth: '',
|
||
formYear: '2026',
|
||
formPeriodRef: '',
|
||
interestSituation: '',
|
||
interestAmount: '',
|
||
interestRef: '',
|
||
externalStatus: 'preparation',
|
||
externalRef: '',
|
||
externalDate: '',
|
||
};
|
||
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 não recuperáveis; originais preservados.');
|
||
return s;
|
||
}
|
||
function maps(
|
||
d: Record<string, unknown>,
|
||
): Record<string, Record<string, ResidualBook>> {
|
||
const v = store(d).ispcResiduals;
|
||
if (v === undefined) return {};
|
||
if (!v || typeof v !== 'object' || Array.isArray(v))
|
||
throw Error('Mapa residualISPC inválido; originais preservados.');
|
||
return v;
|
||
}
|
||
function validate(b: ResidualBook, kind: ResidualKind) {
|
||
const enums: Record<string, string[]> =
|
||
kind === 'occasional'
|
||
? {
|
||
personType: ['', 'singular', 'collective'],
|
||
origin: ['projected', 'effective'],
|
||
noHabitual: ['', 'yes', 'no'],
|
||
territory: ['', 'yes', 'no'],
|
||
sporadic: ['', 'yes', 'no'],
|
||
activity: [
|
||
'',
|
||
'forestry',
|
||
'fishery',
|
||
'livestock',
|
||
'agriculture',
|
||
'poultry',
|
||
'beekeeping',
|
||
'other',
|
||
],
|
||
allQuarterOperations: ['', 'yes', 'no'],
|
||
organizedRequired: ['', 'yes', 'no'],
|
||
option: ['preparation', 'opted', 'not_opted'],
|
||
settlement: ['', 'self', 'withheld'],
|
||
annualOrigin: ['', 'forecast', 'closed_year'],
|
||
buyerIspc: ['', 'yes', 'no'],
|
||
unregistered: ['', 'yes', 'no'],
|
||
}
|
||
: kind === 'acquisitions'
|
||
? {
|
||
route: ['unknown', 'ispc_supplier', 'irps_isolated'],
|
||
noIspcOnExcess: ['', 'yes', 'no'],
|
||
isolatedAct: ['', 'yes', 'no'],
|
||
incomeInMoz: ['', 'yes', 'no'],
|
||
}
|
||
: {
|
||
sourceKind: ['quarter', 'occasional', 'acquisitions'],
|
||
declaration: ['initial', 'substitution'],
|
||
interestSituation: ['', 'none', 'amount'],
|
||
externalStatus: [
|
||
'preparation',
|
||
'submitted',
|
||
'received',
|
||
'paid',
|
||
],
|
||
};
|
||
if (
|
||
b?.input &&
|
||
Object.entries(enums).some(([k, v]) => !v.includes(b.input[k]))
|
||
)
|
||
throw Error('Opções residuais não recuperáveis;entradas preservadas.');
|
||
if (
|
||
!b ||
|
||
b.version !== 1 ||
|
||
b.kind !== kind ||
|
||
!b.input ||
|
||
Object.keys(blank(kind)).some((k) => typeof b.input[k] !== 'string') ||
|
||
(b.history !== undefined && !Array.isArray(b.history)) ||
|
||
(b.result !== undefined &&
|
||
(!b.result.input ||
|
||
!b.result.amounts ||
|
||
!Array.isArray(b.result.missing)))
|
||
)
|
||
throw Error(
|
||
'Livro residualISPC não recuperável; sem substituir por vazio.',
|
||
);
|
||
}
|
||
export function ispcResidualBook(
|
||
d: Record<string, unknown>,
|
||
kind: ResidualKind,
|
||
): ResidualBook {
|
||
const m = maps(d)[acquisitionScope(d)];
|
||
if (m !== undefined && (!m || typeof m !== 'object' || Array.isArray(m)))
|
||
throw Error('Contexto residual inválido.');
|
||
let b = m?.[kind];
|
||
if (!b) {
|
||
const input = blank(kind);
|
||
if (kind === 'occasional') {
|
||
const i = ispcEligibilityBook(d).input;
|
||
Object.assign(input, {
|
||
personType: i.personType,
|
||
currency: i.currency,
|
||
origin: i.origin,
|
||
operationDate: i.assessmentDate,
|
||
territory: i.territory,
|
||
option: i.option,
|
||
optionRef: i.optionReference,
|
||
optionDate: i.optionDate,
|
||
});
|
||
}
|
||
if (kind === 'formalities') {
|
||
if (
|
||
ispcEligibilityBook(d).input.mode === 'occasional' &&
|
||
m?.occasional?.result
|
||
)
|
||
input.sourceKind = 'occasional';
|
||
else if (!ispcQuarterBook(d).result && m?.acquisitions?.result)
|
||
input.sourceKind = 'acquisitions';
|
||
Object.assign(input, {
|
||
name: inputText(d.legal_name ?? d.organization_name ?? ''),
|
||
nuit: inputText(d.nuit ?? ''),
|
||
address: inputText(d.address ?? ''),
|
||
});
|
||
}
|
||
b = { version: 1, kind, input };
|
||
}
|
||
validate(b, kind);
|
||
return b;
|
||
}
|
||
export function saveIspcResidualBook(
|
||
d: Record<string, unknown>,
|
||
b: ResidualBook,
|
||
): string {
|
||
validate(b, b.kind);
|
||
const scope = acquisitionScope(d);
|
||
return JSON.stringify({
|
||
...store(d),
|
||
ispcResiduals: {
|
||
...maps(d),
|
||
[scope]: { ...maps(d)[scope], [b.kind]: b },
|
||
},
|
||
});
|
||
}
|
||
const cmp = (a: FiscalFraction, b: FiscalFraction) =>
|
||
BigInt(a.numerator) * BigInt(b.denominator) -
|
||
BigInt(b.numerator) * BigInt(a.denominator);
|
||
const rate = (a: FiscalFraction, n: number) =>
|
||
fraction(BigInt(a.numerator) * BigInt(n), BigInt(a.denominator) * 100n);
|
||
const min = (a: FiscalFraction, b: FiscalFraction) => (cmp(a, b) < 0n ? a : b);
|
||
const positive = (a: FiscalFraction) =>
|
||
cmp(a, fraction(0n)) > 0n ? a : fraction(0n);
|
||
const validDate = (s: string) =>
|
||
/^2026-\d{2}-\d{2}$/.test(s) &&
|
||
!Number.isNaN(Date.parse(s)) &&
|
||
new Date(s).toISOString().slice(0, 10) === s;
|
||
const amount = (s: string, label: string, r: ResidualResult) => {
|
||
try {
|
||
return decimalFraction(s);
|
||
} catch {
|
||
r.missing.push(label);
|
||
return null;
|
||
}
|
||
};
|
||
function dependency(
|
||
d: Record<string, unknown>,
|
||
kind: ResidualKind,
|
||
i: Record<string, string>,
|
||
): unknown {
|
||
if (kind === 'acquisitions') {
|
||
const e = ispcEligibilityBook(d);
|
||
return [ispcWithholdingBook(d).input, e.input, e.result?.key];
|
||
}
|
||
if (kind === 'formalities') {
|
||
if (i.sourceKind === 'quarter') {
|
||
const b = ispcQuarterBook(d);
|
||
return [b.input, b.result?.key, ispcQuarterCurrent(d, b)];
|
||
}
|
||
const b = ispcResidualBook(
|
||
d,
|
||
i.sourceKind === 'occasional' ? 'occasional' : 'acquisitions',
|
||
);
|
||
return [
|
||
b.input,
|
||
b.result?.key,
|
||
b.result?.calculatedAt,
|
||
ispcResidualCurrent(d, b),
|
||
];
|
||
}
|
||
return null;
|
||
}
|
||
const key = (d: Record<string, unknown>, b: ResidualBook) =>
|
||
JSON.stringify([
|
||
'ispc-residual/1',
|
||
acquisitionScope(d),
|
||
b.kind,
|
||
b.input,
|
||
dependency(d, b.kind, b.input),
|
||
]);
|
||
export const ispcResidualCurrent = (
|
||
d: Record<string, unknown>,
|
||
b: ResidualBook,
|
||
) => !!b.result && b.result.key === key(d, b);
|
||
function occasional(i: Record<string, string>, r: ResidualResult) {
|
||
if (
|
||
i.personType === 'collective' ||
|
||
i.noHabitual === 'no' ||
|
||
i.territory === 'no' ||
|
||
i.sporadic === 'no' ||
|
||
i.activity === 'other'
|
||
) {
|
||
r.status = 'not_applicable';
|
||
r.reason =
|
||
'Os factos excluem o recorte ocasional3(3–4); conserve o percurso habitual ou o imposto próprio aplicável, sem presumir isenção.';
|
||
return;
|
||
}
|
||
if (i.territory !== 'yes')
|
||
r.missing.push(
|
||
'Operação em território nacional,sem inferir da moeda ou nacionalidade',
|
||
);
|
||
if (!i.personType) r.missing.push('Pessoa singular por identificar');
|
||
if (
|
||
i.currency !== 'MZN' ||
|
||
!validDate(i.operationDate) ||
|
||
!['1', '2', '3', '4'].includes(i.quarter) ||
|
||
(validDate(i.operationDate) &&
|
||
Math.ceil(Number(i.operationDate.slice(5, 7)) / 3) !==
|
||
Number(i.quarter))
|
||
)
|
||
r.missing.push('Data/trimestre de2026 coerentes e moedaMZN');
|
||
if (
|
||
i.noHabitual !== 'yes' ||
|
||
i.sporadic !== 'yes' ||
|
||
![
|
||
'forestry',
|
||
'fishery',
|
||
'livestock',
|
||
'agriculture',
|
||
'poultry',
|
||
'beekeeping',
|
||
].includes(i.activity) ||
|
||
!i.reference.trim()
|
||
)
|
||
r.missing.push(
|
||
'Pessoa sem actividade habitual,acto imprevisível/não reiterado,família3(4)e suporte da operação',
|
||
);
|
||
const base = amount(
|
||
i.amount,
|
||
'Volume das operações ocasionais deste trimestre, incluindo zero expresso',
|
||
r,
|
||
);
|
||
r.amounts.base = base;
|
||
r.details.IRPS =
|
||
'Acto isolado8(3h)referente a8(2a),não previsível/reiterado8(4);família3(4)ISPC. Não se infere pelo nome do sector.';
|
||
if (i.settlement === 'withheld') {
|
||
const purchases = amount(
|
||
i.buyerAnnualPurchases,
|
||
'Aquisições globais anuais do adquirente, incluindo estas operações',
|
||
r,
|
||
),
|
||
actual = amount(
|
||
i.withheldActual,
|
||
'Montante efectivamente retido',
|
||
r,
|
||
);
|
||
if (
|
||
i.buyerIspc !== 'yes' ||
|
||
i.unregistered !== 'yes' ||
|
||
!i.withheldRef.trim()
|
||
)
|
||
r.missing.push(
|
||
'AdquirenteISPC,fornecedor não inscrito e documento de retenção liberatória12(3)',
|
||
);
|
||
if (
|
||
purchases &&
|
||
base &&
|
||
(cmp(purchases, base) < 0n ||
|
||
cmp(purchases, fraction(2500000n)) > 0n)
|
||
)
|
||
r.missing.push(
|
||
'Reconciliar aquisições globais do adquirente dentro do limite2.500.000;fora dele usar tratamento próprio',
|
||
);
|
||
if (!r.missing.length && base && actual) {
|
||
r.amounts.tax = rate(base, 5);
|
||
r.amounts.withheld = actual;
|
||
if (cmp(actual, r.amounts.tax) !== 0n) {
|
||
r.missing.push(
|
||
'Retenção documentada diverge de5%da base;reconciliar diferença sem presumir quitação',
|
||
);
|
||
return;
|
||
}
|
||
r.status = 'settled_by_withholding';
|
||
r.details.rate = '5';
|
||
r.reason =
|
||
'Retenção liberatória documentada para estas operações; não somar nova liquidação própria3/4/5%nem usar a retenção como crédito duplicado. Pagamento próprio adicional não é gerado.';
|
||
}
|
||
return;
|
||
}
|
||
if (i.settlement !== 'self') {
|
||
r.missing.push(
|
||
'Via de liquidação própria ou retenção liberatória documentada',
|
||
);
|
||
return;
|
||
}
|
||
if (i.organizedRequired === 'yes') {
|
||
r.status = 'not_applicable';
|
||
r.reason =
|
||
'Obrigação de contabilidade organizada declarada:fora da liquidação própriaISPC4(1b);entradas e documentos conservados,sem presumir isenção noutro imposto.';
|
||
return;
|
||
}
|
||
if (i.allQuarterOperations !== 'yes')
|
||
r.missing.push(
|
||
'Volume de todas as operações ocasionais do sujeito passivo no trimestre',
|
||
);
|
||
const access = amount(
|
||
i.priorYearTurnover,
|
||
'Volume de referência2025, sem inferir zero por ausência de actividade habitual',
|
||
r,
|
||
);
|
||
if (!i.priorYearRef.trim() || i.organizedRequired !== 'no')
|
||
r.missing.push(
|
||
'Suporte da referência anual e inexistência de obrigação de contabilidade organizada4(1)',
|
||
);
|
||
if (access && cmp(access, fraction(4000000n)) > 0n) {
|
||
r.status = 'not_applicable';
|
||
r.reason =
|
||
'Volume de acesso4(1)superior4.000.000MZN declarado:fora da liquidação própriaISPC;sem atribuir taxa de outro regime por presunção.';
|
||
return;
|
||
}
|
||
if (i.option === 'not_opted') {
|
||
r.status = 'not_applicable';
|
||
r.reason =
|
||
'Sem opçãoISPC para esta operação; não aplicar exclusãoIRPS5nem taxaISPC automaticamente.';
|
||
return;
|
||
}
|
||
if (
|
||
!['preparation', 'opted'].includes(i.option) ||
|
||
(i.option === 'opted' &&
|
||
(!i.optionRef.trim() ||
|
||
!validDate(i.optionDate) ||
|
||
i.optionDate > i.operationDate)) ||
|
||
(i.origin === 'effective' && i.option !== 'opted')
|
||
)
|
||
r.missing.push(
|
||
'Opção documentada aplicável à operação efectiva;preparação apenas permite cenário',
|
||
);
|
||
const annual = amount(
|
||
i.annual2026,
|
||
'Volume anual2026 para escalão, distinto do acesso2025',
|
||
r,
|
||
),
|
||
prior = amount(i.priorInYear, 'Volume anterior no ano2026', r);
|
||
if (
|
||
!['forecast', 'closed_year'].includes(i.annualOrigin) ||
|
||
!i.annualRef.trim()
|
||
)
|
||
r.missing.push(
|
||
'Referência anual suportada e previsão identificada como cenário',
|
||
);
|
||
if (
|
||
base &&
|
||
prior &&
|
||
annual &&
|
||
(cmp(annual, addFractions([base, prior])) < 0n ||
|
||
(i.annualOrigin === 'closed_year' &&
|
||
i.quarter === '4' &&
|
||
cmp(annual, addFractions([base, prior])) !== 0n))
|
||
)
|
||
r.missing.push(
|
||
'Reconciliar volume anual com acumulado anterior e trimestre',
|
||
);
|
||
if (r.missing.length || !base || !prior || !annual) return;
|
||
const n =
|
||
cmp(annual, fraction(1000000n)) <= 0n
|
||
? 3
|
||
: cmp(annual, fraction(2500000n)) <= 0n
|
||
? 4
|
||
: 5,
|
||
ordinary = min(
|
||
base,
|
||
positive(subtractFractions(fraction(4000000n), prior)),
|
||
),
|
||
excess = subtractFractions(base, ordinary),
|
||
tax = addFractions([rate(ordinary, n), rate(excess, 20)]);
|
||
r.amounts = {
|
||
...r.amounts,
|
||
ordinary,
|
||
excess,
|
||
tax,
|
||
payment: cmp(tax, fraction(500n)) < 0n ? fraction(0n) : tax,
|
||
};
|
||
r.details.projected =
|
||
i.origin === 'projected' ||
|
||
i.option === 'preparation' ||
|
||
i.annualOrigin === 'forecast'
|
||
? 'yes'
|
||
: 'no';
|
||
r.details.rate = String(n);
|
||
r.details.declaration = 'Mantida mesmo quando prestação inferior500MZN';
|
||
r.status = 'calculated';
|
||
r.reason = `${r.details.projected === 'yes' ? 'Cenário declarado;sem adesão automática.' : 'Opção e factos efectivos declarados.'} Operações ocasionais próprias3(3–4),base trimestral9,taxa${n}%e20%somente no excesso de vendas acima4.000.000. Não há retenção presumida nem dedução de custos/IVA.`;
|
||
}
|
||
function acquisitions(
|
||
d: Record<string, unknown>,
|
||
i: Record<string, string>,
|
||
r: ResidualResult,
|
||
) {
|
||
const w = ispcWithholdingBook(d).input,
|
||
e = ispcEligibilityBook(d);
|
||
if (
|
||
!ispcEligibilityCurrent(d, e) ||
|
||
e.result?.status !== 'compatible' ||
|
||
e.result.optionStatus === 'not_opted' ||
|
||
e.result.optionStatus === 'indeterminate' ||
|
||
(e.input.origin === 'effective' && e.result.optionStatus !== 'recorded')
|
||
) {
|
||
r.missing.push('Enquadramento/opção actual do adquirenteISPC');
|
||
return;
|
||
}
|
||
if (
|
||
w.applicable !== 'yes' ||
|
||
w.naturalOccasional !== 'yes' ||
|
||
w.unregistered !== 'yes' ||
|
||
w.related !== 'yes' ||
|
||
![
|
||
'forestry',
|
||
'fishery',
|
||
'livestock',
|
||
'agriculture',
|
||
'poultry',
|
||
'beekeeping',
|
||
].includes(w.activity) ||
|
||
!w.reference.trim()
|
||
) {
|
||
r.missing.push(
|
||
'Condições específicas12(3–4)e suporte no livro das aquisições',
|
||
);
|
||
return;
|
||
}
|
||
const base = amount(w.base, 'Base das aquisições', r),
|
||
prior = amount(
|
||
w.priorAnnualPurchases,
|
||
'Aquisições globais anteriores do ano',
|
||
r,
|
||
);
|
||
if (!base || !prior) return;
|
||
const covered = min(
|
||
base,
|
||
positive(subtractFractions(fraction(2500000n), prior)),
|
||
),
|
||
excess = subtractFractions(base, covered);
|
||
r.amounts = {
|
||
base,
|
||
prior,
|
||
covered,
|
||
excess,
|
||
coveredTax: rate(covered, 5),
|
||
excessTax: null,
|
||
total: null,
|
||
};
|
||
r.details.quarter = w.quarter;
|
||
r.details.acquisitionRef = w.reference;
|
||
if (cmp(excess, fraction(0n)) <= 0n) {
|
||
r.status = 'not_applicable';
|
||
r.reason =
|
||
'Sem parcela acima do limite; o cálculo5%já disponível permanece no seu livro, sem segunda liquidação.';
|
||
return;
|
||
}
|
||
r.status = 'partial';
|
||
r.reason =
|
||
'Parcela até ao limite separada a5%; parcela excedente quantificada, sem presumir5%ou20%pelo simples excesso. Montante total continua indeterminado até identificar tratamento próprio.';
|
||
if (i.route === 'irps_isolated') {
|
||
const net = amount(
|
||
i.grossExcess,
|
||
'Rendimento ilíquido da parcela excedente, distinto de rendimento líquido e do preço líquido de IVA',
|
||
r,
|
||
);
|
||
if (
|
||
i.noIspcOnExcess !== 'yes' ||
|
||
i.isolatedAct !== 'yes' ||
|
||
i.incomeInMoz !== 'yes' ||
|
||
!i.grossRef.trim() ||
|
||
!i.reference.trim()
|
||
)
|
||
r.missing.push(
|
||
'Acto isoladoIRPS8(3h,4),rendimento emMoçambique,sem opçãoISPC5aplicável à parcela,e referências da classificação/base',
|
||
);
|
||
if (net && cmp(net, excess) !== 0n)
|
||
r.missing.push(
|
||
'Base ilíquida57(4)não coincide com a parcela bruta identificada;reconciliar sem deduzir custos/IVA automaticamente',
|
||
);
|
||
if (!r.missing.length && net) {
|
||
r.amounts.excessTax = rate(net, 20);
|
||
r.amounts.total = addFractions([
|
||
r.amounts.coveredTax!,
|
||
r.amounts.excessTax,
|
||
]);
|
||
r.status = 'calculated';
|
||
r.reason =
|
||
'Correspondência positiva independente:IRPS57(2k)acto isolado,20%sobre rendimento ilíquido57(4)da parcela identificada,sem opçãoISPCaplicável. Não se usa taxa20%do excesso de vendasISPC. Retenções5%e20%separadas,não são crédito do imposto próprio do adquirente.';
|
||
}
|
||
} else if (i.route === 'ispc_supplier') {
|
||
if (!i.supplierOptionRef.trim())
|
||
r.missing.push(
|
||
'Referência da opção/registoISPCdo fornecedor aplicável a estas operações',
|
||
);
|
||
r.reason =
|
||
'Fornecedor com opçãoISPC declarada:art5impede aplicarIRPSporpresunção. A parcela fora12(3)exige factura/registo do fornecedor e liquidação própria correspondente; o comprador não atribui5%ou20%automaticamente. Sem total de retenção presumido.';
|
||
} else
|
||
r.missing.push(
|
||
'Enquadramento específico da parcela excedente:opçãoISPCdo fornecedor ou acto isoladoIRPS,base e suporte',
|
||
);
|
||
}
|
||
function formalities(
|
||
d: Record<string, unknown>,
|
||
i: Record<string, string>,
|
||
r: ResidualResult,
|
||
) {
|
||
let src: ResidualResult | undefined;
|
||
if (i.sourceKind === 'quarter') {
|
||
const b = ispcQuarterBook(d),
|
||
v = b.result;
|
||
if (!v || !ispcQuarterCurrent(d, b) || v.status !== 'calculated') {
|
||
r.missing.push('Apuramento trimestral actual e calculado');
|
||
return;
|
||
}
|
||
r.amounts = {
|
||
base: v.base,
|
||
tax: v.tax,
|
||
payment: v.payment,
|
||
ordinary: v.ordinary,
|
||
excess: v.excess,
|
||
};
|
||
r.details = {
|
||
quarter: v.input.quarter,
|
||
rate: String(v.rate),
|
||
sourceDate: v.calculatedAt,
|
||
};
|
||
} else {
|
||
const b = ispcResidualBook(
|
||
d,
|
||
i.sourceKind === 'occasional' ? 'occasional' : 'acquisitions',
|
||
);
|
||
src = b.result;
|
||
if (!src || !ispcResidualCurrent(d, b)) {
|
||
r.missing.push('Resultado actual do percurso seleccionado');
|
||
return;
|
||
}
|
||
if (src.status === 'settled_by_withholding') {
|
||
r.status = 'not_applicable';
|
||
r.reason =
|
||
'Operação própria já com retenção liberatória documentada:conservar comprovativo,sem emitir liquidação própria duplicada.';
|
||
return;
|
||
}
|
||
if (src.status !== 'calculated') {
|
||
r.missing.push(
|
||
'Cálculo completo do percurso seleccionado;as partes determinadas permanecem consultáveis',
|
||
);
|
||
return;
|
||
}
|
||
r.amounts = {
|
||
...src.amounts,
|
||
tax: src.amounts.total ?? src.amounts.tax,
|
||
payment: src.amounts.total ?? src.amounts.payment,
|
||
};
|
||
r.details = {
|
||
quarter: src.input.quarter || src.details.quarter,
|
||
rate: src.details.rate || 'retenções separadas',
|
||
sourceDate: src.calculatedAt,
|
||
};
|
||
}
|
||
const first = r.amounts.covered ?? r.amounts.ordinary ?? r.amounts.base,
|
||
second = r.amounts.excess ?? fraction(0n),
|
||
firstRate =
|
||
i.sourceKind === 'acquisitions' ? 5 : Number(r.details.rate);
|
||
if (first && [3, 4, 5, 12, 15].includes(firstRate)) {
|
||
const firstTax = r.amounts.coveredTax ?? rate(first, firstRate),
|
||
secondTax = r.amounts.excessTax ?? rate(second, 20);
|
||
r.amounts.q8base01 = first;
|
||
r.amounts.q8tax05 = firstTax;
|
||
r.amounts.q8base02 = second;
|
||
r.amounts.q8tax06 = secondTax;
|
||
r.details.q8rate01 = String(firstRate);
|
||
r.details.q8rate02 = '20';
|
||
if (
|
||
r.amounts.tax &&
|
||
cmp(addFractions([firstTax, secondTax]), r.amounts.tax) !== 0n
|
||
)
|
||
r.missing.push(
|
||
'Parcelas do quadro8divergem do cálculo guardado;conferir apenas essa correspondência',
|
||
);
|
||
}
|
||
if (i.sourceKind === 'acquisitions' && !(i.retentionFormRef ?? '').trim())
|
||
r.missing.push(
|
||
'Modelo/código de entrega da retençãoISPC12(3)e da retençãoIRPS57(2k),distintos do imposto próprio;comunicado9não resolve isoladamente este modelo',
|
||
);
|
||
if (
|
||
r.amounts.tax &&
|
||
r.amounts.payment &&
|
||
cmp(r.amounts.tax, fraction(0n)) > 0n &&
|
||
cmp(r.amounts.tax, fraction(500n)) < 0n &&
|
||
cmp(r.amounts.payment, fraction(0n)) === 0n
|
||
)
|
||
r.missing.push(
|
||
'Representação da dispensa de pagamento10-A noB–Geral:declarar imposto apurado sem presumir zero no campo de montante apurado',
|
||
);
|
||
r.details.model = 'Modelo B–Geral;comunicadoAT9/2026';
|
||
r.details.paymentMonth =
|
||
['Abril2026', 'Julho2026', 'Outubro2026', 'Janeiro2027'][
|
||
Number(r.details.quarter) - 1
|
||
] || 'Trimestre por identificar';
|
||
r.details.formPeriod = i.formMonth
|
||
? `${i.formMonth}/${i.formYear}`
|
||
: 'Indeterminado:quadro2pede mês/ano;comunicado identifica trimestre/mês de pagamento,sem resolver a codificação do período';
|
||
if (
|
||
!/^[0-9]{9}$/.test(i.nuit) ||
|
||
!i.name.trim() ||
|
||
!i.fiscalArea.trim() ||
|
||
!i.address.trim()
|
||
)
|
||
r.missing.push(
|
||
'NUIT9dígitos,nome,área fiscal/UGC e domicílio para formalização;preparação do cálculo não exige esses campos',
|
||
);
|
||
if (
|
||
!/^(0[1-9]|1[0-2])$/.test(i.formMonth) ||
|
||
!['2026', '2027'].includes(i.formYear) ||
|
||
!i.formPeriodRef.trim()
|
||
)
|
||
r.missing.push(
|
||
'Mês/ano a inscrever no quadro2e instrução/correspondência utilizada; não preencher automaticamente com mês de pagamento',
|
||
);
|
||
if (
|
||
!['none', 'amount'].includes(i.interestSituation) ||
|
||
!i.interestRef.trim()
|
||
)
|
||
r.missing.push(
|
||
'Situação dos juros compensatórios e suporte;desconhecido não é zero',
|
||
);
|
||
if (i.interestSituation === 'amount')
|
||
r.amounts.interest = amount(
|
||
i.interestAmount,
|
||
'Juros documentados,não calculados por falta de taxa/período regulamentar',
|
||
r,
|
||
);
|
||
else if (i.interestSituation === 'none') r.amounts.interest = fraction(0n);
|
||
r.amounts.finalPayment =
|
||
r.amounts.payment && r.amounts.interest
|
||
? addFractions([r.amounts.payment, r.amounts.interest])
|
||
: null;
|
||
if (
|
||
i.externalStatus !== 'preparation' &&
|
||
(!i.externalRef.trim() || !i.externalDate.trim())
|
||
)
|
||
r.missing.push(
|
||
'Referência e data da submissão/recepção/pagamento declarado;sem confirmação automáticaAT',
|
||
);
|
||
r.status = r.missing.length ? 'partial' : 'calculated';
|
||
r.reason =
|
||
'Preparação dos campos identificados do B–Geral,com cálculo e prestação separados. O Modelo30antigo não recebe taxas2026. Submissão,recepção e pagamento são actos externos;este registo conserva referências e histórico,sem os efectuar ou certificar.';
|
||
}
|
||
export function priorOccasionalSettlement(
|
||
b: ResidualBook,
|
||
): ResidualResult | undefined {
|
||
if (b.kind !== 'occasional' || b.input.settlement !== 'self') return;
|
||
return [b.result, ...(b.history ?? [])].find(
|
||
(r) =>
|
||
r?.status === 'settled_by_withholding' &&
|
||
r.input.reference.trim() === b.input.reference.trim() &&
|
||
r.input.operationDate === b.input.operationDate &&
|
||
r.input.quarter === b.input.quarter &&
|
||
r.input.amount === b.input.amount,
|
||
);
|
||
}
|
||
export function recordIspcResidual(
|
||
d: Record<string, unknown>,
|
||
b: ResidualBook,
|
||
now = new Date().toISOString(),
|
||
): ResidualBook {
|
||
validate(b, b.kind);
|
||
const r: ResidualResult = {
|
||
key: key(d, b),
|
||
scope: acquisitionScope(d),
|
||
calculatedAt: now,
|
||
status: 'indeterminate',
|
||
reason: 'Dados específicos em falta;sem presumir zero.',
|
||
missing: [],
|
||
input: structuredClone(b.input),
|
||
amounts: {},
|
||
details: {},
|
||
source: residualSources[b.kind],
|
||
};
|
||
if (b.kind === 'occasional') {
|
||
occasional(b.input, r);
|
||
const prior = priorOccasionalSettlement(b);
|
||
if (prior && r.status === 'calculated') {
|
||
r.details.priorWithholdingRef = prior.input.withheldRef;
|
||
r.details.priorWithholdingDate = prior.calculatedAt;
|
||
if (
|
||
b.input.withholdingCorrected === 'yes' &&
|
||
(b.input.withholdingCorrectionRef ?? '').trim()
|
||
) {
|
||
r.details.withholdingCorrectionRef =
|
||
b.input.withholdingCorrectionRef;
|
||
r.reason +=
|
||
' Correcção da retenção anterior declarada segundo referência externa,sem certificar esse acto;histórico preservado.';
|
||
} else {
|
||
r.status = 'partial';
|
||
r.amounts.hypotheticalOwnTax = r.amounts.tax;
|
||
r.amounts.tax = null;
|
||
r.amounts.payment = null;
|
||
r.amounts.withheld =
|
||
prior.amounts.withheld ?? prior.amounts.tax;
|
||
r.missing.push(
|
||
'Retenção liberatória anterior para a mesma operação;reconciliar eventual correcção documentada antes de nova prestação própria',
|
||
);
|
||
r.reason =
|
||
'A via foi alterada,mas a retenção liberatória documentada permanece no histórico. Não é gerada segunda prestação;os dados da operação e as duas versões ficam preservados.';
|
||
}
|
||
}
|
||
} else if (b.kind === 'acquisitions') acquisitions(d, b.input, r);
|
||
else formalities(d, b.input, r);
|
||
return {
|
||
...b,
|
||
...(b.result
|
||
? { history: [...(b.history ?? []), structuredClone(b.result)] }
|
||
: {}),
|
||
result: r,
|
||
};
|
||
}
|
||
const labels: Record<string, string> = {
|
||
base: 'Base',
|
||
prior: 'Acumulado global anterior',
|
||
ordinary: 'Parcela ordinária',
|
||
excess: 'Parcela excedente',
|
||
tax: 'Imposto',
|
||
payment: 'Prestação própria',
|
||
withheld: 'Retenção documentada',
|
||
hypotheticalOwnTax:
|
||
'Cálculo próprio apenas hipotético,sem prestação gerada',
|
||
covered: 'Parcela abrangida pelo limite',
|
||
coveredTax: 'Retenção5%da parcela abrangida',
|
||
excessTax: 'RetençãoIRPS20%da parcela qualificada',
|
||
total: 'Total de retenções',
|
||
interest: 'Juros documentados',
|
||
finalPayment: 'Preparação do montante a entregar',
|
||
};
|
||
export function ispcResidualReportRows(
|
||
d: Record<string, unknown>,
|
||
): Array<[string, string, string, string]> {
|
||
const rows: Array<[string, string, string, string]> = [];
|
||
for (const kind of ['occasional', 'acquisitions', 'formalities'] as const) {
|
||
try {
|
||
const b = ispcResidualBook(d, kind),
|
||
r = b.result;
|
||
if (!r) continue;
|
||
const title = {
|
||
occasional: 'Operações ocasionais próprias',
|
||
acquisitions: 'Aquisições acima do limite',
|
||
formalities: 'Formalidades2026—B–Geral',
|
||
}[kind];
|
||
rows.push([
|
||
'ISPC',
|
||
title,
|
||
`${({ calculated: 'Calculado', settled_by_withholding: 'Retenção liberatória documentada', partial: 'Parcial', indeterminate: 'Indeterminado', not_applicable: 'Não aplicável ao recorte' } as Record<string, string>)[r.status]};${r.reason};falta:${r.missing.join('; ') || 'nenhum dado deste recorte'}`,
|
||
`${ispcResidualCurrent(d, b) ? 'Entradas correspondentes' : 'Histórico preservado;rever entradas'};${r.calculatedAt};${r.source}`,
|
||
]);
|
||
for (const [k, v] of Object.entries(r.amounts).filter(
|
||
([k]) => !k.startsWith('q8'),
|
||
))
|
||
rows.push([
|
||
'ISPC',
|
||
`${title}—${kind === 'formalities' && r.input.sourceKind === 'acquisitions' && k === 'payment' ? 'Retenções a entregar pelo adquirente' : labels[k] || k}`,
|
||
v
|
||
? `${fractionPresentation(v).decimal}MZN (${v.numerator}/${v.denominator})`
|
||
: 'Indeterminado',
|
||
`${r.input.reference || r.details.acquisitionRef || r.input.externalRef || 'Pressupostos/documentos declarados'};${r.scope}`,
|
||
]);
|
||
} catch (e) {
|
||
rows.push([
|
||
'ISPC',
|
||
'Residual não recuperável',
|
||
String(e),
|
||
'Originais preservados.',
|
||
]);
|
||
}
|
||
}
|
||
return rows;
|
||
}
|
||
export function ispcFormalPreparationHtml(d: Record<string, unknown>): string {
|
||
const b = ispcResidualBook(d, 'formalities'),
|
||
r = b.result;
|
||
if (!r || !ispcResidualCurrent(d, b) || !Object.keys(r.amounts).length)
|
||
throw Error(
|
||
'Guarde primeiro preparação actual com cálculo identificado.',
|
||
);
|
||
const esc = (v: unknown) =>
|
||
inputText(v ?? '').replace(
|
||
/[&<>"']/g,
|
||
(c) =>
|
||
({
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": ''',
|
||
})[c]!,
|
||
);
|
||
const v = (n: string) =>
|
||
r.amounts[n]
|
||
? fractionPresentation(r.amounts[n]!).decimal
|
||
: 'Indeterminado';
|
||
const fields = [
|
||
[
|
||
'Quadro1—tipo',
|
||
b.input.declaration === 'initial' ? 'Inicial' : 'Substituição',
|
||
],
|
||
['Quadro2—mês/ano', r.details.formPeriod],
|
||
[
|
||
'Contexto—trimestre e mês de pagamento',
|
||
`2026/T${r.details.quarter};${r.details.paymentMonth} (não copiado automaticamente para quadro2)`,
|
||
],
|
||
['Quadro3—NUIT/ÁreaFiscal', `${b.input.nuit}/${b.input.fiscalArea}`],
|
||
['Quadro5—nome', b.input.name],
|
||
['Quadro6—domicílio', b.input.address],
|
||
[
|
||
'Quadro8—linha1:base01/taxa/montante05',
|
||
`${b.input.sourceKind === 'acquisitions' ? 'ISPC—retenção12(3)' : 'ISPC—parcela ordinária'};base01:${v('q8base01')};taxa:${r.details.q8rate01}%;montante05:${v('q8tax05')}`,
|
||
],
|
||
[
|
||
'Quadro8—linha2:base02/taxa/montante06',
|
||
r.amounts.q8base02 && cmp(r.amounts.q8base02, fraction(0n)) > 0n
|
||
? `${b.input.sourceKind === 'acquisitions' ? 'IRPS—acto isolado57(2k)' : 'ISPC—excesso de vendas8(1)d'};base02:${v('q8base02')};taxa20%;montante06:${v('q8tax06')}`
|
||
: 'Não aplicável ao cálculo seleccionado;sem segunda parcela',
|
||
],
|
||
['Quadro8—subtotal1campo09', v('tax')],
|
||
[
|
||
'Quadro10—montante/juros/importância',
|
||
`Imposto calculado:${v('tax')};prestação legal:${v('payment')};juros:${v('interest')};totalpreparado:${v('finalPayment')}`,
|
||
],
|
||
[
|
||
'Modelo/código das retenções',
|
||
b.input.retentionFormRef || 'Por corresponder se aplicável',
|
||
],
|
||
[
|
||
'Estado externo declarado',
|
||
`${({ preparation: 'Em preparação', submitted: 'Submetido segundo referência declarada', received: 'Recebido segundo referência declarada', paid: 'Pago segundo referência declarada' } as Record<string, string>)[b.input.externalStatus]};${b.input.externalRef};${b.input.externalDate}`,
|
||
],
|
||
['Falta', r.missing.join('; ') || 'Nenhum dado deste recorte'],
|
||
];
|
||
return `<!doctype html><html lang="pt"><meta charset="utf-8"><title>PreparaçãoISPC—B–Geral</title><style>body{font:16px system-ui;margin:32px}td,th{border:1px solid #777;padding:10px;vertical-align:top}table{border-collapse:collapse;width:100%}</style><h1>Preparação de campos ISPC—Modelo B–Geral</h1><p>Correspondência de apoio; não é formulário oficial emitido, declaração submetida ou comprovativo de pagamento. Original e instrução AT preservados,sem os alterar.</p><table>${fields.map(([a, c]) => `<tr><th>${esc(a)}</th><td>${esc(c)}</td></tr>`).join('')}</table><p>${esc(r.source)}</p><p>Quadros8e10têm numeração própria;campos homónimos não são somados entre quadros. Não preencher área de serviços nem assinatura automaticamente.</p></html>`;
|
||
}
|