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

609 lines
24 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';
import {
newCurrencyEvent,
evaluateCurrencyEvent,
type CurrencyEventRow,
} from './iva-currency-events.ts';
import {
travelBook,
travelCurrent,
travelDecimal,
travelMoney,
type TravelLine,
type TravelRow,
} from './iva-travel.ts';
export const travelEventVersion = 'travel-suspensive-events/1';
export const travelEventSource =
'CIVA7(8)/(9),8,5(2),14(1),Lei13/2016PDF7/9/13/p1426(265/267/271); RIVA38/40,PDF6–7/p104–105.7(8) defere facto gerador/exigibilidade até cessar regime; mudança de local sem cessação não é saída fiscal.7(9)/40: primeiro entre pagamento integral e imediatamente antes do início/primeiro serviço. Facto gerador separado de factura/recebimento8; reutiliza motor entregue. Sem base/IVA/dedução, desembaraço ou pagamento automáticos.';
export type TravelEventRow = CurrencyEventRow & {
travelRowId: string;
fullPaymentState: string;
fullPaymentDate: string;
fullPaymentRef: string;
startState: string;
startDate: string;
startRef: string;
regimeClause: string;
entryDate: string;
entryRef: string;
regimeStatus: string;
exitDate: string;
exitRef: string;
cessationConfirmed: string;
};
type TravelOrigin = {
id: string;
period: string;
operatorRef: string;
operationRef: string;
description: string;
scope: string;
revision: number;
current: boolean;
input: TravelRow;
classification: TravelLine;
};
type DueEvent = { date: string; amount: string | null; reason: string };
export type TravelEventLine = {
id: string;
status: 'determined' | 'not_applicable' | 'indeterminate' | 'deferred';
eventDate: string | null;
eventTiming: string | null;
dueEvents: DueEvent[];
remainingPending: boolean;
source: TravelOrigin | null;
baseAmount: null;
vat: null;
deduction: null;
reason: string;
};
export type TravelEventCalculation = {
version: string;
revision: number;
calculatedAt: string;
opportunity: string;
sourceKey: string;
source: string;
inputs: TravelEventRow[];
rows: TravelEventLine[];
};
export type TravelEventBook = {
rows: TravelEventRow[];
result?: TravelEventCalculation;
history?: TravelEventCalculation[];
};
type Store = ReturnType<typeof readAcquisitionStore> & {
travelSpecialEvents?: Record<string, TravelEventBook>;
};
export function newTravelEvent(id: string, currency: string): TravelEventRow {
return {
...newCurrencyEvent(id, currency),
travelRowId: '',
fullPaymentState: '',
fullPaymentDate: '',
fullPaymentRef: '',
startState: '',
startDate: '',
startRef: '',
regimeClause: '',
entryDate: '',
entryRef: '',
regimeStatus: '',
exitDate: '',
exitRef: '',
cessationConfirmed: '',
};
}
export function switchTravelEvent(
row: TravelEventRow,
treatment: string,
): TravelEventRow {
return { ...row, treatment, factsConfirmed: '', invoiceRequired: '' };
}
export function travelEventSources(
draft: Record<string, unknown>,
): TravelOrigin[] {
const b = travelBook(draft),
r = b.result;
return r
? r.inputs.map((input, i) => ({
id: input.id,
period: input.period,
operatorRef: input.operatorRef,
operationRef: input.operationRef,
description: input.description,
scope: input.scope,
revision: r.revision,
current: travelCurrent(draft, b),
input,
classification: r.rows[i],
}))
: [];
}
function readStore(draft: Record<string, unknown>): Store {
const s = readAcquisitionStore(draft.modelo_a_snapshot) as Store;
if (
s.travelSpecialEvents !== undefined &&
(!s.travelSpecialEvents ||
typeof s.travelSpecialEvents !== 'object' ||
Array.isArray(s.travelSpecialEvents))
)
throw new Error(
'Eventos especiais não recuperáveis; originais conservados.',
);
const fields = Object.keys(newTravelEvent('', ''));
for (const b of Object.values(s.travelSpecialEvents ?? {}))
if (
!b ||
!Array.isArray(b.rows) ||
(b.history !== undefined && !Array.isArray(b.history)) ||
b.rows.some(
(r) =>
!r ||
fields.some(
(k) => typeof r[k as keyof TravelEventRow] !== 'string',
),
)
)
throw new Error(
'Linhas de eventos especiais não recuperáveis; sem lista vazia por defeito.',
);
return s;
}
export function travelEventBook(
draft: Record<string, unknown>,
): TravelEventBook {
return (
readStore(draft).travelSpecialEvents?.[acquisitionScope(draft)] ?? {
rows: [],
}
);
}
export function saveTravelEventBook(
draft: Record<string, unknown>,
book: TravelEventBook,
): string {
const s = readStore(draft),
encoded = JSON.stringify({
...s,
travelSpecialEvents: {
...s.travelSpecialEvents,
[acquisitionScope(draft)]: book,
},
});
readStore({ ...draft, modelo_a_snapshot: encoded });
return encoded;
}
function date(s: string): number | null {
const n = Date.parse(s + 'T00:00:00Z');
return /^\d{4}-\d{2}-\d{2}$/.test(s) &&
Number.isFinite(n) &&
new Date(n).toISOString().slice(0, 10) === s
? n
: null;
}
export function evaluateTravelEvent(
draft: Record<string, unknown>,
row: TravelEventRow,
): TravelEventLine {
const result: TravelEventLine = {
id: row.id,
status: 'indeterminate',
eventDate: null,
eventTiming: null,
dueEvents: [],
remainingPending: false,
source: null,
baseAmount: null,
vat: null,
deduction: null,
reason: '',
};
const stop = (
reason: string,
status: TravelEventLine['status'] = 'indeterminate',
) => ({ ...result, status, reason });
if (
!['travel_special', 'suspensive_exit'].includes(row.treatment) ||
!/^2026-(0[1-9]|1[0-2])$/.test(row.period) ||
!['projected', 'effective'].includes(row.origin) ||
date(row.documentDate) === null ||
date(row.reviewDate) === null ||
![
row.description,
row.operationRef,
row.documentRef,
row.scope,
row.evidence,
].every((v) => !!v.trim())
)
return stop(
'Identifique percurso, período2026, operação, âmbito, documentos/pressupostos e data de conferência.',
);
if (row.factsConfirmed === 'no')
return stop(
'Factos não correspondem ao percurso especial; manter enquadramento aplicável, sem isenção ou IVAzero presumidos.',
'not_applicable',
);
if (row.factsConfirmed !== 'yes')
return stop(
'Factos próprios do evento por esclarecer; não inferir pela factura, licenciamento ou viagem anunciada.',
);
const review = date(row.reviewDate)!;
if (row.treatment === 'suspensive_exit') {
const entry = date(row.entryDate);
if (
!['a', 'b', 'c', 'd'].includes(row.regimeClause) ||
entry === null ||
entry > review ||
!row.entryRef.trim()
)
return stop(
'Identifique bens/operação e regime ou procedimento14(1)/5(2), sua colocação/data e referência; uma localização em armazém não demonstra regime suspensivo.',
);
if (row.regimeStatus === 'still')
return stop(
'CIVA7(8): bens/operação continuam abrangidos pelo regime; facto gerador e exigibilidade diferidos. Factura ou recebimento não são tratados como saída fiscal por este percurso.',
'deferred',
);
if (row.regimeStatus !== 'ceased')
return stop(
'Situação actual do regime por esclarecer; ausência de documento não prova permanência nem saída.',
);
const exit = date(row.exitDate);
if (
exit === null ||
exit < entry ||
!row.exitRef.trim() ||
row.cessationConfirmed !== 'yes'
)
return stop(
'Identifique cessação efectiva do regime/procedimento e sua data/âmbito; transferência entre regimes ou mudança física de armazém não demonstram por si cessação.',
);
result.eventDate = row.exitDate;
result.eventTiming = 'cessação efectiva do regime/procedimento';
if (exit > review)
return stop(
'Cessação futura na conferência; não declarar facto gerador/exigibilidade já ocorridos.',
);
result.dueEvents = [
{
date: row.exitDate,
amount: null,
reason: '7(8): cessação do regime/procedimento identificado; montante não apurado',
},
];
return stop(
'P133: facto gerador e exigibilidade pelo momento de cessação efectiva7(8). Não concede isenção14, liquida importação, determina valor aduaneiro, direito, ICE ou dedução.',
'determined',
);
}
let source: TravelOrigin | undefined;
try {
source = travelEventSources(draft).find(
(s) => s.id === row.travelRowId,
);
} catch {
return stop(
'Origem de viagens não recuperável; conservar originais e histórico.',
);
}
if (!source)
return stop(
'Associe uma operação de viagens já classificada nesta oportunidade; não copiar de outro projecto.',
);
result.source = source;
if (!source.current || source.classification.regime === 'indeterminate')
return stop(
'Origem alterada ou natureza do regime indeterminada; rever apenas a associação/versão afectada.',
);
if (
!['special', 'mixed'].includes(source.classification.regime) ||
source.classification.territory !== 'national'
)
return stop(
'Origem segue regime geral ou outro âmbito; evento específico da parte especial não se aplica. Reutilizar eventos gerais próprios sem os substituir.',
'not_applicable',
);
if (source.input.domesticTaxable !== 'yes' || !source.input.taxRef.trim())
return stop(
'Enquadramento tributável da parcela especial por esclarecer. Montantes da margem não são pré-condição do evento, mas o âmbito fiscal deve estar identificado.',
);
if (
row.operationRef !== source.operationRef ||
row.currency !== source.input.currency
)
return stop(
'Referência da operação ou moeda diverge da origem; conservar dados e conferir a associação. O período deste evento pode diferir do período do registo da margem.',
);
if (
!['full', 'partial', 'none'].includes(row.fullPaymentState) ||
!['started', 'not_started'].includes(row.startState)
)
return stop(
'Distinga pagamento integral, parcial ou inexistente e início/primeiro serviço ocorrido ou ainda não iniciado. Desconhecido não é ausência de pagamento.',
);
const full =
row.fullPaymentState === 'full' ? date(row.fullPaymentDate) : null,
start = row.startState === 'started' ? date(row.startDate) : null;
if (
row.fullPaymentState === 'full' &&
(full === null || !row.fullPaymentRef.trim() || full > review)
)
return stop(
'Identifique pagamento integral efectivamente ocorrido ou no cenário de conferência declarado, sem o inferir de sinal/adiantamento.',
);
if (
row.startState === 'started' &&
(start === null || !row.startRef.trim() || start > review)
)
return stop(
'Identifique o primeiro serviço ao cliente/início da viagem ou alojamento, até à conferência. Não subtrair um dia à data: a regra é imediatamente antes desse momento.',
);
if (full !== null && (start === null || full < start)) {
result.eventDate = row.fullPaymentDate;
result.eventTiming = 'acto do pagamento integral';
} else if (start !== null) {
result.eventDate = row.startDate;
result.eventTiming =
full === start
? 'pagamento integral e início na mesma data; ordem intradiária não apurada'
: 'imediatamente antes do início/primeiro serviço';
}
if (row.fullPaymentState === 'partial' && row.paymentState !== 'received')
return stop(
'Pagamento parcial declarado exige identificar a parcela recebida; não o transformar em pagamento integral.',
);
if (row.fullPaymentState === 'none' && row.paymentState !== 'none')
return stop(
'Estado sem pagamentos diverge da parcela recebida/por esclarecer; reconciliar factos sem sobrescrever referências.',
);
if (row.fullPaymentState === 'full' && row.paymentState === 'none')
return stop(
'Pagamento integral diverge de ausência de recebimento; conferir estados.',
);
if (row.fullPaymentState === 'partial') {
const amount = travelDecimal(row.amount),
part = travelDecimal(row.paymentAmount);
if (amount === null || part === null || part <= 0n || part >= amount)
return stop(
'Parcial exige parcela positiva inferior à contraprestação total identificada; igualdade não pode ser tratada como simples sinal.',
);
}
if (row.fullPaymentState === 'full' && row.invoiceRequired === 'yes') {
const amount = travelDecimal(row.amount),
part = travelDecimal(row.paymentAmount),
payment = date(row.paymentDate);
if (
amount === null ||
part === null ||
payment === null ||
payment > full! ||
part <= 0n ||
part > amount ||
(payment === full && part !== amount) ||
(payment < full! && part >= amount)
)
return stop(
'Reconcilie parcela anterior e liquidação integral: data/valor do recebimento deve corresponder ao total na data integral ou a parcela anterior inferior ao total.',
);
}
if (result.eventDate === null) {
if (row.invoiceRequired === 'no')
return stop(
'Sem pagamento integral e sem início: facto gerador7(9) ainda não ocorrido no âmbito declarado; sem antecipação automática por adiantamento.',
'deferred',
);
if (row.invoiceRequired === 'yes' && row.paymentState === 'none') {
const deadline = date(row.legalDeadline),
invoice =
row.invoiceState === 'issued'
? date(row.invoiceDate)
: null;
if (
deadline === null ||
!row.deadlineRef.trim() ||
!['issued', 'not_issued'].includes(row.invoiceState) ||
(row.invoiceState === 'issued' &&
(invoice === null ||
invoice > review ||
!row.invoiceRef.trim()))
)
return stop(
'Facto gerador7(9) ainda não ocorrido; identificar factura e prazo/disposição aplicáveis antes de avaliar8a/b, sem inventar data de viagem.',
);
const due =
invoice !== null && invoice <= deadline
? row.invoiceDate
: row.legalDeadline;
if (date(due)! > review) {
result.remainingPending = true;
return stop(
'Facto gerador7(9) não ocorrido e nenhuma parcela8a/b exigível até à conferência declarada.',
'deferred',
);
}
const amount = travelDecimal(row.amount);
result.dueEvents = [
{
date: due,
amount: amount === null ? null : travelMoney(amount),
reason:
invoice !== null && invoice <= deadline
? '8a: factura antecipada emitida no prazo; facto gerador especial ainda não ocorrido'
: '8b: termo do prazo expressamente identificado; facto gerador especial ainda não ocorrido',
},
];
return stop(
'Exigibilidade8a/b por factura/prazo identificados, sem usar essa data como pagamento integral ou início7(9).',
'determined',
);
}
if (row.invoiceRequired !== 'yes' || row.paymentState !== 'received')
return stop(
'Facto gerador especial ainda não ocorrido; conferir eventual factura ou recebimento8 sem inventar data do início.',
);
const early = evaluateCurrencyEvent({
...row,
treatment: 'advance_payment',
eventDate: '',
eventRef: 'CIVA7(9)/RIVA40 — sem evento integral/início ocorrido',
});
result.dueEvents = early.dueEvents;
result.remainingPending = early.remainingPending;
return stop(
'Facto gerador7(9) ainda não ocorrido. Parcela8 avaliada pelo motor existente: ' +
early.reason,
early.status === 'determined'
? 'determined'
: early.status === 'not_applicable'
? 'not_applicable'
: 'indeterminate',
);
}
const existing = evaluateCurrencyEvent({
...row,
treatment: 'service_completed',
eventDate: result.eventDate!,
eventRef: 'CIVA7(9)/RIVA40: ' + result.eventTiming,
});
result.dueEvents = existing.dueEvents;
result.remainingPending = existing.remainingPending;
if (
existing.status === 'determined' &&
row.fullPaymentState === 'full' &&
row.invoiceRequired === 'yes'
) {
const total = travelDecimal(row.amount)!;
const before = result.dueEvents.filter((e) => date(e.date)! < full!);
let already = 0n;
for (const e of before) {
const value = e.amount === null ? null : travelDecimal(e.amount);
if (value === null)
return stop(
'Parcela anterior com montante indeterminado; conservar evento e esclarecer total antes de reconciliar pagamento integral.',
);
already += value;
}
if (already > total)
return stop(
'Parcelas anteriores excedem total; sem dupla contagem.',
);
result.dueEvents = [
...before,
...(total > already
? [
{
date: row.fullPaymentDate,
amount: travelMoney(total - already),
reason: '8c: saldo recebido na liquidação integral, descontando parcelas já exigíveis',
},
]
: []),
];
result.remainingPending = false;
}
return stop(
'P133:7(9)/RIVA40, ' +
result.eventTiming +
' em ' +
result.eventDate +
'. Exigibilidade8 separada por factura/recebimento, sem duplicação de parcela e restante. ' +
existing.reason,
existing.status === 'determined'
? 'determined'
: existing.status === 'not_applicable'
? 'not_applicable'
: 'indeterminate',
);
}
function key(draft: Record<string, unknown>, book: TravelEventBook): string {
return JSON.stringify({
version: travelEventVersion,
scope: acquisitionScope(draft),
location: draft.selection_labels ?? {},
rows: book.rows,
dependencies: book.rows.map((row) =>
row.treatment === 'travel_special'
? travelEventSources(draft).find(
(s) => s.id === row.travelRowId,
)
: null,
),
});
}
export function travelEventCurrent(
draft: Record<string, unknown>,
book: TravelEventBook,
): boolean {
try {
return !!book.result && book.result.sourceKey === key(draft, book);
} catch {
return false;
}
}
export function recordTravelEvents(
draft: Record<string, unknown>,
book: TravelEventBook,
): TravelEventBook {
if (
!book.rows.length ||
new Set(book.rows.map((r) => r.id)).size !== book.rows.length
)
throw new Error(
'Identifique eventos distintos; originais conservados.',
);
const result: TravelEventCalculation = {
version: travelEventVersion,
revision: (book.result?.revision ?? 0) + 1,
calculatedAt: new Date().toISOString(),
opportunity: acquisitionScope(draft),
sourceKey: key(draft, book),
source: travelEventSource,
inputs: structuredClone(book.rows),
rows: book.rows.map((row) => evaluateTravelEvent(draft, row)),
};
const next = {
...book,
result,
history: [
...(book.history ?? []),
...(book.result ? [book.result] : []),
],
};
saveTravelEventBook(draft, next);
return next;
}
function eventFacts(row: TravelEventRow, line: TravelEventLine): string {
if (row.treatment === 'suspensive_exit')
return `regime14(1)${row.regimeClause};entrada${row.entryDate},${row.entryRef};situação${row.regimeStatus};${row.regimeStatus === 'ceased' ? `cessação${row.exitDate},${row.exitRef},confirmada${row.cessationConfirmed}` : 'dados de cessação sem aplicação conservados no rascunho/histórico, não usados neste resultado'}`;
return `origem viagem${row.travelRowId},versão${line.source?.revision ?? 'Indeterminada'},${line.source?.scope ?? ''};pagamento${row.fullPaymentState}${row.fullPaymentState === 'full' ? ',' + row.fullPaymentDate + ',' + row.fullPaymentRef : ''};início${row.startState}${row.startState === 'started' ? ',' + row.startDate + ',' + row.startRef : ''};factura${row.invoiceRequired};${row.invoiceRequired === 'yes' ? `emissão${row.invoiceState}${row.invoiceState === 'issued' ? ',' + row.invoiceDate + ',' + row.invoiceRef : ''};prazo${row.legalDeadline},${row.deadlineRef};` : ''}recebimento${row.paymentState}${row.paymentState === 'received' ? ',' + row.paymentDate + ',' + row.paymentAmount + ',' + row.paymentRef : ''};total${row.amount}${row.currency};referências sem aplicação conservadas no rascunho/histórico`;
}
export function travelEventReportRows(
draft: Record<string, unknown>,
): Array<[string, string, string, string]> {
try {
const b = travelEventBook(draft),
r = b.result;
if (!r) return [];
return r.inputs.map((row, i) => {
const line = r.rows[i];
return [
'IVA mensal',
`P133 — ${row.period} — ${row.id}`,
`${row.description};${line.reason};facto gerador:${line.eventDate ?? 'Não apurado/ainda não ocorrido'};momento:${line.eventTiming ?? 'Indeterminado'};parcelas:${line.dueEvents.map((p) => p.date + ':' + (p.amount ?? 'Montante indeterminado') + ' ' + row.currency + ' (' + p.reason + ')').join('; ') || 'Nenhuma apurada'};restante:${line.status === 'indeterminate' ? 'Indeterminado' : line.remainingPending ? 'Pendente' : 'Sem restante apurado'};base/IVA/dedução:Indeterminados`,
`${r.opportunity};${travelEventCurrent(draft, b) ? 'Resultado actual' : 'Origem/entradas alteradas; resultado histórico conservado'};versão${r.revision};${r.calculatedAt};${r.source};${row.origin};operação${row.operationRef};âmbito${row.scope};documento${row.documentRef},${row.documentDate};conferência${row.reviewDate};${eventFacts(row, line)};factos${row.evidence};sem lançamento fiscal, nova dedução/campo11 ou totaisModeloA.`,
];
});
} catch {
return [
[
'IVA mensal',
'P133',
'Dados não recuperáveis',
'Originais conservados; nenhum evento por defeito.',
],
];
}
}