501 lines
20 KiB
TypeScript
501 lines
20 KiB
TypeScript
import { inputText } from './input-text.ts';
|
||
import { acquisitionScope } from './iva-acquisitions.ts';
|
||
import {
|
||
decimalFraction,
|
||
fractionPresentation,
|
||
type FiscalFraction,
|
||
} from './fiscal-rational.ts';
|
||
|
||
export const ispcEligibilitySource =
|
||
'L4-A:Lei9/2025,BR248/1.ºsuplemento,republicaçãoISPC,arts2–5,9-A/13;PDF10–11/p2180(10–11);vigência2026art6/PDF9. Enquadramento preliminar de actividade habitual;opção separada;sem liquidação trimestral,retenção,submissão ou alteração de IVA/IRPC/IRPS.';
|
||
export const ispcActivities = [
|
||
['forestry', 'Silvícola'],
|
||
['fishery', 'Pesqueira'],
|
||
['livestock', 'Pecuária'],
|
||
['agriculture', 'Agrícola'],
|
||
['poultry', 'Avícola'],
|
||
['beekeeping', 'Apícola'],
|
||
['industry', 'Industrial'],
|
||
['commerce', 'Comercial'],
|
||
['craft', 'Artesanato'],
|
||
['services12', 'Serviços do artigo8(1)b'],
|
||
['liberal15', 'Profissão liberal do artigo8(1)c'],
|
||
['other', 'Outra actividade,fora dos âmbitos identificados'],
|
||
] as const;
|
||
export type IspcActivity = (typeof ispcActivities)[number][0];
|
||
type Choice = '' | 'yes' | 'no';
|
||
export type IspcEligibilityInput = {
|
||
entityRef: string;
|
||
personType: '' | 'singular' | 'collective';
|
||
origin: 'projected' | 'effective';
|
||
assessmentDate: string;
|
||
currency: string;
|
||
mode: 'regular' | 'occasional';
|
||
activities: IspcActivity[];
|
||
activityDescription: string;
|
||
territory: Choice;
|
||
establishments: string;
|
||
basis: '' | 'previous_year' | 'start_forecast';
|
||
referenceYear: string;
|
||
annualReference: string;
|
||
annualReferenceRef: string;
|
||
forecastConfirmed: Choice;
|
||
forecastConfirmationRef: string;
|
||
organizedRequired: Choice;
|
||
sharesInIspc: Choice;
|
||
sharesAnonymous: Choice;
|
||
otherActivities: Choice;
|
||
unlistedServices: Choice;
|
||
liberalDaysSameEntity: string;
|
||
liberalClientRef: string;
|
||
generalRegimeChosen: Choice;
|
||
generalThreeYearsCompleted: Choice;
|
||
generalRegimeRef: string;
|
||
option: 'preparation' | 'opted' | 'not_opted';
|
||
optionReference: string;
|
||
optionDate: string;
|
||
};
|
||
export type IspcEligibilityResult = {
|
||
calculatedAt: string;
|
||
key: string;
|
||
scope: string;
|
||
input: IspcEligibilityInput;
|
||
status: 'compatible' | 'incompatible' | 'indeterminate';
|
||
reason: string;
|
||
missing: string[];
|
||
annualReference: FiscalFraction | null;
|
||
optionStatus: 'preparation' | 'recorded' | 'not_opted' | 'indeterminate';
|
||
optionReason: string;
|
||
source: string;
|
||
};
|
||
export type IspcEligibilityBook = {
|
||
version: 1;
|
||
input: IspcEligibilityInput;
|
||
result?: IspcEligibilityResult;
|
||
history?: IspcEligibilityResult[];
|
||
};
|
||
const scope = (d: Record<string, unknown>) => acquisitionScope(d);
|
||
export function ispcReuseValues(
|
||
d: Record<string, unknown>,
|
||
): Partial<IspcEligibilityInput> {
|
||
const p: Partial<IspcEligibilityInput> = {},
|
||
person = inputText(d.person_type ?? '');
|
||
if (['singular', 'sole_trader'].includes(person)) p.personType = 'singular';
|
||
else if (person === 'collective') p.personType = 'collective';
|
||
if (typeof d.fiscal_activity === 'string' && d.fiscal_activity.trim())
|
||
p.activityDescription = d.fiscal_activity;
|
||
const establishments = inputText(d.fiscal_establishments ?? '');
|
||
if (/^[1-9]\d{0,4}$/.test(establishments))
|
||
p.establishments = establishments;
|
||
const entity = inputText(d.fiscal_entity_ref || d.organization_name || '');
|
||
if (entity.trim()) p.entityRef = entity;
|
||
return p;
|
||
}
|
||
export function newIspcEligibilityInput(
|
||
d: Record<string, unknown>,
|
||
): IspcEligibilityInput {
|
||
return {
|
||
entityRef: '',
|
||
personType: '',
|
||
origin: 'projected',
|
||
assessmentDate: '2026-01-01',
|
||
currency: d.currency === 'MZN' ? 'MZN' : '',
|
||
mode: 'regular',
|
||
activities: [],
|
||
activityDescription: '',
|
||
territory: '',
|
||
establishments: '',
|
||
basis: '',
|
||
referenceYear: '',
|
||
annualReference: '',
|
||
annualReferenceRef: '',
|
||
forecastConfirmed: '',
|
||
forecastConfirmationRef: '',
|
||
organizedRequired: '',
|
||
sharesInIspc: '',
|
||
sharesAnonymous: '',
|
||
otherActivities: '',
|
||
unlistedServices: '',
|
||
liberalDaysSameEntity: '',
|
||
liberalClientRef: '',
|
||
generalRegimeChosen: '',
|
||
generalThreeYearsCompleted: '',
|
||
generalRegimeRef: '',
|
||
option: 'preparation',
|
||
optionReference: '',
|
||
optionDate: '',
|
||
...ispcReuseValues(d),
|
||
};
|
||
}
|
||
function store(d: Record<string, unknown>): 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 map(d: Record<string, unknown>): Record<string, IspcEligibilityBook> {
|
||
const value = store(d).ispcEligibility;
|
||
if (value === undefined) return {};
|
||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||
throw Error('PreparaçãoISPC não recuperável;originais preservados.');
|
||
return value as Record<string, IspcEligibilityBook>;
|
||
}
|
||
function validate(b: IspcEligibilityBook): void {
|
||
const sample = newIspcEligibilityInput({});
|
||
if (
|
||
!b ||
|
||
b.version !== 1 ||
|
||
!b.input ||
|
||
typeof b.input !== 'object' ||
|
||
Object.keys(sample).some((k) =>
|
||
k === 'activities'
|
||
? !Array.isArray(b.input.activities) ||
|
||
new Set(b.input.activities).size !==
|
||
b.input.activities.length ||
|
||
b.input.activities.some(
|
||
(a) => !ispcActivities.some(([kind]) => kind === a),
|
||
)
|
||
: typeof b.input[k as keyof IspcEligibilityInput] !== 'string',
|
||
) ||
|
||
!['', 'singular', 'collective'].includes(b.input.personType) ||
|
||
!['projected', 'effective'].includes(b.input.origin) ||
|
||
!['regular', 'occasional'].includes(b.input.mode) ||
|
||
!['', 'previous_year', 'start_forecast'].includes(b.input.basis) ||
|
||
!['preparation', 'opted', 'not_opted'].includes(b.input.option) ||
|
||
[
|
||
'territory',
|
||
'forecastConfirmed',
|
||
'organizedRequired',
|
||
'sharesInIspc',
|
||
'sharesAnonymous',
|
||
'otherActivities',
|
||
'unlistedServices',
|
||
'generalRegimeChosen',
|
||
'generalThreeYearsCompleted',
|
||
].some(
|
||
(k) =>
|
||
!['', 'yes', 'no'].includes(
|
||
String(b.input[k as keyof IspcEligibilityInput]),
|
||
),
|
||
) ||
|
||
(b.history !== undefined && !Array.isArray(b.history)) ||
|
||
(b.result !== undefined &&
|
||
(!b.result.input ||
|
||
!Array.isArray(b.result.missing) ||
|
||
!['compatible', 'incompatible', 'indeterminate'].includes(
|
||
b.result.status,
|
||
)))
|
||
)
|
||
throw Error(
|
||
'EntradasISPC não recuperáveis;sem substituir por valores vazios.',
|
||
);
|
||
}
|
||
export function ispcEligibilityBook(
|
||
d: Record<string, unknown>,
|
||
): IspcEligibilityBook {
|
||
const b = map(d)[scope(d)] ?? {
|
||
version: 1,
|
||
input: newIspcEligibilityInput(d),
|
||
};
|
||
validate(b);
|
||
return b;
|
||
}
|
||
export function saveIspcEligibilityBook(
|
||
d: Record<string, unknown>,
|
||
b: IspcEligibilityBook,
|
||
): string {
|
||
validate(b);
|
||
return JSON.stringify({
|
||
...store(d),
|
||
ispcEligibility: { ...map(d), [scope(d)]: b },
|
||
});
|
||
}
|
||
const validDate = (v: string) =>
|
||
/^20\d{2}-(0[1-9]|1[0-2])-\d{2}$/.test(v) &&
|
||
!Number.isNaN(Date.parse(v)) &&
|
||
new Date(v).toISOString().slice(0, 10) === v;
|
||
function option(
|
||
i: IspcEligibilityInput,
|
||
): Pick<IspcEligibilityResult, 'optionStatus' | 'optionReason'> {
|
||
if (i.option === 'not_opted')
|
||
return {
|
||
optionStatus: 'not_opted',
|
||
optionReason:
|
||
'Sem opçãoISPC declarada;não aplicar a exclusão de outros impostos doart5.',
|
||
};
|
||
if (i.option === 'preparation')
|
||
return {
|
||
optionStatus: 'preparation',
|
||
optionReason:
|
||
'Opção em preparação;compatibilidade de acesso não é adesão nem alteração automática de regime.',
|
||
};
|
||
if (
|
||
!i.optionReference.trim() ||
|
||
!validDate(i.optionDate) ||
|
||
!validDate(i.assessmentDate) ||
|
||
i.optionDate > i.assessmentDate
|
||
)
|
||
return {
|
||
optionStatus: 'indeterminate',
|
||
optionReason:
|
||
'Identifique referência e data da opção,até à data de avaliação;não inferir adesão pela selecçãoISPC.',
|
||
};
|
||
return {
|
||
optionStatus: 'recorded',
|
||
optionReason:
|
||
'Opção registada segundo documento ou pressuposto declarado;sem submissãoAT ou reescrita dos livros deIVA/IRPC/IRPS.',
|
||
};
|
||
}
|
||
function assess(
|
||
i: IspcEligibilityInput,
|
||
): Pick<
|
||
IspcEligibilityResult,
|
||
'status' | 'reason' | 'missing' | 'annualReference'
|
||
> {
|
||
let annual: FiscalFraction | null = null;
|
||
try {
|
||
annual = decimalFraction(i.annualReference);
|
||
} catch {
|
||
/* Missing or invalid is never zero. */
|
||
}
|
||
const stop = (
|
||
status: IspcEligibilityResult['status'],
|
||
reason: string,
|
||
missing: string[] = [],
|
||
) => ({ status, reason, missing, annualReference: annual });
|
||
if (
|
||
!validDate(i.assessmentDate) ||
|
||
!i.assessmentDate.startsWith('2026-') ||
|
||
i.currency !== 'MZN'
|
||
)
|
||
return stop(
|
||
'indeterminate',
|
||
'Este incremento cobre apenas avaliação2026emMZN;identifique data e moeda sem conversão silenciosa.',
|
||
['data2026', 'moedaMZN'],
|
||
);
|
||
if (i.mode === 'occasional')
|
||
return stop(
|
||
'indeterminate',
|
||
'Operação ocasional3(3–4):conservar dados;use o percurso específico de operações ocasionais próprias abaixo,com incidênciaIRPS e famílias3(4).',
|
||
['enquadramento específico da operação ocasional'],
|
||
);
|
||
if (i.territory === 'no')
|
||
return stop(
|
||
'incompatible',
|
||
'O âmbito territorial declarado não corresponde ao exercício em território nacional de2(1);nacionalidade do investidor não substitui este facto.',
|
||
);
|
||
if (i.activities.includes('other') || i.otherActivities === 'yes')
|
||
return stop(
|
||
'incompatible',
|
||
'Actividade fora dos âmbitos2/3declarada;exclusão3(2)c,não aplicarISPC por nome do sector.',
|
||
);
|
||
if (i.organizedRequired === 'yes')
|
||
return stop(
|
||
'incompatible',
|
||
'Obrigação de contabilidade organizada declarada:4(1)b não permite este enquadramento. Manter contabilidade voluntária distinto de obrigação.',
|
||
);
|
||
if (i.sharesInIspc === 'yes' || i.sharesAnonymous === 'yes')
|
||
return stop(
|
||
'incompatible',
|
||
'Participações abrangidas por3(2)a/b declaradas;enquadramentoISPC incompatível.',
|
||
);
|
||
if (/^[1-9]\d{0,4}$/.test(i.establishments) && Number(i.establishments) > 1)
|
||
return stop(
|
||
'incompatible',
|
||
'Actividade distribuída por vários estabelecimentos:exclusão3(2)d;sem presumir um estabelecimento por oportunidade.',
|
||
);
|
||
const service = i.activities.some((a) =>
|
||
['services12', 'liberal15'].includes(a),
|
||
),
|
||
liberal = i.activities.includes('liberal15');
|
||
if (service && i.unlistedServices === 'yes')
|
||
return stop(
|
||
'incompatible',
|
||
'Serviços fora dos previstos declarados:exclusão3(2)f. Categoria e natureza próprias devem ser identificadas.',
|
||
);
|
||
if (
|
||
liberal &&
|
||
/^\d{1,3}$/.test(i.liberalDaysSameEntity) &&
|
||
Number(i.liberalDaysSameEntity) <= 366 &&
|
||
Number(i.liberalDaysSameEntity) > 183
|
||
)
|
||
return stop(
|
||
'incompatible',
|
||
'Serviços8(1)càmesma entidade por mais183dias durante o ano:exclusão3(2)e. Não estender esta condição automaticamente aos serviços8(1)b.',
|
||
);
|
||
if (
|
||
i.generalRegimeChosen === 'yes' &&
|
||
i.generalThreeYearsCompleted === 'no'
|
||
)
|
||
return stop(
|
||
'incompatible',
|
||
'Permanência mínima de três anos nos impostos gerais5(3)ainda não cumprida,segundo factos declarados.',
|
||
);
|
||
if (
|
||
annual &&
|
||
((i.basis === 'previous_year' && i.referenceYear === '2025') ||
|
||
(i.basis === 'start_forecast' && i.referenceYear === '2026')) &&
|
||
BigInt(annual.numerator) > 4000000n * BigInt(annual.denominator)
|
||
)
|
||
return stop(
|
||
'incompatible',
|
||
'Volume anual de referência superior4.000.000MZN:fora do acesso4(1)a/4(2). Excesso do próprio ano e20%de8(1)d têm tratamento separado;não liquidado aqui.',
|
||
);
|
||
const missing: string[] = [];
|
||
if (!i.personType) missing.push('pessoa singular ou colectiva');
|
||
if (i.territory !== 'yes') missing.push('exercício em território nacional');
|
||
if (!i.activities.length || !i.activityDescription.trim())
|
||
missing.push('actividade/natureza identificadas');
|
||
if (!/^[1-9]\d{0,4}$/.test(i.establishments))
|
||
missing.push('número de estabelecimentos');
|
||
for (const [field, label] of [
|
||
['organizedRequired', 'obrigação de contabilidade organizada'],
|
||
['sharesInIspc', 'participações noutras sociedadesISPC'],
|
||
['sharesAnonymous', 'participaçõesSA/sócios não identificáveis'],
|
||
['otherActivities', 'actividades fora do âmbito'],
|
||
['generalRegimeChosen', 'opção pelos impostos gerais'],
|
||
] as const)
|
||
if (i[field] === '') missing.push(label);
|
||
if (!i.basis) missing.push('origem do volume anual de referência');
|
||
if (!annual) missing.push('montante anual de referência');
|
||
const expected =
|
||
i.basis === 'previous_year'
|
||
? '2025'
|
||
: i.basis === 'start_forecast'
|
||
? '2026'
|
||
: '';
|
||
if (expected && i.referenceYear !== expected)
|
||
missing.push(`ano${expected}da referência;não reinterpretar outro ano`);
|
||
if (
|
||
i.basis === 'start_forecast' &&
|
||
(i.forecastConfirmed !== 'yes' ||
|
||
(i.origin === 'effective' && !i.forecastConfirmationRef.trim()))
|
||
)
|
||
missing.push(
|
||
'previsão da declaração de início confirmadaAT4(2),ou pressuposto expresso na projecção',
|
||
);
|
||
if (service && i.unlistedServices === '')
|
||
missing.push('correspondência dos serviços aos âmbitos daLei');
|
||
if (
|
||
liberal &&
|
||
(!/^\d{1,3}$/.test(i.liberalDaysSameEntity) ||
|
||
Number(i.liberalDaysSameEntity) > 366 ||
|
||
!i.liberalClientRef.trim())
|
||
)
|
||
missing.push(
|
||
'maior duração anual por mesma entidade e suporte declarado',
|
||
);
|
||
if (
|
||
i.generalRegimeChosen === 'yes' &&
|
||
(i.generalThreeYearsCompleted !== 'yes' || !i.generalRegimeRef.trim())
|
||
)
|
||
missing.push(
|
||
'cumprimento dos três anos5(3)e referência da opção/registo',
|
||
);
|
||
if (missing.length)
|
||
return stop(
|
||
'indeterminate',
|
||
'Condições específicas por esclarecer;desconhecido não significa não aplicável nemzero.',
|
||
missing,
|
||
);
|
||
return stop(
|
||
'compatible',
|
||
'Condições de acesso da actividade habitual compatíveis com os dados de referência declarados;opção separada. Volume do ano em curso, trimestre, excesso e mínimo têm apuramento trimestral próprio; retenções têm registo separado.' +
|
||
(liberal
|
||
? ' Serviçosliberais:limite5anos9-A,contagemtransitória13a partir da vigência2026;sem presumir permanência fora da cobertura.'
|
||
: ''),
|
||
);
|
||
}
|
||
const key = (d: Record<string, unknown>, b: IspcEligibilityBook) =>
|
||
JSON.stringify(['ispc-eligibility/1', scope(d), b.input]);
|
||
export function ispcEligibilityCurrent(
|
||
d: Record<string, unknown>,
|
||
b: IspcEligibilityBook,
|
||
): boolean {
|
||
return !!b.result && b.result.key === key(d, b);
|
||
}
|
||
export function recordIspcEligibility(
|
||
d: Record<string, unknown>,
|
||
b: IspcEligibilityBook,
|
||
now = new Date().toISOString(),
|
||
): IspcEligibilityBook {
|
||
validate(b);
|
||
const result: IspcEligibilityResult = {
|
||
calculatedAt: now,
|
||
key: key(d, b),
|
||
scope: scope(d),
|
||
input: structuredClone(b.input),
|
||
...assess(b.input),
|
||
...option(b.input),
|
||
source: ispcEligibilitySource,
|
||
};
|
||
return {
|
||
...b,
|
||
...(b.result
|
||
? { history: [...(b.history ?? []), structuredClone(b.result)] }
|
||
: {}),
|
||
result,
|
||
};
|
||
}
|
||
export function ispcReuseDiff(
|
||
d: Record<string, unknown>,
|
||
b: IspcEligibilityBook,
|
||
): Array<{ field: keyof IspcEligibilityInput; before: string; after: string }> {
|
||
return Object.entries(ispcReuseValues(d))
|
||
.filter(
|
||
([field, value]) =>
|
||
b.input[field as keyof IspcEligibilityInput] !== value,
|
||
)
|
||
.map(([field, value]) => ({
|
||
field: field as keyof IspcEligibilityInput,
|
||
before: String(b.input[field as keyof IspcEligibilityInput]),
|
||
after: String(value),
|
||
}));
|
||
}
|
||
export function ispcEligibilityReportRows(
|
||
d: Record<string, unknown>,
|
||
): Array<[string, string, string, string]> {
|
||
try {
|
||
const b = ispcEligibilityBook(d),
|
||
r = b.result;
|
||
if (!r) return [];
|
||
const i = r.input;
|
||
return [
|
||
[
|
||
'ISPC',
|
||
'L4-A — enquadramento2026',
|
||
`${r.status === 'compatible' ? 'Compatível com os dados de referência' : r.status === 'incompatible' ? 'Incompatível' : 'Indeterminado'};${r.reason};falta:${r.missing.join('; ') || 'nenhum dado deste recorte'}`,
|
||
`${r.scope};${ispcEligibilityCurrent(d, b) ? 'Entradas correspondentes' : 'Histórico preservado;rever entradas'};${r.calculatedAt};${r.source}`,
|
||
],
|
||
[
|
||
'ISPC',
|
||
'Origem e âmbito',
|
||
`${i.entityRef || 'Entidade por identificar'};${i.personType};${i.origin === 'projected' ? 'Pressupostos de simulação' : 'Dados efectivos declarados'};${i.activities.map((a) => ispcActivities.find(([kind]) => kind === a)?.[1] ?? a).join('; ')};${i.activityDescription};estabelecimentos${i.establishments};territórionacional${i.territory}`,
|
||
`Referênciaanual:${i.basis}/${i.referenceYear};${r.annualReference ? fractionPresentation(r.annualReference).decimal : 'Indeterminado'}MZN;${i.annualReferenceRef};previsãoAT${i.forecastConfirmed}/${i.forecastConfirmationRef};liberal${i.liberalDaysSameEntity}dias/${i.liberalClientRef};factos:${JSON.stringify({ organizedRequired: i.organizedRequired, sharesInIspc: i.sharesInIspc, sharesAnonymous: i.sharesAnonymous, otherActivities: i.otherActivities, unlistedServices: i.unlistedServices, generalRegimeChosen: i.generalRegimeChosen, generalThreeYearsCompleted: i.generalThreeYearsCompleted, generalRegimeRef: i.generalRegimeRef })}`,
|
||
],
|
||
[
|
||
'ISPC',
|
||
'Opção e próximo passo',
|
||
`${r.optionReason};referência${i.optionReference};data${i.optionDate}`,
|
||
`Prossiga no apuramento trimestral; esta avaliação não adopta regime nem calcula imposto. ${r.source}`,
|
||
],
|
||
];
|
||
} catch (error) {
|
||
return [
|
||
[
|
||
'ISPC',
|
||
'Enquadramento',
|
||
'Dados não recuperáveis;originais preservados',
|
||
String(error),
|
||
],
|
||
];
|
||
}
|
||
}
|