jaz-cli 2.6.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 +4 -4
- 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 +1 -1
- 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/asset-disposal.js +17 -13
- package/dist/calc/fixed-deposit.js +26 -17
- package/dist/calc/lease.js +7 -3
- package/package.json +5 -2
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|