556 lines
21 KiB
TypeScript
556 lines
21 KiB
TypeScript
import { inputText } from './input-text.ts';
|
||
import { assessFiscalClaim } from './fiscal-claim-events.ts';
|
||
import {
|
||
closureBook,
|
||
closureCurrent,
|
||
closureFingerprint,
|
||
} from './iva-closure.ts';
|
||
import {
|
||
subtractFractions,
|
||
fraction,
|
||
fractionPresentation,
|
||
type FiscalFraction,
|
||
} from './fiscal-rational.ts';
|
||
|
||
export const refundSource =
|
||
'L3-D:CIVA21(5/6/7),Lei13/2016 PDF15–16,p1426(273–274);Lei22/2022 PDF3–4;Lei10/2025 PDF13/p2180(13),21(7a/8revogado/10/13/15). ModeloA/VAT Return9c0b99a8,p2/quadro11:26+27=25. Pedido,decisão e recebimento separados. Formalidades doRegulamento78/2017/30/2022/52/2025 não automatizadas;sem concessão automática.';
|
||
type Choice = '' | 'yes' | 'no';
|
||
export type RefundSource = {
|
||
closureId: string;
|
||
period: string;
|
||
entityRef: string;
|
||
origin: 'projected' | 'effective';
|
||
allocationKey: string;
|
||
calculatedAt: string;
|
||
credit: FiscalFraction;
|
||
carry: FiscalFraction;
|
||
requested: FiscalFraction;
|
||
};
|
||
export type RefundReceipt = {
|
||
id: string;
|
||
reference: string;
|
||
date: string;
|
||
amount: string;
|
||
};
|
||
export type RefundInput = {
|
||
id: string;
|
||
source: RefundSource | null;
|
||
ground: '' | 'four_months' | 'high_credit' | 'cessation';
|
||
originPeriod: string;
|
||
entitlementDate: string;
|
||
cessationDate: string;
|
||
groundRef: string;
|
||
factsConfirmed: Choice;
|
||
oldestFirst: Choice;
|
||
stage: 'preparation' | 'submitted';
|
||
requestReference: string;
|
||
requestDate: string;
|
||
decision: 'pending' | 'approved' | 'partial' | 'rejected';
|
||
decisionReference: string;
|
||
decisionDate: string;
|
||
decisionIssuer: string;
|
||
approvedAmount: string;
|
||
receipts: RefundReceipt[];
|
||
};
|
||
export type RefundResult = {
|
||
id: string;
|
||
status: 'determined' | 'indeterminate';
|
||
reason: string;
|
||
inputKey: string;
|
||
eligibility: 'compatible' | 'not_demonstrated' | 'incompatible';
|
||
requested: FiscalFraction | null;
|
||
reported: FiscalFraction | null;
|
||
approved: FiscalFraction | null;
|
||
received: FiscalFraction | null;
|
||
awaitingReceipt: FiscalFraction | null;
|
||
notGranted: FiscalFraction | null;
|
||
source: string;
|
||
};
|
||
export type RefundCalculation = {
|
||
calculatedAt: string;
|
||
key: string;
|
||
inputs: RefundInput[];
|
||
rows: RefundResult[];
|
||
};
|
||
export type RefundBook = {
|
||
version: 1;
|
||
rows: RefundInput[];
|
||
result?: RefundCalculation;
|
||
history?: RefundCalculation[];
|
||
};
|
||
const scope = (d: Record<string, unknown>) =>
|
||
inputText(d.context_opportunity || 'general'),
|
||
norm = (s: string) => s.trim().toLocaleLowerCase();
|
||
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 period = (s: string) => /^20\d{2}-(0[1-9]|1[0-2])$/.test(s);
|
||
const months = (a: string, b: string) =>
|
||
(Number(b.slice(0, 4)) - Number(a.slice(0, 4))) * 12 +
|
||
Number(b.slice(5)) -
|
||
Number(a.slice(5));
|
||
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)
|
||
throw Error('Livros não recuperáveis;originais preservados.');
|
||
return s;
|
||
}
|
||
function maps(d: Record<string, unknown>): Record<string, RefundBook> {
|
||
const m = store(d).ivaRefunds;
|
||
if (m === undefined) return {};
|
||
if (!m || typeof m !== 'object' || Array.isArray(m))
|
||
throw Error('Mapa de reembolsos não recuperável;sem o substituir.');
|
||
return m as Record<string, RefundBook>;
|
||
}
|
||
function validate(b: RefundBook): void {
|
||
if (
|
||
!b ||
|
||
b.version !== 1 ||
|
||
!Array.isArray(b.rows) ||
|
||
(b.history !== undefined && !Array.isArray(b.history)) ||
|
||
(b.result !== undefined &&
|
||
(!Array.isArray(b.result.rows) || !Array.isArray(b.result.inputs)))
|
||
)
|
||
throw Error(
|
||
'Livro de reembolsos não recuperável;originais preservados.',
|
||
);
|
||
for (const r of b.rows)
|
||
if (
|
||
!r ||
|
||
![
|
||
'id',
|
||
'originPeriod',
|
||
'entitlementDate',
|
||
'cessationDate',
|
||
'groundRef',
|
||
'requestReference',
|
||
'requestDate',
|
||
'decisionReference',
|
||
'decisionDate',
|
||
'decisionIssuer',
|
||
'approvedAmount',
|
||
].every((k) => typeof r[k as keyof RefundInput] === 'string') ||
|
||
!['', 'four_months', 'high_credit', 'cessation'].includes(
|
||
r.ground,
|
||
) ||
|
||
!['', 'yes', 'no'].includes(r.factsConfirmed) ||
|
||
!['', 'yes', 'no'].includes(r.oldestFirst) ||
|
||
!['preparation', 'submitted'].includes(r.stage) ||
|
||
!['pending', 'approved', 'partial', 'rejected'].includes(
|
||
r.decision,
|
||
) ||
|
||
!Array.isArray(r.receipts) ||
|
||
r.receipts.some(
|
||
(p) =>
|
||
!p ||
|
||
!['id', 'reference', 'date', 'amount'].every(
|
||
(k) => typeof p[k as keyof RefundReceipt] === 'string',
|
||
),
|
||
)
|
||
)
|
||
throw Error('EntradasD não recuperáveis;sem montantes presumidos.');
|
||
}
|
||
export function refundBook(d: Record<string, unknown>): RefundBook {
|
||
const b = maps(d)[scope(d)] ?? { version: 1, rows: [] };
|
||
validate(b);
|
||
return b;
|
||
}
|
||
export function saveRefundBook(
|
||
d: Record<string, unknown>,
|
||
b: RefundBook,
|
||
): string {
|
||
validate(b);
|
||
const s = store(d);
|
||
return JSON.stringify({ ...s, ivaRefunds: { ...maps(d), [scope(d)]: b } });
|
||
}
|
||
export function refundSources(d: Record<string, unknown>): RefundSource[] {
|
||
const b = closureBook(d);
|
||
if (!closureCurrent(d, b)) return [];
|
||
return (b.result?.rows ?? []).flatMap((r) => {
|
||
const p = r.fields['27'],
|
||
c = r.fields['25'],
|
||
carry = r.fields['26'];
|
||
if (
|
||
r.status !== 'determined' ||
|
||
!p ||
|
||
!c ||
|
||
!carry ||
|
||
BigInt(p.numerator) <= 0n
|
||
)
|
||
return [];
|
||
const dependentUnreconciled = b.rows.some(
|
||
(input) =>
|
||
input.credits.some(
|
||
(credit) =>
|
||
credit.kind === 'previous' &&
|
||
credit.previousId === r.id,
|
||
) &&
|
||
!b.result!.rows.some(
|
||
(result) =>
|
||
result.id === input.id &&
|
||
result.status === 'determined',
|
||
),
|
||
);
|
||
if (dependentUnreconciled) return [];
|
||
return [
|
||
{
|
||
closureId: r.id,
|
||
period: r.period,
|
||
entityRef: r.entityRef,
|
||
origin: r.origin,
|
||
allocationKey: closureFingerprint([
|
||
r.id,
|
||
r.entityRef,
|
||
r.origin,
|
||
r.period,
|
||
c,
|
||
carry,
|
||
p,
|
||
]),
|
||
calculatedAt: b.result!.calculatedAt,
|
||
credit: c,
|
||
carry,
|
||
requested: p,
|
||
},
|
||
];
|
||
});
|
||
}
|
||
export function newRefund(
|
||
id: string,
|
||
source: RefundSource | null,
|
||
): RefundInput {
|
||
return {
|
||
id,
|
||
source: source ? structuredClone(source) : null,
|
||
ground: '',
|
||
originPeriod: '',
|
||
entitlementDate: '',
|
||
cessationDate: '',
|
||
groundRef: '',
|
||
factsConfirmed: '',
|
||
oldestFirst: '',
|
||
stage: 'preparation',
|
||
requestReference: '',
|
||
requestDate: '',
|
||
decision: 'pending',
|
||
decisionReference: '',
|
||
decisionDate: '',
|
||
decisionIssuer: '',
|
||
approvedAmount: '',
|
||
receipts: [],
|
||
};
|
||
}
|
||
// A new calculation timestamp alone does not change an identical allocation.
|
||
function available(d: Record<string, unknown>, r: RefundInput): boolean {
|
||
return (
|
||
!!r.source &&
|
||
refundSources(d).some(
|
||
(s) =>
|
||
s.allocationKey === r.source!.allocationKey &&
|
||
s.closureId === r.source!.closureId &&
|
||
s.entityRef === r.source!.entityRef &&
|
||
s.origin === r.source!.origin &&
|
||
s.period === r.source!.period &&
|
||
JSON.stringify([s.credit, s.carry, s.requested]) ===
|
||
JSON.stringify([
|
||
r.source!.credit,
|
||
r.source!.carry,
|
||
r.source!.requested,
|
||
]),
|
||
)
|
||
);
|
||
}
|
||
function identity(r: RefundInput): string {
|
||
return JSON.stringify([
|
||
norm(r.source?.entityRef ?? ''),
|
||
r.source?.origin,
|
||
r.source?.period,
|
||
r.source?.closureId,
|
||
]);
|
||
}
|
||
function uses(d: Record<string, unknown>, b: RefundBook): RefundInput[] {
|
||
return [
|
||
...b.rows,
|
||
...Object.entries(maps(d))
|
||
.filter(([id]) => id !== scope(d))
|
||
.flatMap(([, v]) => {
|
||
validate(v);
|
||
return v.rows.filter((r) => r.source?.origin === 'effective');
|
||
}),
|
||
];
|
||
}
|
||
function receiptIdentity(r: RefundInput, p: RefundReceipt): string {
|
||
return JSON.stringify([
|
||
norm(r.source?.entityRef ?? ''),
|
||
r.source?.origin,
|
||
norm(p.reference),
|
||
]);
|
||
}
|
||
function past(d: Record<string, unknown>, b: RefundBook): RefundInput[] {
|
||
return Object.entries({ ...maps(d), [scope(d)]: b }).flatMap(
|
||
([context, v]) =>
|
||
[...(v.history ?? []), ...(v.result ? [v.result] : [])].flatMap(
|
||
(c) =>
|
||
c.inputs.filter(
|
||
(r) =>
|
||
context === scope(d) ||
|
||
r.source?.origin === 'effective',
|
||
),
|
||
),
|
||
);
|
||
}
|
||
function key(d: Record<string, unknown>, b: RefundBook): string {
|
||
const all = uses(d, b),
|
||
old = past(d, b);
|
||
return closureFingerprint([
|
||
b.rows,
|
||
b.rows.map((r) => available(d, r)),
|
||
all.map((r) => [
|
||
r.id,
|
||
identity(r),
|
||
r.receipts.map((p) => receiptIdentity(r, p)),
|
||
]),
|
||
b.rows.map((r) =>
|
||
old
|
||
.filter(
|
||
(v) =>
|
||
v.id !== r.id &&
|
||
(v.stage === 'submitted' ||
|
||
v.decision !== 'pending' ||
|
||
v.receipts.length),
|
||
)
|
||
.some(
|
||
(v) =>
|
||
identity(v) === identity(r) ||
|
||
r.receipts.some((p) =>
|
||
v.receipts.some(
|
||
(q) =>
|
||
receiptIdentity(v, q) ===
|
||
receiptIdentity(r, p),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
]);
|
||
}
|
||
export function refundCurrent(
|
||
d: Record<string, unknown>,
|
||
b: RefundBook,
|
||
): boolean {
|
||
return (
|
||
!!b.result &&
|
||
b.result.key === key(d, b) &&
|
||
b.result.rows.length === b.rows.length &&
|
||
b.rows.every((r) =>
|
||
b.result!.rows.some(
|
||
(v) => v.id === r.id && v.inputKey === closureFingerprint(r),
|
||
),
|
||
)
|
||
);
|
||
}
|
||
export function recordRefund(
|
||
d: Record<string, unknown>,
|
||
b: RefundBook,
|
||
now = new Date().toISOString(),
|
||
): RefundBook {
|
||
validate(b);
|
||
if (new Set(b.rows.map((r) => r.id)).size !== b.rows.length)
|
||
throw Error('Identidade de pedido repetida.');
|
||
const all = uses(d, b),
|
||
old = past(d, b);
|
||
const rows = b.rows.map((row) => {
|
||
const r: RefundResult = {
|
||
id: row.id,
|
||
status: 'indeterminate',
|
||
reason: '',
|
||
inputKey: closureFingerprint(row),
|
||
eligibility: 'not_demonstrated',
|
||
requested: null,
|
||
reported: null,
|
||
approved: null,
|
||
received: null,
|
||
awaitingReceipt: null,
|
||
notGranted: null,
|
||
source: refundSource,
|
||
};
|
||
const stop = (reason: string) => ({ ...r, reason });
|
||
const s = row.source;
|
||
if (
|
||
!s ||
|
||
!available(d, row) ||
|
||
d.currency !== 'MZN' ||
|
||
!/^2026-(0[1-9]|1[0-2])$/.test(s.period)
|
||
)
|
||
return stop(
|
||
'Escolha afectação26/27 actual e recuperável do mesmo período2026/MZN;crédito já reportado não é pedido disponível.',
|
||
);
|
||
if (
|
||
all.filter((v) => identity(v) === identity(row)).length > 1 ||
|
||
old.some(
|
||
(v) =>
|
||
v.id !== row.id &&
|
||
identity(v) === identity(row) &&
|
||
(v.stage === 'submitted' ||
|
||
v.decision !== 'pending' ||
|
||
v.receipts.length),
|
||
)
|
||
)
|
||
return stop(
|
||
'Parcela já ligada a outro pedido no âmbito recuperado;sem pedir ou consumir duas vezes o mesmo crédito.',
|
||
);
|
||
r.requested = s.requested;
|
||
r.reported = s.carry;
|
||
let eligibilityReason = '';
|
||
if (
|
||
!row.ground ||
|
||
!period(row.originPeriod) ||
|
||
row.originPeriod > s.period ||
|
||
!date(row.entitlementDate) ||
|
||
row.entitlementDate.slice(0, 7) !== row.originPeriod ||
|
||
!row.groundRef.trim() ||
|
||
row.factsConfirmed !== 'yes' ||
|
||
row.oldestFirst !== 'yes'
|
||
)
|
||
eligibilityReason =
|
||
'Fundamento/origem/nascimento do crédito,suporte ou consideração dos créditos mais antigos ainda não demonstrados;sem direito presumido.';
|
||
if (
|
||
!date(row.requestDate) ||
|
||
row.requestDate.slice(0, 7) < s.period ||
|
||
!row.requestReference.trim()
|
||
)
|
||
return stop(
|
||
'Identifique referência e data do pedido ou preparação;separar da decisão.',
|
||
);
|
||
if (!eligibilityReason) {
|
||
const lastYear = Number(row.entitlementDate.slice(0, 4)) + 10,
|
||
lastMonth = Number(row.entitlementDate.slice(5, 7)),
|
||
lastDay = Math.min(
|
||
Number(row.entitlementDate.slice(8)),
|
||
new Date(Date.UTC(lastYear, lastMonth, 0)).getUTCDate(),
|
||
);
|
||
const anniversary = `${lastYear}-${String(lastMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||
const above = (n: bigint) =>
|
||
BigInt(subtractFractions(s.credit, fraction(n)).numerator) > 0n;
|
||
if (
|
||
row.requestDate < row.entitlementDate ||
|
||
row.requestDate > anniversary
|
||
) {
|
||
r.eligibility = 'incompatible';
|
||
eligibilityReason =
|
||
'Datas declaradas incompatíveis com nascimento do direito/limite10anos de21(15),Lei10/2025;conservar documentos e identificar eventual excepção.';
|
||
} else if (
|
||
(row.ground === 'four_months' &&
|
||
(months(row.originPeriod, s.period) < 4 ||
|
||
!above(100000n))) ||
|
||
(row.ground === 'high_credit' && !above(500000n)) ||
|
||
(row.ground === 'cessation' &&
|
||
(!date(row.cessationDate) ||
|
||
row.cessationDate > row.requestDate))
|
||
) {
|
||
r.eligibility = 'incompatible';
|
||
eligibilityReason =
|
||
'Factos declarados não demonstram21(6),quatro meses/crédito superior100000;21(7a),superior500000;ou21(7b),cessação identificada.';
|
||
} else r.eligibility = 'compatible';
|
||
}
|
||
const events = assessFiscalClaim(row, s.requested, (p) => {
|
||
const id = receiptIdentity(row, p);
|
||
return (
|
||
all.flatMap((v) =>
|
||
v.receipts.filter((q) => receiptIdentity(v, q) === id),
|
||
).length > 1 ||
|
||
old.some(
|
||
(v) =>
|
||
v.id !== row.id &&
|
||
v.receipts.some((q) => receiptIdentity(v, q) === id),
|
||
)
|
||
);
|
||
});
|
||
Object.assign(r, events);
|
||
if (events.status === 'indeterminate') return r;
|
||
if (row.decision === 'pending') {
|
||
if (eligibilityReason) {
|
||
r.status = 'indeterminate';
|
||
return stop(
|
||
eligibilityReason +
|
||
' Pedido/estado conservados;formalidades regulamentares não automatizadas.',
|
||
);
|
||
}
|
||
r.reason =
|
||
row.stage === 'preparation'
|
||
? 'Pedido em preparação;parcela separada do reporte,sem submissão,decisão ou recebimento.'
|
||
: 'Pedido apresentado fora doInvesteMoz;decisão e recebimento ainda não identificados.';
|
||
return r;
|
||
}
|
||
r.reason =
|
||
(eligibilityReason
|
||
? 'Decisão externa documentada registada independentemente da avaliação automática do fundamento: ' +
|
||
eligibilityReason +
|
||
' '
|
||
: '') +
|
||
'Pedido,decisão e recebimento separados;parcela27 não é reutilizada em26. Parte não deferida/indeferimento não libertam crédito automaticamente;eventual retorno exige procedimento documentado e reconciliação,sem duplicar20. Recebimento é registo declarado,sem execução bancária ou alteração silenciosa de caixa. Formalidades e suspensões não automatizadas;150dias não são promessa de recebimento.';
|
||
return r;
|
||
});
|
||
const result = {
|
||
calculatedAt: now,
|
||
key: key(d, b),
|
||
inputs: structuredClone(b.rows),
|
||
rows,
|
||
};
|
||
return {
|
||
...b,
|
||
...(b.result
|
||
? { history: [...(b.history ?? []), structuredClone(b.result)] }
|
||
: {}),
|
||
result,
|
||
};
|
||
}
|
||
export function refundReportRows(
|
||
d: Record<string, unknown>,
|
||
): Array<[string, string, string, string]> {
|
||
try {
|
||
const b = refundBook(d);
|
||
if (!b.rows.length) return [];
|
||
const out: Array<[string, string, string, string]> = [
|
||
[
|
||
'IVA mensal',
|
||
'L3-D — reembolso geral',
|
||
refundCurrent(d, b)
|
||
? 'Resultado correspondente às entradas'
|
||
: 'Histórico preservado;rever entradas/afectação',
|
||
refundSource,
|
||
],
|
||
],
|
||
fmt = (v: FiscalFraction | null | undefined) =>
|
||
v ? fractionPresentation(v).decimal : 'Indeterminado';
|
||
for (const r of b.result?.rows ?? []) {
|
||
const input = b.result!.inputs.find((v) => v.id === r.id),
|
||
s = input?.source;
|
||
out.push([
|
||
'IVA mensal',
|
||
`Reembolso — ${s?.period ?? 'por identificar'}`,
|
||
`Fundamento:${r.eligibility === 'compatible' ? 'Compatível com factos declarados' : r.eligibility === 'incompatible' ? 'Divergência nos factos — ver suporte' : 'Não demonstrado automaticamente'};Pedido27:${fmt(r.requested)};reporte26 separado:${fmt(r.reported)};reconhecido:${fmt(r.approved)};recebido:${fmt(r.received)};aguarda recebimento:${fmt(r.awaitingReceipt)};não deferido:${fmt(r.notGranted)}`,
|
||
`${s?.entityRef ?? ''};${scope(d)};MZN;${s?.origin === 'effective' ? 'Dados efectivos declarados' : 'Pressuposto de simulação'};${input?.stage === 'submitted' ? 'Apresentado fora doInvesteMoz' : 'Em preparação'};pedido ${input?.requestReference ?? ''},${input?.requestDate ?? ''};origem${input?.originPeriod ?? ''},nascimento${input?.entitlementDate ?? ''};${input?.groundRef ?? ''};${r.reason}`,
|
||
]);
|
||
out.push([
|
||
'IVA mensal',
|
||
`Decisão e recebimento — ${s?.period ?? ''}`,
|
||
`Decisão:${input?.decision === 'approved' ? 'Deferido' : input?.decision === 'partial' ? 'Deferimento parcial' : input?.decision === 'rejected' ? 'Indeferido' : 'Pendente'};documento${input?.decisionReference ?? ''};${input?.decisionDate ?? ''};${input?.decisionIssuer ?? ''};recebimentos:${input?.receipts.map((p) => `${p.reference};${p.date};${p.amount}MZN`).join(' | ') || 'nenhum registado'}`,
|
||
`Afectação do fecho${s?.closureId ?? ''},versão${s?.calculatedAt ?? ''};25:${fmt(s?.credit)}=26:${fmt(s?.carry)}+27:${fmt(s?.requested)};âmbito apenas desta parcela;versão guardada${b.result!.calculatedAt};${refundSource}`,
|
||
]);
|
||
}
|
||
return out;
|
||
} catch {
|
||
return [
|
||
[
|
||
'IVA mensal',
|
||
'L3-D',
|
||
'Pedido não recuperável',
|
||
'Originais preservados;sem concessão/recebimento presumidos.',
|
||
],
|
||
];
|
||
}
|
||
}
|