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,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
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { CalcValidationError, validatePositive, validateNonNegative, validatePositiveInteger, validateSalvageLessThanCost, validateDateFormat, validateRate, } from '../calc/validate.js';
|
|
3
|
+
describe('validatePositive', () => {
|
|
4
|
+
it('passes for positive values', () => {
|
|
5
|
+
expect(() => validatePositive(1, 'x')).not.toThrow();
|
|
6
|
+
expect(() => validatePositive(0.01, 'x')).not.toThrow();
|
|
7
|
+
expect(() => validatePositive(1_000_000, 'x')).not.toThrow();
|
|
8
|
+
});
|
|
9
|
+
it('rejects zero', () => {
|
|
10
|
+
expect(() => validatePositive(0, 'Amount')).toThrow(CalcValidationError);
|
|
11
|
+
expect(() => validatePositive(0, 'Amount')).toThrow('positive number');
|
|
12
|
+
});
|
|
13
|
+
it('rejects negative', () => {
|
|
14
|
+
expect(() => validatePositive(-5, 'Principal')).toThrow(CalcValidationError);
|
|
15
|
+
});
|
|
16
|
+
it('rejects NaN and Infinity', () => {
|
|
17
|
+
expect(() => validatePositive(NaN, 'x')).toThrow(CalcValidationError);
|
|
18
|
+
expect(() => validatePositive(Infinity, 'x')).toThrow(CalcValidationError);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
describe('validateNonNegative', () => {
|
|
22
|
+
it('passes for zero and positive', () => {
|
|
23
|
+
expect(() => validateNonNegative(0, 'x')).not.toThrow();
|
|
24
|
+
expect(() => validateNonNegative(100, 'x')).not.toThrow();
|
|
25
|
+
});
|
|
26
|
+
it('rejects negative', () => {
|
|
27
|
+
expect(() => validateNonNegative(-1, 'Salvage')).toThrow(CalcValidationError);
|
|
28
|
+
expect(() => validateNonNegative(-1, 'Salvage')).toThrow('zero or positive');
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
describe('validatePositiveInteger', () => {
|
|
32
|
+
it('passes for positive integers', () => {
|
|
33
|
+
expect(() => validatePositiveInteger(1, 'x')).not.toThrow();
|
|
34
|
+
expect(() => validatePositiveInteger(60, 'x')).not.toThrow();
|
|
35
|
+
});
|
|
36
|
+
it('rejects zero', () => {
|
|
37
|
+
expect(() => validatePositiveInteger(0, 'Term')).toThrow(CalcValidationError);
|
|
38
|
+
});
|
|
39
|
+
it('rejects non-integers', () => {
|
|
40
|
+
expect(() => validatePositiveInteger(1.5, 'Term')).toThrow(CalcValidationError);
|
|
41
|
+
});
|
|
42
|
+
it('rejects negative integers', () => {
|
|
43
|
+
expect(() => validatePositiveInteger(-3, 'Term')).toThrow(CalcValidationError);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
describe('validateSalvageLessThanCost', () => {
|
|
47
|
+
it('passes when salvage < cost', () => {
|
|
48
|
+
expect(() => validateSalvageLessThanCost(5000, 50000)).not.toThrow();
|
|
49
|
+
});
|
|
50
|
+
it('rejects when salvage >= cost', () => {
|
|
51
|
+
expect(() => validateSalvageLessThanCost(50000, 50000)).toThrow(CalcValidationError);
|
|
52
|
+
expect(() => validateSalvageLessThanCost(60000, 50000)).toThrow(CalcValidationError);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
describe('validateDateFormat', () => {
|
|
56
|
+
it('passes for valid YYYY-MM-DD', () => {
|
|
57
|
+
expect(() => validateDateFormat('2025-01-01')).not.toThrow();
|
|
58
|
+
expect(() => validateDateFormat('2025-12-31')).not.toThrow();
|
|
59
|
+
});
|
|
60
|
+
it('passes for undefined (optional)', () => {
|
|
61
|
+
expect(() => validateDateFormat(undefined)).not.toThrow();
|
|
62
|
+
});
|
|
63
|
+
it('rejects wrong format', () => {
|
|
64
|
+
expect(() => validateDateFormat('01-01-2025')).toThrow(CalcValidationError);
|
|
65
|
+
expect(() => validateDateFormat('2025/01/01')).toThrow(CalcValidationError);
|
|
66
|
+
expect(() => validateDateFormat('Jan 1 2025')).toThrow(CalcValidationError);
|
|
67
|
+
});
|
|
68
|
+
it('rejects invalid dates', () => {
|
|
69
|
+
expect(() => validateDateFormat('2025-13-01')).toThrow(CalcValidationError);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
describe('validateRate', () => {
|
|
73
|
+
it('passes for normal rates', () => {
|
|
74
|
+
expect(() => validateRate(0, 'Rate')).not.toThrow();
|
|
75
|
+
expect(() => validateRate(6, 'Rate')).not.toThrow();
|
|
76
|
+
expect(() => validateRate(100, 'Rate')).not.toThrow();
|
|
77
|
+
});
|
|
78
|
+
it('rejects negative rates', () => {
|
|
79
|
+
expect(() => validateRate(-1, 'Rate')).toThrow(CalcValidationError);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -77,19 +77,23 @@ export function calculateAssetDisposal(inputs) {
|
|
|
77
77
|
validateNonNegative(proceeds, 'Disposal proceeds');
|
|
78
78
|
validateDateFormat(acquisitionDate);
|
|
79
79
|
validateDateFormat(disposalDate);
|
|
80
|
+
// Normalize monetary inputs to 2dp to guarantee balanced journals
|
|
81
|
+
const cost2 = round2(cost);
|
|
82
|
+
const salvage2 = round2(salvageValue);
|
|
83
|
+
const proceeds2 = round2(proceeds);
|
|
80
84
|
// Validate disposal is after acquisition
|
|
81
85
|
if (disposalDate <= acquisitionDate) {
|
|
82
86
|
throw new CalcValidationError('Disposal date must be after acquisition date.');
|
|
83
87
|
}
|
|
84
88
|
const monthsHeld = monthsBetween(acquisitionDate, disposalDate);
|
|
85
|
-
const accumulatedDepreciation = computeAccumDepreciation(
|
|
86
|
-
const netBookValue = round2(
|
|
87
|
-
const gainOrLoss = round2(
|
|
89
|
+
const accumulatedDepreciation = computeAccumDepreciation(cost2, salvage2, usefulLifeYears, method, monthsHeld);
|
|
90
|
+
const netBookValue = round2(cost2 - accumulatedDepreciation);
|
|
91
|
+
const gainOrLoss = round2(proceeds2 - netBookValue);
|
|
88
92
|
const isGain = gainOrLoss >= 0;
|
|
89
93
|
// Build disposal journal entry
|
|
90
94
|
const lines = [];
|
|
91
|
-
if (
|
|
92
|
-
lines.push({ account: 'Cash / Bank Account', debit:
|
|
95
|
+
if (proceeds2 > 0) {
|
|
96
|
+
lines.push({ account: 'Cash / Bank Account', debit: proceeds2, credit: 0 });
|
|
93
97
|
}
|
|
94
98
|
if (accumulatedDepreciation > 0) {
|
|
95
99
|
lines.push({ account: 'Accumulated Depreciation', debit: accumulatedDepreciation, credit: 0 });
|
|
@@ -97,7 +101,7 @@ export function calculateAssetDisposal(inputs) {
|
|
|
97
101
|
if (!isGain) {
|
|
98
102
|
lines.push({ account: 'Loss on Disposal', debit: Math.abs(gainOrLoss), credit: 0 });
|
|
99
103
|
}
|
|
100
|
-
lines.push({ account: 'Fixed Asset (at cost)', debit: 0, credit:
|
|
104
|
+
lines.push({ account: 'Fixed Asset (at cost)', debit: 0, credit: cost2 });
|
|
101
105
|
if (isGain && gainOrLoss > 0) {
|
|
102
106
|
lines.push({ account: 'Gain on Disposal', debit: 0, credit: gainOrLoss });
|
|
103
107
|
}
|
|
@@ -110,16 +114,16 @@ export function calculateAssetDisposal(inputs) {
|
|
|
110
114
|
const methodLabel = method === 'sl' ? 'Straight-line' : method === 'ddb' ? 'Double declining' : '150% declining';
|
|
111
115
|
const workings = [
|
|
112
116
|
`Asset Disposal Workings (IAS 16)`,
|
|
113
|
-
`Cost: ${fmtAmt(
|
|
117
|
+
`Cost: ${fmtAmt(cost2, c)} | Salvage: ${fmtAmt(salvage2, c)} | Life: ${usefulLifeYears} years (${methodLabel})`,
|
|
114
118
|
`Acquired: ${acquisitionDate} | Disposed: ${disposalDate} | Held: ${monthsHeld} months`,
|
|
115
119
|
`Accumulated depreciation: ${fmtAmt(accumulatedDepreciation, c)} | NBV: ${fmtAmt(netBookValue, c)}`,
|
|
116
|
-
`Proceeds: ${fmtAmt(
|
|
117
|
-
`Method: IAS 16.68 — Gain/Loss = Proceeds − NBV = ${fmtAmt(
|
|
120
|
+
`Proceeds: ${fmtAmt(proceeds2, c)} | ${isGain ? (gainOrLoss > 0 ? `Gain: ${fmtAmt(gainOrLoss, c)}` : 'At book value (no gain/loss)') : `Loss: ${fmtAmt(Math.abs(gainOrLoss), c)}`}`,
|
|
121
|
+
`Method: IAS 16.68 — Gain/Loss = Proceeds − NBV = ${fmtAmt(proceeds2, c)} − ${fmtAmt(netBookValue, c)} = ${fmtAmt(gainOrLoss, c)}`,
|
|
118
122
|
].join('\n');
|
|
119
123
|
let blueprint = null;
|
|
120
124
|
blueprint = {
|
|
121
125
|
capsuleType: 'Asset Disposal',
|
|
122
|
-
capsuleName: `Asset Disposal — ${fmtCapsuleAmount(
|
|
126
|
+
capsuleName: `Asset Disposal — ${fmtCapsuleAmount(cost2, currency)} asset — ${disposalDate}`,
|
|
123
127
|
capsuleDescription: workings,
|
|
124
128
|
tags: ['Asset Disposal'],
|
|
125
129
|
customFields: { 'Asset Description': null },
|
|
@@ -132,12 +136,12 @@ export function calculateAssetDisposal(inputs) {
|
|
|
132
136
|
type: 'asset-disposal',
|
|
133
137
|
currency: currency ?? null,
|
|
134
138
|
inputs: {
|
|
135
|
-
cost,
|
|
136
|
-
salvageValue,
|
|
139
|
+
cost: cost2,
|
|
140
|
+
salvageValue: salvage2,
|
|
137
141
|
usefulLifeYears,
|
|
138
142
|
acquisitionDate,
|
|
139
143
|
disposalDate,
|
|
140
|
-
proceeds,
|
|
144
|
+
proceeds: proceeds2,
|
|
141
145
|
method,
|
|
142
146
|
},
|
|
143
147
|
monthsHeld,
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { fv } from 'financial';
|
|
12
12
|
import { round2, addMonths } from './types.js';
|
|
13
|
-
import { validatePositive, validatePositiveInteger, validateDateFormat, validateRate } from './validate.js';
|
|
13
|
+
import { CalcValidationError, validatePositive, validatePositiveInteger, validateDateFormat, validateRate } from './validate.js';
|
|
14
14
|
import { journalStep, cashOutStep, cashInStep, fmtCapsuleAmount, fmtAmt } from './blueprint.js';
|
|
15
15
|
export function calculateFixedDeposit(inputs) {
|
|
16
16
|
const { principal, annualRate, termMonths, compounding = 'none', startDate, currency, } = inputs;
|
|
@@ -18,26 +18,28 @@ export function calculateFixedDeposit(inputs) {
|
|
|
18
18
|
validateRate(annualRate, 'Annual rate');
|
|
19
19
|
validatePositiveInteger(termMonths, 'Term (months)');
|
|
20
20
|
validateDateFormat(startDate);
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
// Compound interval validation — term must align with compounding frequency
|
|
22
|
+
const compIntervalMonths = compounding === 'monthly' ? 1 : compounding === 'quarterly' ? 3 : compounding === 'annually' ? 12 : 0;
|
|
23
|
+
if (compounding !== 'none' && termMonths % compIntervalMonths !== 0) {
|
|
24
|
+
throw new CalcValidationError(`Term (${termMonths} months) must be a multiple of ${compIntervalMonths} for ${compounding} compounding.`);
|
|
25
|
+
}
|
|
26
|
+
// Compute maturity value
|
|
23
27
|
let maturityValue;
|
|
24
28
|
if (compounding === 'none') {
|
|
25
29
|
// Simple interest: Principal × Rate × Time
|
|
26
30
|
maturityValue = round2(principal + principal * (annualRate / 100) * (termMonths / 12));
|
|
27
31
|
}
|
|
28
32
|
else {
|
|
29
|
-
// Compound interest using fv()
|
|
30
|
-
|
|
31
|
-
const periodsPerYear = compounding === 'monthly' ? 12 : compounding === 'quarterly' ? 4 : 1;
|
|
33
|
+
// Compound interest using fv() — one consistent periodic rate
|
|
34
|
+
const periodsPerYear = 12 / compIntervalMonths;
|
|
32
35
|
const compoundRate = annualRate / 100 / periodsPerYear;
|
|
33
|
-
const totalCompoundPeriods =
|
|
34
|
-
// fv() with negative pv (outflow) returns positive (inflow at maturity)
|
|
36
|
+
const totalCompoundPeriods = termMonths / compIntervalMonths;
|
|
35
37
|
maturityValue = round2(fv(compoundRate, totalCompoundPeriods, 0, -principal));
|
|
36
38
|
}
|
|
37
39
|
const totalInterest = round2(maturityValue - principal);
|
|
38
40
|
// Effective annual rate (for display)
|
|
39
41
|
const effectiveRate = round2((Math.pow(maturityValue / principal, 12 / termMonths) - 1) * 100 * 100) / 100;
|
|
40
|
-
// Build
|
|
42
|
+
// Build accrual schedule — accrue at compound intervals using the same periodic rate
|
|
41
43
|
const schedule = [];
|
|
42
44
|
let accruedTotal = 0;
|
|
43
45
|
if (compounding === 'none') {
|
|
@@ -67,31 +69,38 @@ export function calculateFixedDeposit(inputs) {
|
|
|
67
69
|
}
|
|
68
70
|
}
|
|
69
71
|
else {
|
|
70
|
-
// Compound interest:
|
|
72
|
+
// Compound interest: accrue at compound intervals using the periodic rate
|
|
73
|
+
// e.g. quarterly = every 3 months, interest accrues on the growing carrying amount
|
|
74
|
+
const periodicRate = annualRate / 100 / (12 / compIntervalMonths);
|
|
71
75
|
let balance = principal;
|
|
72
|
-
|
|
76
|
+
const totalPeriods = termMonths / compIntervalMonths;
|
|
77
|
+
for (let p = 1; p <= totalPeriods; p++) {
|
|
73
78
|
const openingBalance = round2(balance);
|
|
74
|
-
const isFinal =
|
|
79
|
+
const isFinal = p === totalPeriods;
|
|
75
80
|
let interest;
|
|
76
81
|
if (isFinal) {
|
|
77
82
|
// Final period: close to exact maturity value
|
|
78
83
|
interest = round2(maturityValue - openingBalance);
|
|
79
84
|
}
|
|
80
85
|
else {
|
|
81
|
-
interest = round2(openingBalance *
|
|
86
|
+
interest = round2(openingBalance * periodicRate);
|
|
82
87
|
}
|
|
83
88
|
balance = round2(openingBalance + interest);
|
|
84
89
|
accruedTotal = round2(accruedTotal + interest);
|
|
85
|
-
const
|
|
90
|
+
const monthOffset = p * compIntervalMonths;
|
|
91
|
+
const date = startDate ? addMonths(startDate, monthOffset) : null;
|
|
92
|
+
const periodLabel = compounding === 'monthly'
|
|
93
|
+
? `Month ${p}` : compounding === 'quarterly'
|
|
94
|
+
? `Quarter ${p}` : `Year ${p}`;
|
|
86
95
|
const journal = {
|
|
87
|
-
description: `Interest accrual —
|
|
96
|
+
description: `Interest accrual — ${periodLabel} of ${totalPeriods}`,
|
|
88
97
|
lines: [
|
|
89
98
|
{ account: 'Accrued Interest Receivable', debit: interest, credit: 0 },
|
|
90
99
|
{ account: 'Interest Income', debit: 0, credit: interest },
|
|
91
100
|
],
|
|
92
101
|
};
|
|
93
102
|
schedule.push({
|
|
94
|
-
period:
|
|
103
|
+
period: p,
|
|
95
104
|
date,
|
|
96
105
|
openingBalance,
|
|
97
106
|
interest,
|
|
@@ -121,7 +130,7 @@ export function calculateFixedDeposit(inputs) {
|
|
|
121
130
|
const steps = [
|
|
122
131
|
cashOutStep(1, 'Transfer funds from operating account to fixed deposit', startDate, placementJournal.lines),
|
|
123
132
|
...schedule.map((row, idx) => journalStep(idx + 2, row.journal.description, row.date, row.journal.lines)),
|
|
124
|
-
cashInStep(
|
|
133
|
+
cashInStep(schedule.length + 2, 'Fixed deposit maturity — principal + accrued interest returned to Cash', addMonths(startDate, termMonths), maturityJournal.lines),
|
|
125
134
|
];
|
|
126
135
|
const c = currency ?? undefined;
|
|
127
136
|
const compoundLabel = compounding === 'none' ? 'Simple interest' : `Compound ${compounding}`;
|
package/dist/calc/lease.js
CHANGED
|
@@ -11,17 +11,21 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { pv } from 'financial';
|
|
13
13
|
import { round2, addMonths } from './types.js';
|
|
14
|
-
import { validatePositive, validatePositiveInteger, validateDateFormat, validateRate } from './validate.js';
|
|
14
|
+
import { CalcValidationError, validatePositive, validatePositiveInteger, validateDateFormat, validateRate } from './validate.js';
|
|
15
15
|
import { journalStep, fixedAssetStep, fmtCapsuleAmount, fmtAmt } from './blueprint.js';
|
|
16
16
|
export function calculateLease(inputs) {
|
|
17
17
|
const { monthlyPayment, termMonths, annualRate, usefulLifeMonths, startDate, currency } = inputs;
|
|
18
18
|
validatePositive(monthlyPayment, 'Monthly payment');
|
|
19
19
|
validateRate(annualRate, 'Annual rate (IBR)');
|
|
20
20
|
validatePositiveInteger(termMonths, 'Term (months)');
|
|
21
|
-
if (usefulLifeMonths !== undefined)
|
|
21
|
+
if (usefulLifeMonths !== undefined) {
|
|
22
22
|
validatePositiveInteger(usefulLifeMonths, 'Useful life (months)');
|
|
23
|
+
if (usefulLifeMonths < termMonths) {
|
|
24
|
+
throw new CalcValidationError(`Useful life (${usefulLifeMonths} months) must be >= lease term (${termMonths} months) for hire purchase.`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
23
27
|
validateDateFormat(startDate);
|
|
24
|
-
const isHirePurchase = usefulLifeMonths !== undefined
|
|
28
|
+
const isHirePurchase = usefulLifeMonths !== undefined;
|
|
25
29
|
const monthlyRate = annualRate / 100 / 12;
|
|
26
30
|
// PV of an ordinary annuity (payments at end of period)
|
|
27
31
|
// pv() returns negative, negate for positive value
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jaz-cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.0",
|
|
4
4
|
"description": "CLI to install Jaz AI skills for Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
"scripts": {
|
|
14
14
|
"build": "tsc && node --input-type=module -e \"import{readFileSync,writeFileSync}from'fs';const c=readFileSync('dist/index.js','utf8');if(!c.startsWith('#!')){writeFileSync('dist/index.js','#!/usr/bin/env node\\n'+c)}\"",
|
|
15
15
|
"dev": "node --loader ts-node/esm src/index.ts",
|
|
16
|
+
"test": "vitest run",
|
|
17
|
+
"test:watch": "vitest",
|
|
16
18
|
"prepublishOnly": "npm run build"
|
|
17
19
|
},
|
|
18
20
|
"keywords": [
|
|
@@ -45,6 +47,7 @@
|
|
|
45
47
|
"devDependencies": {
|
|
46
48
|
"@types/node": "^22.10.1",
|
|
47
49
|
"@types/prompts": "^2.4.9",
|
|
48
|
-
"typescript": "^5.7.2"
|
|
50
|
+
"typescript": "^5.7.2",
|
|
51
|
+
"vitest": "^4.0.18"
|
|
49
52
|
}
|
|
50
53
|
}
|