551 lines
19 KiB
JavaScript
551 lines
19 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import {
|
|
newTravel,
|
|
newTravelCost,
|
|
newTravelPeriod,
|
|
travelConditions,
|
|
travelCostKinds,
|
|
evaluateTravel,
|
|
recordTravel,
|
|
saveTravelBook,
|
|
travelBook,
|
|
travelCurrent,
|
|
travelReportRows,
|
|
switchTravelResources,
|
|
} from '../../resources/js/lib/iva-travel.ts';
|
|
import {
|
|
newTravelEvent,
|
|
evaluateTravelEvent,
|
|
recordTravelEvents,
|
|
saveTravelEventBook,
|
|
travelEventBook,
|
|
travelEventCurrent,
|
|
travelEventReportRows,
|
|
switchTravelEvent,
|
|
} from '../../resources/js/lib/iva-travel-events.ts';
|
|
const frozen = JSON.parse(
|
|
fs.readFileSync(
|
|
'tests/frontend/fixtures/iva-travel-events-preserved.json',
|
|
'utf8',
|
|
),
|
|
),
|
|
prior = JSON.parse(frozen.modelo_a_snapshot);
|
|
const draft = {
|
|
...frozen,
|
|
context_opportunity: 'opportunity:360',
|
|
project_title: 'SYNTHETIC QA — Viagens e eventos / Manica',
|
|
};
|
|
function cost(id, amount, kind = 'direct') {
|
|
return {
|
|
...newTravelCost(id),
|
|
kind,
|
|
description: 'SYNTHETIC QA ' + kind,
|
|
amount,
|
|
ref: 'SYNTHETIC QA documento/custo ' + id,
|
|
thirdParty: 'yes',
|
|
directBenefit: 'yes',
|
|
};
|
|
}
|
|
function trip(id, period, gross, costAmount, patch = {}) {
|
|
return {
|
|
...newTravel(id, 'MZN'),
|
|
description: 'SYNTHETIC QA viagem ' + id,
|
|
period,
|
|
operatorRef: 'SYNTHETIC QA operador1',
|
|
operationRef: 'SYNTHETIC QA op ' + id,
|
|
documentRef: 'SYNTHETIC QA contrato/registo',
|
|
documentDate: '2026-10-01',
|
|
scope: 'SYNTHETIC QA pacote/parcela/período identificado',
|
|
evidence: 'SYNTHETIC QA factos/pressupostos',
|
|
agencyCountry: 'MZ',
|
|
agencyRef: 'SYNTHETIC QA sede fornecedora MZ',
|
|
resources: 'third_party',
|
|
retailer: 'no',
|
|
grossPrice: gross,
|
|
domesticTaxable: 'yes',
|
|
taxRef: 'SYNTHETIC QA parcela tributável nacional/divisor17(1)',
|
|
conditions: Object.fromEntries(
|
|
travelConditions.map(([k]) => [k, 'yes']),
|
|
),
|
|
costs: [cost(id + '-cost', costAmount)],
|
|
...patch,
|
|
};
|
|
}
|
|
function period(id, month, operator = 'SYNTHETIC QA operador1', mode = 'none') {
|
|
return {
|
|
...newTravelPeriod(id),
|
|
period: month,
|
|
operatorRef: operator,
|
|
scope: 'SYNTHETIC QA registo completo do âmbito simulado',
|
|
complete: 'yes',
|
|
registerRef: 'SYNTHETIC QA livro/previsão completo',
|
|
priorMode: mode,
|
|
};
|
|
}
|
|
const trips = [
|
|
trip('A', '2026-10', '1160', '580'),
|
|
trip('B', '2026-10', '1160', '1740'),
|
|
trip('C', '2026-11', '1160', '2320'),
|
|
trip('D', '2026-12', '2320', '580'),
|
|
trip('mixed', '2026-12', '2320', '580', {
|
|
operatorRef: 'SYNTHETIC QA operador2',
|
|
resources: 'mixed',
|
|
thirdPartyGross: '1160',
|
|
allocationRef:
|
|
'SYNTHETIC QA repartição contratual sem percentagem presumida',
|
|
}),
|
|
trip('own', '2026-12', '1160', '0', { resources: 'own' }),
|
|
trip('intermediary', '2026-12', '1160', '0', {
|
|
conditions: { operator: 'yes', singlePrice: 'yes', ownName: 'no' },
|
|
}),
|
|
trip('retail', '2026-12', '1160', '0', {
|
|
retailer: 'yes',
|
|
newPackage: 'no',
|
|
}),
|
|
trip('retail_new', '2026-12', '1160', '580', {
|
|
operatorRef: 'SYNTHETIC QA operador4',
|
|
retailer: 'yes',
|
|
newPackage: 'yes',
|
|
packageRef: 'SYNTHETIC QA conjunto novo/serviços substanciais',
|
|
}),
|
|
trip('foreign', '2026-12', '1160', '580', { agencyCountry: 'foreign' }),
|
|
];
|
|
trips[0].costs.push(
|
|
...travelCostKinds
|
|
.filter(([k]) =>
|
|
[
|
|
'exchange',
|
|
'advertising',
|
|
'communications',
|
|
'sales_commission',
|
|
].includes(k),
|
|
)
|
|
.map(([kind], i) => cost('A-' + kind, String((i + 1) * 10), kind)),
|
|
);
|
|
const periods = [
|
|
period('oct', '2026-10'),
|
|
period('nov', '2026-11', undefined, 'previous'),
|
|
period('dec', '2026-12', undefined, 'previous'),
|
|
period('mixed', '2026-12', 'SYNTHETIC QA operador2'),
|
|
period('retail_new', '2026-12', 'SYNTHETIC QA operador4'),
|
|
];
|
|
let travel = recordTravel(draft, { rows: trips, periods });
|
|
draft.modelo_a_snapshot = saveTravelBook(draft, travel);
|
|
for (const row of trips)
|
|
void test('P129 ' + row.id + ' regime and component scope', () => {
|
|
const v = evaluateTravel(row, 'MZN');
|
|
assert.equal(
|
|
v.regime,
|
|
row.id === 'mixed'
|
|
? 'mixed'
|
|
: ['own', 'intermediary', 'retail'].includes(row.id)
|
|
? 'general'
|
|
: row.id === 'foreign'
|
|
? 'outside'
|
|
: 'special',
|
|
);
|
|
assert.equal(
|
|
v.status,
|
|
['own', 'intermediary', 'retail', 'foreign'].includes(row.id)
|
|
? 'not_applicable'
|
|
: 'determined',
|
|
);
|
|
if (row.id === 'mixed') {
|
|
assert.equal(v.specialGross, '1160.00');
|
|
assert.equal(v.generalGross, '1160.00');
|
|
}
|
|
});
|
|
void test('P129 period-global margin offsets package gains/losses; excludes four legal costs; no individual-margin aggregation', () => {
|
|
const p = travel.result.periods;
|
|
assert.equal(p[0].grossReceipts, '2320.00');
|
|
assert.equal(p[0].directCosts, '2320.00');
|
|
assert.equal(p[0].excludedCosts, '100.00');
|
|
assert.equal(p[0].baseAmount, '0.00');
|
|
assert.equal(p[0].carryForward, '0.00');
|
|
assert.equal(p[1].grossDifference, '-1160.00');
|
|
assert.equal(p[1].carryForward, '1160.00');
|
|
assert.equal(p[2].priorExcess, '1160.00');
|
|
assert.equal(p[2].baseAmount, '500.00');
|
|
assert.equal(p[2].carryForward, '0.00');
|
|
assert.equal(p[3].baseAmount, '500.00');
|
|
for (const x of p) {
|
|
assert.equal(x.vat, null);
|
|
assert.equal(x.deduction, null);
|
|
assert.equal(x.baseDivisorRate, '16%');
|
|
}
|
|
});
|
|
void test('P129 unknown amounts, scope, cost benefit, currency and previous excess do not become zero', () => {
|
|
for (const patch of [
|
|
{ grossPrice: '' },
|
|
{ currency: 'USD' },
|
|
{ taxRef: '' },
|
|
{ domesticTaxable: '' },
|
|
{ agencyCountry: '' },
|
|
{ documentDate: '2026-02-30' },
|
|
{ operatorRef: '' },
|
|
{ resources: '' },
|
|
{ conditions: { operator: 'yes', singlePrice: '', ownName: 'yes' } },
|
|
{ costs: [{ ...trips[0].costs[0], directBenefit: '' }] },
|
|
])
|
|
assert.equal(
|
|
evaluateTravel({ ...trips[0], ...patch }, 'MZN').status,
|
|
'indeterminate',
|
|
);
|
|
for (const patch of [
|
|
{ complete: 'no' },
|
|
{ priorMode: '' },
|
|
{ priorMode: 'previous' },
|
|
{ registerRef: '' },
|
|
])
|
|
assert.equal(
|
|
recordTravel(draft, {
|
|
rows: [trips[0]],
|
|
periods: [{ ...periods[0], ...patch }],
|
|
}).result.periods[0].status,
|
|
'indeterminate',
|
|
);
|
|
assert.equal(
|
|
evaluateTravel({ ...trips[4], thirdPartyGross: '9999' }, 'MZN').status,
|
|
'indeterminate',
|
|
);
|
|
assert.equal(
|
|
evaluateTravel({ ...trips[8], packageRef: '' }, 'MZN').status,
|
|
'indeterminate',
|
|
);
|
|
const unknown = { ...trips[1], grossPrice: '' };
|
|
assert.equal(
|
|
recordTravel(draft, {
|
|
rows: [trips[0], unknown],
|
|
periods: [periods[0]],
|
|
}).result.periods[0].status,
|
|
'indeterminate',
|
|
);
|
|
const both = { ...trips[1], origin: 'effective' };
|
|
assert.equal(
|
|
recordTravel(draft, { rows: [trips[0], both], periods: [periods[0]] })
|
|
.result.periods[0].status,
|
|
'indeterminate',
|
|
);
|
|
});
|
|
void test('P129 explicit prior excess and nearest-unit rounding; cost exclusions never become deduction', () => {
|
|
const t = { ...trips[0], grossPrice: '1.75', costs: [] },
|
|
p = {
|
|
...periods[0],
|
|
priorMode: 'manual',
|
|
priorAmount: '0',
|
|
priorRef: 'SYNTHETIC QA fecho anterior sem excesso',
|
|
};
|
|
assert.equal(
|
|
recordTravel(draft, { rows: [t], periods: [p] }).result.periods[0]
|
|
.baseAmount,
|
|
'2.00',
|
|
);
|
|
const indirect = { ...trips[0], costs: [cost('indirect', '500', 'other')] };
|
|
indirect.costs[0].directBenefit = 'no';
|
|
assert.equal(evaluateTravel(indirect, 'MZN').eligibleCosts, '0.00');
|
|
assert.equal(evaluateTravel(indirect, 'MZN').excludedCosts, '500.00');
|
|
const correction = {
|
|
...periods[2],
|
|
priorMode: 'manual',
|
|
priorAmount: '500',
|
|
priorRef: 'SYNTHETIC QA correcção referenciada',
|
|
};
|
|
const pending = recordTravel(draft, {
|
|
rows: trips,
|
|
periods: [periods[0], periods[1], correction],
|
|
});
|
|
assert.equal(pending.result.periods[2].status, 'indeterminate');
|
|
assert.match(pending.result.periods[2].reason, /Nada foi sobrescrito/);
|
|
const confirmed = recordTravel(draft, {
|
|
rows: trips,
|
|
periods: [
|
|
periods[0],
|
|
periods[1],
|
|
{ ...correction, priorOverride: 'yes' },
|
|
],
|
|
});
|
|
assert.equal(confirmed.result.periods[2].priorExcess, '500.00');
|
|
assert.equal(confirmed.result.periods[2].baseAmount, '1069.00');
|
|
});
|
|
function event(id, patch = {}) {
|
|
return {
|
|
...newTravelEvent(id, 'MZN'),
|
|
treatment: 'travel_special',
|
|
travelRowId: 'A',
|
|
description: 'SYNTHETIC QA evento ' + id,
|
|
period: '2026-12',
|
|
operationRef: trips[0].operationRef,
|
|
documentRef: 'SYNTHETIC QA documento evento',
|
|
documentDate: '2026-12-01',
|
|
reviewDate: '2026-12-31',
|
|
scope: 'SYNTHETIC QA parcela viagem/bens identificados',
|
|
evidence: 'SYNTHETIC QA factos/pressupostos',
|
|
factsConfirmed: 'yes',
|
|
fullPaymentState: 'full',
|
|
fullPaymentDate: '2026-12-05',
|
|
fullPaymentRef: 'SYNTHETIC QA liquidação integral',
|
|
startState: 'started',
|
|
startDate: '2026-12-10',
|
|
startRef: 'SYNTHETIC QA primeiro serviço',
|
|
invoiceRequired: 'no',
|
|
paymentState: 'received',
|
|
paymentDate: '2026-12-05',
|
|
paymentAmount: '1160',
|
|
paymentRef: 'SYNTHETIC QA recebimento',
|
|
amount: '1160',
|
|
...patch,
|
|
};
|
|
}
|
|
const events = [
|
|
event('full_first'),
|
|
event('start_first', {
|
|
fullPaymentDate: '2026-12-20',
|
|
paymentDate: '2026-12-20',
|
|
}),
|
|
event('same_day', {
|
|
fullPaymentDate: '2026-12-10',
|
|
paymentDate: '2026-12-10',
|
|
}),
|
|
event('partial_8c', {
|
|
fullPaymentState: 'partial',
|
|
startState: 'not_started',
|
|
paymentAmount: '232',
|
|
invoiceRequired: 'yes',
|
|
invoiceState: 'not_issued',
|
|
legalDeadline: '2026-12-20',
|
|
deadlineRef:
|
|
'SYNTHETIC QA prazo identificado,sem cinco dias por defeito',
|
|
reviewDate: '2026-12-10',
|
|
}),
|
|
event('partial_then_full', {
|
|
fullPaymentDate: '2026-12-08',
|
|
paymentAmount: '232',
|
|
invoiceRequired: 'yes',
|
|
invoiceState: 'issued',
|
|
invoiceDate: '2026-12-15',
|
|
invoiceRef: 'SYNTHETIC QA factura',
|
|
legalDeadline: '2026-12-20',
|
|
deadlineRef: 'SYNTHETIC QA prazo identificado',
|
|
}),
|
|
event('invoice_before_start', {
|
|
fullPaymentState: 'none',
|
|
startState: 'not_started',
|
|
paymentState: 'none',
|
|
invoiceRequired: 'yes',
|
|
invoiceState: 'issued',
|
|
invoiceDate: '2026-12-02',
|
|
invoiceRef: 'SYNTHETIC QA factura antecipada',
|
|
legalDeadline: '2026-12-20',
|
|
deadlineRef: 'SYNTHETIC QA prazo identificado',
|
|
}),
|
|
event('travel_deferred', {
|
|
fullPaymentState: 'none',
|
|
startState: 'not_started',
|
|
paymentState: 'none',
|
|
}),
|
|
];
|
|
for (const clause of ['a', 'b', 'c', 'd'])
|
|
events.push(
|
|
event('exit_' + clause, {
|
|
treatment: 'suspensive_exit',
|
|
travelRowId: '',
|
|
regimeClause: clause,
|
|
entryDate: '2026-11-01',
|
|
entryRef: 'SYNTHETIC QA colocação regime/bens',
|
|
regimeStatus: 'ceased',
|
|
exitDate: '2026-12-10',
|
|
exitRef: 'SYNTHETIC QA cessação efectiva',
|
|
cessationConfirmed: 'yes',
|
|
}),
|
|
);
|
|
events.push({ ...events[7], id: 'still_held', regimeStatus: 'still' });
|
|
for (const row of events)
|
|
void test(
|
|
'P133 ' + row.id + ' special event separate from tax amounts',
|
|
() => {
|
|
const v = evaluateTravelEvent(draft, row);
|
|
assert.equal(
|
|
v.status,
|
|
['travel_deferred', 'still_held'].includes(row.id)
|
|
? 'deferred'
|
|
: 'determined',
|
|
);
|
|
assert.equal(v.baseAmount, null);
|
|
assert.equal(v.vat, null);
|
|
assert.equal(v.deduction, null);
|
|
if (row.id.startsWith('exit_')) {
|
|
assert.equal(v.eventDate, '2026-12-10');
|
|
assert.equal(v.dueEvents[0].amount, null);
|
|
}
|
|
},
|
|
);
|
|
void test('P133 full/start timing and partial/full/early invoice never duplicate amounts', () => {
|
|
assert.equal(evaluateTravelEvent(draft, events[0]).eventDate, '2026-12-05');
|
|
assert.equal(evaluateTravelEvent(draft, events[1]).eventDate, '2026-12-10');
|
|
assert.match(
|
|
evaluateTravelEvent(draft, events[2]).eventTiming,
|
|
/ordem intradiária não apurada/,
|
|
);
|
|
const early = evaluateTravelEvent(draft, events[3]);
|
|
assert.equal(early.eventDate, null);
|
|
assert.deepEqual(
|
|
early.dueEvents.map((x) => [x.date, x.amount]),
|
|
[['2026-12-05', '232.00']],
|
|
);
|
|
assert.equal(early.remainingPending, true);
|
|
const full = evaluateTravelEvent(draft, events[4]);
|
|
assert.equal(full.eventDate, '2026-12-08');
|
|
assert.deepEqual(
|
|
full.dueEvents.map((x) => [x.date, x.amount]),
|
|
[
|
|
['2026-12-05', '232.00'],
|
|
['2026-12-08', '928.00'],
|
|
],
|
|
);
|
|
assert.equal(full.remainingPending, false);
|
|
const invoice = evaluateTravelEvent(draft, events[5]);
|
|
assert.equal(invoice.eventDate, null);
|
|
assert.equal(invoice.dueEvents[0].date, '2026-12-02');
|
|
const invoiceFirst = { ...events[4], invoiceDate: '2026-12-03' };
|
|
assert.deepEqual(
|
|
evaluateTravelEvent(draft, invoiceFirst).dueEvents.map((x) => [
|
|
x.date,
|
|
x.amount,
|
|
]),
|
|
[['2026-12-03', '1160.00']],
|
|
);
|
|
});
|
|
void test('P133 absence, conflicting facts, future/invalid dates and regime transfer do not invent events', () => {
|
|
for (const patch of [
|
|
{ travelRowId: '' },
|
|
{ operationRef: 'another' },
|
|
{ fullPaymentState: '' },
|
|
{ startState: '' },
|
|
{ fullPaymentState: 'partial', paymentAmount: '1160' },
|
|
{ fullPaymentDate: '2026-12-99' },
|
|
{ startDate: '2027-01-01' },
|
|
{ fullPaymentState: 'none', paymentState: 'received' },
|
|
{ factsConfirmed: '' },
|
|
])
|
|
assert.equal(
|
|
evaluateTravelEvent(draft, { ...events[0], ...patch }).status,
|
|
'indeterminate',
|
|
);
|
|
for (const patch of [
|
|
{ regimeClause: '' },
|
|
{ entryRef: '' },
|
|
{ entryDate: '2026-12-20' },
|
|
{ regimeStatus: '' },
|
|
{ cessationConfirmed: 'no' },
|
|
{ exitRef: '' },
|
|
{ exitDate: '2027-01-01' },
|
|
])
|
|
assert.equal(
|
|
evaluateTravelEvent(draft, { ...events[7], ...patch }).status,
|
|
'indeterminate',
|
|
);
|
|
assert.equal(
|
|
evaluateTravelEvent(
|
|
{ ...draft, context_opportunity: 'opportunity:361' },
|
|
events[0],
|
|
).status,
|
|
'indeterminate',
|
|
);
|
|
assert.equal(
|
|
evaluateTravelEvent(draft, { ...events[0], travelRowId: 'own' }).status,
|
|
'not_applicable',
|
|
);
|
|
});
|
|
void test('P133 classification dependency is guarded; unknown margin amounts do not block an independently documented event', () => {
|
|
const changed = {
|
|
...travel,
|
|
rows: travel.rows.map((r) =>
|
|
r.id === 'A' ? { ...r, grossPrice: '' } : r,
|
|
),
|
|
},
|
|
d = { ...draft, modelo_a_snapshot: saveTravelBook(draft, changed) };
|
|
assert.equal(evaluateTravelEvent(d, events[0]).status, 'indeterminate');
|
|
d.modelo_a_snapshot = saveTravelBook(d, recordTravel(d, changed));
|
|
assert.equal(evaluateTravelEvent(d, events[0]).status, 'determined');
|
|
const b = recordTravelEvents(draft, { rows: events }),
|
|
fresh = { ...draft, modelo_a_snapshot: saveTravelEventBook(draft, b) };
|
|
assert.equal(travelEventCurrent(fresh, b), true);
|
|
fresh.modelo_a_snapshot = saveTravelBook(
|
|
fresh,
|
|
recordTravel(fresh, travel),
|
|
);
|
|
assert.equal(travelEventCurrent(fresh, b), false);
|
|
assert.equal(b.result.rows[0].source.revision, 1);
|
|
});
|
|
void test('Private save/recovery/history/report preserve123 delivered books, digital limitation and partial drafts', () => {
|
|
const t = recordTravel(draft, travel),
|
|
d = { ...draft, modelo_a_snapshot: saveTravelBook(draft, t) },
|
|
a = recordTravelEvents(d, { rows: events });
|
|
d.modelo_a_snapshot = saveTravelEventBook(d, a);
|
|
const b = recordTravelEvents(d, a);
|
|
d.modelo_a_snapshot = saveTravelEventBook(d, b);
|
|
assert.equal(t.history.length, 1);
|
|
assert.equal(b.history.length, 1);
|
|
assert.equal(travelReportRows(d).length, 15);
|
|
assert.equal(travelEventReportRows(d).length, 12);
|
|
assert.equal(travelCurrent(d, t), true);
|
|
assert.equal(travelEventCurrent(d, b), true);
|
|
const report = travelEventReportRows(d);
|
|
assert.doesNotMatch(report[3][3], /SYNTHETIC QA liquidação integral/);
|
|
assert.doesNotMatch(report[3][3], /SYNTHETIC QA primeiro serviço/);
|
|
assert.doesNotMatch(report[11][3], /SYNTHETIC QA cessação efectiva/);
|
|
assert.equal(b.rows[3].fullPaymentRef, 'SYNTHETIC QA liquidação integral');
|
|
assert.equal(b.rows[11].exitRef, 'SYNTHETIC QA cessação efectiva');
|
|
const store = JSON.parse(d.modelo_a_snapshot);
|
|
delete store.travelOperations;
|
|
delete store.travelSpecialEvents;
|
|
assert.deepEqual(store, prior);
|
|
assert.deepEqual(
|
|
travelBook({ ...d, context_opportunity: 'opportunity:361' }),
|
|
{ rows: [], periods: [] },
|
|
);
|
|
assert.deepEqual(
|
|
travelEventBook({ ...d, context_opportunity: 'opportunity:361' }),
|
|
{ rows: [] },
|
|
);
|
|
assert.throws(() =>
|
|
recordTravel(d, { rows: [trips[0], trips[0]], periods }),
|
|
);
|
|
assert.throws(() =>
|
|
recordTravelEvents(d, { rows: [events[0], events[0]] }),
|
|
);
|
|
assert.throws(() =>
|
|
travelBook({
|
|
...d,
|
|
modelo_a_snapshot: JSON.stringify({
|
|
...store,
|
|
travelOperations: [],
|
|
}),
|
|
}),
|
|
);
|
|
assert.throws(() =>
|
|
travelEventBook({
|
|
...d,
|
|
modelo_a_snapshot: JSON.stringify({
|
|
...store,
|
|
travelSpecialEvents: [],
|
|
}),
|
|
}),
|
|
);
|
|
const switched = switchTravelResources(trips[4], 'own');
|
|
assert.equal(switched.allocationRef, trips[4].allocationRef);
|
|
assert.deepEqual(switched.costs, trips[4].costs);
|
|
assert.equal(switched.conditions.singlePrice, '');
|
|
assert.equal(
|
|
switchTravelEvent(events[0], 'suspensive_exit').fullPaymentRef,
|
|
events[0].fullPaymentRef,
|
|
);
|
|
if (process.env.IVA_TRAVEL_EVENTS_FIXTURE)
|
|
fs.writeFileSync(
|
|
process.env.IVA_TRAVEL_EVENTS_FIXTURE,
|
|
JSON.stringify(d, null, 2) + '\n',
|
|
);
|
|
});
|