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,214 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { calculateFixedDeposit } from '../calc/fixed-deposit.js';
|
|
3
|
+
import { CalcValidationError } from '../calc/validate.js';
|
|
4
|
+
describe('calculateFixedDeposit (simple interest)', () => {
|
|
5
|
+
const base = { principal: 100000, annualRate: 3.5, termMonths: 12 };
|
|
6
|
+
it('maturity = principal + simple interest', () => {
|
|
7
|
+
const r = calculateFixedDeposit(base);
|
|
8
|
+
// 100000 * 3.5% * 12/12 = 3500
|
|
9
|
+
expect(r.maturityValue).toBe(103500);
|
|
10
|
+
});
|
|
11
|
+
it('totalInterest = maturity - principal', () => {
|
|
12
|
+
const r = calculateFixedDeposit(base);
|
|
13
|
+
expect(r.totalInterest).toBe(3500);
|
|
14
|
+
});
|
|
15
|
+
it('schedule has termMonths periods', () => {
|
|
16
|
+
const r = calculateFixedDeposit(base);
|
|
17
|
+
expect(r.schedule).toHaveLength(12);
|
|
18
|
+
});
|
|
19
|
+
it('equal monthly accrual (except final rounding)', () => {
|
|
20
|
+
const r = calculateFixedDeposit(base);
|
|
21
|
+
const monthlyAccrual = Math.round(3500 / 12 * 100) / 100; // 291.67
|
|
22
|
+
for (let i = 0; i < 11; i++) {
|
|
23
|
+
expect(r.schedule[i].interest).toBe(monthlyAccrual);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
it('total accrued interest = totalInterest exactly', () => {
|
|
27
|
+
const r = calculateFixedDeposit(base);
|
|
28
|
+
const totalAccrued = r.schedule.reduce((s, row) => s + row.interest, 0);
|
|
29
|
+
expect(Math.round(totalAccrued * 100) / 100).toBe(3500);
|
|
30
|
+
});
|
|
31
|
+
it('opening balance stays at principal (simple interest)', () => {
|
|
32
|
+
const r = calculateFixedDeposit(base);
|
|
33
|
+
for (const row of r.schedule) {
|
|
34
|
+
expect(row.openingBalance).toBe(100000);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
it('every journal entry is balanced', () => {
|
|
38
|
+
const r = calculateFixedDeposit(base);
|
|
39
|
+
for (const row of r.schedule) {
|
|
40
|
+
const debits = row.journal.lines.reduce((s, l) => s + l.debit, 0);
|
|
41
|
+
const credits = row.journal.lines.reduce((s, l) => s + l.credit, 0);
|
|
42
|
+
expect(debits).toBe(credits);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
it('accrual journals use Accrued Interest / Interest Income', () => {
|
|
46
|
+
const r = calculateFixedDeposit(base);
|
|
47
|
+
expect(r.schedule[0].journal.lines[0].account).toBe('Accrued Interest Receivable');
|
|
48
|
+
expect(r.schedule[0].journal.lines[1].account).toBe('Interest Income');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe('calculateFixedDeposit (compound quarterly)', () => {
|
|
52
|
+
const base = { principal: 100000, annualRate: 4, termMonths: 12, compounding: 'quarterly' };
|
|
53
|
+
it('schedule has 4 periods (quarterly)', () => {
|
|
54
|
+
const r = calculateFixedDeposit(base);
|
|
55
|
+
expect(r.schedule).toHaveLength(4);
|
|
56
|
+
});
|
|
57
|
+
it('maturity > simple interest equivalent', () => {
|
|
58
|
+
const simple = calculateFixedDeposit({ principal: 100000, annualRate: 4, termMonths: 12 });
|
|
59
|
+
const compound = calculateFixedDeposit(base);
|
|
60
|
+
expect(compound.maturityValue).toBeGreaterThan(simple.maturityValue);
|
|
61
|
+
});
|
|
62
|
+
it('final closing balance = maturity value', () => {
|
|
63
|
+
const r = calculateFixedDeposit(base);
|
|
64
|
+
expect(r.schedule[r.schedule.length - 1].closingBalance).toBe(r.maturityValue);
|
|
65
|
+
});
|
|
66
|
+
it('opening balance = previous closing balance', () => {
|
|
67
|
+
const r = calculateFixedDeposit(base);
|
|
68
|
+
for (let i = 1; i < r.schedule.length; i++) {
|
|
69
|
+
expect(r.schedule[i].openingBalance).toBe(r.schedule[i - 1].closingBalance);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
it('total accrued = totalInterest', () => {
|
|
73
|
+
const r = calculateFixedDeposit(base);
|
|
74
|
+
const totalAccrued = r.schedule.reduce((s, row) => s + row.interest, 0);
|
|
75
|
+
expect(Math.round(totalAccrued * 100) / 100).toBe(r.totalInterest);
|
|
76
|
+
});
|
|
77
|
+
it('every journal entry is balanced', () => {
|
|
78
|
+
const r = calculateFixedDeposit(base);
|
|
79
|
+
for (const row of r.schedule) {
|
|
80
|
+
const debits = row.journal.lines.reduce((s, l) => s + l.debit, 0);
|
|
81
|
+
const credits = row.journal.lines.reduce((s, l) => s + l.credit, 0);
|
|
82
|
+
expect(debits).toBe(credits);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
describe('calculateFixedDeposit (compound monthly)', () => {
|
|
87
|
+
it('monthly compound: 12 periods', () => {
|
|
88
|
+
const r = calculateFixedDeposit({
|
|
89
|
+
principal: 100000, annualRate: 4, termMonths: 12, compounding: 'monthly',
|
|
90
|
+
});
|
|
91
|
+
expect(r.schedule).toHaveLength(12);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe('calculateFixedDeposit (compound annually)', () => {
|
|
95
|
+
it('annual compound: 1 period for 12 months', () => {
|
|
96
|
+
const r = calculateFixedDeposit({
|
|
97
|
+
principal: 100000, annualRate: 4, termMonths: 12, compounding: 'annually',
|
|
98
|
+
});
|
|
99
|
+
expect(r.schedule).toHaveLength(1);
|
|
100
|
+
expect(r.maturityValue).toBe(104000); // 100000 * 1.04
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
describe('calculateFixedDeposit (placement/maturity journals)', () => {
|
|
104
|
+
const base = { principal: 100000, annualRate: 3.5, termMonths: 12 };
|
|
105
|
+
it('placement journal: debit Fixed Deposit, credit Cash', () => {
|
|
106
|
+
const r = calculateFixedDeposit(base);
|
|
107
|
+
expect(r.placementJournal.lines[0].account).toBe('Fixed Deposit');
|
|
108
|
+
expect(r.placementJournal.lines[0].debit).toBe(100000);
|
|
109
|
+
expect(r.placementJournal.lines[1].account).toBe('Cash / Bank Account');
|
|
110
|
+
expect(r.placementJournal.lines[1].credit).toBe(100000);
|
|
111
|
+
});
|
|
112
|
+
it('maturity journal: debit Cash, credit FD + Accrued Interest', () => {
|
|
113
|
+
const r = calculateFixedDeposit(base);
|
|
114
|
+
expect(r.maturityJournal.lines[0].account).toBe('Cash / Bank Account');
|
|
115
|
+
expect(r.maturityJournal.lines[0].debit).toBe(103500);
|
|
116
|
+
expect(r.maturityJournal.lines[1].account).toBe('Fixed Deposit');
|
|
117
|
+
expect(r.maturityJournal.lines[1].credit).toBe(100000);
|
|
118
|
+
expect(r.maturityJournal.lines[2].account).toBe('Accrued Interest Receivable');
|
|
119
|
+
expect(r.maturityJournal.lines[2].credit).toBe(3500);
|
|
120
|
+
});
|
|
121
|
+
it('placement journal is balanced', () => {
|
|
122
|
+
const r = calculateFixedDeposit(base);
|
|
123
|
+
const debits = r.placementJournal.lines.reduce((s, l) => s + l.debit, 0);
|
|
124
|
+
const credits = r.placementJournal.lines.reduce((s, l) => s + l.credit, 0);
|
|
125
|
+
expect(debits).toBe(credits);
|
|
126
|
+
});
|
|
127
|
+
it('maturity journal is balanced', () => {
|
|
128
|
+
const r = calculateFixedDeposit(base);
|
|
129
|
+
const debits = r.maturityJournal.lines.reduce((s, l) => s + l.debit, 0);
|
|
130
|
+
const credits = r.maturityJournal.lines.reduce((s, l) => s + l.credit, 0);
|
|
131
|
+
expect(debits).toBe(credits);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
describe('calculateFixedDeposit (blueprint)', () => {
|
|
135
|
+
it('blueprint present when startDate given', () => {
|
|
136
|
+
const r = calculateFixedDeposit({ principal: 100000, annualRate: 3.5, termMonths: 12, startDate: '2025-01-01' });
|
|
137
|
+
expect(r.blueprint).not.toBeNull();
|
|
138
|
+
expect(r.blueprint.capsuleDescription).toBeTruthy();
|
|
139
|
+
});
|
|
140
|
+
it('blueprint null without startDate', () => {
|
|
141
|
+
const r = calculateFixedDeposit({ principal: 100000, annualRate: 3.5, termMonths: 12 });
|
|
142
|
+
expect(r.blueprint).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
it('step 1 = cash-out (placement)', () => {
|
|
145
|
+
const r = calculateFixedDeposit({ principal: 100000, annualRate: 3.5, termMonths: 12, startDate: '2025-01-01' });
|
|
146
|
+
expect(r.blueprint.steps[0].action).toBe('cash-out');
|
|
147
|
+
});
|
|
148
|
+
it('last step = cash-in (maturity)', () => {
|
|
149
|
+
const r = calculateFixedDeposit({ principal: 100000, annualRate: 3.5, termMonths: 12, startDate: '2025-01-01' });
|
|
150
|
+
const lastStep = r.blueprint.steps[r.blueprint.steps.length - 1];
|
|
151
|
+
expect(lastStep.action).toBe('cash-in');
|
|
152
|
+
});
|
|
153
|
+
it('step count = 1 placement + N accruals + 1 maturity', () => {
|
|
154
|
+
const r = calculateFixedDeposit({ principal: 100000, annualRate: 3.5, termMonths: 12, startDate: '2025-01-01' });
|
|
155
|
+
expect(r.blueprint.steps).toHaveLength(14); // 1 + 12 + 1
|
|
156
|
+
});
|
|
157
|
+
it('compound quarterly: step count uses compound periods', () => {
|
|
158
|
+
const r = calculateFixedDeposit({
|
|
159
|
+
principal: 100000, annualRate: 4, termMonths: 12,
|
|
160
|
+
compounding: 'quarterly', startDate: '2025-01-01',
|
|
161
|
+
});
|
|
162
|
+
expect(r.blueprint.steps).toHaveLength(6); // 1 + 4 + 1
|
|
163
|
+
});
|
|
164
|
+
it('capsuleType is Fixed Deposit', () => {
|
|
165
|
+
const r = calculateFixedDeposit({ principal: 100000, annualRate: 3.5, termMonths: 12, startDate: '2025-01-01' });
|
|
166
|
+
expect(r.blueprint.capsuleType).toBe('Fixed Deposit');
|
|
167
|
+
});
|
|
168
|
+
it('capsuleDescription contains IFRS 9', () => {
|
|
169
|
+
const r = calculateFixedDeposit({ principal: 100000, annualRate: 3.5, termMonths: 12, startDate: '2025-01-01' });
|
|
170
|
+
expect(r.blueprint.capsuleDescription).toContain('IFRS 9');
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
describe('calculateFixedDeposit (currency)', () => {
|
|
174
|
+
it('currency passes through', () => {
|
|
175
|
+
const r = calculateFixedDeposit({ principal: 100000, annualRate: 3.5, termMonths: 12, currency: 'SGD' });
|
|
176
|
+
expect(r.currency).toBe('SGD');
|
|
177
|
+
});
|
|
178
|
+
it('currency null when not provided', () => {
|
|
179
|
+
const r = calculateFixedDeposit({ principal: 100000, annualRate: 3.5, termMonths: 12 });
|
|
180
|
+
expect(r.currency).toBeNull();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
describe('calculateFixedDeposit (validation)', () => {
|
|
184
|
+
it('rejects zero principal', () => {
|
|
185
|
+
expect(() => calculateFixedDeposit({ principal: 0, annualRate: 3.5, termMonths: 12 })).toThrow(CalcValidationError);
|
|
186
|
+
});
|
|
187
|
+
it('rejects negative rate', () => {
|
|
188
|
+
expect(() => calculateFixedDeposit({ principal: 100000, annualRate: -1, termMonths: 12 })).toThrow(CalcValidationError);
|
|
189
|
+
});
|
|
190
|
+
it('rejects zero term', () => {
|
|
191
|
+
expect(() => calculateFixedDeposit({ principal: 100000, annualRate: 3.5, termMonths: 0 })).toThrow(CalcValidationError);
|
|
192
|
+
});
|
|
193
|
+
it('rejects non-multiple term for quarterly compounding', () => {
|
|
194
|
+
expect(() => calculateFixedDeposit({
|
|
195
|
+
principal: 100000, annualRate: 4, termMonths: 7, compounding: 'quarterly',
|
|
196
|
+
})).toThrow(CalcValidationError);
|
|
197
|
+
});
|
|
198
|
+
it('rejects non-multiple term for annual compounding', () => {
|
|
199
|
+
expect(() => calculateFixedDeposit({
|
|
200
|
+
principal: 100000, annualRate: 4, termMonths: 18, compounding: 'annually',
|
|
201
|
+
})).toThrow(CalcValidationError);
|
|
202
|
+
});
|
|
203
|
+
it('allows any term for monthly compounding', () => {
|
|
204
|
+
const r = calculateFixedDeposit({
|
|
205
|
+
principal: 100000, annualRate: 4, termMonths: 7, compounding: 'monthly',
|
|
206
|
+
});
|
|
207
|
+
expect(r.schedule).toHaveLength(7);
|
|
208
|
+
});
|
|
209
|
+
it('rejects invalid date format', () => {
|
|
210
|
+
expect(() => calculateFixedDeposit({
|
|
211
|
+
principal: 100000, annualRate: 3.5, termMonths: 12, startDate: '01-01-2025',
|
|
212
|
+
})).toThrow(CalcValidationError);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { calculateFxReval } from '../calc/fx-reval.js';
|
|
3
|
+
import { CalcValidationError } from '../calc/validate.js';
|
|
4
|
+
describe('calculateFxReval (gain)', () => {
|
|
5
|
+
const base = { amount: 50000, bookRate: 1.35, closingRate: 1.38 };
|
|
6
|
+
it('bookValue = amount * bookRate', () => {
|
|
7
|
+
const r = calculateFxReval(base);
|
|
8
|
+
expect(r.bookValue).toBe(67500); // 50000 * 1.35
|
|
9
|
+
});
|
|
10
|
+
it('closingValue = amount * closingRate', () => {
|
|
11
|
+
const r = calculateFxReval(base);
|
|
12
|
+
expect(r.closingValue).toBe(69000); // 50000 * 1.38
|
|
13
|
+
});
|
|
14
|
+
it('gain = closingValue - bookValue', () => {
|
|
15
|
+
const r = calculateFxReval(base);
|
|
16
|
+
expect(r.gainOrLoss).toBe(1500);
|
|
17
|
+
expect(r.isGain).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
it('journal debits Monetary Item, credits FX Unrealized Gain', () => {
|
|
20
|
+
const r = calculateFxReval(base);
|
|
21
|
+
expect(r.journal.lines[0].account).toContain('Monetary Item');
|
|
22
|
+
expect(r.journal.lines[0].debit).toBe(1500);
|
|
23
|
+
expect(r.journal.lines[1].account).toBe('FX Unrealized Gain');
|
|
24
|
+
expect(r.journal.lines[1].credit).toBe(1500);
|
|
25
|
+
});
|
|
26
|
+
it('journal is balanced', () => {
|
|
27
|
+
const r = calculateFxReval(base);
|
|
28
|
+
const debits = r.journal.lines.reduce((s, l) => s + l.debit, 0);
|
|
29
|
+
const credits = r.journal.lines.reduce((s, l) => s + l.credit, 0);
|
|
30
|
+
expect(debits).toBe(credits);
|
|
31
|
+
});
|
|
32
|
+
it('reversal is exact negative of revaluation', () => {
|
|
33
|
+
const r = calculateFxReval(base);
|
|
34
|
+
for (let i = 0; i < r.journal.lines.length; i++) {
|
|
35
|
+
expect(r.reversalJournal.lines[i].debit).toBe(r.journal.lines[i].credit);
|
|
36
|
+
expect(r.reversalJournal.lines[i].credit).toBe(r.journal.lines[i].debit);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
it('reversal journal is balanced', () => {
|
|
40
|
+
const r = calculateFxReval(base);
|
|
41
|
+
const debits = r.reversalJournal.lines.reduce((s, l) => s + l.debit, 0);
|
|
42
|
+
const credits = r.reversalJournal.lines.reduce((s, l) => s + l.credit, 0);
|
|
43
|
+
expect(debits).toBe(credits);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
describe('calculateFxReval (loss)', () => {
|
|
47
|
+
const base = { amount: 50000, bookRate: 1.38, closingRate: 1.35 };
|
|
48
|
+
it('loss is negative gainOrLoss', () => {
|
|
49
|
+
const r = calculateFxReval(base);
|
|
50
|
+
expect(r.gainOrLoss).toBe(-1500);
|
|
51
|
+
expect(r.isGain).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
it('journal debits FX Unrealized Loss, credits Monetary Item', () => {
|
|
54
|
+
const r = calculateFxReval(base);
|
|
55
|
+
expect(r.journal.lines[0].account).toBe('FX Unrealized Loss');
|
|
56
|
+
expect(r.journal.lines[0].debit).toBe(1500);
|
|
57
|
+
expect(r.journal.lines[1].account).toContain('Monetary Item');
|
|
58
|
+
expect(r.journal.lines[1].credit).toBe(1500);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe('calculateFxReval (zero movement)', () => {
|
|
62
|
+
it('zero gain/loss when rates equal', () => {
|
|
63
|
+
const r = calculateFxReval({ amount: 50000, bookRate: 1.35, closingRate: 1.35 });
|
|
64
|
+
expect(r.gainOrLoss).toBe(0);
|
|
65
|
+
expect(r.isGain).toBe(true); // >= 0 treated as gain
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
describe('calculateFxReval (blueprint)', () => {
|
|
69
|
+
it('blueprint always present (no startDate needed)', () => {
|
|
70
|
+
const r = calculateFxReval({ amount: 50000, bookRate: 1.35, closingRate: 1.38 });
|
|
71
|
+
expect(r.blueprint).not.toBeNull();
|
|
72
|
+
expect(r.blueprint.capsuleDescription).toBeTruthy();
|
|
73
|
+
});
|
|
74
|
+
it('blueprint has 2 steps (reval + reversal)', () => {
|
|
75
|
+
const r = calculateFxReval({ amount: 50000, bookRate: 1.35, closingRate: 1.38 });
|
|
76
|
+
expect(r.blueprint.steps).toHaveLength(2);
|
|
77
|
+
});
|
|
78
|
+
it('both steps are journal type', () => {
|
|
79
|
+
const r = calculateFxReval({ amount: 50000, bookRate: 1.35, closingRate: 1.38 });
|
|
80
|
+
for (const step of r.blueprint.steps) {
|
|
81
|
+
expect(step.action).toBe('journal');
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
it('capsuleType is FX Revaluation', () => {
|
|
85
|
+
const r = calculateFxReval({ amount: 50000, bookRate: 1.35, closingRate: 1.38 });
|
|
86
|
+
expect(r.blueprint.capsuleType).toBe('FX Revaluation');
|
|
87
|
+
});
|
|
88
|
+
it('capsuleDescription contains IAS 21', () => {
|
|
89
|
+
const r = calculateFxReval({ amount: 50000, bookRate: 1.35, closingRate: 1.38 });
|
|
90
|
+
expect(r.blueprint.capsuleDescription).toContain('IAS 21');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
describe('calculateFxReval (defaults + passthrough)', () => {
|
|
94
|
+
it('defaults to USD/SGD', () => {
|
|
95
|
+
const r = calculateFxReval({ amount: 1000, bookRate: 1.3, closingRate: 1.4 });
|
|
96
|
+
expect(r.currency).toBe('USD');
|
|
97
|
+
expect(r.inputs.baseCurrency).toBe('SGD');
|
|
98
|
+
});
|
|
99
|
+
it('custom currency passes through', () => {
|
|
100
|
+
const r = calculateFxReval({ amount: 1000, bookRate: 1.3, closingRate: 1.4, currency: 'EUR', baseCurrency: 'PHP' });
|
|
101
|
+
expect(r.currency).toBe('EUR');
|
|
102
|
+
expect(r.inputs.baseCurrency).toBe('PHP');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
describe('calculateFxReval (validation)', () => {
|
|
106
|
+
it('rejects zero amount', () => {
|
|
107
|
+
expect(() => calculateFxReval({ amount: 0, bookRate: 1.35, closingRate: 1.38 })).toThrow(CalcValidationError);
|
|
108
|
+
});
|
|
109
|
+
it('rejects negative bookRate', () => {
|
|
110
|
+
expect(() => calculateFxReval({ amount: 50000, bookRate: -1, closingRate: 1.38 })).toThrow(CalcValidationError);
|
|
111
|
+
});
|
|
112
|
+
it('rejects zero closingRate', () => {
|
|
113
|
+
expect(() => calculateFxReval({ amount: 50000, bookRate: 1.35, closingRate: 0 })).toThrow(CalcValidationError);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { calculateLease } from '../calc/lease.js';
|
|
3
|
+
import { CalcValidationError } from '../calc/validate.js';
|
|
4
|
+
describe('calculateLease (standard)', () => {
|
|
5
|
+
const base = { monthlyPayment: 5000, termMonths: 36, annualRate: 5 };
|
|
6
|
+
it('produces correct schedule length', () => {
|
|
7
|
+
const r = calculateLease(base);
|
|
8
|
+
expect(r.schedule).toHaveLength(36);
|
|
9
|
+
});
|
|
10
|
+
it('final closing balance is exactly zero', () => {
|
|
11
|
+
const r = calculateLease(base);
|
|
12
|
+
expect(r.schedule[r.schedule.length - 1].closingBalance).toBe(0);
|
|
13
|
+
});
|
|
14
|
+
it('PV is less than total cash payments', () => {
|
|
15
|
+
const r = calculateLease(base);
|
|
16
|
+
expect(r.presentValue).toBeLessThan(r.totalCashPayments);
|
|
17
|
+
});
|
|
18
|
+
it('ROU depreciation = PV / term', () => {
|
|
19
|
+
const r = calculateLease(base);
|
|
20
|
+
const expected = Math.round(r.presentValue / 36 * 100) / 100;
|
|
21
|
+
expect(r.monthlyRouDepreciation).toBe(expected);
|
|
22
|
+
});
|
|
23
|
+
it('isHirePurchase is false without usefulLifeMonths', () => {
|
|
24
|
+
const r = calculateLease(base);
|
|
25
|
+
expect(r.isHirePurchase).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
it('opening balance = previous closing balance', () => {
|
|
28
|
+
const r = calculateLease(base);
|
|
29
|
+
for (let i = 1; i < r.schedule.length; i++) {
|
|
30
|
+
expect(r.schedule[i].openingBalance).toBe(r.schedule[i - 1].closingBalance);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
it('every journal entry is balanced', () => {
|
|
34
|
+
const r = calculateLease(base);
|
|
35
|
+
for (const row of r.schedule) {
|
|
36
|
+
const debits = row.journal.lines.reduce((s, l) => s + l.debit, 0);
|
|
37
|
+
const credits = row.journal.lines.reduce((s, l) => s + l.credit, 0);
|
|
38
|
+
expect(Math.abs(debits - credits)).toBeLessThan(0.01);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
// Blueprint
|
|
42
|
+
it('blueprint step 1 = journal (initial recognition)', () => {
|
|
43
|
+
const r = calculateLease({ ...base, startDate: '2025-01-01' });
|
|
44
|
+
expect(r.blueprint.steps[0].action).toBe('journal');
|
|
45
|
+
});
|
|
46
|
+
it('blueprint step 2 = fixed-asset (ROU registration)', () => {
|
|
47
|
+
const r = calculateLease({ ...base, startDate: '2025-01-01' });
|
|
48
|
+
expect(r.blueprint.steps[1].action).toBe('fixed-asset');
|
|
49
|
+
});
|
|
50
|
+
it('blueprint has correct step count (1 recognition + 1 FA + N payments)', () => {
|
|
51
|
+
const r = calculateLease({ ...base, startDate: '2025-01-01' });
|
|
52
|
+
expect(r.blueprint.steps).toHaveLength(38); // 1 + 1 + 36
|
|
53
|
+
});
|
|
54
|
+
it('blueprint null without startDate', () => {
|
|
55
|
+
const r = calculateLease(base);
|
|
56
|
+
expect(r.blueprint).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
it('capsuleDescription contains IFRS 16', () => {
|
|
59
|
+
const r = calculateLease({ ...base, startDate: '2025-01-01' });
|
|
60
|
+
expect(r.blueprint.capsuleDescription).toContain('IFRS 16');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
describe('calculateLease (hire purchase)', () => {
|
|
64
|
+
it('isHirePurchase is true when usefulLifeMonths provided', () => {
|
|
65
|
+
const r = calculateLease({ monthlyPayment: 5000, termMonths: 36, annualRate: 5, usefulLifeMonths: 60 });
|
|
66
|
+
expect(r.isHirePurchase).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
it('depreciation months = useful life (not term)', () => {
|
|
69
|
+
const r = calculateLease({ monthlyPayment: 5000, termMonths: 36, annualRate: 5, usefulLifeMonths: 60 });
|
|
70
|
+
expect(r.depreciationMonths).toBe(60);
|
|
71
|
+
});
|
|
72
|
+
it('treats usefulLifeMonths == termMonths as HP', () => {
|
|
73
|
+
const r = calculateLease({ monthlyPayment: 5000, termMonths: 36, annualRate: 5, usefulLifeMonths: 36 });
|
|
74
|
+
expect(r.isHirePurchase).toBe(true);
|
|
75
|
+
expect(r.depreciationMonths).toBe(36);
|
|
76
|
+
});
|
|
77
|
+
it('rejects usefulLifeMonths < termMonths', () => {
|
|
78
|
+
expect(() => calculateLease({
|
|
79
|
+
monthlyPayment: 5000, termMonths: 36, annualRate: 5, usefulLifeMonths: 24,
|
|
80
|
+
})).toThrow(CalcValidationError);
|
|
81
|
+
});
|
|
82
|
+
it('HP blueprint capsuleType is Hire Purchase', () => {
|
|
83
|
+
const r = calculateLease({
|
|
84
|
+
monthlyPayment: 5000, termMonths: 36, annualRate: 5, usefulLifeMonths: 60, startDate: '2025-01-01',
|
|
85
|
+
});
|
|
86
|
+
expect(r.blueprint.capsuleType).toBe('Hire Purchase');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
describe('calculateLease (validation)', () => {
|
|
90
|
+
it('rejects negative payment', () => {
|
|
91
|
+
expect(() => calculateLease({ monthlyPayment: -5000, termMonths: 36, annualRate: 5 })).toThrow(CalcValidationError);
|
|
92
|
+
});
|
|
93
|
+
it('rejects zero term', () => {
|
|
94
|
+
expect(() => calculateLease({ monthlyPayment: 5000, termMonths: 0, annualRate: 5 })).toThrow(CalcValidationError);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { calculateLoan } from '../calc/loan.js';
|
|
3
|
+
import { CalcValidationError } from '../calc/validate.js';
|
|
4
|
+
describe('calculateLoan', () => {
|
|
5
|
+
const base = { principal: 100000, annualRate: 6, termMonths: 12 };
|
|
6
|
+
it('produces correct schedule length', () => {
|
|
7
|
+
const r = calculateLoan(base);
|
|
8
|
+
expect(r.schedule).toHaveLength(12);
|
|
9
|
+
});
|
|
10
|
+
it('final closing balance is exactly zero', () => {
|
|
11
|
+
const r = calculateLoan(base);
|
|
12
|
+
expect(r.schedule[r.schedule.length - 1].closingBalance).toBe(0);
|
|
13
|
+
});
|
|
14
|
+
it('total principal equals loan principal', () => {
|
|
15
|
+
const r = calculateLoan(base);
|
|
16
|
+
expect(r.totalPrincipal).toBe(100000);
|
|
17
|
+
});
|
|
18
|
+
it('total payments = principal + interest', () => {
|
|
19
|
+
const r = calculateLoan(base);
|
|
20
|
+
expect(r.totalPayments).toBe(r.totalPrincipal + r.totalInterest);
|
|
21
|
+
});
|
|
22
|
+
it('opening balance = previous closing balance', () => {
|
|
23
|
+
const r = calculateLoan({ ...base, termMonths: 60 });
|
|
24
|
+
for (let i = 1; i < r.schedule.length; i++) {
|
|
25
|
+
expect(r.schedule[i].openingBalance).toBe(r.schedule[i - 1].closingBalance);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
it('every journal entry is balanced (debits = credits)', () => {
|
|
29
|
+
const r = calculateLoan({ ...base, termMonths: 60 });
|
|
30
|
+
for (const row of r.schedule) {
|
|
31
|
+
const debits = row.journal.lines.reduce((s, l) => s + l.debit, 0);
|
|
32
|
+
const credits = row.journal.lines.reduce((s, l) => s + l.credit, 0);
|
|
33
|
+
expect(Math.abs(debits - credits)).toBeLessThan(0.01);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
it('all amounts are 2dp (round2 invariant)', () => {
|
|
37
|
+
const r = calculateLoan(base);
|
|
38
|
+
const round2 = (n) => Math.round(n * 100) / 100;
|
|
39
|
+
for (const row of r.schedule) {
|
|
40
|
+
expect(round2(row.interest)).toBe(row.interest);
|
|
41
|
+
expect(round2(row.principal)).toBe(row.principal);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
// Blueprint
|
|
45
|
+
it('blueprint is null when no startDate', () => {
|
|
46
|
+
const r = calculateLoan(base);
|
|
47
|
+
expect(r.blueprint).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
it('blueprint present when startDate given', () => {
|
|
50
|
+
const r = calculateLoan({ ...base, startDate: '2025-01-01' });
|
|
51
|
+
expect(r.blueprint).not.toBeNull();
|
|
52
|
+
expect(r.blueprint.capsuleDescription).toBeTruthy();
|
|
53
|
+
});
|
|
54
|
+
it('blueprint step 1 is cash-in (loan disbursement)', () => {
|
|
55
|
+
const r = calculateLoan({ ...base, startDate: '2025-01-01' });
|
|
56
|
+
expect(r.blueprint.steps[0].action).toBe('cash-in');
|
|
57
|
+
});
|
|
58
|
+
it('blueprint has correct step count (1 disbursement + N payments)', () => {
|
|
59
|
+
const r = calculateLoan({ ...base, startDate: '2025-01-01' });
|
|
60
|
+
expect(r.blueprint.steps).toHaveLength(13); // 1 + 12
|
|
61
|
+
});
|
|
62
|
+
it('currency passes through to result', () => {
|
|
63
|
+
const r = calculateLoan({ ...base, currency: 'SGD' });
|
|
64
|
+
expect(r.currency).toBe('SGD');
|
|
65
|
+
});
|
|
66
|
+
it('currency null when not provided', () => {
|
|
67
|
+
const r = calculateLoan(base);
|
|
68
|
+
expect(r.currency).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
// Validation
|
|
71
|
+
it('rejects negative principal', () => {
|
|
72
|
+
expect(() => calculateLoan({ ...base, principal: -100 })).toThrow(CalcValidationError);
|
|
73
|
+
});
|
|
74
|
+
it('rejects zero term', () => {
|
|
75
|
+
expect(() => calculateLoan({ ...base, termMonths: 0 })).toThrow(CalcValidationError);
|
|
76
|
+
});
|
|
77
|
+
it('rejects invalid date format', () => {
|
|
78
|
+
expect(() => calculateLoan({ ...base, startDate: '01-01-2025' })).toThrow(CalcValidationError);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { calculateProvision } from '../calc/provision.js';
|
|
3
|
+
import { CalcValidationError } from '../calc/validate.js';
|
|
4
|
+
describe('calculateProvision (standard)', () => {
|
|
5
|
+
const base = { amount: 500000, annualRate: 4, termMonths: 60 };
|
|
6
|
+
it('PV < nominal amount', () => {
|
|
7
|
+
const r = calculateProvision(base);
|
|
8
|
+
expect(r.presentValue).toBeLessThan(r.nominalAmount);
|
|
9
|
+
});
|
|
10
|
+
it('totalUnwinding = nominal - PV', () => {
|
|
11
|
+
const r = calculateProvision(base);
|
|
12
|
+
expect(r.totalUnwinding).toBe(Math.round((r.nominalAmount - r.presentValue) * 100) / 100);
|
|
13
|
+
});
|
|
14
|
+
it('schedule length = termMonths', () => {
|
|
15
|
+
const r = calculateProvision(base);
|
|
16
|
+
expect(r.schedule).toHaveLength(60);
|
|
17
|
+
});
|
|
18
|
+
it('final closing balance = nominal amount exactly', () => {
|
|
19
|
+
const r = calculateProvision(base);
|
|
20
|
+
expect(r.schedule[r.schedule.length - 1].closingBalance).toBe(500000);
|
|
21
|
+
});
|
|
22
|
+
it('opening balance = previous closing balance', () => {
|
|
23
|
+
const r = calculateProvision(base);
|
|
24
|
+
for (let i = 1; i < r.schedule.length; i++) {
|
|
25
|
+
expect(r.schedule[i].openingBalance).toBe(r.schedule[i - 1].closingBalance);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
it('first opening balance = PV', () => {
|
|
29
|
+
const r = calculateProvision(base);
|
|
30
|
+
expect(r.schedule[0].openingBalance).toBe(r.presentValue);
|
|
31
|
+
});
|
|
32
|
+
it('all interest amounts are positive', () => {
|
|
33
|
+
const r = calculateProvision(base);
|
|
34
|
+
for (const row of r.schedule) {
|
|
35
|
+
expect(row.interest).toBeGreaterThan(0);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
it('closing balance increases monotonically (unwinding)', () => {
|
|
39
|
+
const r = calculateProvision(base);
|
|
40
|
+
for (let i = 1; i < r.schedule.length; i++) {
|
|
41
|
+
expect(r.schedule[i].closingBalance).toBeGreaterThan(r.schedule[i - 1].openingBalance);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
describe('calculateProvision (journal entries)', () => {
|
|
46
|
+
const base = { amount: 500000, annualRate: 4, termMonths: 60 };
|
|
47
|
+
it('initial journal: debits Provision Expense, credits Provision for Obligations', () => {
|
|
48
|
+
const r = calculateProvision(base);
|
|
49
|
+
expect(r.initialJournal.lines[0].account).toBe('Provision Expense');
|
|
50
|
+
expect(r.initialJournal.lines[0].debit).toBe(r.presentValue);
|
|
51
|
+
expect(r.initialJournal.lines[1].account).toBe('Provision for Obligations');
|
|
52
|
+
expect(r.initialJournal.lines[1].credit).toBe(r.presentValue);
|
|
53
|
+
});
|
|
54
|
+
it('initial journal is balanced', () => {
|
|
55
|
+
const r = calculateProvision(base);
|
|
56
|
+
const debits = r.initialJournal.lines.reduce((s, l) => s + l.debit, 0);
|
|
57
|
+
const credits = r.initialJournal.lines.reduce((s, l) => s + l.credit, 0);
|
|
58
|
+
expect(debits).toBe(credits);
|
|
59
|
+
});
|
|
60
|
+
it('unwinding journals: debits Finance Cost, credits Provision', () => {
|
|
61
|
+
const r = calculateProvision(base);
|
|
62
|
+
for (const row of r.schedule) {
|
|
63
|
+
expect(row.journal.lines[0].account).toBe('Finance Cost \u2014 Unwinding');
|
|
64
|
+
expect(row.journal.lines[1].account).toBe('Provision for Obligations');
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
it('every unwinding journal is balanced', () => {
|
|
68
|
+
const r = calculateProvision(base);
|
|
69
|
+
for (const row of r.schedule) {
|
|
70
|
+
const debits = row.journal.lines.reduce((s, l) => s + l.debit, 0);
|
|
71
|
+
const credits = row.journal.lines.reduce((s, l) => s + l.credit, 0);
|
|
72
|
+
expect(debits).toBe(credits);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
describe('calculateProvision (blueprint)', () => {
|
|
77
|
+
it('blueprint present when startDate given', () => {
|
|
78
|
+
const r = calculateProvision({ amount: 500000, annualRate: 4, termMonths: 60, startDate: '2025-01-01' });
|
|
79
|
+
expect(r.blueprint).not.toBeNull();
|
|
80
|
+
expect(r.blueprint.capsuleDescription).toBeTruthy();
|
|
81
|
+
});
|
|
82
|
+
it('blueprint null without startDate', () => {
|
|
83
|
+
const r = calculateProvision({ amount: 500000, annualRate: 4, termMonths: 60 });
|
|
84
|
+
expect(r.blueprint).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
it('step count = 1 recognition + N unwinding + 1 settlement', () => {
|
|
87
|
+
const r = calculateProvision({ amount: 500000, annualRate: 4, termMonths: 12, startDate: '2025-01-01' });
|
|
88
|
+
expect(r.blueprint.steps).toHaveLength(14); // 1 + 12 + 1
|
|
89
|
+
});
|
|
90
|
+
it('step 1 = journal (initial recognition)', () => {
|
|
91
|
+
const r = calculateProvision({ amount: 500000, annualRate: 4, termMonths: 12, startDate: '2025-01-01' });
|
|
92
|
+
expect(r.blueprint.steps[0].action).toBe('journal');
|
|
93
|
+
});
|
|
94
|
+
it('last step = cash-out (settlement)', () => {
|
|
95
|
+
const r = calculateProvision({ amount: 500000, annualRate: 4, termMonths: 12, startDate: '2025-01-01' });
|
|
96
|
+
const lastStep = r.blueprint.steps[r.blueprint.steps.length - 1];
|
|
97
|
+
expect(lastStep.action).toBe('cash-out');
|
|
98
|
+
});
|
|
99
|
+
it('settlement amount = nominal obligation', () => {
|
|
100
|
+
const r = calculateProvision({ amount: 500000, annualRate: 4, termMonths: 12, startDate: '2025-01-01' });
|
|
101
|
+
const lastStep = r.blueprint.steps[r.blueprint.steps.length - 1];
|
|
102
|
+
const debits = lastStep.lines.reduce((s, l) => s + l.debit, 0);
|
|
103
|
+
expect(debits).toBe(500000);
|
|
104
|
+
});
|
|
105
|
+
it('capsuleType is Provisions', () => {
|
|
106
|
+
const r = calculateProvision({ amount: 500000, annualRate: 4, termMonths: 12, startDate: '2025-01-01' });
|
|
107
|
+
expect(r.blueprint.capsuleType).toBe('Provisions');
|
|
108
|
+
});
|
|
109
|
+
it('capsuleDescription contains IAS 37', () => {
|
|
110
|
+
const r = calculateProvision({ amount: 500000, annualRate: 4, termMonths: 12, startDate: '2025-01-01' });
|
|
111
|
+
expect(r.blueprint.capsuleDescription).toContain('IAS 37');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
describe('calculateProvision (currency)', () => {
|
|
115
|
+
it('currency passes through', () => {
|
|
116
|
+
const r = calculateProvision({ amount: 500000, annualRate: 4, termMonths: 12, currency: 'SGD' });
|
|
117
|
+
expect(r.currency).toBe('SGD');
|
|
118
|
+
});
|
|
119
|
+
it('currency null when not provided', () => {
|
|
120
|
+
const r = calculateProvision({ amount: 500000, annualRate: 4, termMonths: 12 });
|
|
121
|
+
expect(r.currency).toBeNull();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
describe('calculateProvision (validation)', () => {
|
|
125
|
+
it('rejects zero amount', () => {
|
|
126
|
+
expect(() => calculateProvision({ amount: 0, annualRate: 4, termMonths: 60 })).toThrow(CalcValidationError);
|
|
127
|
+
});
|
|
128
|
+
it('rejects negative rate', () => {
|
|
129
|
+
expect(() => calculateProvision({ amount: 500000, annualRate: -1, termMonths: 60 })).toThrow(CalcValidationError);
|
|
130
|
+
});
|
|
131
|
+
it('rejects zero term', () => {
|
|
132
|
+
expect(() => calculateProvision({ amount: 500000, annualRate: 4, termMonths: 0 })).toThrow(CalcValidationError);
|
|
133
|
+
});
|
|
134
|
+
it('rejects invalid date format', () => {
|
|
135
|
+
expect(() => calculateProvision({ amount: 500000, annualRate: 4, termMonths: 60, startDate: '01-01-2025' })).toThrow(CalcValidationError);
|
|
136
|
+
});
|
|
137
|
+
it('allows zero discount rate', () => {
|
|
138
|
+
const r = calculateProvision({ amount: 500000, annualRate: 0, termMonths: 12 });
|
|
139
|
+
expect(r.presentValue).toBe(500000); // no discounting
|
|
140
|
+
});
|
|
141
|
+
});
|