Files
Investidor-V2/resources/js/lib/ispc-eligibility.ts
T

501 lines
20 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 { 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),
],
];
}
}