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.
@@ -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(cost, salvageValue, usefulLifeYears, method, monthsHeld);
86
- const netBookValue = round2(cost - accumulatedDepreciation);
87
- const gainOrLoss = round2(proceeds - netBookValue);
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 (proceeds > 0) {
92
- lines.push({ account: 'Cash / Bank Account', debit: proceeds, credit: 0 });
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: cost });
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(cost, c)} | Salvage: ${fmtAmt(salvageValue, c)} | Life: ${usefulLifeYears} years (${methodLabel})`,
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(proceeds, c)} | ${isGain ? (gainOrLoss > 0 ? `Gain: ${fmtAmt(gainOrLoss, c)}` : 'At book value (no gain/loss)') : `Loss: ${fmtAmt(Math.abs(gainOrLoss), c)}`}`,
117
- `Method: IAS 16.68 — Gain/Loss = Proceeds − NBV = ${fmtAmt(proceeds, c)} − ${fmtAmt(netBookValue, c)} = ${fmtAmt(gainOrLoss, c)}`,
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(cost, currency)} asset — ${disposalDate}`,
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
- const monthlyRate = annualRate / 100 / 12;
22
- // Compute total interest based on compounding method
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() from financial package
30
- // fv(rate, nper, pmt, pv) — returns negative, negate
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 = Math.floor(termMonths / (12 / periodsPerYear));
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 monthly accrual schedule
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: effective interest on growing carrying amount
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
- for (let i = 1; i <= termMonths; i++) {
76
+ const totalPeriods = termMonths / compIntervalMonths;
77
+ for (let p = 1; p <= totalPeriods; p++) {
73
78
  const openingBalance = round2(balance);
74
- const isFinal = i === termMonths;
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 * monthlyRate);
86
+ interest = round2(openingBalance * periodicRate);
82
87
  }
83
88
  balance = round2(openingBalance + interest);
84
89
  accruedTotal = round2(accruedTotal + interest);
85
- const date = startDate ? addMonths(startDate, i) : null;
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 — Month ${i} of ${termMonths}`,
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: i,
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(termMonths + 2, 'Fixed deposit maturity — principal + accrued interest returned to Cash', addMonths(startDate, termMonths), maturityJournal.lines),
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}`;
@@ -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 && usefulLifeMonths > termMonths;
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.6.0",
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
  }