340 lines
13 KiB
JavaScript
340 lines
13 KiB
JavaScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import fs from 'node:fs';
|
||
import {
|
||
normalBook,
|
||
normalCurrent,
|
||
calculateNormal,
|
||
recordNormal,
|
||
saveNormalBook,
|
||
normalHtml,
|
||
normalFinancialSource,
|
||
} from '../../resources/js/lib/irpc-normal.ts';
|
||
import { newNormalPeriodFx } from '../../resources/js/lib/irpc-normal-period-fx.ts';
|
||
import {
|
||
emptyFxSelection,
|
||
selectFx,
|
||
} from '../../resources/js/lib/iva-fx-selection.ts';
|
||
import { createProjectDossierReport } from '../../resources/js/lib/project-dossier-report.ts';
|
||
import { newCurrencyEvent } from '../../resources/js/lib/iva-currency-events.ts';
|
||
import { irpcFxSources } from '../../resources/js/lib/irpc-fx.ts';
|
||
const seed = () =>
|
||
JSON.parse(
|
||
fs.readFileSync(
|
||
new URL(
|
||
'./fixtures/irpc-depreciation-preserved.json',
|
||
import.meta.url,
|
||
),
|
||
'utf8',
|
||
),
|
||
),
|
||
val = (f) => (f ? Number(f.numerator) / Number(f.denominator) : null);
|
||
function fx(direction = 'mzn_per_unit', rate = '64') {
|
||
const s = {
|
||
...emptyFxSelection(),
|
||
nature: 'commercial',
|
||
submissionDate: '2026-10-26',
|
||
bulletinDate: '2026-10-26',
|
||
bulletinRef: 'QA pressuposto sintético64; não boletim real',
|
||
source: 'https://www.bancomoc.mz/',
|
||
sourceCurrency: 'USD',
|
||
direction,
|
||
mean: rate,
|
||
};
|
||
return { ...s, applied: selectFx(s) };
|
||
}
|
||
function setup(currency = 'MZN', kind = 'start') {
|
||
const d = seed(),
|
||
b = normalBook(d),
|
||
p = JSON.parse(d.business_plan_snapshot);
|
||
p.plan.currency = currency;
|
||
d.business_plan_snapshot = JSON.stringify(p);
|
||
b.rows = [];
|
||
b.costs = undefined;
|
||
b.schedules.rows = [];
|
||
b.schedules.interest.amount = currency === 'USD' ? '7680' : '120';
|
||
for (const t of Object.values(b.profit.topics)) {
|
||
t.state = 'absent';
|
||
t.amount = '';
|
||
t.reference = 'QA ausência declarada no período seleccionado';
|
||
}
|
||
b.base.losses = [];
|
||
b.base.lossesState = 'absent';
|
||
b.base.lossesReference = 'QA ausência de perdas reportadas';
|
||
b.depreciation.assets[0].accounting = currency === 'USD' ? '3840' : '60';
|
||
b.depreciation.assets[0].cost = currency === 'USD' ? '32000' : '500';
|
||
b.depreciation.assets[0].start = '2026-07-01';
|
||
b.depreciation.assets[0].timing = 'months';
|
||
b.periodsFx = {
|
||
...newNormalPeriodFx(),
|
||
kind,
|
||
from: '2026-07-01',
|
||
to: '2026-12-31',
|
||
planStart: '2026-01-01',
|
||
reference:
|
||
'QA início documentado1Julho;correspondência completa dos meses7–12 ao período do sujeito',
|
||
mznMapsReference:
|
||
'QA activo/quotas/juros e demais mapas reconciliados explicitamente emMZN no período',
|
||
conversionReference:
|
||
currency === 'USD'
|
||
? 'QA pressuposto de simulação64MZN/USD aplicado aos meses seleccionados;sem regra fiscal anual confirmada'
|
||
: '',
|
||
};
|
||
if (currency === 'USD')
|
||
b.periodsFx.months = Array.from({ length: 6 }, (_, n) => ({
|
||
period: n + 7,
|
||
currency: 'USD',
|
||
fxSelection: fx(),
|
||
}));
|
||
return { d, b };
|
||
}
|
||
void test('installed7 stays current without opt-in; optional8 preserves original result and plan while selecting6 months', () => {
|
||
const d = seed(),
|
||
old = normalBook(d);
|
||
assert.equal(normalCurrent(d, old), true);
|
||
const { d: next, b } = setup(),
|
||
m = calculateNormal(next, b);
|
||
assert.equal(m.version, 'IRPC-normal-adjustments/8');
|
||
assert.equal(val(m.accountingProfit), 3120);
|
||
assert.equal(val(m.depreciation.assets[0].quota), 50);
|
||
assert.equal(val(m.taxableProfit), 3130);
|
||
assert.equal(val(m.taxableBase), 3130);
|
||
assert.equal(val(m.colecta), 1001.6);
|
||
assert.equal(m.assessment.period.from, '2026-07-01');
|
||
assert.equal(m.assessment.finalBalance.payable, null);
|
||
assert.equal(JSON.parse(next.business_plan_snapshot).plan.periods, 12);
|
||
});
|
||
void test('monthly conversion reuses delivered selection and direction; originalUSD and assumptions remain reportable', () => {
|
||
const { d, b } = setup('USD'),
|
||
m = calculateNormal(d, b);
|
||
assert.equal(val(m.accountingProfit), 199680);
|
||
assert.equal(val(m.depreciation.adjustment), 640);
|
||
assert.equal(val(m.taxableProfit), 200320);
|
||
assert.equal(val(m.colecta), 64102.4);
|
||
assert.equal(m.periodsFx.originalCurrency, 'USD');
|
||
assert.equal(m.periodsFx.rows[0].evidence.column, 'mean');
|
||
assert.match(m.periodsFx.rule, /simulação/);
|
||
const inv = setup('USD');
|
||
for (const e of inv.b.periodsFx.months)
|
||
e.fxSelection = fx('units_per_mzn', '0.015625');
|
||
assert.equal(val(calculateNormal(inv.d, inv.b).colecta), 64102.4);
|
||
});
|
||
void test('annual plan in another currency and shorterplan aligned to July both reconcile without annualising', () => {
|
||
const a = setup('USD');
|
||
a.b.periodsFx.kind = 'annual';
|
||
a.b.periodsFx.from = '2026-01-01';
|
||
a.b.periodsFx.months = Array.from({ length: 12 }, (_, n) => ({
|
||
period: n + 1,
|
||
currency: 'USD',
|
||
fxSelection: fx(),
|
||
}));
|
||
a.b.schedules.interest.amount = '15360';
|
||
a.b.depreciation.assets[0].accounting = '7680';
|
||
a.b.depreciation.assets[0].start = '2026-01-01';
|
||
assert.equal(val(calculateNormal(a.d, a.b).colecta), 128204.8);
|
||
const { d, b } = setup();
|
||
const p = JSON.parse(d.business_plan_snapshot);
|
||
p.plan.periods = 6;
|
||
p.plan.assetDepreciation = '60';
|
||
p.plan.depreciationLifeMonths = 6;
|
||
d.business_plan_snapshot = JSON.stringify(p);
|
||
b.periodsFx.planStart = '2026-07-01';
|
||
assert.equal(val(calculateNormal(d, b).colecta), 1001.6);
|
||
});
|
||
void test('cessation and same-year start/end use documented dates; unsupportedpartial months and contradictions remain pending', () => {
|
||
const { d, b } = setup();
|
||
b.periodsFx.kind = 'cessation';
|
||
b.periodsFx.from = '2026-01-01';
|
||
b.periodsFx.to = '2026-06-30';
|
||
b.depreciation.assets[0].start = '2026-01-01';
|
||
assert.equal(val(calculateNormal(d, b).colecta), 1001.6);
|
||
b.periodsFx.kind = 'same_year';
|
||
b.periodsFx.from = '2026-04-01';
|
||
b.periodsFx.to = '2026-09-30';
|
||
assert.equal(val(calculateNormal(d, b).colecta), 1001.6);
|
||
for (const patch of [
|
||
{ from: '2026-07-15' },
|
||
{ to: '2026-12-30' },
|
||
{ planStart: '' },
|
||
{ from: '2026-01-01', kind: 'start' },
|
||
{ to: '2026-02-31' },
|
||
{ reference: '' },
|
||
{ mznMapsReference: '' },
|
||
]) {
|
||
const x = setup();
|
||
Object.assign(x.b.periodsFx, patch);
|
||
assert.equal(calculateNormal(x.d, x.b).colecta, null);
|
||
}
|
||
});
|
||
void test('missingmonth selection,sourcecurrency mismatch and editedapplied rate never become defaultFX', () => {
|
||
for (const mutate of [
|
||
(i) => i.months.pop(),
|
||
(i) => (i.months[0].currency = 'EUR'),
|
||
(i) => (i.months[0].fxSelection.mean = '65'),
|
||
(i) => (i.conversionReference = ''),
|
||
(i) => i.months.push({ ...i.months[0], period: 1 }),
|
||
]) {
|
||
const { d, b } = setup('USD');
|
||
mutate(b.periodsFx);
|
||
const m = calculateNormal(d, b);
|
||
assert.equal(m.accountingProfit, null);
|
||
assert.equal(m.colecta, null);
|
||
assert.ok(m.periodsFx.rows.some((r) => r.value));
|
||
}
|
||
});
|
||
void test('fractionalcent conversion blocks dependenttotals while retaining exact source and otherknownmonths', () => {
|
||
const { d, b } = setup('USD');
|
||
b.periodsFx.months[0].fxSelection = fx('mzn_per_unit', '0.33333333');
|
||
const m = calculateNormal(d, b);
|
||
assert.equal(m.colecta, null);
|
||
assert.ok(
|
||
m.periodsFx.pending.some((s) => s.includes('fracção de cêntimo')),
|
||
);
|
||
assert.equal(m.periodsFx.rows[0].originalCurrency, 'USD');
|
||
});
|
||
void test('shortperiod priorloss expiry and annualcaps stay explicitlypending; no change to independent arithmetic', () => {
|
||
const x = setup();
|
||
x.b.base = structuredClone(normalBook(seed()).base);
|
||
const m = calculateNormal(x.d, x.b);
|
||
assert.equal(val(m.taxableProfit), 3130);
|
||
assert.equal(m.taxableBase, null);
|
||
assert.ok(m.base.pending.some((s) => s.includes('41(5)')));
|
||
const y = setup();
|
||
y.b.costs = structuredClone(normalBook(seed()).costs);
|
||
assert.equal(calculateNormal(y.d, y.b).colecta, null);
|
||
assert.ok(
|
||
calculateNormal(y.d, y.b).costs.pending.some((s) =>
|
||
s.includes('Limite anual neste período curto'),
|
||
),
|
||
);
|
||
y.b.costs = undefined;
|
||
y.b.depreciation.assets[0].timing = 'annual';
|
||
assert.equal(calculateNormal(y.d, y.b).colecta, null);
|
||
});
|
||
void test('save/recovery/report preserves7 history and sourceplan; changed period makes8 historical', () => {
|
||
const { d, b } = setup('USD'),
|
||
original = d.business_plan_snapshot,
|
||
old = structuredClone(b.result);
|
||
d.modelo_a_snapshot = saveNormalBook(
|
||
d,
|
||
recordNormal(d, b, '2026-10-26T10:00:00+02:00'),
|
||
);
|
||
const back = normalBook(JSON.parse(JSON.stringify(d)));
|
||
assert.equal(normalCurrent(d, back), true);
|
||
assert.deepEqual(back.history.at(-1), old);
|
||
assert.equal(d.business_plan_snapshot, original);
|
||
assert.match(normalHtml(d), /64102.40/);
|
||
assert.match(normalHtml(d), /regra fiscal anual não confirmada/);
|
||
assert.match(
|
||
JSON.stringify(
|
||
createProjectDossierReport(d, {
|
||
selectedSections: new Set(['finance']),
|
||
}),
|
||
),
|
||
/USD/,
|
||
);
|
||
back.periodsFx.reference += 'changed';
|
||
assert.equal(normalCurrent(d, back), false);
|
||
});
|
||
void test('newcases prepare privateAPI persistence and dossiers including insufficientinformation', () => {
|
||
const cases = {
|
||
short: setup(),
|
||
fx: setup('USD'),
|
||
missing: setup('USD'),
|
||
loss: setup(),
|
||
};
|
||
cases.missing.b.periodsFx.months.pop();
|
||
cases.loss.b.base = structuredClone(normalBook(seed()).base);
|
||
fs.mkdirSync('outputs/irpc-l6-periods-fx', { recursive: true });
|
||
for (const [name, { d, b }] of Object.entries(cases)) {
|
||
d.modelo_a_snapshot = saveNormalBook(
|
||
d,
|
||
recordNormal(d, b, '2026-10-26T10:00:00+02:00'),
|
||
);
|
||
fs.writeFileSync(
|
||
'outputs/irpc-l6-periods-fx/' + name + '.json',
|
||
JSON.stringify(d),
|
||
);
|
||
fs.writeFileSync(
|
||
'outputs/irpc-l6-periods-fx/' + name + '.html',
|
||
normalHtml(d),
|
||
);
|
||
}
|
||
});
|
||
void test('different monthrates require MZN map reconciliation; UI source uses converted subset without relabellingUSD credit balances', () => {
|
||
const { d, b } = setup('USD');
|
||
b.periodsFx.months[0].fxSelection = fx('mzn_per_unit', '65');
|
||
assert.equal(calculateNormal(d, b).colecta, null);
|
||
b.depreciation.assets[0].accounting = '3850';
|
||
b.schedules.interest.amount = '7700';
|
||
assert.equal(val(calculateNormal(d, b).colecta), 64272);
|
||
d.modelo_a_snapshot = saveNormalBook(d, b);
|
||
const source = normalFinancialSource(d);
|
||
assert.equal(source.currency, 'MZN');
|
||
assert.equal(source.periods, 6);
|
||
assert.equal(
|
||
source.accountingLines.find((l) => l.code === 'depreciation').total,
|
||
'3850.00',
|
||
);
|
||
assert.deepEqual(source.cashSchedule, []);
|
||
});
|
||
void test('noncalendar annual mapping cannot bypass existing authorised period and unsupportedshortdates stay pending', () => {
|
||
const { d, b } = setup();
|
||
b.periodsFx.kind = 'annual';
|
||
b.periodsFx.from = '2025-07-01';
|
||
b.periodsFx.to = '2026-06-30';
|
||
b.periodsFx.planStart = '2025-07-01';
|
||
assert.equal(calculateNormal(d, b).colecta, null);
|
||
assert.ok(
|
||
calculateNormal(d, b).pending.some((s) =>
|
||
s.includes('autorizado/comunicado'),
|
||
),
|
||
);
|
||
});
|
||
void test('reusedFX link invalidates current result when existing source changes and never changes historical result', () => {
|
||
const { d, b } = setup('USD'),
|
||
snapshot = JSON.parse(d.modelo_a_snapshot);
|
||
snapshot.currencyEvents ??= {};
|
||
snapshot.currencyEvents[d.context_opportunity] = {
|
||
version: 1,
|
||
rows: [
|
||
{
|
||
...newCurrencyEvent('fx-shared', 'MZN'),
|
||
sourceCurrency: 'USD',
|
||
fxSelection: fx(),
|
||
},
|
||
],
|
||
};
|
||
d.modelo_a_snapshot = JSON.stringify(snapshot);
|
||
const linked = irpcFxSources(d)[0];
|
||
assert.ok(linked);
|
||
b.periodsFx.months[0].fxLink = {
|
||
id: linked.id,
|
||
fingerprint: linked.fingerprint,
|
||
};
|
||
b.periodsFx.months[0].fxSelection = linked.selection;
|
||
d.modelo_a_snapshot = saveNormalBook(d, recordNormal(d, b));
|
||
const book = normalBook(d),
|
||
old = structuredClone(book.result);
|
||
assert.equal(normalCurrent(d, book), true);
|
||
const changed = JSON.parse(d.modelo_a_snapshot);
|
||
changed.currencyEvents[d.context_opportunity].rows[0].description =
|
||
'New source context';
|
||
d.modelo_a_snapshot = JSON.stringify(changed);
|
||
assert.equal(normalCurrent(d, normalBook(d)), false);
|
||
assert.deepEqual(normalBook(d).result, old);
|
||
assert.equal(calculateNormal(d, normalBook(d)).colecta, null);
|
||
});
|
||
|
||
void test('known nondepreciable asset in shortperiod keeps full addback without asking irrelevant timing', () => {
|
||
const { d, b } = setup();
|
||
Object.assign(b.depreciation.assets[0], {
|
||
category: 'nondepreciable',
|
||
timing: '',
|
||
method: '',
|
||
});
|
||
const m = calculateNormal(d, b);
|
||
assert.equal(val(m.depreciation.adjustment), 60);
|
||
assert.equal(val(m.colecta), 1017.6);
|
||
});
|