jaz-cli 2.5.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/skills/api/SKILL.md +12 -2
- package/assets/skills/api/references/dependencies.md +3 -2
- package/assets/skills/api/references/endpoints.md +78 -0
- package/assets/skills/api/references/feature-glossary.md +5 -5
- package/assets/skills/api/references/field-map.md +17 -0
- package/assets/skills/api/references/full-api-surface.md +1 -1
- package/assets/skills/conversion/SKILL.md +1 -1
- package/assets/skills/transaction-recipes/SKILL.md +53 -19
- package/assets/skills/transaction-recipes/references/asset-disposal.md +174 -0
- package/assets/skills/transaction-recipes/references/fixed-deposit.md +164 -0
- package/assets/skills/transaction-recipes/references/hire-purchase.md +190 -0
- package/dist/__tests__/amortization.test.js +101 -0
- package/dist/__tests__/asset-disposal.test.js +249 -0
- package/dist/__tests__/blueprint.test.js +72 -0
- package/dist/__tests__/depreciation.test.js +125 -0
- package/dist/__tests__/ecl.test.js +134 -0
- package/dist/__tests__/fixed-deposit.test.js +214 -0
- package/dist/__tests__/fx-reval.test.js +115 -0
- package/dist/__tests__/lease.test.js +96 -0
- package/dist/__tests__/loan.test.js +80 -0
- package/dist/__tests__/provision.test.js +141 -0
- package/dist/__tests__/validate.test.js +81 -0
- package/dist/calc/amortization.js +21 -3
- package/dist/calc/asset-disposal.js +155 -0
- package/dist/calc/blueprint.js +26 -1
- package/dist/calc/depreciation.js +24 -1
- package/dist/calc/ecl.js +13 -1
- package/dist/calc/fixed-deposit.js +178 -0
- package/dist/calc/format.js +107 -2
- package/dist/calc/fx-reval.js +11 -1
- package/dist/calc/lease.js +42 -9
- package/dist/calc/loan.js +12 -2
- package/dist/calc/provision.js +17 -1
- package/dist/commands/calc.js +54 -2
- package/package.json +5 -2
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { calculateAssetDisposal } from '../calc/asset-disposal.js';
|
|
3
|
+
import { CalcValidationError } from '../calc/validate.js';
|
|
4
|
+
describe('calculateAssetDisposal (gain)', () => {
|
|
5
|
+
const base = {
|
|
6
|
+
cost: 50000,
|
|
7
|
+
salvageValue: 5000,
|
|
8
|
+
usefulLifeYears: 5,
|
|
9
|
+
acquisitionDate: '2022-01-01',
|
|
10
|
+
disposalDate: '2025-06-15',
|
|
11
|
+
proceeds: 20000,
|
|
12
|
+
};
|
|
13
|
+
it('NBV = cost - accumulated depreciation', () => {
|
|
14
|
+
const r = calculateAssetDisposal(base);
|
|
15
|
+
expect(r.netBookValue).toBe(r.inputs.cost - r.accumulatedDepreciation);
|
|
16
|
+
});
|
|
17
|
+
it('gainOrLoss = proceeds - NBV', () => {
|
|
18
|
+
const r = calculateAssetDisposal(base);
|
|
19
|
+
expect(r.gainOrLoss).toBe(r.inputs.proceeds - r.netBookValue);
|
|
20
|
+
});
|
|
21
|
+
it('isGain is true when proceeds > NBV', () => {
|
|
22
|
+
const r = calculateAssetDisposal(base);
|
|
23
|
+
expect(r.isGain).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
it('monthsHeld is calculated correctly', () => {
|
|
26
|
+
const r = calculateAssetDisposal(base);
|
|
27
|
+
// Jan 2022 to Jun 2025 = 42 months, day 15 >= day 01 so +1 = 42
|
|
28
|
+
expect(r.monthsHeld).toBe(42);
|
|
29
|
+
});
|
|
30
|
+
it('journal is balanced', () => {
|
|
31
|
+
const r = calculateAssetDisposal(base);
|
|
32
|
+
const debits = r.disposalJournal.lines.reduce((s, l) => s + l.debit, 0);
|
|
33
|
+
const credits = r.disposalJournal.lines.reduce((s, l) => s + l.credit, 0);
|
|
34
|
+
expect(Math.abs(debits - credits)).toBeLessThan(0.01);
|
|
35
|
+
});
|
|
36
|
+
it('journal includes Cash, Accum Dep, FA at cost, and Gain', () => {
|
|
37
|
+
const r = calculateAssetDisposal(base);
|
|
38
|
+
const accounts = r.disposalJournal.lines.map(l => l.account);
|
|
39
|
+
expect(accounts).toContain('Cash / Bank Account');
|
|
40
|
+
expect(accounts).toContain('Accumulated Depreciation');
|
|
41
|
+
expect(accounts).toContain('Fixed Asset (at cost)');
|
|
42
|
+
expect(accounts).toContain('Gain on Disposal');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
describe('calculateAssetDisposal (loss)', () => {
|
|
46
|
+
const base = {
|
|
47
|
+
cost: 50000,
|
|
48
|
+
salvageValue: 5000,
|
|
49
|
+
usefulLifeYears: 5,
|
|
50
|
+
acquisitionDate: '2022-01-01',
|
|
51
|
+
disposalDate: '2023-06-15',
|
|
52
|
+
proceeds: 5000, // low proceeds = loss
|
|
53
|
+
};
|
|
54
|
+
it('isGain is false when proceeds < NBV', () => {
|
|
55
|
+
const r = calculateAssetDisposal(base);
|
|
56
|
+
expect(r.isGain).toBe(false);
|
|
57
|
+
expect(r.gainOrLoss).toBeLessThan(0);
|
|
58
|
+
});
|
|
59
|
+
it('journal includes Loss on Disposal', () => {
|
|
60
|
+
const r = calculateAssetDisposal(base);
|
|
61
|
+
const accounts = r.disposalJournal.lines.map(l => l.account);
|
|
62
|
+
expect(accounts).toContain('Loss on Disposal');
|
|
63
|
+
expect(accounts).not.toContain('Gain on Disposal');
|
|
64
|
+
});
|
|
65
|
+
it('journal is balanced', () => {
|
|
66
|
+
const r = calculateAssetDisposal(base);
|
|
67
|
+
const debits = r.disposalJournal.lines.reduce((s, l) => s + l.debit, 0);
|
|
68
|
+
const credits = r.disposalJournal.lines.reduce((s, l) => s + l.credit, 0);
|
|
69
|
+
expect(Math.abs(debits - credits)).toBeLessThan(0.01);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
describe('calculateAssetDisposal (scrap / write-off)', () => {
|
|
73
|
+
const base = {
|
|
74
|
+
cost: 50000,
|
|
75
|
+
salvageValue: 5000,
|
|
76
|
+
usefulLifeYears: 5,
|
|
77
|
+
acquisitionDate: '2022-01-01',
|
|
78
|
+
disposalDate: '2024-01-01',
|
|
79
|
+
proceeds: 0,
|
|
80
|
+
};
|
|
81
|
+
it('gainOrLoss is negative (loss) when proceeds = 0', () => {
|
|
82
|
+
const r = calculateAssetDisposal(base);
|
|
83
|
+
expect(r.isGain).toBe(false);
|
|
84
|
+
expect(r.gainOrLoss).toBeLessThan(0);
|
|
85
|
+
});
|
|
86
|
+
it('journal has no Cash line when proceeds = 0', () => {
|
|
87
|
+
const r = calculateAssetDisposal(base);
|
|
88
|
+
const accounts = r.disposalJournal.lines.map(l => l.account);
|
|
89
|
+
expect(accounts).not.toContain('Cash / Bank Account');
|
|
90
|
+
});
|
|
91
|
+
it('journal is still balanced', () => {
|
|
92
|
+
const r = calculateAssetDisposal(base);
|
|
93
|
+
const debits = r.disposalJournal.lines.reduce((s, l) => s + l.debit, 0);
|
|
94
|
+
const credits = r.disposalJournal.lines.reduce((s, l) => s + l.credit, 0);
|
|
95
|
+
expect(Math.abs(debits - credits)).toBeLessThan(0.01);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
describe('calculateAssetDisposal (at book value)', () => {
|
|
99
|
+
it('gainOrLoss = 0 and isGain = true when proceeds = NBV', () => {
|
|
100
|
+
// Fully depreciated over 5 years: NBV = salvage = 5000
|
|
101
|
+
const r = calculateAssetDisposal({
|
|
102
|
+
cost: 50000,
|
|
103
|
+
salvageValue: 5000,
|
|
104
|
+
usefulLifeYears: 5,
|
|
105
|
+
acquisitionDate: '2020-01-01',
|
|
106
|
+
disposalDate: '2025-01-01',
|
|
107
|
+
proceeds: 5000, // exactly = salvage (fully depreciated)
|
|
108
|
+
});
|
|
109
|
+
expect(r.gainOrLoss).toBe(0);
|
|
110
|
+
expect(r.isGain).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
describe('calculateAssetDisposal (fully depreciated)', () => {
|
|
114
|
+
it('accum dep capped at depreciable base (held past useful life)', () => {
|
|
115
|
+
const r = calculateAssetDisposal({
|
|
116
|
+
cost: 50000,
|
|
117
|
+
salvageValue: 5000,
|
|
118
|
+
usefulLifeYears: 5,
|
|
119
|
+
acquisitionDate: '2018-01-01', // held 7+ years
|
|
120
|
+
disposalDate: '2025-06-01',
|
|
121
|
+
proceeds: 3000,
|
|
122
|
+
});
|
|
123
|
+
expect(r.accumulatedDepreciation).toBe(45000); // max = cost - salvage
|
|
124
|
+
expect(r.netBookValue).toBe(5000);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
describe('calculateAssetDisposal (DDB method)', () => {
|
|
128
|
+
it('DDB accumulated dep is correct', () => {
|
|
129
|
+
const r = calculateAssetDisposal({
|
|
130
|
+
cost: 50000,
|
|
131
|
+
salvageValue: 5000,
|
|
132
|
+
usefulLifeYears: 5,
|
|
133
|
+
acquisitionDate: '2022-01-01',
|
|
134
|
+
disposalDate: '2022-12-31', // ~12 months (monthsBetween rounds up partial)
|
|
135
|
+
proceeds: 30000,
|
|
136
|
+
method: 'ddb',
|
|
137
|
+
});
|
|
138
|
+
// DDB year 1: 50000 * 2/5 = 20000
|
|
139
|
+
expect(r.accumulatedDepreciation).toBe(20000);
|
|
140
|
+
expect(r.netBookValue).toBe(30000);
|
|
141
|
+
expect(r.gainOrLoss).toBe(0); // proceeds = NBV
|
|
142
|
+
});
|
|
143
|
+
it('DDB journal is balanced', () => {
|
|
144
|
+
const r = calculateAssetDisposal({
|
|
145
|
+
cost: 50000,
|
|
146
|
+
salvageValue: 5000,
|
|
147
|
+
usefulLifeYears: 5,
|
|
148
|
+
acquisitionDate: '2022-01-01',
|
|
149
|
+
disposalDate: '2024-06-15',
|
|
150
|
+
proceeds: 10000,
|
|
151
|
+
method: 'ddb',
|
|
152
|
+
});
|
|
153
|
+
const debits = r.disposalJournal.lines.reduce((s, l) => s + l.debit, 0);
|
|
154
|
+
const credits = r.disposalJournal.lines.reduce((s, l) => s + l.credit, 0);
|
|
155
|
+
expect(Math.abs(debits - credits)).toBeLessThan(0.01);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
describe('calculateAssetDisposal (rounding)', () => {
|
|
159
|
+
it('normalizes inputs to 2dp and journals still balance', () => {
|
|
160
|
+
const r = calculateAssetDisposal({
|
|
161
|
+
cost: 50000.999,
|
|
162
|
+
salvageValue: 5000.111,
|
|
163
|
+
usefulLifeYears: 5,
|
|
164
|
+
acquisitionDate: '2022-01-01',
|
|
165
|
+
disposalDate: '2024-01-01',
|
|
166
|
+
proceeds: 20000.555,
|
|
167
|
+
});
|
|
168
|
+
// Inputs should be normalized
|
|
169
|
+
expect(r.inputs.cost).toBe(50001);
|
|
170
|
+
expect(r.inputs.salvageValue).toBe(5000.11);
|
|
171
|
+
expect(r.inputs.proceeds).toBe(20000.56);
|
|
172
|
+
// Journal must still balance
|
|
173
|
+
const debits = r.disposalJournal.lines.reduce((s, l) => s + l.debit, 0);
|
|
174
|
+
const credits = r.disposalJournal.lines.reduce((s, l) => s + l.credit, 0);
|
|
175
|
+
expect(Math.abs(debits - credits)).toBeLessThan(0.01);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
describe('calculateAssetDisposal (blueprint)', () => {
|
|
179
|
+
const base = {
|
|
180
|
+
cost: 50000,
|
|
181
|
+
salvageValue: 5000,
|
|
182
|
+
usefulLifeYears: 5,
|
|
183
|
+
acquisitionDate: '2022-01-01',
|
|
184
|
+
disposalDate: '2025-06-15',
|
|
185
|
+
proceeds: 20000,
|
|
186
|
+
};
|
|
187
|
+
it('blueprint always present (no startDate needed)', () => {
|
|
188
|
+
const r = calculateAssetDisposal(base);
|
|
189
|
+
expect(r.blueprint).not.toBeNull();
|
|
190
|
+
expect(r.blueprint.capsuleDescription).toBeTruthy();
|
|
191
|
+
});
|
|
192
|
+
it('blueprint has 2 steps (journal + note)', () => {
|
|
193
|
+
const r = calculateAssetDisposal(base);
|
|
194
|
+
expect(r.blueprint.steps).toHaveLength(2);
|
|
195
|
+
});
|
|
196
|
+
it('step 1 = journal (disposal entry)', () => {
|
|
197
|
+
const r = calculateAssetDisposal(base);
|
|
198
|
+
expect(r.blueprint.steps[0].action).toBe('journal');
|
|
199
|
+
});
|
|
200
|
+
it('step 2 = note (FA register update)', () => {
|
|
201
|
+
const r = calculateAssetDisposal(base);
|
|
202
|
+
expect(r.blueprint.steps[1].action).toBe('note');
|
|
203
|
+
});
|
|
204
|
+
it('capsuleType is Asset Disposal', () => {
|
|
205
|
+
const r = calculateAssetDisposal(base);
|
|
206
|
+
expect(r.blueprint.capsuleType).toBe('Asset Disposal');
|
|
207
|
+
});
|
|
208
|
+
it('capsuleDescription contains IAS 16', () => {
|
|
209
|
+
const r = calculateAssetDisposal(base);
|
|
210
|
+
expect(r.blueprint.capsuleDescription).toContain('IAS 16');
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
describe('calculateAssetDisposal (validation)', () => {
|
|
214
|
+
const base = {
|
|
215
|
+
cost: 50000,
|
|
216
|
+
salvageValue: 5000,
|
|
217
|
+
usefulLifeYears: 5,
|
|
218
|
+
acquisitionDate: '2022-01-01',
|
|
219
|
+
disposalDate: '2025-06-15',
|
|
220
|
+
proceeds: 20000,
|
|
221
|
+
};
|
|
222
|
+
it('rejects zero cost', () => {
|
|
223
|
+
expect(() => calculateAssetDisposal({ ...base, cost: 0 })).toThrow(CalcValidationError);
|
|
224
|
+
});
|
|
225
|
+
it('rejects negative proceeds', () => {
|
|
226
|
+
expect(() => calculateAssetDisposal({ ...base, proceeds: -100 })).toThrow(CalcValidationError);
|
|
227
|
+
});
|
|
228
|
+
it('rejects salvage >= cost', () => {
|
|
229
|
+
expect(() => calculateAssetDisposal({ ...base, salvageValue: 50000 })).toThrow(CalcValidationError);
|
|
230
|
+
});
|
|
231
|
+
it('rejects disposal before acquisition', () => {
|
|
232
|
+
expect(() => calculateAssetDisposal({
|
|
233
|
+
...base, acquisitionDate: '2025-01-01', disposalDate: '2024-01-01',
|
|
234
|
+
})).toThrow(CalcValidationError);
|
|
235
|
+
});
|
|
236
|
+
it('rejects invalid date format', () => {
|
|
237
|
+
expect(() => calculateAssetDisposal({
|
|
238
|
+
...base, acquisitionDate: '01-01-2022',
|
|
239
|
+
})).toThrow(CalcValidationError);
|
|
240
|
+
});
|
|
241
|
+
it('allows zero salvage value', () => {
|
|
242
|
+
const r = calculateAssetDisposal({ ...base, salvageValue: 0 });
|
|
243
|
+
expect(r.accumulatedDepreciation).toBeGreaterThan(0);
|
|
244
|
+
});
|
|
245
|
+
it('allows zero proceeds (scrap)', () => {
|
|
246
|
+
const r = calculateAssetDisposal({ ...base, proceeds: 0 });
|
|
247
|
+
expect(r.isGain).toBe(false);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { journalStep, billStep, invoiceStep, cashOutStep, cashInStep, fixedAssetStep, noteStep, fmtCapsuleAmount, fmtAmt, } from '../calc/blueprint.js';
|
|
3
|
+
describe('step builders', () => {
|
|
4
|
+
const lines = [
|
|
5
|
+
{ account: 'Cash', debit: 100, credit: 0 },
|
|
6
|
+
{ account: 'Loan', debit: 0, credit: 100 },
|
|
7
|
+
];
|
|
8
|
+
it('journalStep sets action to journal', () => {
|
|
9
|
+
const s = journalStep(1, 'Test', '2025-01-01', lines);
|
|
10
|
+
expect(s.action).toBe('journal');
|
|
11
|
+
expect(s.step).toBe(1);
|
|
12
|
+
expect(s.description).toBe('Test');
|
|
13
|
+
expect(s.date).toBe('2025-01-01');
|
|
14
|
+
expect(s.lines).toBe(lines);
|
|
15
|
+
});
|
|
16
|
+
it('billStep sets action to bill', () => {
|
|
17
|
+
const s = billStep(1, 'Supplier bill', '2025-01-01', lines);
|
|
18
|
+
expect(s.action).toBe('bill');
|
|
19
|
+
});
|
|
20
|
+
it('invoiceStep sets action to invoice', () => {
|
|
21
|
+
const s = invoiceStep(1, 'Customer invoice', '2025-01-01', lines);
|
|
22
|
+
expect(s.action).toBe('invoice');
|
|
23
|
+
});
|
|
24
|
+
it('cashOutStep sets action to cash-out', () => {
|
|
25
|
+
const s = cashOutStep(1, 'Payment', '2025-01-01', lines);
|
|
26
|
+
expect(s.action).toBe('cash-out');
|
|
27
|
+
});
|
|
28
|
+
it('cashInStep sets action to cash-in', () => {
|
|
29
|
+
const s = cashInStep(1, 'Receipt', '2025-01-01', lines);
|
|
30
|
+
expect(s.action).toBe('cash-in');
|
|
31
|
+
});
|
|
32
|
+
it('fixedAssetStep sets action to fixed-asset with empty lines', () => {
|
|
33
|
+
const s = fixedAssetStep(2, 'Register ROU', '2025-01-01');
|
|
34
|
+
expect(s.action).toBe('fixed-asset');
|
|
35
|
+
expect(s.lines).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
it('noteStep sets action to note with empty lines', () => {
|
|
38
|
+
const s = noteStep(3, 'Update FA register');
|
|
39
|
+
expect(s.action).toBe('note');
|
|
40
|
+
expect(s.lines).toEqual([]);
|
|
41
|
+
expect(s.date).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
it('null date passes through', () => {
|
|
44
|
+
const s = journalStep(1, 'Test', null, lines);
|
|
45
|
+
expect(s.date).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
describe('fmtCapsuleAmount', () => {
|
|
49
|
+
it('formats with currency', () => {
|
|
50
|
+
expect(fmtCapsuleAmount(100000, 'SGD')).toBe('SGD 100,000');
|
|
51
|
+
});
|
|
52
|
+
it('formats without currency', () => {
|
|
53
|
+
expect(fmtCapsuleAmount(100000)).toBe('100,000');
|
|
54
|
+
});
|
|
55
|
+
it('formats small amounts', () => {
|
|
56
|
+
expect(fmtCapsuleAmount(50, 'USD')).toBe('USD 50');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
describe('fmtAmt', () => {
|
|
60
|
+
it('formats with currency and 2dp', () => {
|
|
61
|
+
expect(fmtAmt(1234.5, 'SGD')).toBe('SGD 1,234.50');
|
|
62
|
+
});
|
|
63
|
+
it('formats without currency', () => {
|
|
64
|
+
expect(fmtAmt(1234.56)).toBe('1,234.56');
|
|
65
|
+
});
|
|
66
|
+
it('formats zero', () => {
|
|
67
|
+
expect(fmtAmt(0)).toBe('0.00');
|
|
68
|
+
});
|
|
69
|
+
it('handles null currency', () => {
|
|
70
|
+
expect(fmtAmt(100, null)).toBe('100.00');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { calculateDepreciation } from '../calc/depreciation.js';
|
|
3
|
+
import { CalcValidationError } from '../calc/validate.js';
|
|
4
|
+
describe('calculateDepreciation (straight-line)', () => {
|
|
5
|
+
const base = { cost: 50000, salvageValue: 5000, usefulLifeYears: 5, method: 'sl' };
|
|
6
|
+
it('total depreciation = cost - salvage', () => {
|
|
7
|
+
const r = calculateDepreciation(base);
|
|
8
|
+
expect(r.totalDepreciation).toBe(45000);
|
|
9
|
+
});
|
|
10
|
+
it('correct period count (annual)', () => {
|
|
11
|
+
const r = calculateDepreciation(base);
|
|
12
|
+
expect(r.schedule).toHaveLength(5);
|
|
13
|
+
});
|
|
14
|
+
it('equal depreciation per year (except final rounding)', () => {
|
|
15
|
+
const r = calculateDepreciation(base);
|
|
16
|
+
// 45000 / 5 = 9000 exactly, no rounding needed
|
|
17
|
+
for (const row of r.schedule) {
|
|
18
|
+
expect(row.depreciation).toBe(9000);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
it('final book value = salvage', () => {
|
|
22
|
+
const r = calculateDepreciation(base);
|
|
23
|
+
expect(r.schedule[r.schedule.length - 1].closingBookValue).toBe(5000);
|
|
24
|
+
});
|
|
25
|
+
it('all methodUsed = SL', () => {
|
|
26
|
+
const r = calculateDepreciation(base);
|
|
27
|
+
for (const row of r.schedule) {
|
|
28
|
+
expect(row.methodUsed).toBe('SL');
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
it('monthly frequency produces 60 periods', () => {
|
|
32
|
+
const r = calculateDepreciation({ ...base, frequency: 'monthly' });
|
|
33
|
+
expect(r.schedule).toHaveLength(60);
|
|
34
|
+
});
|
|
35
|
+
it('monthly: final book value = salvage', () => {
|
|
36
|
+
const r = calculateDepreciation({ ...base, frequency: 'monthly' });
|
|
37
|
+
expect(r.schedule[r.schedule.length - 1].closingBookValue).toBe(5000);
|
|
38
|
+
});
|
|
39
|
+
it('every journal entry balanced', () => {
|
|
40
|
+
const r = calculateDepreciation(base);
|
|
41
|
+
for (const row of r.schedule) {
|
|
42
|
+
const debits = row.journal.lines.reduce((s, l) => s + l.debit, 0);
|
|
43
|
+
const credits = row.journal.lines.reduce((s, l) => s + l.credit, 0);
|
|
44
|
+
expect(debits).toBe(credits);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
describe('calculateDepreciation (DDB)', () => {
|
|
49
|
+
const base = { cost: 50000, salvageValue: 5000, usefulLifeYears: 5, method: 'ddb' };
|
|
50
|
+
it('total depreciation = cost - salvage', () => {
|
|
51
|
+
const r = calculateDepreciation(base);
|
|
52
|
+
const totalDep = r.schedule.reduce((s, row) => s + row.depreciation, 0);
|
|
53
|
+
expect(Math.round(totalDep * 100) / 100).toBe(45000);
|
|
54
|
+
});
|
|
55
|
+
it('starts with DDB, switches to SL', () => {
|
|
56
|
+
const r = calculateDepreciation(base);
|
|
57
|
+
expect(r.schedule[0].methodUsed).toBe('DDB');
|
|
58
|
+
expect(r.schedule[r.schedule.length - 1].methodUsed).toBe('SL');
|
|
59
|
+
});
|
|
60
|
+
it('first year DDB amount = cost * 2/life', () => {
|
|
61
|
+
const r = calculateDepreciation(base);
|
|
62
|
+
expect(r.schedule[0].ddbAmount).toBe(20000); // 50000 * 0.4
|
|
63
|
+
});
|
|
64
|
+
it('book value never falls below salvage', () => {
|
|
65
|
+
const r = calculateDepreciation(base);
|
|
66
|
+
for (const row of r.schedule) {
|
|
67
|
+
expect(row.closingBookValue).toBeGreaterThanOrEqual(5000);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
it('final book value = salvage', () => {
|
|
71
|
+
const r = calculateDepreciation(base);
|
|
72
|
+
expect(r.schedule[r.schedule.length - 1].closingBookValue).toBe(5000);
|
|
73
|
+
});
|
|
74
|
+
it('correct period count', () => {
|
|
75
|
+
const r = calculateDepreciation(base);
|
|
76
|
+
expect(r.schedule).toHaveLength(5);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
describe('calculateDepreciation (150DB)', () => {
|
|
80
|
+
const base = { cost: 50000, salvageValue: 5000, usefulLifeYears: 5, method: '150db' };
|
|
81
|
+
it('total depreciation = cost - salvage', () => {
|
|
82
|
+
const r = calculateDepreciation(base);
|
|
83
|
+
const totalDep = r.schedule.reduce((s, row) => s + row.depreciation, 0);
|
|
84
|
+
expect(Math.round(totalDep * 100) / 100).toBe(45000);
|
|
85
|
+
});
|
|
86
|
+
it('first year uses 150DB rate (1.5/life)', () => {
|
|
87
|
+
const r = calculateDepreciation(base);
|
|
88
|
+
expect(r.schedule[0].ddbAmount).toBe(15000); // 50000 * 0.3
|
|
89
|
+
});
|
|
90
|
+
it('final book value = salvage', () => {
|
|
91
|
+
const r = calculateDepreciation(base);
|
|
92
|
+
expect(r.schedule[r.schedule.length - 1].closingBookValue).toBe(5000);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
describe('calculateDepreciation (blueprint)', () => {
|
|
96
|
+
it('blueprint always present (no startDate needed for depreciation)', () => {
|
|
97
|
+
const r = calculateDepreciation({ cost: 50000, salvageValue: 5000, usefulLifeYears: 5, method: 'sl' });
|
|
98
|
+
expect(r.blueprint).not.toBeNull();
|
|
99
|
+
expect(r.blueprint.capsuleDescription).toBeTruthy();
|
|
100
|
+
});
|
|
101
|
+
it('step count matches schedule length', () => {
|
|
102
|
+
const r = calculateDepreciation({ cost: 50000, salvageValue: 5000, usefulLifeYears: 5, method: 'ddb' });
|
|
103
|
+
expect(r.blueprint.steps).toHaveLength(r.schedule.length);
|
|
104
|
+
});
|
|
105
|
+
it('all steps are journal type', () => {
|
|
106
|
+
const r = calculateDepreciation({ cost: 50000, salvageValue: 5000, usefulLifeYears: 5, method: 'sl' });
|
|
107
|
+
for (const step of r.blueprint.steps) {
|
|
108
|
+
expect(step.action).toBe('journal');
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
describe('calculateDepreciation (validation)', () => {
|
|
113
|
+
it('rejects zero cost', () => {
|
|
114
|
+
expect(() => calculateDepreciation({ cost: 0, salvageValue: 0, usefulLifeYears: 5, method: 'sl' }))
|
|
115
|
+
.toThrow(CalcValidationError);
|
|
116
|
+
});
|
|
117
|
+
it('rejects salvage >= cost', () => {
|
|
118
|
+
expect(() => calculateDepreciation({ cost: 50000, salvageValue: 50000, usefulLifeYears: 5, method: 'sl' }))
|
|
119
|
+
.toThrow(CalcValidationError);
|
|
120
|
+
});
|
|
121
|
+
it('allows zero salvage', () => {
|
|
122
|
+
const r = calculateDepreciation({ cost: 50000, salvageValue: 0, usefulLifeYears: 5, method: 'sl' });
|
|
123
|
+
expect(r.totalDepreciation).toBe(50000);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { calculateEcl } from '../calc/ecl.js';
|
|
3
|
+
import { CalcValidationError } from '../calc/validate.js';
|
|
4
|
+
const standardBuckets = [
|
|
5
|
+
{ name: 'Current', balance: 100000, rate: 0.5 },
|
|
6
|
+
{ name: '1-30 days', balance: 50000, rate: 2 },
|
|
7
|
+
{ name: '31-60 days', balance: 20000, rate: 5 },
|
|
8
|
+
{ name: '61-90 days', balance: 10000, rate: 10 },
|
|
9
|
+
{ name: '91+ days', balance: 5000, rate: 50 },
|
|
10
|
+
];
|
|
11
|
+
describe('calculateEcl (standard)', () => {
|
|
12
|
+
it('bucket ECL = balance * rate / 100', () => {
|
|
13
|
+
const r = calculateEcl({ buckets: standardBuckets });
|
|
14
|
+
expect(r.bucketDetails[0].ecl).toBe(500); // 100000 * 0.5%
|
|
15
|
+
expect(r.bucketDetails[1].ecl).toBe(1000); // 50000 * 2%
|
|
16
|
+
expect(r.bucketDetails[2].ecl).toBe(1000); // 20000 * 5%
|
|
17
|
+
expect(r.bucketDetails[3].ecl).toBe(1000); // 10000 * 10%
|
|
18
|
+
expect(r.bucketDetails[4].ecl).toBe(2500); // 5000 * 50%
|
|
19
|
+
});
|
|
20
|
+
it('totalEcl = sum of bucket ECLs', () => {
|
|
21
|
+
const r = calculateEcl({ buckets: standardBuckets });
|
|
22
|
+
expect(r.totalEcl).toBe(6000); // 500+1000+1000+1000+2500
|
|
23
|
+
});
|
|
24
|
+
it('totalReceivables = sum of balances', () => {
|
|
25
|
+
const r = calculateEcl({ buckets: standardBuckets });
|
|
26
|
+
expect(r.totalReceivables).toBe(185000);
|
|
27
|
+
});
|
|
28
|
+
it('adjustmentRequired = totalEcl - existingProvision', () => {
|
|
29
|
+
const r = calculateEcl({ buckets: standardBuckets, existingProvision: 4000 });
|
|
30
|
+
expect(r.adjustmentRequired).toBe(2000);
|
|
31
|
+
expect(r.isIncrease).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
it('negative adjustment when over-provisioned', () => {
|
|
34
|
+
const r = calculateEcl({ buckets: standardBuckets, existingProvision: 8000 });
|
|
35
|
+
expect(r.adjustmentRequired).toBe(-2000);
|
|
36
|
+
expect(r.isIncrease).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
it('zero adjustment when existing = required', () => {
|
|
39
|
+
const r = calculateEcl({ buckets: standardBuckets, existingProvision: 6000 });
|
|
40
|
+
expect(r.adjustmentRequired).toBe(0);
|
|
41
|
+
expect(r.isIncrease).toBe(true); // >= 0
|
|
42
|
+
});
|
|
43
|
+
it('existingProvision defaults to 0', () => {
|
|
44
|
+
const r = calculateEcl({ buckets: standardBuckets });
|
|
45
|
+
expect(r.adjustmentRequired).toBe(6000); // totalEcl - 0
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
describe('calculateEcl (journal)', () => {
|
|
49
|
+
it('increase: debits Bad Debt Expense, credits Allowance', () => {
|
|
50
|
+
const r = calculateEcl({ buckets: standardBuckets });
|
|
51
|
+
expect(r.journal.lines[0].account).toBe('Bad Debt Expense');
|
|
52
|
+
expect(r.journal.lines[0].debit).toBe(6000);
|
|
53
|
+
expect(r.journal.lines[1].account).toBe('Allowance for Doubtful Debts');
|
|
54
|
+
expect(r.journal.lines[1].credit).toBe(6000);
|
|
55
|
+
});
|
|
56
|
+
it('release: debits Allowance, credits Bad Debt Expense', () => {
|
|
57
|
+
const r = calculateEcl({ buckets: standardBuckets, existingProvision: 8000 });
|
|
58
|
+
expect(r.journal.lines[0].account).toBe('Allowance for Doubtful Debts');
|
|
59
|
+
expect(r.journal.lines[0].debit).toBe(2000);
|
|
60
|
+
expect(r.journal.lines[1].account).toBe('Bad Debt Expense');
|
|
61
|
+
expect(r.journal.lines[1].credit).toBe(2000);
|
|
62
|
+
});
|
|
63
|
+
it('journal is balanced', () => {
|
|
64
|
+
const r = calculateEcl({ buckets: standardBuckets });
|
|
65
|
+
const debits = r.journal.lines.reduce((s, l) => s + l.debit, 0);
|
|
66
|
+
const credits = r.journal.lines.reduce((s, l) => s + l.credit, 0);
|
|
67
|
+
expect(debits).toBe(credits);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe('calculateEcl (blueprint)', () => {
|
|
71
|
+
it('blueprint always present', () => {
|
|
72
|
+
const r = calculateEcl({ buckets: standardBuckets });
|
|
73
|
+
expect(r.blueprint).not.toBeNull();
|
|
74
|
+
expect(r.blueprint.capsuleDescription).toBeTruthy();
|
|
75
|
+
});
|
|
76
|
+
it('blueprint has 1 step (journal)', () => {
|
|
77
|
+
const r = calculateEcl({ buckets: standardBuckets });
|
|
78
|
+
expect(r.blueprint.steps).toHaveLength(1);
|
|
79
|
+
expect(r.blueprint.steps[0].action).toBe('journal');
|
|
80
|
+
});
|
|
81
|
+
it('capsuleType is ECL Provision', () => {
|
|
82
|
+
const r = calculateEcl({ buckets: standardBuckets });
|
|
83
|
+
expect(r.blueprint.capsuleType).toBe('ECL Provision');
|
|
84
|
+
});
|
|
85
|
+
it('capsuleDescription contains IFRS 9', () => {
|
|
86
|
+
const r = calculateEcl({ buckets: standardBuckets });
|
|
87
|
+
expect(r.blueprint.capsuleDescription).toContain('IFRS 9');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe('calculateEcl (currency)', () => {
|
|
91
|
+
it('currency passes through', () => {
|
|
92
|
+
const r = calculateEcl({ buckets: standardBuckets, currency: 'SGD' });
|
|
93
|
+
expect(r.currency).toBe('SGD');
|
|
94
|
+
});
|
|
95
|
+
it('currency null when not provided', () => {
|
|
96
|
+
const r = calculateEcl({ buckets: standardBuckets });
|
|
97
|
+
expect(r.currency).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
describe('calculateEcl (weighted rate)', () => {
|
|
101
|
+
it('weighted average loss rate is correct', () => {
|
|
102
|
+
const r = calculateEcl({ buckets: standardBuckets });
|
|
103
|
+
// round2(6000/185000 * 100 * 100) / 100 = 3.2432%
|
|
104
|
+
expect(r.weightedRate).toBe(3.2432);
|
|
105
|
+
});
|
|
106
|
+
it('weighted rate is 0 when all balances are zero', () => {
|
|
107
|
+
const r = calculateEcl({ buckets: [{ name: 'Current', balance: 0, rate: 5 }] });
|
|
108
|
+
expect(r.weightedRate).toBe(0);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
describe('calculateEcl (validation)', () => {
|
|
112
|
+
it('rejects negative balance', () => {
|
|
113
|
+
expect(() => calculateEcl({
|
|
114
|
+
buckets: [{ name: 'Current', balance: -100, rate: 5 }],
|
|
115
|
+
})).toThrow(CalcValidationError);
|
|
116
|
+
});
|
|
117
|
+
it('rejects negative loss rate', () => {
|
|
118
|
+
expect(() => calculateEcl({
|
|
119
|
+
buckets: [{ name: 'Current', balance: 100, rate: -5 }],
|
|
120
|
+
})).toThrow(CalcValidationError);
|
|
121
|
+
});
|
|
122
|
+
it('rejects negative existing provision', () => {
|
|
123
|
+
expect(() => calculateEcl({
|
|
124
|
+
buckets: [{ name: 'Current', balance: 100, rate: 5 }],
|
|
125
|
+
existingProvision: -1000,
|
|
126
|
+
})).toThrow(CalcValidationError);
|
|
127
|
+
});
|
|
128
|
+
it('allows zero balance and zero rate', () => {
|
|
129
|
+
const r = calculateEcl({
|
|
130
|
+
buckets: [{ name: 'Current', balance: 0, rate: 0 }],
|
|
131
|
+
});
|
|
132
|
+
expect(r.totalEcl).toBe(0);
|
|
133
|
+
});
|
|
134
|
+
});
|