519 lines
20 KiB
TypeScript
519 lines
20 KiB
TypeScript
import { acquisitionScope, readAcquisitionStore } from './iva-acquisitions.ts';
|
||
|
||
export const exclusionBaseVersion = 'consideration-exclusions-base/1';
|
||
export const exclusionBaseSource =
|
||
'CIVA republicado Lei13/2016,15(6)(a–d),PDF14/p1426(272):exclui indemnizações declaradas judicialmente por incumprimento total/parcial de contratos;descontos,abatimentos ou bónus concedidos;quantias pagas em nome e por conta do adquirente/destinatário registadas em adequadas contas de terceiros;embalagens não efectivamente transaccionadas com elementos27(5)(b)na factura/documento equivalente.27(5)(b),PDF17/p1426(275):indicação separada e menção expressa da devolução acordada. Lei22/2022PDF3mantém15(6);Lei10/2025PDF14mantém27(5)(b),altera27(10),não15. Recorte2026/MZN,base apenas,sem IVA/dedução/campo11/regularizações ou totaisModeloA.';
|
||
export const exclusionTreatments = [
|
||
{
|
||
id: 'judicial_indemnity',
|
||
label: 'Indemnização declarada judicialmente por incumprimento contratual',
|
||
},
|
||
{ id: 'granted_discount', label: 'Desconto,abatimento ou bónus concedido' },
|
||
{
|
||
id: 'third_party_payment',
|
||
label: 'Quantia paga em nome e por conta do adquirente/destinatário',
|
||
},
|
||
{
|
||
id: 'returnable_packaging',
|
||
label: 'Embalagem não efectivamente transaccionada,com devolução acordada',
|
||
},
|
||
] as const;
|
||
export const exclusionConditions = [
|
||
[
|
||
'operationIdentified',
|
||
'A operação e os montantes relacionados estão identificados',
|
||
],
|
||
[
|
||
'initialNet',
|
||
'O montante inicial exclui o próprio IVA e tem a composição aplicável15(5)identificada,antes das exclusões ainda não consideradas',
|
||
],
|
||
[
|
||
'completeComposition',
|
||
'As exclusões aplicáveis estão reconciliadas com o montante inicial,sem omissões ou dupla subtracção',
|
||
],
|
||
[
|
||
'specialRules',
|
||
'Esta composição respeita a regra aplicável da operação,sem substituir bases especiais ou regularizações de outros períodos',
|
||
],
|
||
] as const;
|
||
export const exclusionPartConditions = {
|
||
judicial_indemnity: [
|
||
[
|
||
'judicialDeclaration',
|
||
'A indemnização recebida foi declarada judicialmente',
|
||
],
|
||
[
|
||
'contractBreach',
|
||
'Corresponde ao incumprimento total ou parcial de contrato',
|
||
],
|
||
],
|
||
granted_discount: [
|
||
[
|
||
'granted',
|
||
'O desconto,abatimento ou bónus está concedido e corresponde a esta operação',
|
||
],
|
||
],
|
||
third_party_payment: [
|
||
[
|
||
'paidInName',
|
||
'A quantia foi paga em nome do adquirente/destinatário identificado',
|
||
],
|
||
[
|
||
'paidForAccount',
|
||
'A quantia foi paga por conta desse adquirente/destinatário',
|
||
],
|
||
[
|
||
'thirdPartyAccounts',
|
||
'Está registada pelo contribuinte em adequadas contas de terceiros',
|
||
],
|
||
],
|
||
returnable_packaging: [
|
||
[
|
||
'notTransacted',
|
||
'As embalagens não foram efectivamente transaccionadas',
|
||
],
|
||
[
|
||
'separateIndication',
|
||
'Constam separadamente na factura ou documento equivalente',
|
||
],
|
||
[
|
||
'returnMention',
|
||
'Consta menção expressa de que foi acordada a devolução das embalagens',
|
||
],
|
||
],
|
||
} as const;
|
||
export function partConditions(
|
||
treatment: string,
|
||
): ReadonlyArray<readonly [string, string]> {
|
||
return (
|
||
exclusionPartConditions[
|
||
treatment as keyof typeof exclusionPartConditions
|
||
] ?? []
|
||
);
|
||
}
|
||
type Choice = '' | 'yes' | 'no';
|
||
type Condition = (typeof exclusionConditions)[number][0];
|
||
export type ExclusionPart = {
|
||
id: string;
|
||
treatment: string;
|
||
description: string;
|
||
amount: string;
|
||
included: Choice;
|
||
reference: string;
|
||
scope: string;
|
||
accountRef: string;
|
||
beneficiaryRef: string;
|
||
documentExcerpt: string;
|
||
conditions: Partial<Record<string, Choice>>;
|
||
};
|
||
export type ExclusionRow = {
|
||
id: string;
|
||
description: string;
|
||
period: string;
|
||
origin: 'projected' | 'effective';
|
||
currency: string;
|
||
initialAmount: string;
|
||
initialRef: string;
|
||
operationRef: string;
|
||
documentRef: string;
|
||
documentDate: string;
|
||
exclusionRef: string;
|
||
evidence: string;
|
||
conditions: Partial<Record<Condition, Choice>>;
|
||
parts: ExclusionPart[];
|
||
};
|
||
type LineResult = {
|
||
id: string;
|
||
status: 'determined' | 'not_applicable' | 'indeterminate';
|
||
baseAmount: string | null;
|
||
vat: null;
|
||
rate: null;
|
||
reason: string;
|
||
};
|
||
export type ExclusionCalculation = {
|
||
version: string;
|
||
revision: number;
|
||
calculatedAt: string;
|
||
opportunity: string;
|
||
location: string;
|
||
sourceKey: string;
|
||
source: string;
|
||
inputs: ExclusionRow[];
|
||
rows: LineResult[];
|
||
};
|
||
export type ExclusionBook = {
|
||
rows: ExclusionRow[];
|
||
result?: ExclusionCalculation;
|
||
history?: ExclusionCalculation[];
|
||
};
|
||
type Store = ReturnType<typeof readAcquisitionStore> & {
|
||
considerationExclusions?: Record<string, ExclusionBook>;
|
||
};
|
||
export function newExclusionPart(id: string): ExclusionPart {
|
||
return {
|
||
id,
|
||
treatment: '',
|
||
description: '',
|
||
amount: '',
|
||
included: '',
|
||
reference: '',
|
||
scope: '',
|
||
accountRef: '',
|
||
beneficiaryRef: '',
|
||
documentExcerpt: '',
|
||
conditions: {},
|
||
};
|
||
}
|
||
export function newExclusionBase(id: string, currency: string): ExclusionRow {
|
||
return {
|
||
id,
|
||
description: '',
|
||
period: '',
|
||
origin: 'projected',
|
||
currency,
|
||
initialAmount: '',
|
||
initialRef: '',
|
||
operationRef: '',
|
||
documentRef: '',
|
||
documentDate: '',
|
||
exclusionRef: '',
|
||
evidence: '',
|
||
conditions: {},
|
||
parts: [],
|
||
};
|
||
}
|
||
export function switchExclusionPart(
|
||
part: ExclusionPart,
|
||
treatment: string,
|
||
): ExclusionPart {
|
||
return { ...part, treatment, conditions: {} };
|
||
}
|
||
function readStore(draft: Record<string, unknown>): Store {
|
||
const store = readAcquisitionStore(draft.modelo_a_snapshot) as Store;
|
||
if (
|
||
store.considerationExclusions !== undefined &&
|
||
(!store.considerationExclusions ||
|
||
typeof store.considerationExclusions !== 'object' ||
|
||
Array.isArray(store.considerationExclusions))
|
||
)
|
||
throw new Error('Exclusões não recuperáveis;originais preservados.');
|
||
const fields = Object.keys(newExclusionBase('', '')).filter(
|
||
(key) => !['conditions', 'parts'].includes(key),
|
||
);
|
||
const partFields = Object.keys(newExclusionPart('')).filter(
|
||
(key) => key !== 'conditions',
|
||
);
|
||
for (const book of Object.values(store.considerationExclusions ?? {}))
|
||
if (
|
||
!book ||
|
||
!Array.isArray(book.rows) ||
|
||
(book.history !== undefined && !Array.isArray(book.history)) ||
|
||
book.rows.some(
|
||
(row) =>
|
||
!row ||
|
||
fields.some(
|
||
(key) =>
|
||
typeof row[key as keyof ExclusionRow] !== 'string',
|
||
) ||
|
||
!row.conditions ||
|
||
typeof row.conditions !== 'object' ||
|
||
Array.isArray(row.conditions) ||
|
||
!Array.isArray(row.parts) ||
|
||
row.parts.some(
|
||
(part) =>
|
||
!part ||
|
||
partFields.some(
|
||
(key) =>
|
||
typeof part[key as keyof ExclusionPart] !==
|
||
'string',
|
||
) ||
|
||
!part.conditions ||
|
||
typeof part.conditions !== 'object' ||
|
||
Array.isArray(part.conditions),
|
||
),
|
||
)
|
||
)
|
||
throw new Error(
|
||
'Linhas de exclusão não recuperáveis;originais preservados,sem substituição por lista vazia.',
|
||
);
|
||
return store;
|
||
}
|
||
export function exclusionBook(draft: Record<string, unknown>): ExclusionBook {
|
||
return (
|
||
readStore(draft).considerationExclusions?.[acquisitionScope(draft)] ?? {
|
||
rows: [],
|
||
}
|
||
);
|
||
}
|
||
export function saveExclusionBook(
|
||
draft: Record<string, unknown>,
|
||
book: ExclusionBook,
|
||
): string {
|
||
const current = readStore(draft),
|
||
encoded = JSON.stringify({
|
||
...current,
|
||
considerationExclusions: {
|
||
...current.considerationExclusions,
|
||
[acquisitionScope(draft)]: book,
|
||
},
|
||
});
|
||
readStore({ ...draft, modelo_a_snapshot: encoded });
|
||
return encoded;
|
||
}
|
||
function decimal(raw: unknown): bigint | null {
|
||
if (typeof raw !== 'string' || !/^\d{1,15}(\.\d{1,6})?$/.test(raw.trim()))
|
||
return null;
|
||
const [whole, fraction = ''] = raw.trim().split('.');
|
||
return BigInt(whole) * 1000000n + BigInt(fraction.padEnd(6, '0'));
|
||
}
|
||
function money(n: bigint): string {
|
||
return `${n / 1000000n}.${(n % 1000000n).toString().padStart(6, '0').replace(/0+$/, '').padEnd(2, '0')}`;
|
||
}
|
||
function validDate(value: string): boolean {
|
||
const d = new Date(value + 'T00:00:00Z');
|
||
return (
|
||
/^\d{4}-\d{2}-\d{2}$/.test(value) &&
|
||
Number.isFinite(d.getTime()) &&
|
||
d.toISOString().slice(0, 10) === value
|
||
);
|
||
}
|
||
export function evaluateExclusion(
|
||
row: ExclusionRow,
|
||
currency: unknown,
|
||
): LineResult {
|
||
const result: LineResult = {
|
||
id: row.id,
|
||
status: 'indeterminate',
|
||
baseAmount: null,
|
||
vat: null,
|
||
rate: null,
|
||
reason: '',
|
||
};
|
||
const stop = (
|
||
reason: string,
|
||
status: LineResult['status'] = 'indeterminate',
|
||
) => ({ ...result, status, reason });
|
||
if (currency !== 'MZN' || row.currency !== currency)
|
||
return stop(
|
||
'Moeda fora do âmbito MZN ou divergente do projecto;sem conversão silenciosa.',
|
||
);
|
||
if (
|
||
!/^2026-(0[1-9]|1[0-2])$/.test(row.period) ||
|
||
!['projected', 'effective'].includes(row.origin)
|
||
)
|
||
return stop(
|
||
'Indique mês em2026 e dados efectivos declarados ou pressupostos de projecção.',
|
||
);
|
||
const failed = exclusionConditions.filter(
|
||
([key]) => row.conditions[key] === 'no',
|
||
);
|
||
if (failed.length)
|
||
return stop(
|
||
'Não se aplica esta composição: ' +
|
||
failed.map(([, label]) => label).join('; ') +
|
||
'. Não é isenção nem IVA zero.',
|
||
'not_applicable',
|
||
);
|
||
const missing = exclusionConditions.filter(
|
||
([key]) => row.conditions[key] !== 'yes',
|
||
);
|
||
if (missing.length)
|
||
return stop(
|
||
'Condições por indicar: ' +
|
||
missing.map(([, label]) => label).join('; ') +
|
||
'. Desconhecido não é zero.',
|
||
);
|
||
if (
|
||
!row.description.trim() ||
|
||
!row.initialRef.trim() ||
|
||
!row.operationRef.trim() ||
|
||
!row.documentRef.trim() ||
|
||
!row.exclusionRef.trim() ||
|
||
!row.evidence.trim() ||
|
||
!validDate(row.documentDate)
|
||
)
|
||
return stop(
|
||
'Identifique operação,montante inicial,documento/data,composição e fundamento ou pressupostos expressos;reutilize referências existentes,sem certificado/aprovador novo.',
|
||
);
|
||
const initial = decimal(row.initialAmount);
|
||
if (initial === null)
|
||
return stop(
|
||
'Indique o montante inicial sem próprio IVA,com as inclusões aplicáveis consideradas e antes das exclusões ainda não realizadas;desconhecido não é zero.',
|
||
);
|
||
if (
|
||
!row.parts.length ||
|
||
new Set(row.parts.map((p) => p.id)).size !== row.parts.length ||
|
||
row.parts.some((p) => !p.id.trim())
|
||
)
|
||
return stop(
|
||
'Identifique as parcelas desta operação com identificadores distintos;sem subtracção de linhas vazias ou duplicadas.',
|
||
);
|
||
let exclusions = 0n;
|
||
const formulas: string[] = [];
|
||
for (const part of row.parts) {
|
||
const rule = exclusionTreatments.find((t) => t.id === part.treatment);
|
||
if (!rule)
|
||
return stop(
|
||
'Identifique o tratamento de cada parcela;uma descrição genérica não demonstra exclusão.',
|
||
);
|
||
const own = partConditions(part.treatment),
|
||
failed = own.filter(([key]) => part.conditions[key] === 'no');
|
||
if (failed.length)
|
||
return stop(
|
||
'Não se aplica a exclusão da parcela ' +
|
||
part.id +
|
||
': ' +
|
||
failed.map(([, label]) => label).join('; ') +
|
||
'. Sem subtracção automática ou IVA zero.',
|
||
'not_applicable',
|
||
);
|
||
if (
|
||
own.some(([key]) => part.conditions[key] !== 'yes') ||
|
||
!['yes', 'no'].includes(part.included)
|
||
)
|
||
return stop(
|
||
'Confirme as condições próprias da parcela ' +
|
||
part.id +
|
||
' e se ainda está incluída no montante inicial;desconhecido não permite presumir exclusão ou zero.',
|
||
);
|
||
if (
|
||
!part.description.trim() ||
|
||
!part.reference.trim() ||
|
||
!part.scope.trim()
|
||
)
|
||
return stop(
|
||
'Identifique descrição,referência e âmbito da parcela ' +
|
||
part.id +
|
||
',ou pressuposto expresso. Na indemnização,a referência identifica a declaração judicial e o contrato;no desconto,a concessão.',
|
||
);
|
||
if (
|
||
part.treatment === 'third_party_payment' &&
|
||
(!part.accountRef.trim() || !part.beneficiaryRef.trim())
|
||
)
|
||
return stop(
|
||
'Identifique as adequadas contas de terceiros e o adquirente/destinatário em nome e por conta de quem se pagou,ou pressupostos expressos;despesa própria não equivale a pagamento por conta alheia.',
|
||
);
|
||
if (
|
||
part.treatment === 'returnable_packaging' &&
|
||
!part.documentExcerpt.trim()
|
||
)
|
||
return stop(
|
||
'Identifique na referência da factura/documento a indicação separada e a menção expressa da devolução acordada,ou pressuposto expresso;sem exigir devolução já realizada.',
|
||
);
|
||
const amount = decimal(part.amount);
|
||
if (amount === null)
|
||
return stop(
|
||
'Indique o montante exacto da parcela ' +
|
||
part.id +
|
||
';desconhecido não é zero.',
|
||
);
|
||
if (part.included === 'yes') exclusions += amount;
|
||
formulas.push(
|
||
`${part.id}:${rule.label},${money(amount)}MZN,${part.included === 'yes' ? 'excluída uma vez' : 'já excluída,sem segunda subtracção'}`,
|
||
);
|
||
}
|
||
if (exclusions > initial)
|
||
return stop(
|
||
'As exclusões ainda incluídas excedem o montante inicial;reveja a composição,sem base negativa ou piso zero presumido.',
|
||
);
|
||
const base = initial - exclusions;
|
||
return {
|
||
...result,
|
||
status: 'determined',
|
||
baseAmount: money(base),
|
||
reason: `Base15(6):montante inicial ${money(initial)} − exclusões ainda incluídas ${money(exclusions)} = ${money(base)}MZN;${formulas.join('; ')},${row.origin === 'projected' ? 'nas condições expressamente assumidas na projecção' : 'nos dados efectivos declarados'}. Base única por operação. Base não é imposto;IVA/taxa,dedução/campo11,regularizações e totaisModeloA não são determinados por esta composição.`,
|
||
};
|
||
}
|
||
function key(draft: Record<string, unknown>, book: ExclusionBook): string {
|
||
return JSON.stringify({
|
||
version: exclusionBaseVersion,
|
||
scope: acquisitionScope(draft),
|
||
location: draft.selection_labels ?? {},
|
||
currency: draft.currency,
|
||
rows: book.rows,
|
||
});
|
||
}
|
||
export function exclusionCurrent(
|
||
draft: Record<string, unknown>,
|
||
book: ExclusionBook,
|
||
): boolean {
|
||
return !!book.result && book.result.sourceKey === key(draft, book);
|
||
}
|
||
export function recordExclusions(
|
||
draft: Record<string, unknown>,
|
||
book: ExclusionBook,
|
||
): ExclusionBook {
|
||
if (
|
||
!book.rows.length ||
|
||
new Set(book.rows.map((row) => row.id)).size !== book.rows.length
|
||
)
|
||
throw new Error(
|
||
'Indique operações com identificadores distintos;originais conservados.',
|
||
);
|
||
const result: ExclusionCalculation = {
|
||
version: exclusionBaseVersion,
|
||
revision: (book.result?.revision ?? 0) + 1,
|
||
calculatedAt: new Date().toISOString(),
|
||
opportunity: acquisitionScope(draft),
|
||
location: JSON.stringify(draft.selection_labels ?? {}),
|
||
sourceKey: key(draft, book),
|
||
source: exclusionBaseSource,
|
||
inputs: structuredClone(book.rows),
|
||
rows: book.rows.map((row) => evaluateExclusion(row, draft.currency)),
|
||
};
|
||
const next = {
|
||
...book,
|
||
result,
|
||
history: [
|
||
...(book.history ?? []),
|
||
...(book.result ? [book.result] : []),
|
||
],
|
||
};
|
||
saveExclusionBook(draft, next);
|
||
return next;
|
||
}
|
||
export function exclusionReportRows(
|
||
draft: Record<string, unknown>,
|
||
): Array<[string, string, string, string]> {
|
||
try {
|
||
const book = exclusionBook(draft),
|
||
result = book.result;
|
||
if (!result) return [];
|
||
return result.inputs.map((row, index) => [
|
||
'IVA mensal',
|
||
`Base P102 — ${row.period} — ${row.id}`,
|
||
`${row.description};base tributável: ${result.rows[index].baseAmount ?? 'Indeterminada'} ${row.currency};IVA: Indeterminado;${result.rows[index].reason}`,
|
||
`${result.opportunity};${result.location};${exclusionCurrent(draft, book) ? 'Resultado actual' : 'Entradas alteradas;resultado histórico conservado'};versão${result.revision};${result.calculatedAt};${result.source};${row.origin === 'projected' ? 'Projecção' : 'Dados efectivos declarados'};operação ${row.operationRef};montante inicial ${row.initialAmount} ${row.currency},referência ${row.initialRef};parcelas ${row.parts
|
||
.map(
|
||
(p) =>
|
||
`${p.id}/${p.treatment},${p.description},${p.amount} ${row.currency},já incluída:${p.included === 'yes' ? 'Sim' : p.included === 'no' ? 'Não' : 'Por indicar'},referência ${p.reference},âmbito ${p.scope}${p.treatment === 'third_party_payment' ? `,contas ${p.accountRef},adquirente/destinatário ${p.beneficiaryRef}` : ''}${p.treatment === 'returnable_packaging' ? `,menção documental ${p.documentExcerpt}` : ''},condições ${partConditions(
|
||
p.treatment,
|
||
)
|
||
.map(
|
||
([key, label]) =>
|
||
label +
|
||
': ' +
|
||
(p.conditions[key] === 'yes'
|
||
? 'Sim'
|
||
: p.conditions[key] === 'no'
|
||
? 'Não'
|
||
: 'Por indicar'),
|
||
)
|
||
.join('; ')}`,
|
||
)
|
||
.join(
|
||
'; ',
|
||
)};composição ${row.exclusionRef};documento ${row.documentRef},${row.documentDate};condições ${exclusionConditions.map(([key, label]) => label + ': ' + (row.conditions[key] === 'yes' ? 'Sim' : row.conditions[key] === 'no' ? 'Não' : 'Por indicar')).join('; ')};fundamento ${row.evidence};não preenche totaisModeloA ou campo11.`,
|
||
]);
|
||
} catch {
|
||
return [
|
||
[
|
||
'IVA mensal',
|
||
'Composição da contraprestação',
|
||
'Dados não recuperáveis',
|
||
'Originais preservados;sem substituir por zero.',
|
||
],
|
||
];
|
||
}
|
||
}
|