379 lines
15 KiB
JavaScript
379 lines
15 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,
|
||
normalReportRows,
|
||
} from '../../resources/js/lib/irpc-normal.ts';
|
||
import {
|
||
newNormalSchedules,
|
||
newScheduleRow,
|
||
newNormalAsset,
|
||
} from '../../resources/js/lib/irpc-normal-schedules.ts';
|
||
import { normalFormFields } from '../../resources/js/lib/irpc-normal-forms.ts';
|
||
import { fraction } from '../../resources/js/lib/fiscal-rational.ts';
|
||
import { createProjectDossierReport } from '../../resources/js/lib/project-dossier-report.ts';
|
||
const seed = () =>
|
||
JSON.parse(
|
||
fs.readFileSync(
|
||
new URL(
|
||
'./fixtures/irpc-assessment-preserved.json',
|
||
import.meta.url,
|
||
),
|
||
'utf8',
|
||
),
|
||
);
|
||
function scenario() {
|
||
const d = seed(),
|
||
b = normalBook(d),
|
||
p = JSON.parse(d.business_plan_snapshot);
|
||
p.plan.monthlyInterestExpense = '20';
|
||
p.plan.assetDepreciation = '120';
|
||
d.business_plan_snapshot = JSON.stringify(p);
|
||
b.schedules = newNormalSchedules();
|
||
const s = b.schedules;
|
||
s.period = {
|
||
kind: 'calendar',
|
||
from: '2026-01-01',
|
||
to: '2026-12-31',
|
||
reference: 'QA doze meses do plano correspondem ao ano civil completo',
|
||
};
|
||
s.interest = {
|
||
amount: '120',
|
||
reference:
|
||
'QA contrato bancário, exploração, sem relação especial, limites especiais ou capitalização; pagamentos separados',
|
||
conditions: 'yes',
|
||
};
|
||
for (const [id, rule, amount, sourceCode, fiscalYear] of [
|
||
['thirdparty', 'tax_other', '250', 'operating_expenses', ''],
|
||
['gain', 'patrimonial_gain', '200', '', ''],
|
||
['cost', 'patrimonial_cost', '50', '', ''],
|
||
['timing', 'future_cost', '100', 'operating_expenses', '2027'],
|
||
])
|
||
s.rows.push({
|
||
...newScheduleRow(id),
|
||
rule,
|
||
amount,
|
||
sourceCode,
|
||
fiscalYear,
|
||
document: 'QA-' + id,
|
||
line: '1',
|
||
reference:
|
||
'QA factos documentados no recorte ordinário, não é operação especial',
|
||
conditions: 'yes',
|
||
});
|
||
for (const [k, amount] of Object.entries({
|
||
expenseCorrections: '250',
|
||
positivePatrimonial: '200',
|
||
negativePatrimonial: '50',
|
||
timing: '100',
|
||
}))
|
||
b.profit.topics[k] = {
|
||
state: 'known',
|
||
amount,
|
||
reference:
|
||
'QA reconciliação integral do tema com os documentos do mapa',
|
||
};
|
||
b.profit.lines.find((l) => l.code === 'depreciation').state = 'known';
|
||
s.assetsReference =
|
||
'QA único activo; total contabilístico120 reconciliado com o plano';
|
||
s.assets = [
|
||
{
|
||
...newNormalAsset('server'),
|
||
category: 'server',
|
||
description: 'Servidor QA',
|
||
cost: '500',
|
||
residual: '0',
|
||
rate: '20',
|
||
accounting: '120',
|
||
previousFiscal: '0',
|
||
inUseFrom: '2026-01-01',
|
||
tableReference:
|
||
'Decreto72/2013 tabelaII grupo3 item9.1, sem taxa específica da tabelaI',
|
||
reference:
|
||
'QA factura500, activo próprio, vida útil fiscal, sem regras especiais',
|
||
conditions: 'yes',
|
||
},
|
||
];
|
||
return { d, b };
|
||
}
|
||
const val = (f) => (f ? Number(f.numerator) / Number(f.denominator) : null);
|
||
void test('new rules reconcile financial interest, patrimonial variations, timing and server quota through profit/base/colecta', () => {
|
||
const { d, b } = scenario(),
|
||
m = calculateNormal(d, b);
|
||
assert.equal(val(m.accountingProfit), 6240);
|
||
assert.equal(val(m.additions), 220);
|
||
assert.equal(val(m.schedules.interest), 120);
|
||
assert.equal(val(m.schedules.depreciation), 20);
|
||
assert.equal(val(m.schedules.net), 520);
|
||
assert.equal(val(m.taxableProfit), 6980);
|
||
assert.equal(val(m.taxableBase), 4680);
|
||
assert.equal(val(m.colecta), 1497.6);
|
||
assert.equal(m.tax, null);
|
||
assert.equal(m.assessment.finalBalance.payable, null);
|
||
assert.equal(m.schedules.assets[0].fiscal.numerator, '100');
|
||
assert.equal(m.version, 'IRPC-normal-adjustments/5');
|
||
});
|
||
void test('ordinary interest is qualified independently from compensatory interest and cash payments', () => {
|
||
const { d, b } = scenario();
|
||
b.schedules.interest.amount = '119';
|
||
const m = calculateNormal(d, b);
|
||
assert.equal(m.taxableProfit, null);
|
||
assert.equal(val(m.schedules.net), 520);
|
||
assert.equal(m.schedules.interest, null);
|
||
assert.match(m.profit.pending.join(' '), /Juros financeiros/);
|
||
const c = scenario();
|
||
c.b.schedules.rows[0].sourceCode = 'interest_expense';
|
||
c.b.schedules.rows[0].amount = '20';
|
||
c.b.profit.topics.expenseCorrections.amount = '20';
|
||
assert.equal(calculateNormal(c.d, c.b).taxableProfit, null);
|
||
c.b.schedules.interest.amount = '100';
|
||
assert.equal(val(calculateNormal(c.d, c.b).schedules.interest), 100);
|
||
assert.notEqual(calculateNormal(c.d, c.b).taxableProfit, null);
|
||
});
|
||
void test('missing rule evidence blocks dependent totals while independent amounts remain calculated', () => {
|
||
const { d, b } = scenario();
|
||
b.schedules.rows[0].reference = '';
|
||
const m = calculateNormal(d, b);
|
||
assert.equal(m.taxableProfit, null);
|
||
assert.equal(m.colecta, null);
|
||
assert.equal(
|
||
val(m.schedules.effects.find((e) => e.id === 'gain').effect),
|
||
200,
|
||
);
|
||
assert.equal(val(m.schedules.depreciation), 20);
|
||
assert.equal(val(m.accountingProfit), 6240);
|
||
});
|
||
void test('duplicate documents, over-allocation, wrong source and contradictory topic absence are rejected', () => {
|
||
for (const mutate of [
|
||
(b) => {
|
||
b.schedules.rows[0].document = b.rows[0].document + ' ';
|
||
b.schedules.rows[0].line = b.rows[0].line;
|
||
},
|
||
(b) => {
|
||
b.schedules.rows[0].amount = '1201';
|
||
b.profit.topics.expenseCorrections.amount = '1201';
|
||
},
|
||
(b) => (b.schedules.rows[0].sourceCode = 'revenue'),
|
||
(b) =>
|
||
(b.profit.topics.positivePatrimonial = {
|
||
state: 'absent',
|
||
amount: '',
|
||
reference: 'QA declared absent',
|
||
}),
|
||
]) {
|
||
const { d, b } = scenario();
|
||
mutate(b);
|
||
assert.equal(calculateNormal(d, b).taxableProfit, null);
|
||
}
|
||
});
|
||
void test('capital contributions and distributions outside result are known amounts with zero tax effect, not ordinary gains/costs', () => {
|
||
const { d, b } = scenario();
|
||
b.schedules.rows.find((r) => r.id === 'gain').rule = 'capital_in';
|
||
b.schedules.rows.find((r) => r.id === 'cost').rule = 'capital_out';
|
||
const m = calculateNormal(d, b);
|
||
assert.equal(val(m.taxableProfit), 6830);
|
||
assert.equal(
|
||
val(m.schedules.effects.find((e) => e.id === 'gain').effect),
|
||
0,
|
||
);
|
||
assert.equal(
|
||
val(m.schedules.effects.find((e) => e.id === 'cost').amount),
|
||
50,
|
||
);
|
||
});
|
||
void test('timing belongs to documented year; previous years and inconsistent mapping stay pending', () => {
|
||
for (const y of ['', '2025', '2026']) {
|
||
const { d, b } = scenario();
|
||
b.schedules.rows.find((r) => r.id === 'timing').fiscalYear = y;
|
||
assert.equal(calculateNormal(d, b).taxableProfit, null);
|
||
}
|
||
});
|
||
void test('asset quota uses remaining fiscal value; unsupported rates, short-year facts and incomplete reconciliation do not default to accounting', () => {
|
||
const { d, b } = scenario();
|
||
b.schedules.assets[0].previousFiscal = '450';
|
||
let m = calculateNormal(d, b);
|
||
assert.equal(val(m.schedules.assets[0].fiscal), 50);
|
||
assert.equal(val(m.schedules.depreciation), 70);
|
||
for (const patch of [
|
||
{ rate: '25' },
|
||
{ category: 'other' },
|
||
{ inUseFrom: '2026-02-01' },
|
||
{ inUseFrom: '2025-02-31' },
|
||
{ inUseFrom: '2014-01-01' },
|
||
{ accounting: '119' },
|
||
{ conditions: '' },
|
||
]) {
|
||
const { d, b } = scenario();
|
||
Object.assign(b.schedules.assets[0], patch);
|
||
m = calculateNormal(d, b);
|
||
assert.equal(m.taxableProfit, null);
|
||
assert.equal(m.schedules.depreciation, null);
|
||
}
|
||
});
|
||
void test('known current loss gives zero base/colecta only with documented current-loss treatment and no self-deduction; no official carryforward created', () => {
|
||
const { d, b } = scenario(),
|
||
p = JSON.parse(d.business_plan_snapshot);
|
||
p.plan.monthlyOperatingExpenses = '1000';
|
||
d.business_plan_snapshot = JSON.stringify(p);
|
||
b.base.losses = [];
|
||
b.base.lossesState = 'absent';
|
||
b.base.lossesReference = 'QA ausência expressa de perdas anteriores';
|
||
let m = calculateNormal(d, b);
|
||
assert.equal(val(m.taxableProfit), -3820);
|
||
assert.equal(m.taxableBase, null);
|
||
b.schedules.currentLossReference =
|
||
'QA perda fiscal corrente apurada, sem dedução de perdas anteriores ou reporte automático';
|
||
m = calculateNormal(d, b);
|
||
assert.equal(val(m.currentLoss), 3820);
|
||
assert.equal(val(m.taxableBase), 0);
|
||
assert.equal(val(m.colecta), 0);
|
||
assert.deepEqual(b.base.losses, []);
|
||
assert.equal(
|
||
normalFormFields(m).fields.find((f) => f.code === '269').value
|
||
.numerator,
|
||
'3820',
|
||
);
|
||
assert.equal(
|
||
normalFormFields(m).fields.find((f) => f.code === '270').value,
|
||
null,
|
||
);
|
||
d.modelo_a_snapshot = saveNormalBook(d, recordNormal(d, b));
|
||
fs.mkdirSync('outputs/irpc-l6-sets', { recursive: true });
|
||
fs.writeFileSync('outputs/irpc-l6-sets/loss.json', JSON.stringify(d));
|
||
});
|
||
void test('EP ordinary annual MZN uses its attributable financial result; unsupported currency/year remains case-specific', () => {
|
||
const { d, b } = scenario();
|
||
b.scope = 'ep_general';
|
||
b.scopeReference =
|
||
'QA único EP em Moçambique, imputação completa e exclusiva de custos/proveitos do EP, CIRPC15(1)c';
|
||
assert.equal(val(calculateNormal(d, b).colecta), 1497.6);
|
||
b.year = '2025';
|
||
assert.equal(calculateNormal(d, b).colecta, null);
|
||
});
|
||
void test('form preparation keeps exact fractions and unmapped fields without provisional codes or fabricated final settlement', () => {
|
||
const { d, b } = scenario(),
|
||
m = calculateNormal(d, b),
|
||
f = normalFormFields(m);
|
||
assert.equal(val(f.fields.find((f) => f.code === '278').value), 4680);
|
||
assert.equal(
|
||
f.fields.find((f) => f.code === '300').transcriptionReady,
|
||
true,
|
||
);
|
||
for (const code of ['200', '302', '309–316'])
|
||
assert.equal(f.fields.find((f) => f.code === code).value, null);
|
||
assert.equal(f.m20.status, 'partial');
|
||
assert.equal(val(f.m20.fields.find((f) => f.account === '6.5').value), 120);
|
||
assert.match(f.m20.reason, /campo201.*campo200/);
|
||
const partial = normalFormFields({
|
||
...m,
|
||
colecta: fraction(2n, 625n),
|
||
}).fields.find((f) => f.code === '300');
|
||
assert.deepEqual(partial.value, { numerator: '2', denominator: '625' });
|
||
assert.equal(partial.transcriptionReady, false);
|
||
assert.match(partial.reason, /sem arredondamento/);
|
||
});
|
||
void test('installed4 is current without recalculation; new inputs survive save/recovery/history/export/dossier and become stale on edit', () => {
|
||
const original = seed(),
|
||
old = normalBook(original);
|
||
assert.equal(normalCurrent(original, old), true);
|
||
assert.equal(old.result.model.version, 'IRPC-normal-adjustments/4');
|
||
const { d, b } = scenario(),
|
||
before = JSON.parse(d.modelo_a_snapshot);
|
||
d.modelo_a_snapshot = saveNormalBook(
|
||
d,
|
||
recordNormal(d, b, '2026-10-25T23:50:00Z'),
|
||
);
|
||
const recovered = JSON.parse(JSON.stringify(d)),
|
||
back = normalBook(recovered);
|
||
assert.equal(normalCurrent(recovered, back), true);
|
||
assert.deepEqual(back.history.at(-1), old.result);
|
||
assert.deepEqual(JSON.parse(d.modelo_a_snapshot).books, before.books);
|
||
assert.deepEqual(
|
||
JSON.parse(d.modelo_a_snapshot).irpcWithholdings,
|
||
before.irpcWithholdings,
|
||
);
|
||
assert.match(normalHtml(d), /1497.6/);
|
||
assert.match(
|
||
JSON.stringify(
|
||
createProjectDossierReport(d, {
|
||
selectedSections: new Set(['finance']),
|
||
}),
|
||
),
|
||
/M22 campo 300/,
|
||
);
|
||
assert.match(JSON.stringify(normalReportRows(d)), /M20/);
|
||
fs.writeFileSync('outputs/irpc-l6-sets/calculated.json', JSON.stringify(d));
|
||
fs.writeFileSync('outputs/irpc-l6-sets/preparation.html', normalHtml(d));
|
||
back.schedules.rows[0].amount = '251';
|
||
recovered.modelo_a_snapshot = saveNormalBook(recovered, back);
|
||
assert.equal(normalCurrent(recovered, normalBook(recovered)), false);
|
||
assert.throws(() => normalHtml(recovered));
|
||
const missing = normalBook(d);
|
||
missing.schedules.interest.conditions = '';
|
||
d.modelo_a_snapshot = saveNormalBook(d, recordNormal(d, missing));
|
||
fs.writeFileSync('outputs/irpc-l6-sets/missing.json', JSON.stringify(d));
|
||
});
|
||
void test('unknown schema/rules fail without silently replacing saved originals', () => {
|
||
const { d, b } = scenario();
|
||
b.schedules.version = 2;
|
||
assert.throws(() => saveNormalBook(d, b));
|
||
b.schedules.version = 1;
|
||
b.schedules.rows[0].rule = 'fiction';
|
||
assert.throws(() => saveNormalBook(d, b));
|
||
});
|
||
|
||
void test('documented noncalendar EP year preserves12 monthly periods; short periods and absent legal basis stay case-specific', () => {
|
||
const { d, b } = scenario();
|
||
b.scope = 'ep_general';
|
||
b.schedules.period = {
|
||
kind: 'ep_annual',
|
||
from: '2025-07-01',
|
||
to: '2026-06-30',
|
||
reference:
|
||
'QA comunicação expressa AT anterior, início e manutenção5exercícios, plano completo do EP',
|
||
};
|
||
b.schedules.assets[0].inUseFrom = '2025-07-01';
|
||
let m = calculateNormal(d, b);
|
||
assert.equal(val(m.colecta), 1497.6);
|
||
assert.equal(m.assessment.period.from, '2025-07-01');
|
||
assert.equal(m.assessment.period.to, '2026-06-30');
|
||
d.modelo_a_snapshot = saveNormalBook(d, recordNormal(d, b));
|
||
fs.writeFileSync('outputs/irpc-l6-sets/ep.json', JSON.stringify(d));
|
||
b.schedules.period.from = '2026-01-01';
|
||
assert.equal(calculateNormal(d, b).colecta, null);
|
||
b.schedules.period.reference = '';
|
||
assert.equal(calculateNormal(d, b).taxableProfit, null);
|
||
});
|
||
|
||
void test('ordinary tax/undocumented addbacks and current income/cost timing apply their distinct signs without overwriting original accounting', () => {
|
||
for (const rule of ['tax_own', 'undocumented']) {
|
||
const { d, b } = scenario();
|
||
b.schedules.rows[0].rule = rule;
|
||
assert.equal(val(calculateNormal(d, b).taxableProfit), 6980);
|
||
}
|
||
for (const [rule, delta] of [
|
||
['current_cost', -200],
|
||
['current_income', 0],
|
||
]) {
|
||
const { d, b } = scenario(),
|
||
r = b.schedules.rows.find((r) => r.id === 'timing');
|
||
r.rule = rule;
|
||
r.sourceCode = '';
|
||
r.fiscalYear = '2026';
|
||
assert.equal(val(calculateNormal(d, b).taxableProfit), 6980 + delta);
|
||
}
|
||
const { d, b } = scenario(),
|
||
r = b.schedules.rows.find((r) => r.id === 'timing');
|
||
r.rule = 'future_income';
|
||
r.sourceCode = 'revenue';
|
||
assert.equal(val(calculateNormal(d, b).taxableProfit), 6780);
|
||
assert.equal(normalFinancialSource(d).accountingProfitBeforeTax, '6240.00');
|
||
});
|