Files
Investidor-V2/resources/js/lib/iva-travel.ts
T

677 lines
26 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 { acquisitionScope, readAcquisitionStore } from './iva-acquisitions.ts';
export const travelVersion = 'travel-period-margin/1';
export const travelSource =
'CIVA4(5),Lei13/2016PDF7/p1426(265); RIVA Decreto7/2008,38–46,PDF6–8/p104–106: âmbito, prestação única, margem global por período, custos directos de terceiros, excesso negativo e facturação IVA incluído. CIVA17(1),Lei22/2022PDF3:16% usado apenas no divisor da base em2026;Lei10/2025 não altera4/7/8/17. Sem IVA devido, dedução/campo11, regularização ou totaisModeloA calculados. O cálculo fica limitado ao registo e âmbito declarados, não fecha apuramento de toda a organização.';
type Choice = '' | 'yes' | 'no';
export const travelConditions = [
[
'operator',
'Agência de viagens ou organizador de circuitos turísticos identificado, actuando nessa qualidade',
],
[
'singlePrice',
'Conjunto combinado de serviços por preço único, sem discriminar preços finais por componente',
],
['ownName', 'Actua em nome próprio perante o cliente'],
] as const;
export const travelCostKinds = [
['direct', 'Bem/serviço de terceiro para benefício directo do cliente'],
['exchange', 'Compra/venda/troca de divisas'],
['advertising', 'Publicidade/promoção'],
['communications', 'Comunicações e correspondência da agência'],
['sales_commission', 'Comissão paga à agência vendedora'],
['other', 'Outro custo com natureza a esclarecer'],
] as const;
export type TravelCost = {
id: string;
kind: string;
description: string;
amount: string;
ref: string;
thirdParty: Choice;
directBenefit: Choice;
};
export type TravelRow = {
id: string;
description: string;
period: string;
origin: 'projected' | 'effective';
currency: string;
operatorRef: string;
operationRef: string;
documentRef: string;
documentDate: string;
scope: string;
evidence: string;
agencyCountry: string;
agencyRef: string;
resources: string;
retailer: Choice;
newPackage: Choice;
packageRef: string;
grossPrice: string;
thirdPartyGross: string;
allocationRef: string;
domesticTaxable: Choice;
taxRef: string;
conditions: Partial<Record<string, Choice>>;
costs: TravelCost[];
};
export type TravelPeriod = {
id: string;
period: string;
operatorRef: string;
scope: string;
complete: Choice;
registerRef: string;
priorMode: string;
priorAmount: string;
priorRef: string;
priorOverride: Choice;
};
export type TravelLine = {
id: string;
status: 'determined' | 'not_applicable' | 'indeterminate';
regime: 'special' | 'mixed' | 'general' | 'outside' | 'indeterminate';
territory: 'national' | 'outside' | null;
specialGross: string | null;
generalGross: string | null;
eligibleCosts: string | null;
excludedCosts: string | null;
reason: string;
};
export type TravelPeriodResult = {
id: string;
period: string;
operatorRef: string;
status: 'determined' | 'indeterminate';
grossReceipts: string | null;
directCosts: string | null;
excludedCosts: string | null;
priorExcess: string | null;
grossDifference: string | null;
carryForward: string | null;
baseAmount: string | null;
baseDivisorRate: string | null;
vat: null;
deduction: null;
reason: string;
};
export type TravelCalculation = {
version: string;
revision: number;
calculatedAt: string;
opportunity: string;
sourceKey: string;
source: string;
inputs: TravelRow[];
periodInputs: TravelPeriod[];
rows: TravelLine[];
periods: TravelPeriodResult[];
};
export type TravelBook = {
rows: TravelRow[];
periods: TravelPeriod[];
result?: TravelCalculation;
history?: TravelCalculation[];
};
type Store = ReturnType<typeof readAcquisitionStore> & {
travelOperations?: Record<string, TravelBook>;
};
export function newTravelCost(id: string): TravelCost {
return {
id,
kind: '',
description: '',
amount: '',
ref: '',
thirdParty: '',
directBenefit: '',
};
}
export function newTravel(id: string, currency: string): TravelRow {
return {
id,
description: '',
period: '',
origin: 'projected',
currency,
operatorRef: '',
operationRef: '',
documentRef: '',
documentDate: '',
scope: '',
evidence: '',
agencyCountry: '',
agencyRef: '',
resources: '',
retailer: '',
newPackage: '',
packageRef: '',
grossPrice: '',
thirdPartyGross: '',
allocationRef: '',
domesticTaxable: '',
taxRef: '',
conditions: {},
costs: [],
};
}
export function newTravelPeriod(id: string): TravelPeriod {
return {
id,
period: '',
operatorRef: '',
scope: '',
complete: '',
registerRef: '',
priorMode: '',
priorAmount: '',
priorRef: '',
priorOverride: '',
};
}
export function switchTravelResources(
row: TravelRow,
resources: string,
): TravelRow {
return {
...row,
resources,
conditions: { ...row.conditions, singlePrice: '', ownName: '' },
};
}
function readStore(draft: Record<string, unknown>): Store {
const s = readAcquisitionStore(draft.modelo_a_snapshot) as Store;
if (
s.travelOperations !== undefined &&
(!s.travelOperations ||
typeof s.travelOperations !== 'object' ||
Array.isArray(s.travelOperations))
)
throw new Error('Viagens não recuperáveis; originais conservados.');
const rf = Object.keys(newTravel('', '')).filter(
(k) => !['conditions', 'costs'].includes(k),
),
pf = Object.keys(newTravelPeriod('')),
cf = Object.keys(newTravelCost(''));
for (const b of Object.values(s.travelOperations ?? {}))
if (
!b ||
!Array.isArray(b.rows) ||
!Array.isArray(b.periods) ||
(b.history !== undefined && !Array.isArray(b.history)) ||
b.rows.some(
(r) =>
!r ||
rf.some(
(k) => typeof r[k as keyof TravelRow] !== 'string',
) ||
!r.conditions ||
typeof r.conditions !== 'object' ||
Array.isArray(r.conditions) ||
!Array.isArray(r.costs) ||
r.costs.some(
(c) =>
!c ||
cf.some(
(k) =>
typeof c[k as keyof TravelCost] !==
'string',
),
),
) ||
b.periods.some(
(p) =>
!p ||
pf.some(
(k) => typeof p[k as keyof TravelPeriod] !== 'string',
),
)
)
throw new Error(
'Registo de viagens não recuperável; sem substituição por lista vazia.',
);
return s;
}
export function travelBook(draft: Record<string, unknown>): TravelBook {
return (
readStore(draft).travelOperations?.[acquisitionScope(draft)] ?? {
rows: [],
periods: [],
}
);
}
export function saveTravelBook(
draft: Record<string, unknown>,
book: TravelBook,
): string {
const s = readStore(draft),
encoded = JSON.stringify({
...s,
travelOperations: {
...s.travelOperations,
[acquisitionScope(draft)]: book,
},
});
readStore({ ...draft, modelo_a_snapshot: encoded });
return encoded;
}
export function travelDecimal(s: string): bigint | null {
if (!/^\d{1,15}(\.\d{1,6})?$/.test(s.trim())) return null;
const [w, f = ''] = s.trim().split('.');
return BigInt(w) * 1000000n + BigInt(f.padEnd(6, '0'));
}
function money(n: bigint): string {
if (n < 0n) return '-' + money(-n);
return `${n / 1000000n}.${(n % 1000000n).toString().padStart(6, '0').replace(/0+$/, '').padEnd(2, '0')}`;
}
export { money as travelMoney };
function validDate(s: string): boolean {
const d = new Date(s + 'T00:00:00Z');
return (
/^\d{4}-\d{2}-\d{2}$/.test(s) &&
Number.isFinite(d.getTime()) &&
d.toISOString().slice(0, 10) === s
);
}
export function evaluateTravel(row: TravelRow, currency: unknown): TravelLine {
const result: TravelLine = {
id: row.id,
status: 'indeterminate',
regime: 'indeterminate',
territory: null,
specialGross: null,
generalGross: null,
eligibleCosts: null,
excludedCosts: null,
reason: '',
};
const stop = (
reason: string,
status: TravelLine['status'] = 'indeterminate',
) => ({ ...result, status, reason });
if (
!/^2026-(0[1-9]|1[0-2])$/.test(row.period) ||
!['projected', 'effective'].includes(row.origin) ||
![
row.description,
row.operatorRef,
row.operationRef,
row.documentRef,
row.scope,
row.evidence,
row.agencyRef,
].every((v) => !!v.trim()) ||
!validDate(row.documentDate)
)
return stop(
'Identifique operador, operação, período do registo2026, documento ou previsão, estabelecimento, âmbito e factos. O período do registo não é inferido da data da viagem.',
);
if (row.conditions.operator === 'no')
return stop(
'Qualidade de agência/organizador não demonstrada; aplicar enquadramento próprio, sem benefício presumido.',
'not_applicable',
);
if (row.conditions.operator !== 'yes')
return stop('Qualidade em que o operador actua por esclarecer.');
if (
row.conditions.singlePrice === 'no' ||
row.conditions.ownName === 'no' ||
row.resources === 'own'
)
return {
...stop(
'RIVA38(2)/(3): operação avulsa/em nome de outrem ou meios próprios segue regime geral, sem margem especial. Reutilize o percurso de serviço/localização aplicável.',
'not_applicable',
),
regime: 'general',
};
if (
!['third_party', 'mixed'].includes(row.resources) ||
row.conditions.singlePrice !== 'yes' ||
row.conditions.ownName !== 'yes' ||
!['yes', 'no'].includes(row.retailer)
)
return stop(
'Esclareça pacote a preço único, nome próprio, meios próprios/terceiros e venda retalhista.',
);
if (row.retailer === 'yes') {
if (row.newPackage === 'no')
return {
...stop(
'RIVA38(4): revenda retalhista sem novo conjunto com serviços não meramente acessórios/complementares segue regime geral.',
'not_applicable',
),
regime: 'general',
};
if (row.newPackage !== 'yes' || !row.packageRef.trim())
return stop(
'Na revenda retalhista, identifique novo conjunto em nome próprio e serviços acrescidos não meramente acessórios/complementares.',
);
}
if (!['MZ', 'foreign'].includes(row.agencyCountry))
return stop(
'Identifique sede/estabelecimento a partir do qual a agência presta o serviço.',
);
if (row.agencyCountry === 'foreign')
return {
...stop(
'RIVA39(2): sede/estabelecimento prestador fora do território; esta margem nacional não se aplica. Conferir localização/regime próprio, sem isenção ou IVA estrangeiro presumido.',
'not_applicable',
),
regime: 'outside',
territory: 'outside',
};
result.regime = row.resources === 'mixed' ? 'mixed' : 'special';
result.territory = 'national';
if (currency !== 'MZN' || row.currency !== currency)
return stop(
'Natureza especial identificada; cálculo em moeda estrangeira ou divergente não apurado, sem conversão fiscal presumida. P104 mantém âmbito próprio.',
);
if (row.domesticTaxable !== 'yes' || !row.taxRef.trim())
return stop(
'Localização39(2) identificada; enquadramento tributável desta parcela/período por esclarecer antes do divisor17(1). Não presumir isenção, taxa ou benefício pela categoria turística.',
);
const gross = travelDecimal(row.grossPrice);
if (gross === null)
return stop(
'Identifique contraprestação com IVA incluído registada neste período; desconhecido não é zero.',
);
const special =
row.resources === 'mixed' ? travelDecimal(row.thirdPartyGross) : gross;
if (
special === null ||
special > gross ||
(row.resources === 'mixed' && !row.allocationRef.trim())
)
return stop(
'Meios mistos: delimite contraprestação da parte de terceiros e fundamento de repartição; não aplicar margem ao conjunto nem repartir por percentagem inventada.',
);
let eligible = 0n,
excluded = 0n;
if (new Set(row.costs.map((c) => c.id)).size !== row.costs.length)
return stop('Identifique custos sem duplicar identidades.');
for (const cost of row.costs) {
const value = travelDecimal(cost.amount);
if (
value === null ||
!cost.ref.trim() ||
!cost.description.trim() ||
!travelCostKinds.some(([k]) => k === cost.kind)
)
return stop(
'Identifique natureza, montante com IVA e referência de cada custo registado no período. Lista vazia corresponde apenas à ausência declarada de custos no âmbito completo.',
);
if (
[
'exchange',
'advertising',
'communications',
'sales_commission',
].includes(cost.kind)
) {
excluded += value;
continue;
}
if (cost.thirdParty === 'no' || cost.directBenefit === 'no') {
excluded += value;
continue;
}
if (cost.thirdParty !== 'yes' || cost.directBenefit !== 'yes')
return stop(
'Benefício directo e fornecimento por terceiro por esclarecer neste custo; não o excluir ou incluir por defeito.',
);
eligible += value;
}
return {
...result,
status: 'determined',
specialGross: money(special),
generalGross: money(gross - special),
eligibleCosts: money(eligible),
excludedCosts: money(excluded),
reason: 'RIVA38/39: prestação única no âmbito da parte de terceiros; registo43 de contraprestações e custos directos com IVA incluído. Custos41(3) excluídos da margem, não transformados em dedução. A base é apurada globalmente por operador/período, não por pacote. Meios próprios/parte geral conservados fora da margem; factura45 sem discriminar IVA, menção IVA incluído; registo especial46. Sem factura oficial emitida, dedução ou regularização automática.',
};
}
function previousMonth(period: string): string {
const [y, m] = period.split('-').map(Number);
return new Date(Date.UTC(y, m - 2, 1)).toISOString().slice(0, 7);
}
export function evaluateTravelPeriods(
book: TravelBook,
lines: TravelLine[],
): TravelPeriodResult[] {
const results: TravelPeriodResult[] = [];
for (const input of [...book.periods].sort((a, b) =>
a.period.localeCompare(b.period),
)) {
const r: TravelPeriodResult = {
id: input.id,
period: input.period,
operatorRef: input.operatorRef,
status: 'indeterminate',
grossReceipts: null,
directCosts: null,
excludedCosts: null,
priorExcess: null,
grossDifference: null,
carryForward: null,
baseAmount: null,
baseDivisorRate: null,
vat: null,
deduction: null,
reason: '',
};
const stop = (reason: string) => {
r.reason = reason;
results.push(r);
};
if (
!/^2026-(0[1-9]|1[0-2])$/.test(input.period) ||
!input.operatorRef.trim() ||
!input.scope.trim() ||
!input.registerRef.trim() ||
input.complete !== 'yes'
) {
stop(
'Identifique período, operador e âmbito completo do registo simulado, incluindo contraprestações/custos e referência. Não apresentar soma parcial como apuramento global da organização.',
);
continue;
}
const rows = book.rows
.map((row, i) => ({ row, line: lines[i] }))
.filter(
({ row }) =>
row.operatorRef === input.operatorRef &&
row.period === input.period,
);
if (
book.rows.some(
(row) => !row.operatorRef.trim() || !row.period.trim(),
) ||
!rows.length ||
rows.some(({ line }) => line.status === 'indeterminate')
) {
stop(
'Linhas por esclarecer ou âmbito sem registo: período indeterminado; não omitir silenciosamente operação/custo da margem global.',
);
continue;
}
const selected = rows.filter(
({ line }) =>
line.status === 'determined' &&
['special', 'mixed'].includes(line.regime),
);
if (new Set(selected.map(({ row }) => row.origin)).size > 1) {
stop(
'Não somar dados efectivos e pressupostos projectados no mesmo registo global sem distinguir o cenário. Conservar as linhas e delimitar o âmbito.',
);
continue;
}
let prior: bigint | null = null;
if (input.priorMode === 'none') prior = 0n;
else if (input.priorMode === 'manual' && input.priorRef.trim())
prior = travelDecimal(input.priorAmount);
else if (input.priorMode === 'previous') {
const prev = results.find(
(x) =>
x.period === previousMonth(input.period) &&
x.operatorRef === input.operatorRef &&
x.status === 'determined',
);
if (prev?.carryForward !== null && prev?.carryForward !== undefined)
prior = travelDecimal(prev.carryForward);
}
if (prior === null) {
stop(
'Excesso negativo anterior por esclarecer: indicar inexistência, montante/referência ou reutilizar período imediatamente anterior determinado do mesmo operador.',
);
continue;
}
const existingPrevious = results.find(
(x) =>
x.period === previousMonth(input.period) &&
x.operatorRef === input.operatorRef &&
x.status === 'determined',
);
if (
input.priorMode !== 'previous' &&
existingPrevious?.carryForward !== null &&
existingPrevious?.carryForward !== undefined &&
travelDecimal(existingPrevious.carryForward) !== prior &&
(input.priorMode !== 'manual' ||
!input.priorRef.trim() ||
input.priorOverride !== 'yes')
) {
stop(
'Excesso indicado ' +
money(prior) +
' diverge do período anterior calculado ' +
existingPrevious.carryForward +
'. A alteração muda os custos/base e o excesso seguinte; indique montante manual e referência da correcção e confirme a utilização, ou reutilize o anterior. Nada foi sobrescrito.',
);
continue;
}
let gross = 0n,
costs = 0n,
excluded = 0n;
for (const { line } of selected) {
gross += travelDecimal(line.specialGross!)!;
costs += travelDecimal(line.eligibleCosts!)!;
excluded += travelDecimal(line.excludedCosts!)!;
}
const difference = gross - costs - prior,
positive = difference > 0n ? difference : 0n,
baseWhole = (positive * 100n + 58000000n) / 116000000n;
Object.assign(r, {
status: 'determined',
grossReceipts: money(gross),
directCosts: money(costs),
excludedCosts: money(excluded),
priorExcess: money(prior),
grossDifference: money(difference),
carryForward: money(difference < 0n ? -difference : 0n),
baseAmount: money(baseWhole * 1000000n),
baseDivisorRate: '16%',
reason: 'RIVA41(4)/(5),43a/b: soma global das parcelas especiais registadas no âmbito declarado, custos directos de terceiros e excesso anterior. Diferença positiva dividida por1.16, base arredondada à unidade; diferença negativa transportada como custo para o período seguinte, não dedução nem imposto a reembolsar. Taxa16% apenas no divisor da base; IVA devido e dedução43d não calculados. Alteração de custos/contraprestação44 exige versão referenciada, sem reescrever histórico ou gerar regularização automática.',
});
results.push(r);
}
return results;
}
function key(draft: Record<string, unknown>, book: TravelBook): string {
return JSON.stringify({
version: travelVersion,
scope: acquisitionScope(draft),
location: draft.selection_labels ?? {},
currency: draft.currency,
rows: book.rows,
periods: book.periods,
});
}
export function travelCurrent(
draft: Record<string, unknown>,
book: TravelBook,
): boolean {
return !!book.result && book.result.sourceKey === key(draft, book);
}
export function recordTravel(
draft: Record<string, unknown>,
book: TravelBook,
): TravelBook {
const costIds = book.rows.flatMap((r) => r.costs.map((c) => c.id));
if (
!book.rows.length ||
new Set(book.rows.map((r) => r.id)).size !== book.rows.length ||
new Set(
book.rows.map(
(r) => r.operatorRef + '|' + r.period + '|' + r.operationRef,
),
).size !== book.rows.length ||
new Set(costIds).size !== costIds.length ||
new Set(book.periods.map((p) => p.id)).size !== book.periods.length ||
new Set(book.periods.map((p) => p.operatorRef + '|' + p.period))
.size !== book.periods.length
)
throw new Error(
'Identidades de operações/custos/períodos devem ser distintas; evite dupla soma, originais conservados.',
);
const rows = book.rows.map((row) => evaluateTravel(row, draft.currency));
const result: TravelCalculation = {
version: travelVersion,
revision: (book.result?.revision ?? 0) + 1,
calculatedAt: new Date().toISOString(),
opportunity: acquisitionScope(draft),
sourceKey: key(draft, book),
source: travelSource,
inputs: structuredClone(book.rows),
periodInputs: structuredClone(book.periods),
rows,
periods: evaluateTravelPeriods(book, rows),
};
const next = {
...book,
result,
history: [
...(book.history ?? []),
...(book.result ? [book.result] : []),
],
};
saveTravelBook(draft, next);
return next;
}
export function travelReportRows(
draft: Record<string, unknown>,
): Array<[string, string, string, string]> {
try {
const b = travelBook(draft),
r = b.result;
if (!r) return [];
const state = `${r.opportunity};${travelCurrent(draft, b) ? 'Resultado actual' : 'Entradas alteradas; resultado histórico conservado'};versão${r.revision};${r.calculatedAt};${r.source}`;
return [
...r.inputs.map((row, i): [string, string, string, string] => [
'IVA mensal',
`P129 — ${row.period} — ${row.id}`,
`${row.description};regime:${r.rows[i].regime};${r.rows[i].reason};contraprestação parte terceiros:${r.rows[i].specialGross ?? 'Indeterminada'};parte geral:${r.rows[i].generalGross ?? 'Indeterminada'};custos elegíveis:${r.rows[i].eligibleCosts ?? 'Indeterminados'};custos excluídos:${r.rows[i].excludedCosts ?? 'Indeterminados'};base apenas no período global`,
`${state};${row.origin};operação${row.operationRef};operador${row.operatorRef};sede/estabelecimento${row.agencyCountry},${row.agencyRef};âmbito${row.scope};documento${row.documentRef},${row.documentDate};meios${row.resources};retalhista${row.retailer};novo conjunto${row.newPackage},${row.packageRef};preço comIVA${row.grossPrice}${row.currency};parte terceiros${row.thirdPartyGross},repartição${row.allocationRef};enquadramento${row.domesticTaxable},${row.taxRef};condições${JSON.stringify(row.conditions)};custos${JSON.stringify(row.costs)};factos${row.evidence}`,
]),
...r.periods.map((p, i): [string, string, string, string] => [
'IVA mensal',
`P129 — margem global ${p.period} — ${p.operatorRef}`,
`contraprestações:${p.grossReceipts ?? 'Indeterminadas'};custos directos:${p.directCosts ?? 'Indeterminados'};excesso anterior:${p.priorExcess ?? 'Indeterminado'};diferença:${p.grossDifference ?? 'Indeterminada'};base:${p.baseAmount ?? 'Indeterminada'}MZN;excesso seguinte:${p.carryForward ?? 'Indeterminado'};${p.reason};IVA/dedução:Indeterminados`,
`${state};registo${JSON.stringify(r.periodInputs.find((x) => x.id === p.id) ?? r.periodInputs[i])};sem total organizacional presumido, nova dedução/campo11, regularização ou totaisModeloA.`,
]),
];
} catch {
return [
[
'IVA mensal',
'P129',
'Dados não recuperáveis',
'Originais preservados; sem margem por defeito.',
],
];
}
}