326 lines
14 KiB
TypeScript
326 lines
14 KiB
TypeScript
import { inputText } from './input-text.ts';
|
|
import { salesBook, salesCurrent } from './iva-operations.ts';
|
|
import { acquisitionBook, acquisitionCurrent } from './iva-acquisitions.ts';
|
|
import { travelBook, travelCurrent } from './iva-travel.ts';
|
|
import { travelEventBook, travelEventCurrent } from './iva-travel-events.ts';
|
|
import { secondhandBook, secondhandCurrent } from './iva-secondhand.ts';
|
|
import {
|
|
currencyEventBook,
|
|
currencyEventCurrent,
|
|
} from './iva-currency-events.ts';
|
|
export type FiscalCalculationGap = {
|
|
id: string;
|
|
book: string;
|
|
period: string;
|
|
calculation: string;
|
|
reason: string;
|
|
required: string[];
|
|
resolution: string;
|
|
kind: 'inputs_or_rule' | 'implementation' | 'stale' | 'not_calculated';
|
|
source: string;
|
|
};
|
|
type Row = Record<string, unknown>;
|
|
const fieldLabels: Record<string, string> = {
|
|
net: 'Base líquida',
|
|
vat: 'IVA',
|
|
gross: 'Total com IVA',
|
|
deductible: 'IVA dedutível',
|
|
baseAmount: 'Base tributável',
|
|
deduction: 'Dedução',
|
|
requested: 'Pedido',
|
|
approved: 'Reconhecido',
|
|
received: 'Recebido',
|
|
awaitingReceipt: 'Por receber',
|
|
delta: 'Regularização',
|
|
grossMargin: 'Margem bruta',
|
|
scenarioAmount: 'Montante do cenário',
|
|
outstanding: 'Por liquidar',
|
|
};
|
|
const financial = new Set([
|
|
'net',
|
|
'vat',
|
|
'gross',
|
|
'deductible',
|
|
'baseAmount',
|
|
'deduction',
|
|
'requested',
|
|
'approved',
|
|
'received',
|
|
'awaitingReceipt',
|
|
'delta',
|
|
'grossMargin',
|
|
'scenarioAmount',
|
|
'outstanding',
|
|
]);
|
|
const details: Record<string, { required: string[]; resolution: string }> = {
|
|
ivaBeverageRestitutions: {
|
|
required: [
|
|
'origem e parcela13(2) actuais,sem sobreposição',
|
|
'referência/data do pedido e da decisão externa,quando exista',
|
|
'montante reconhecido e comprovativos dos recebimentos efectivamente ocorridos',
|
|
],
|
|
resolution:
|
|
'Reconciliar a parcela ou completar apenas o acto externo em falta. Sem decisão/recebimento documentado,conservar esses estados pendentes;sem concessão ou caixa presumidas.',
|
|
},
|
|
ivaRefunds: {
|
|
required: [
|
|
'afectação25/26/27 actual',
|
|
'fundamento e origem do crédito',
|
|
'pedido,decisão e comprovativos de recebimento próprios',
|
|
],
|
|
resolution:
|
|
'Resolver a condição concreta do pedido ou registar o acto externo documentado;pedido não equivale a decisão ou recebimento.',
|
|
},
|
|
ivaSubstitutions: {
|
|
required: [
|
|
'declaração anterior congelada e fonte actual',
|
|
'referência,motivo e data da substituição',
|
|
'juros e pagamentos adicionais próprios',
|
|
],
|
|
resolution:
|
|
'Reconciliar anterior e actual,sem repetir créditos/pagamentos;guardar novo diferencial. A correspondência ModeloB/edição recebida permanece separada.',
|
|
},
|
|
|
|
salesOperations: {
|
|
required: [
|
|
'natureza e localização da operação',
|
|
'preço e indicação de IVA incluído/sem IVA',
|
|
'tratamento, condições e suporte próprios do fornecimento',
|
|
],
|
|
resolution:
|
|
'Resolver o motivo da linha e calcular a operação de venda. O IVA documental da aquisição não é origem do IVA do fornecimento.',
|
|
},
|
|
books: {
|
|
required: [
|
|
'IVA documental e documento legal',
|
|
'direito e afectação da aquisição',
|
|
'cobertura das parcelas do período e vendas/regularizações aplicáveis',
|
|
],
|
|
resolution:
|
|
'Resolver as linhas/campos indicados e guardar novo apuramento; preservar versões e valores indeterminados até completar as parcelas.',
|
|
},
|
|
regulatedSecondhands: {
|
|
required: [
|
|
'categoria, origem e regime do bem',
|
|
'venda, compra ou adjudicação e comissão documentadas',
|
|
'base da margem e regra de liquidação aplicável',
|
|
],
|
|
resolution:
|
|
'A base/margem já demonstrada conserva-se. Implementar separadamente a liquidação e a integração nos totais com a regra e o âmbito próprios; base calculada não significa IVA calculado.',
|
|
},
|
|
travelOperations: {
|
|
required: [
|
|
'operador, período e âmbito do registo global',
|
|
'contraprestação da parte especial e custos directos elegíveis de terceiros',
|
|
'excesso anterior, completude e enquadramento tributável',
|
|
],
|
|
resolution:
|
|
'Completar o registo global e a base; implementar IVA devido, dedução43(d) e integração do período com correspondência própria. A taxa do divisor da base não preenche o IVA devido.',
|
|
},
|
|
travelSpecialEvents: {
|
|
required: [
|
|
'origem da operação e natureza do evento',
|
|
'data e referência do pagamento integral/início ou cessação do regime',
|
|
'base, taxa e direito à dedução no cálculo próprio da operação',
|
|
],
|
|
resolution:
|
|
'Resolver o momento do evento e ligá-lo ao apuramento próprio. Momento determinado ou diferimento não calcula base, IVA ou dedução.',
|
|
},
|
|
currencyEvents: {
|
|
required: [
|
|
'natureza e âmbito do evento',
|
|
'datas de factura, prazo legal e recebimentos aplicáveis',
|
|
'moeda e câmbio documentado quando aplicável',
|
|
'base e regra fiscal da operação',
|
|
],
|
|
resolution:
|
|
'Resolver os factos/câmbio indicados e implementar ou reutilizar o cálculo próprio da operação; o evento isolado não liquida imposto.',
|
|
},
|
|
};
|
|
function record(value: unknown): value is Row {
|
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
/** Reads the applicable scope only; never recalculates or promotes a frozen result. */
|
|
export function fiscalCalculationGaps(
|
|
d: Record<string, unknown>,
|
|
): FiscalCalculationGap[] {
|
|
const store = JSON.parse(
|
|
inputText(d.modelo_a_snapshot || '{"version":1,"books":{}}'),
|
|
);
|
|
if (store.version !== 1 || !record(store.books))
|
|
throw Error(
|
|
'Livros não recuperáveis; conservar o original antes de diagnosticar.',
|
|
);
|
|
const scope = inputText(d.context_opportunity || 'general'),
|
|
out: FiscalCalculationGap[] = [];
|
|
const current: Record<string, () => boolean> = {
|
|
salesOperations: () => salesCurrent(d, salesBook(d)),
|
|
books: () => acquisitionCurrent(d, acquisitionBook(d)),
|
|
travelOperations: () => travelCurrent(d, travelBook(d)),
|
|
travelSpecialEvents: () => travelEventCurrent(d, travelEventBook(d)),
|
|
regulatedSecondhands: () => secondhandCurrent(d, secondhandBook(d)),
|
|
currencyEvents: () => currencyEventCurrent(d, currencyEventBook(d)),
|
|
};
|
|
for (const [name, map] of Object.entries(store)) {
|
|
if (!record(map) || !(scope in map)) continue;
|
|
if (!record(map[scope]))
|
|
throw Error(`Livro ${name} não recuperável;original preservado.`);
|
|
const book = map[scope] as Row;
|
|
if (book.result !== undefined && !record(book.result))
|
|
throw Error(
|
|
`Resultado ${name} não recuperável;original preservado.`,
|
|
);
|
|
const result = record(book.result) ? book.result : null,
|
|
inputs = (
|
|
Array.isArray(book.rows)
|
|
? book.rows
|
|
: Array.isArray(book.events)
|
|
? book.events
|
|
: []
|
|
) as Row[],
|
|
spec = details[name];
|
|
if (!result) {
|
|
for (const row of inputs)
|
|
out.push({
|
|
id: `${name}:${inputText(row.id ?? out.length)}:uncalculated`,
|
|
book: name,
|
|
period: inputText(row.period ?? ''),
|
|
calculation: inputText(row.description ?? row.id ?? name),
|
|
reason: 'Preparação sem resultado guardado; nenhum valor fiscal foi presumido.',
|
|
required: spec?.required ?? [
|
|
'dados e condições específicos da preparação existente',
|
|
],
|
|
resolution:
|
|
spec?.resolution ??
|
|
'Completar a preparação no componente existente e guardar o cálculo correspondente.',
|
|
kind: 'not_calculated',
|
|
source: '',
|
|
});
|
|
continue;
|
|
}
|
|
let fresh = true;
|
|
try {
|
|
fresh = current[name]
|
|
? current[name]()
|
|
: !(
|
|
Array.isArray(result.inputs) &&
|
|
JSON.stringify(result.inputs) !== JSON.stringify(inputs)
|
|
);
|
|
} catch {
|
|
fresh = false;
|
|
}
|
|
if (!fresh)
|
|
out.push({
|
|
id: `${name}:stale`,
|
|
book: name,
|
|
period: '',
|
|
calculation: 'Actualidade do resultado',
|
|
reason: 'Entradas ou origem alteradas; resultado histórico conservado.',
|
|
required: ['reconciliação das entradas/origens alteradas'],
|
|
resolution:
|
|
'Rever apenas o delta e guardar nova versão antes de usar os valores como actuais.',
|
|
kind: 'stale',
|
|
source: inputText(result.source ?? result.sources ?? ''),
|
|
});
|
|
const visit = (value: unknown, path: string) => {
|
|
if (Array.isArray(value)) {
|
|
value.forEach((v, i) => visit(v, `${path}[${i}]`));
|
|
return;
|
|
}
|
|
if (!record(value) || value.status === 'not_applicable') return;
|
|
const missing = Object.entries(value)
|
|
.filter(([k, v]) => v === null && financial.has(k))
|
|
.map(([k]) => k);
|
|
if (record(value.fields))
|
|
for (const [k, v] of Object.entries(value.fields))
|
|
if (v === null) missing.push(`campo${k}`);
|
|
if (value.status === 'indeterminate' || missing.length) {
|
|
const frozen = Array.isArray(result.inputs)
|
|
? (result.inputs as Row[])
|
|
: inputs,
|
|
input = frozen.find((i) => i.id === value.id),
|
|
origin = record(input?.source) ? input.source : null,
|
|
prior = record(input?.prior) ? input.prior : null,
|
|
supply =
|
|
name === 'salesOperations' &&
|
|
inputText(input?.treatment ?? '').startsWith(
|
|
'onboard_',
|
|
);
|
|
const implementation =
|
|
missing.length > 0 &&
|
|
value.status !== 'indeterminate' &&
|
|
[
|
|
'regulatedSecondhands',
|
|
'travelOperations',
|
|
'travelSpecialEvents',
|
|
'currencyEvents',
|
|
].includes(name);
|
|
out.push({
|
|
id: `${name}:${path}`,
|
|
book: name,
|
|
period: inputText(
|
|
value.period ??
|
|
input?.period ??
|
|
origin?.period ??
|
|
prior?.period ??
|
|
'',
|
|
),
|
|
calculation: `${inputText(input?.description ?? origin?.documentRef ?? value.id ?? (value.period ? 'Apuramento do período' : 'Apuramento'))}${missing.length ? ` — ${missing.map((f) => fieldLabels[f] ?? f.replace('campo', 'Campo ')).join(', ')}` : ''}`,
|
|
reason: inputText(
|
|
value.reason ??
|
|
(Array.isArray(value.reasons)
|
|
? value.reasons.join('; ')
|
|
: 'Parcelas incompletas; consultar o apuramento existente.'),
|
|
),
|
|
required: supply
|
|
? [
|
|
'natureza do abastecimento e meio elegível',
|
|
'tratamento e montante liquidado do fornecimento, separados da aquisição',
|
|
'procedimento próprio de13(2), quando aplicável',
|
|
]
|
|
: (spec?.required ?? [
|
|
'dados, documentos e parâmetros indicados no motivo desta linha',
|
|
]),
|
|
resolution: supply
|
|
? 'Identificar e implementar o tratamento do fornecimento e respectivo montante liquidado; conservar o IVA da aquisição separado. O acompanhamento E não resolve este cálculo.'
|
|
: (spec?.resolution ??
|
|
'Resolver o motivo concreto no percurso de origem e guardar nova versão; campos em falta continuam indeterminados.'),
|
|
kind:
|
|
implementation || supply
|
|
? 'implementation'
|
|
: 'inputs_or_rule',
|
|
source: inputText(
|
|
value.source ?? result.source ?? result.sources ?? '',
|
|
),
|
|
});
|
|
}
|
|
for (const [k, v] of Object.entries(value))
|
|
if (['rows', 'periods', 'events'].includes(k))
|
|
visit(v, `${path}.${k}`);
|
|
};
|
|
visit(result, 'result');
|
|
}
|
|
return out;
|
|
}
|
|
export function fiscalCalculationGapReportRows(
|
|
d: Record<string, unknown>,
|
|
): Array<[string, string, string, string]> {
|
|
try {
|
|
return fiscalCalculationGaps(d).map((g) => [
|
|
'IVA mensal',
|
|
`Cálculo pendente — ${g.calculation}`,
|
|
`${g.period};${g.kind};${g.reason};Dados necessários:${g.required.join('; ')};Condição:${g.resolution}`,
|
|
`${g.book};${g.id};${g.source};sem inferir IVA do fornecimento a partir da aquisição.`,
|
|
]);
|
|
} catch (error) {
|
|
return [
|
|
[
|
|
'IVA mensal',
|
|
'Diagnóstico dos cálculos',
|
|
'Livros não recuperáveis; originais preservados',
|
|
String(error),
|
|
],
|
|
];
|
|
}
|
|
}
|