566 lines
19 KiB
JavaScript
566 lines
19 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,
|
|
} from '../../resources/js/lib/irpc-normal.ts';
|
|
import {
|
|
newNormalDepreciation,
|
|
newDepreciationAsset,
|
|
reuseNormalAssets,
|
|
} from '../../resources/js/lib/irpc-normal-depreciation.ts';
|
|
import { createProjectDossierReport } from '../../resources/js/lib/project-dossier-report.ts';
|
|
const seed = () =>
|
|
JSON.parse(
|
|
fs.readFileSync(
|
|
new URL(
|
|
'./fixtures/irpc-costs-preserved.json',
|
|
import.meta.url,
|
|
),
|
|
'utf8',
|
|
),
|
|
),
|
|
val = (f) => (f ? Number(f.numerator) / Number(f.denominator) : null);
|
|
function asset(p = {}) {
|
|
return {
|
|
...newDepreciationAsset('server'),
|
|
assetKey: 'asset-server',
|
|
description: 'Servidor QA',
|
|
document: 'QA-activo-servidor',
|
|
line: '1',
|
|
category: 'server',
|
|
valuation: 'historical_new',
|
|
specificRate: 'no',
|
|
method: 'constant',
|
|
rate: '20',
|
|
timing: 'annual',
|
|
start: '2026-01-01',
|
|
exitState: 'absent',
|
|
cost: '500',
|
|
residual: '0',
|
|
accounting: '120',
|
|
previousAccounting: '0',
|
|
previousFiscal: '0',
|
|
priorAvailable: '0',
|
|
previousReference:
|
|
'QA ausência de quotas anteriores demonstrada,ou saldos anteriores conforme documentos',
|
|
consistentMethod: 'yes',
|
|
conditions: 'yes',
|
|
reference:
|
|
'QA factura/classificação/base histórica/período documentados,sem reavaliações ou regimes especiais',
|
|
...p,
|
|
};
|
|
}
|
|
function setup(a = asset(), bookExpense = a.accounting) {
|
|
const d = seed(),
|
|
b = normalBook(d),
|
|
p = JSON.parse(d.business_plan_snapshot);
|
|
p.plan.assetDepreciation = bookExpense;
|
|
d.business_plan_snapshot = JSON.stringify(p);
|
|
b.depreciation = {
|
|
...newNormalDepreciation(),
|
|
completenessReference:
|
|
'QA todos activos/quotas e plano reconciliados no sujeito/exercício2026',
|
|
assets: [a],
|
|
};
|
|
return { d, b };
|
|
}
|
|
const model = (a, expense) => {
|
|
const { d, b } = setup(a, expense);
|
|
return calculateNormal(d, b);
|
|
};
|
|
void test('version7 reuses existing server and replaces20 legacy adjustment exactly once without changing financial result or old maps', () => {
|
|
const { d, b } = setup(),
|
|
m = calculateNormal(d, b);
|
|
assert.equal(val(m.accountingProfit), 6240);
|
|
assert.equal(val(m.depreciation.adjustment), 20);
|
|
assert.equal(val(m.taxableProfit), 6730);
|
|
assert.equal(val(m.taxableBase), 4430);
|
|
assert.equal(val(m.colecta), 1417.6);
|
|
assert.equal(m.version, 'IRPC-normal-adjustments/7');
|
|
assert.equal(val(m.schedules.net), 520);
|
|
assert.deepEqual(m.schedules.assets, []);
|
|
assert.deepEqual(m.schedules.input.assets, b.schedules.assets);
|
|
assert.equal(m.tax, null);
|
|
});
|
|
void test('documented first-year duodecimos include July and exclude month of exit', () => {
|
|
let m = model(asset({ start: '2026-07-15', timing: 'months' }));
|
|
assert.equal(m.depreciation.assets[0].months, 6);
|
|
assert.equal(val(m.depreciation.assets[0].deduction), 50);
|
|
assert.equal(val(m.taxableProfit), 6780);
|
|
assert.equal(val(m.taxableBase), 4480);
|
|
assert.equal(val(m.colecta), 1433.6);
|
|
m = model(
|
|
asset({
|
|
start: '2026-07-15',
|
|
timing: 'months',
|
|
exitState: 'known',
|
|
exitDate: '2026-10-15',
|
|
}),
|
|
);
|
|
assert.equal(m.depreciation.assets[0].months, 3);
|
|
assert.equal(val(m.depreciation.assets[0].deduction), 25);
|
|
assert.equal(val(m.colecta), 1441.6);
|
|
});
|
|
void test('annual first-year option remains distinct from duodecimos; contradictory dates and unknown event are not defaulted', () => {
|
|
assert.equal(
|
|
val(
|
|
model(asset({ start: '2026-07-15' })).depreciation.assets[0]
|
|
.deduction,
|
|
),
|
|
100,
|
|
);
|
|
for (const p of [
|
|
{ exitState: '' },
|
|
{ exitState: 'known', exitDate: '' },
|
|
{ exitState: 'absent', exitDate: '2026-10-15' },
|
|
{ start: '2026-02-31' },
|
|
{ exitState: 'known', exitDate: '2025-12-31' },
|
|
])
|
|
assert.equal(model(asset(p)).colecta, null);
|
|
});
|
|
void test('declining method uses fiscal remaining basis and coefficient capped by minimum life; result differs from accounting depreciation', () => {
|
|
const m = model(
|
|
asset({
|
|
method: 'declining',
|
|
factor: '2',
|
|
newTangible: 'yes',
|
|
switchToStraight: 'no',
|
|
accounting: '240',
|
|
}),
|
|
);
|
|
assert.equal(val(m.depreciation.assets[0].quota), 200);
|
|
assert.equal(val(m.depreciation.assets[0].deduction), 200);
|
|
assert.equal(val(m.depreciation.adjustment), 40);
|
|
assert.equal(val(m.taxableProfit), 6630);
|
|
assert.equal(val(m.taxableBase), 4330);
|
|
assert.equal(val(m.colecta), 1385.6);
|
|
for (const p of [
|
|
{ factor: '2.1' },
|
|
{ newTangible: 'no' },
|
|
{ category: 'furniture', factor: '2' },
|
|
{ category: 'commercial_building', factor: '2' },
|
|
])
|
|
assert.equal(
|
|
model(
|
|
asset({
|
|
method: 'declining',
|
|
factor: '2',
|
|
newTangible: 'yes',
|
|
switchToStraight: 'no',
|
|
...p,
|
|
}),
|
|
).colecta,
|
|
null,
|
|
);
|
|
});
|
|
void test('coefficient bands1.5/2/2.5 and half-to-maximum constant-rate recorte are applied by catalog life', () => {
|
|
const c = model(
|
|
asset({
|
|
category: 'computer',
|
|
method: 'declining',
|
|
factor: '1.5',
|
|
newTangible: 'yes',
|
|
switchToStraight: 'no',
|
|
accounting: '200',
|
|
}),
|
|
);
|
|
assert.equal(val(c.depreciation.assets[0].quota), 187.5);
|
|
assert.equal(
|
|
model(
|
|
asset({
|
|
category: 'computer',
|
|
method: 'declining',
|
|
factor: '2',
|
|
newTangible: 'yes',
|
|
switchToStraight: 'no',
|
|
}),
|
|
).colecta,
|
|
null,
|
|
);
|
|
const m = model(
|
|
asset({
|
|
category: 'laundry_machine',
|
|
specificRate: 'yes',
|
|
method: 'declining',
|
|
factor: '2.5',
|
|
newTangible: 'yes',
|
|
switchToStraight: 'no',
|
|
accounting: '200',
|
|
}),
|
|
);
|
|
assert.equal(val(m.depreciation.assets[0].quota), 156.25);
|
|
assert.equal(
|
|
val(model(asset({ rate: '10' })).depreciation.assets[0].deduction),
|
|
50,
|
|
);
|
|
assert.equal(model(asset({ rate: '9.99' })).colecta, null);
|
|
});
|
|
void test('previous taxable accounting quotas are only recovered within current quota and documented available balance', () => {
|
|
const m = model(
|
|
asset({
|
|
start: '2025-01-01',
|
|
accounting: '80',
|
|
previousAccounting: '120',
|
|
previousFiscal: '80',
|
|
priorAvailable: '40',
|
|
}),
|
|
);
|
|
assert.equal(val(m.depreciation.assets[0].deduction), 100);
|
|
assert.equal(val(m.depreciation.assets[0].priorUsed), 20);
|
|
assert.equal(val(m.depreciation.assets[0].priorRemaining), 20);
|
|
assert.equal(val(m.depreciation.adjustment), -20);
|
|
assert.equal(val(m.taxableProfit), 6730);
|
|
assert.equal(m.depreciation.input.assets[0].priorAvailable, '40');
|
|
for (const p of [
|
|
{ priorAvailable: '' },
|
|
{ priorAvailable: '41' },
|
|
{ previousFiscal: '121' },
|
|
{ previousReference: '' },
|
|
])
|
|
assert.equal(
|
|
model(
|
|
asset({
|
|
start: '2025-01-01',
|
|
accounting: '80',
|
|
previousAccounting: '120',
|
|
previousFiscal: '80',
|
|
priorAvailable: '40',
|
|
...p,
|
|
}),
|
|
).colecta,
|
|
null,
|
|
);
|
|
});
|
|
void test('unrecorded quotas are not invented as a deduction; zero previous balance remains explicit', () => {
|
|
const m = model(asset({ accounting: '0' }));
|
|
assert.equal(val(m.depreciation.assets[0].quota), 100);
|
|
assert.equal(val(m.depreciation.assets[0].deduction), 0);
|
|
assert.equal(val(m.depreciation.adjustment), 0);
|
|
assert.equal(model(asset({ previousAccounting: '' })).colecta, null);
|
|
});
|
|
void test('article8-2 option requires documented past condition and remaining minimum life,not an automatic switch', () => {
|
|
const a = asset({
|
|
start: '2022-01-01',
|
|
method: 'declining',
|
|
factor: '2',
|
|
newTangible: 'yes',
|
|
switchToStraight: 'yes',
|
|
priorFullQuotas: 'yes',
|
|
previousAccounting: '500',
|
|
previousFiscal: '420',
|
|
priorAvailable: '80',
|
|
accounting: '0',
|
|
}),
|
|
m = model(a);
|
|
assert.equal(val(m.depreciation.assets[0].quota), 80);
|
|
assert.equal(val(m.depreciation.assets[0].deduction), 80);
|
|
assert.equal(val(m.depreciation.assets[0].priorUsed), 80);
|
|
assert.equal(model({ ...a, priorFullQuotas: '' }).colecta, null);
|
|
assert.equal(model({ ...a, priorFullQuotas: 'no' }).colecta, null);
|
|
});
|
|
void test('specific tableI prevails over generic tableII and known codes are never replaced with a free rate', () => {
|
|
const m = model(
|
|
asset({
|
|
category: 'hotel_decoration',
|
|
specificRate: 'yes',
|
|
rate: '20',
|
|
}),
|
|
);
|
|
assert.equal(m.depreciation.assets[0].catalog.table, 'I');
|
|
assert.equal(m.depreciation.assets[0].catalog.code, '84.2/B');
|
|
assert.equal(val(m.depreciation.assets[0].quota), 100);
|
|
assert.equal(model(asset({ specificRate: 'yes' })).colecta, null);
|
|
assert.equal(model(asset({ category: 'other' })).colecta, null);
|
|
assert.equal(
|
|
model(asset({ category: 'hotel_decoration', specificRate: 'no' }))
|
|
.colecta,
|
|
null,
|
|
);
|
|
assert.equal(model(asset({ valuation: 'used' })).colecta, null);
|
|
assert.equal(model(asset({ valuation: '' })).colecta, null);
|
|
});
|
|
void test('commercial building excludes separately known land; zero land is not presumed when missing', () => {
|
|
const m = model(
|
|
asset({
|
|
category: 'commercial_building',
|
|
rate: '2',
|
|
cost: '1000',
|
|
land: '200',
|
|
accounting: '30',
|
|
}),
|
|
);
|
|
assert.equal(val(m.depreciation.assets[0].basis), 800);
|
|
assert.equal(val(m.depreciation.assets[0].deduction), 16);
|
|
assert.equal(val(m.depreciation.adjustment), 14);
|
|
assert.equal(
|
|
model(asset({ category: 'commercial_building', rate: '2', land: '' }))
|
|
.colecta,
|
|
null,
|
|
);
|
|
});
|
|
void test('vehicle ceiling800000 and non-eligible accounting share are separate from prior recoverable quotas', () => {
|
|
let m = model(
|
|
asset({
|
|
category: 'passenger_vehicle',
|
|
rate: '25',
|
|
vehicleException: 'no',
|
|
cost: '900000',
|
|
accounting: '225000',
|
|
}),
|
|
);
|
|
assert.equal(val(m.depreciation.assets[0].taxBasis), 800000);
|
|
assert.equal(val(m.depreciation.assets[0].deduction), 200000);
|
|
assert.equal(val(m.depreciation.adjustment), 25000);
|
|
m = model(
|
|
asset({
|
|
category: 'passenger_vehicle',
|
|
rate: '25',
|
|
vehicleException: 'no',
|
|
cost: '900000',
|
|
accounting: '9000',
|
|
}),
|
|
);
|
|
assert.equal(val(m.depreciation.assets[0].eligibleCurrent), 8000);
|
|
assert.equal(val(m.depreciation.assets[0].deduction), 8000);
|
|
assert.equal(
|
|
model(
|
|
asset({
|
|
category: 'passenger_vehicle',
|
|
rate: '25',
|
|
vehicleException: '',
|
|
}),
|
|
).colecta,
|
|
null,
|
|
);
|
|
assert.equal(
|
|
model(
|
|
asset({
|
|
category: 'passenger_vehicle',
|
|
rate: '25',
|
|
vehicleException: 'no',
|
|
cost: '900000',
|
|
start: '2025-01-01',
|
|
previousAccounting: '225000',
|
|
previousFiscal: '200000',
|
|
priorAvailable: '25000',
|
|
}),
|
|
).colecta,
|
|
null,
|
|
);
|
|
});
|
|
void test('low value is an explicit individual-asset option,not automatic just because cost is below20000', () => {
|
|
const m = model(
|
|
asset({ method: 'low_value', individual: 'yes', accounting: '500' }),
|
|
);
|
|
assert.equal(val(m.depreciation.assets[0].deduction), 500);
|
|
assert.equal(val(m.colecta), 1289.6);
|
|
assert.equal(
|
|
model(asset({ method: 'low_value', individual: 'no' })).colecta,
|
|
null,
|
|
);
|
|
assert.equal(
|
|
model(
|
|
asset({
|
|
category: 'other',
|
|
method: 'low_value',
|
|
individual: 'yes',
|
|
}),
|
|
).colecta,
|
|
null,
|
|
);
|
|
assert.equal(
|
|
model(
|
|
asset({ method: 'low_value', individual: 'yes', cost: '20000.01' }),
|
|
).colecta,
|
|
null,
|
|
);
|
|
});
|
|
void test('software conditions and explicit nondepreciable category have distinct outcomes', () => {
|
|
assert.equal(
|
|
model(asset({ category: 'software', rate: '25', limitedRights: '' }))
|
|
.colecta,
|
|
null,
|
|
);
|
|
assert.equal(
|
|
val(
|
|
model(
|
|
asset({
|
|
category: 'software',
|
|
rate: '25',
|
|
limitedRights: 'yes',
|
|
}),
|
|
).depreciation.assets[0].deduction,
|
|
),
|
|
120,
|
|
);
|
|
const m = model(
|
|
asset({
|
|
category: 'nondepreciable',
|
|
start: '',
|
|
method: '',
|
|
previousAccounting: '',
|
|
}),
|
|
);
|
|
assert.equal(val(m.depreciation.assets[0].deduction), 0);
|
|
assert.equal(val(m.depreciation.adjustment), 120);
|
|
});
|
|
void test('maximum life exhaustion excludes current quota; first-use future period is known nondeduction', () => {
|
|
const m = model(
|
|
asset({
|
|
start: '2014-01-01',
|
|
previousAccounting: '400',
|
|
previousFiscal: '400',
|
|
priorAvailable: '0',
|
|
}),
|
|
);
|
|
assert.equal(val(m.depreciation.assets[0].deduction), 0);
|
|
assert.match(m.depreciation.assets[0].reason, /vida útil máxima esgotada/);
|
|
assert.equal(
|
|
val(
|
|
model(asset({ start: '2027-01-01' })).depreciation.assets[0]
|
|
.deduction,
|
|
),
|
|
0,
|
|
);
|
|
assert.equal(
|
|
model(asset({ category: 'computer', rate: '25', start: '2018-07-01' }))
|
|
.colecta,
|
|
null,
|
|
);
|
|
const clipped = model(
|
|
asset({
|
|
category: 'computer',
|
|
rate: '25',
|
|
start: '2018-07-01',
|
|
timing: 'months',
|
|
}),
|
|
);
|
|
assert.equal(clipped.depreciation.assets[0].months, 6);
|
|
});
|
|
void test('duplicate asset/document and unreconciled accounting cannot create a second depreciation adjustment', () => {
|
|
const { d, b } = setup();
|
|
b.depreciation.assets.push({ ...asset({ id: 'second' }), accounting: '0' });
|
|
assert.equal(calculateNormal(d, b).colecta, null);
|
|
const c = setup();
|
|
c.b.depreciation.assets[0].document = c.b.rows[0].document;
|
|
c.b.depreciation.assets[0].line = c.b.rows[0].line;
|
|
assert.equal(calculateNormal(c.d, c.b).colecta, null);
|
|
assert.equal(model(asset(), '121').colecta, null);
|
|
});
|
|
void test('one unknown asset keeps independent quotas visible but total profit/base/colecta dependent on reconciliation remains unknown', () => {
|
|
const { d, b } = setup(asset({ accounting: '60' }), '120');
|
|
b.depreciation.assets.push(
|
|
asset({
|
|
id: 'second',
|
|
assetKey: 'second',
|
|
document: 'QA-second',
|
|
category: 'other',
|
|
accounting: '60',
|
|
}),
|
|
);
|
|
const m = calculateNormal(d, b);
|
|
assert.equal(val(m.depreciation.assets[0].deduction), 60);
|
|
assert.equal(m.depreciation.assets[1].deduction, null);
|
|
assert.equal(m.taxableProfit, null);
|
|
assert.equal(m.taxableBase, null);
|
|
assert.equal(m.colecta, null);
|
|
});
|
|
void test('reuse copies declared values without inventing previous accounting/carry data; installed6 stays current and histories survive7', () => {
|
|
const d = seed(),
|
|
old = normalBook(d);
|
|
assert.equal(normalCurrent(d, old), true);
|
|
assert.equal(old.result.model.version, 'IRPC-normal-adjustments/6');
|
|
const reused = reuseNormalAssets(old.schedules.assets);
|
|
assert.equal(reused.assets[0].cost, old.schedules.assets[0].cost);
|
|
assert.equal(reused.assets[0].previousAccounting, '');
|
|
assert.equal(reused.assets[0].priorAvailable, '');
|
|
const { d: next, b } = setup(
|
|
asset({ start: '2026-07-15', timing: 'months' }),
|
|
),
|
|
before = JSON.parse(next.modelo_a_snapshot);
|
|
const oldAssets = structuredClone(b.schedules.assets);
|
|
next.modelo_a_snapshot = saveNormalBook(
|
|
next,
|
|
recordNormal(next, b, '2026-10-26T10:00:00+02:00'),
|
|
);
|
|
const back = normalBook(JSON.parse(JSON.stringify(next)));
|
|
assert.equal(normalCurrent(next, back), true);
|
|
assert.deepEqual(back.history.at(-1), old.result);
|
|
assert.deepEqual(back.schedules.assets, oldAssets);
|
|
assert.deepEqual(JSON.parse(next.modelo_a_snapshot).books, before.books);
|
|
assert.deepEqual(
|
|
JSON.parse(next.modelo_a_snapshot).irpcWithholdings,
|
|
before.irpcWithholdings,
|
|
);
|
|
assert.match(normalHtml(next), /1433.60/);
|
|
assert.match(
|
|
JSON.stringify(
|
|
createProjectDossierReport(next, {
|
|
selectedSections: new Set(['finance']),
|
|
}),
|
|
),
|
|
/quota anterior/,
|
|
);
|
|
const changed = structuredClone(back);
|
|
changed.depreciation.assets[0].priorAvailable = '1';
|
|
next.modelo_a_snapshot = saveNormalBook(next, changed);
|
|
assert.equal(normalCurrent(next, normalBook(next)), false);
|
|
assert.throws(() => normalHtml(next));
|
|
});
|
|
void test('recoverable scenarios generate five API/report proofs without recalculating accepted predecessor suites', () => {
|
|
const cases = {
|
|
months: setup(asset({ start: '2026-07-15', timing: 'months' })),
|
|
declining: setup(
|
|
asset({
|
|
method: 'declining',
|
|
factor: '2',
|
|
newTangible: 'yes',
|
|
switchToStraight: 'no',
|
|
accounting: '240',
|
|
}),
|
|
),
|
|
prior: setup(
|
|
asset({
|
|
start: '2025-01-01',
|
|
accounting: '80',
|
|
previousAccounting: '120',
|
|
previousFiscal: '80',
|
|
priorAvailable: '40',
|
|
}),
|
|
),
|
|
specific: setup(
|
|
asset({
|
|
category: 'laundry_machine',
|
|
specificRate: 'yes',
|
|
rate: '12.5',
|
|
}),
|
|
),
|
|
missing: setup(asset({ previousAccounting: '' })),
|
|
};
|
|
fs.mkdirSync('outputs/irpc-l6-depreciation', { 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-depreciation/' + name + '.json',
|
|
JSON.stringify(d),
|
|
);
|
|
fs.writeFileSync(
|
|
'outputs/irpc-l6-depreciation/' + name + '.html',
|
|
normalHtml(d),
|
|
);
|
|
}
|
|
assert.equal(
|
|
val(normalBook(cases.specific.d).result.model.colecta),
|
|
1429.6,
|
|
);
|
|
});
|