410 lines
16 KiB
TypeScript
410 lines
16 KiB
TypeScript
import { acquisitionScope, readAcquisitionStore } from './iva-acquisitions.ts';
|
|
|
|
export const compositionBaseVersion = 'consideration-composition-base/1';
|
|
export const compositionBaseSource =
|
|
'CIVA republicado Lei13/2016,15(5)(a)/(b),PDF14/p1426(272):o valor tributável inclui impostos,direitos,taxas e outras imposições,excepto o próprioIVA;e despesas acessórias debitadas relativas a comissões,embalagem,transporte e seguros por conta do cliente. Respeitar regras especiais15(2) e exclusões15(6);não substitui a exclusãoTC emP095. Lei22/2022PDF3mantém15(5)por remissão;Lei10/2025não altera15. Recorte2026/MZN,composição da base apenas,sem incidência,taxas,IVA,dedução/campo11 ou totaisModeloA.';
|
|
export const compositionTreatments = [
|
|
{
|
|
id: 'tax_component',
|
|
label: 'Impostos,direitos,taxas ou outras imposições,excepto próprio IVA',
|
|
},
|
|
{
|
|
id: 'accessory_component',
|
|
label: 'Despesas acessórias debitadas por conta do cliente',
|
|
},
|
|
] as const;
|
|
export const compositionConditions = [
|
|
[
|
|
'taxableOperation',
|
|
'A operação sujeita a IVA e a contraprestação correspondente estão identificadas',
|
|
],
|
|
[
|
|
'initialNet',
|
|
'O montante inicial está identificado,sem próprio IVA e com as exclusões aplicáveis15(6)já consideradas; a referência explica a composição',
|
|
],
|
|
[
|
|
'completeComposition',
|
|
'As parcelas aplicáveis15(5)estão reconciliadas com o montante inicial,sem omissões ou dupla contagem',
|
|
],
|
|
[
|
|
'specialRules',
|
|
'Esta composição respeita a regra aplicável da operação,sem substituir bases especiais15(2),exclusões15(6)ou a exclusão da TC de combustíveis',
|
|
],
|
|
] as const;
|
|
type Choice = '' | 'yes' | 'no';
|
|
type Condition = (typeof compositionConditions)[number][0];
|
|
export type CompositionPart = {
|
|
id: string;
|
|
treatment: string;
|
|
description: string;
|
|
amount: string;
|
|
included: Choice;
|
|
reference: string;
|
|
natureConfirmed: Choice;
|
|
ownVatExcluded: Choice;
|
|
};
|
|
export type CompositionRow = {
|
|
id: string;
|
|
description: string;
|
|
period: string;
|
|
origin: 'projected' | 'effective';
|
|
currency: string;
|
|
initialAmount: string;
|
|
initialRef: string;
|
|
operationRef: string;
|
|
documentRef: string;
|
|
documentDate: string;
|
|
compositionRef: string;
|
|
evidence: string;
|
|
conditions: Partial<Record<Condition, Choice>>;
|
|
parts: CompositionPart[];
|
|
};
|
|
type LineResult = {
|
|
id: string;
|
|
status: 'determined' | 'not_applicable' | 'indeterminate';
|
|
baseAmount: string | null;
|
|
vat: null;
|
|
rate: null;
|
|
reason: string;
|
|
};
|
|
export type CompositionCalculation = {
|
|
version: string;
|
|
revision: number;
|
|
calculatedAt: string;
|
|
opportunity: string;
|
|
location: string;
|
|
sourceKey: string;
|
|
source: string;
|
|
inputs: CompositionRow[];
|
|
rows: LineResult[];
|
|
};
|
|
export type CompositionBook = {
|
|
rows: CompositionRow[];
|
|
result?: CompositionCalculation;
|
|
history?: CompositionCalculation[];
|
|
};
|
|
type Store = ReturnType<typeof readAcquisitionStore> & {
|
|
considerationCompositions?: Record<string, CompositionBook>;
|
|
};
|
|
export function newCompositionPart(id: string): CompositionPart {
|
|
return {
|
|
id,
|
|
treatment: '',
|
|
description: '',
|
|
amount: '',
|
|
included: '',
|
|
reference: '',
|
|
natureConfirmed: '',
|
|
ownVatExcluded: '',
|
|
};
|
|
}
|
|
export function newCompositionBase(
|
|
id: string,
|
|
currency: string,
|
|
): CompositionRow {
|
|
return {
|
|
id,
|
|
description: '',
|
|
period: '',
|
|
origin: 'projected',
|
|
currency,
|
|
initialAmount: '',
|
|
initialRef: '',
|
|
operationRef: '',
|
|
documentRef: '',
|
|
documentDate: '',
|
|
compositionRef: '',
|
|
evidence: '',
|
|
conditions: {},
|
|
parts: [],
|
|
};
|
|
}
|
|
export function switchCompositionPart(
|
|
part: CompositionPart,
|
|
treatment: string,
|
|
): CompositionPart {
|
|
return { ...part, treatment, natureConfirmed: '' };
|
|
}
|
|
function readStore(draft: Record<string, unknown>): Store {
|
|
const store = readAcquisitionStore(draft.modelo_a_snapshot) as Store;
|
|
if (
|
|
store.considerationCompositions !== undefined &&
|
|
(!store.considerationCompositions ||
|
|
typeof store.considerationCompositions !== 'object' ||
|
|
Array.isArray(store.considerationCompositions))
|
|
)
|
|
throw new Error('Composições não recuperáveis;originais preservados.');
|
|
const fields = Object.keys(newCompositionBase('', '')).filter(
|
|
(key) => !['conditions', 'parts'].includes(key),
|
|
);
|
|
const partFields = Object.keys(newCompositionPart(''));
|
|
for (const book of Object.values(store.considerationCompositions ?? {}))
|
|
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 CompositionRow] !==
|
|
'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 CompositionPart
|
|
] !== 'string',
|
|
),
|
|
),
|
|
)
|
|
)
|
|
throw new Error(
|
|
'Linhas de composição não recuperáveis;originais preservados,sem substituição por lista vazia.',
|
|
);
|
|
return store;
|
|
}
|
|
export function compositionBook(
|
|
draft: Record<string, unknown>,
|
|
): CompositionBook {
|
|
return (
|
|
readStore(draft).considerationCompositions?.[
|
|
acquisitionScope(draft)
|
|
] ?? { rows: [] }
|
|
);
|
|
}
|
|
export function saveCompositionBook(
|
|
draft: Record<string, unknown>,
|
|
book: CompositionBook,
|
|
): string {
|
|
const current = readStore(draft),
|
|
encoded = JSON.stringify({
|
|
...current,
|
|
considerationCompositions: {
|
|
...current.considerationCompositions,
|
|
[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 evaluateComposition(
|
|
row: CompositionRow,
|
|
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 = compositionConditions.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 = compositionConditions.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.compositionRef.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 e com exclusões aplicáveis consideradas;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 soma de linhas vazias ou duplicadas.',
|
|
);
|
|
let additions = 0n;
|
|
const formulas: string[] = [];
|
|
for (const part of row.parts) {
|
|
const rule = compositionTreatments.find((t) => t.id === part.treatment);
|
|
if (!rule)
|
|
return stop(
|
|
'Identifique a natureza de cada parcela:imposição excepto próprioIVA ou despesa acessória debitada por conta do cliente.',
|
|
);
|
|
if (part.natureConfirmed === 'no' || part.ownVatExcluded === 'no')
|
|
return stop(
|
|
'A parcela ' +
|
|
part.id +
|
|
' não corresponde ao tratamento ou inclui próprioIVA;reveja a composição,não se transforma em exclusão/isenção automática.',
|
|
'not_applicable',
|
|
);
|
|
if (
|
|
part.natureConfirmed !== 'yes' ||
|
|
part.ownVatExcluded !== 'yes' ||
|
|
!['yes', 'no'].includes(part.included)
|
|
)
|
|
return stop(
|
|
'Confirme a natureza,exclusão do próprioIVA e se a parcela ' +
|
|
part.id +
|
|
' já está no montante inicial;desconhecido não permite presumir soma ou ausência.',
|
|
);
|
|
if (!part.description.trim() || !part.reference.trim())
|
|
return stop(
|
|
'Identifique descrição e referência da parcela ' +
|
|
part.id +
|
|
',ou pressuposto expresso;sem novo documento obrigatório.',
|
|
);
|
|
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 === 'no') additions += amount;
|
|
formulas.push(
|
|
`${part.id}:${rule.label},${money(amount)}MZN,${part.included === 'yes' ? 'já incluída,sem segunda soma' : 'acrescida uma vez'}`,
|
|
);
|
|
}
|
|
const base = initial + additions;
|
|
return {
|
|
...result,
|
|
status: 'determined',
|
|
baseAmount: money(base),
|
|
reason: `Base15(5):montante inicial ${money(initial)} + parcelas ainda não incluídas ${money(additions)} = ${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,não uma base por parcela. Base não é imposto;IVA/taxa,dedução/campo11 e totaisModeloA não são determinados por esta composição.`,
|
|
};
|
|
}
|
|
function key(draft: Record<string, unknown>, book: CompositionBook): string {
|
|
return JSON.stringify({
|
|
version: compositionBaseVersion,
|
|
scope: acquisitionScope(draft),
|
|
location: draft.selection_labels ?? {},
|
|
currency: draft.currency,
|
|
rows: book.rows,
|
|
});
|
|
}
|
|
export function compositionCurrent(
|
|
draft: Record<string, unknown>,
|
|
book: CompositionBook,
|
|
): boolean {
|
|
return !!book.result && book.result.sourceKey === key(draft, book);
|
|
}
|
|
export function recordCompositions(
|
|
draft: Record<string, unknown>,
|
|
book: CompositionBook,
|
|
): CompositionBook {
|
|
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: CompositionCalculation = {
|
|
version: compositionBaseVersion,
|
|
revision: (book.result?.revision ?? 0) + 1,
|
|
calculatedAt: new Date().toISOString(),
|
|
opportunity: acquisitionScope(draft),
|
|
location: JSON.stringify(draft.selection_labels ?? {}),
|
|
sourceKey: key(draft, book),
|
|
source: compositionBaseSource,
|
|
inputs: structuredClone(book.rows),
|
|
rows: book.rows.map((row) => evaluateComposition(row, draft.currency)),
|
|
};
|
|
const next = {
|
|
...book,
|
|
result,
|
|
history: [
|
|
...(book.history ?? []),
|
|
...(book.result ? [book.result] : []),
|
|
],
|
|
};
|
|
saveCompositionBook(draft, next);
|
|
return next;
|
|
}
|
|
export function compositionReportRows(
|
|
draft: Record<string, unknown>,
|
|
): Array<[string, string, string, string]> {
|
|
try {
|
|
const book = compositionBook(draft),
|
|
result = book.result;
|
|
if (!result) return [];
|
|
return result.inputs.map((row, index) => [
|
|
'IVA mensal',
|
|
`Base P101 — ${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};${compositionCurrent(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'},natureza:${p.natureConfirmed},sem próprioIVA:${p.ownVatExcluded},referência ${p.reference}`).join('; ')};composição ${row.compositionRef};documento ${row.documentRef},${row.documentDate};condições ${compositionConditions.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.',
|
|
],
|
|
];
|
|
}
|
|
}
|