jaz-cli 2.6.0 → 2.8.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.
Files changed (62) hide show
  1. package/assets/skills/api/SKILL.md +12 -2
  2. package/assets/skills/api/references/dependencies.md +3 -2
  3. package/assets/skills/api/references/endpoints.md +78 -0
  4. package/assets/skills/api/references/feature-glossary.md +4 -4
  5. package/assets/skills/api/references/field-map.md +17 -0
  6. package/assets/skills/api/references/full-api-surface.md +1 -1
  7. package/assets/skills/conversion/SKILL.md +1 -1
  8. package/assets/skills/jobs/SKILL.md +104 -0
  9. package/assets/skills/jobs/references/audit-prep.md +319 -0
  10. package/assets/skills/jobs/references/bank-recon.md +234 -0
  11. package/assets/skills/jobs/references/building-blocks.md +135 -0
  12. package/assets/skills/jobs/references/credit-control.md +273 -0
  13. package/assets/skills/jobs/references/fa-review.md +267 -0
  14. package/assets/skills/jobs/references/gst-vat-filing.md +250 -0
  15. package/assets/skills/jobs/references/month-end-close.md +308 -0
  16. package/assets/skills/jobs/references/payment-run.md +246 -0
  17. package/assets/skills/jobs/references/quarter-end-close.md +268 -0
  18. package/assets/skills/jobs/references/supplier-recon.md +330 -0
  19. package/assets/skills/jobs/references/year-end-close.md +341 -0
  20. package/assets/skills/transaction-recipes/SKILL.md +1 -1
  21. package/dist/__tests__/amortization.test.js +101 -0
  22. package/dist/__tests__/asset-disposal.test.js +249 -0
  23. package/dist/__tests__/blueprint.test.js +72 -0
  24. package/dist/__tests__/depreciation.test.js +125 -0
  25. package/dist/__tests__/ecl.test.js +134 -0
  26. package/dist/__tests__/fixed-deposit.test.js +214 -0
  27. package/dist/__tests__/fx-reval.test.js +115 -0
  28. package/dist/__tests__/jobs-audit-prep.test.js +125 -0
  29. package/dist/__tests__/jobs-bank-recon.test.js +108 -0
  30. package/dist/__tests__/jobs-credit-control.test.js +98 -0
  31. package/dist/__tests__/jobs-fa-review.test.js +104 -0
  32. package/dist/__tests__/jobs-gst-vat.test.js +113 -0
  33. package/dist/__tests__/jobs-month-end.test.js +162 -0
  34. package/dist/__tests__/jobs-payment-run.test.js +106 -0
  35. package/dist/__tests__/jobs-quarter-end.test.js +155 -0
  36. package/dist/__tests__/jobs-supplier-recon.test.js +115 -0
  37. package/dist/__tests__/jobs-validate.test.js +181 -0
  38. package/dist/__tests__/jobs-year-end.test.js +149 -0
  39. package/dist/__tests__/lease.test.js +96 -0
  40. package/dist/__tests__/loan.test.js +80 -0
  41. package/dist/__tests__/provision.test.js +141 -0
  42. package/dist/__tests__/validate.test.js +81 -0
  43. package/dist/calc/asset-disposal.js +17 -13
  44. package/dist/calc/fixed-deposit.js +26 -17
  45. package/dist/calc/lease.js +7 -3
  46. package/dist/commands/jobs.js +184 -0
  47. package/dist/index.js +2 -0
  48. package/dist/jobs/audit-prep.js +211 -0
  49. package/dist/jobs/bank-recon.js +163 -0
  50. package/dist/jobs/credit-control.js +126 -0
  51. package/dist/jobs/fa-review.js +121 -0
  52. package/dist/jobs/format.js +102 -0
  53. package/dist/jobs/gst-vat.js +187 -0
  54. package/dist/jobs/month-end.js +232 -0
  55. package/dist/jobs/payment-run.js +199 -0
  56. package/dist/jobs/quarter-end.js +135 -0
  57. package/dist/jobs/supplier-recon.js +132 -0
  58. package/dist/jobs/types.js +36 -0
  59. package/dist/jobs/validate.js +115 -0
  60. package/dist/jobs/year-end.js +153 -0
  61. package/dist/types/index.js +2 -1
  62. package/package.json +5 -2
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generateSupplierReconBlueprint } from '../jobs/supplier-recon.js';
3
+ describe('generateSupplierReconBlueprint (basic)', () => {
4
+ it('produces a valid JobBlueprint with correct jobType', () => {
5
+ const bp = generateSupplierReconBlueprint();
6
+ expect(bp.jobType).toBe('supplier-recon');
7
+ });
8
+ it('has 4 phases and 7 total steps', () => {
9
+ const bp = generateSupplierReconBlueprint();
10
+ expect(bp.phases).toHaveLength(4);
11
+ expect(bp.summary.totalSteps).toBe(7);
12
+ });
13
+ it('phase names are correct', () => {
14
+ const bp = generateSupplierReconBlueprint();
15
+ expect(bp.phases.map(p => p.name)).toEqual([
16
+ 'Pull AP Data',
17
+ 'Compare Against Supplier Statement',
18
+ 'Resolve Discrepancies',
19
+ 'Verification',
20
+ ]);
21
+ });
22
+ it('step categories match expectations', () => {
23
+ const bp = generateSupplierReconBlueprint();
24
+ const categories = bp.phases.flatMap(p => p.steps.map(s => s.category));
25
+ expect(categories).toEqual([
26
+ 'report', // 1 — AP aging report
27
+ 'verify', // 2 — list bills for supplier
28
+ 'verify', // 3 — match against statement
29
+ 'review', // 4 — identify mismatches
30
+ 'resolve', // 5 — create missing bills
31
+ 'adjust', // 6 — record adjustments
32
+ 'verify', // 7 — re-check AP aging
33
+ ]);
34
+ });
35
+ it('summary has non-empty apiCalls', () => {
36
+ const bp = generateSupplierReconBlueprint();
37
+ expect(bp.summary.apiCalls.length).toBeGreaterThan(0);
38
+ });
39
+ it('mode is standalone', () => {
40
+ const bp = generateSupplierReconBlueprint();
41
+ expect(bp.mode).toBe('standalone');
42
+ });
43
+ });
44
+ describe('generateSupplierReconBlueprint (defaults)', () => {
45
+ it('default currency is SGD', () => {
46
+ const bp = generateSupplierReconBlueprint();
47
+ expect(bp.currency).toBe('SGD');
48
+ });
49
+ it('default period is current', () => {
50
+ const bp = generateSupplierReconBlueprint();
51
+ expect(bp.period).toBe('current');
52
+ });
53
+ });
54
+ describe('generateSupplierReconBlueprint (options)', () => {
55
+ it('custom currency works', () => {
56
+ const bp = generateSupplierReconBlueprint({ currency: 'PHP' });
57
+ expect(bp.currency).toBe('PHP');
58
+ });
59
+ it('supplier filter is applied to bills search', () => {
60
+ const bp = generateSupplierReconBlueprint({ supplier: 'Acme Corp' });
61
+ const step2 = bp.phases[0].steps[1];
62
+ const filters = step2.apiBody.filters;
63
+ expect(filters).toHaveProperty('supplierName', 'Acme Corp');
64
+ });
65
+ it('supplier filter absent by default', () => {
66
+ const bp = generateSupplierReconBlueprint();
67
+ const step2 = bp.phases[0].steps[1];
68
+ const filters = step2.apiBody.filters;
69
+ expect(filters).not.toHaveProperty('supplierName');
70
+ });
71
+ it('supplier is reflected in AP aging notes', () => {
72
+ const bp = generateSupplierReconBlueprint({ supplier: 'Acme Corp' });
73
+ const step1 = bp.phases[0].steps[0];
74
+ expect(step1.notes).toContain('Acme Corp');
75
+ });
76
+ it('supplier is reflected in verification notes', () => {
77
+ const bp = generateSupplierReconBlueprint({ supplier: 'Acme Corp' });
78
+ const verifyStep = bp.phases[3].steps[0];
79
+ expect(verifyStep.notes).toContain('Acme Corp');
80
+ });
81
+ it('period filter is applied to bills search with correct end-of-month', () => {
82
+ const bp = generateSupplierReconBlueprint({ period: '2025-06' });
83
+ expect(bp.period).toBe('2025-06');
84
+ const step2 = bp.phases[0].steps[1];
85
+ const filters = step2.apiBody.filters;
86
+ expect(filters).toHaveProperty('dateFrom', '2025-06-01');
87
+ expect(filters).toHaveProperty('dateTo', '2025-06-30');
88
+ });
89
+ it('period filter handles February correctly', () => {
90
+ const bp = generateSupplierReconBlueprint({ period: '2025-02' });
91
+ const step2 = bp.phases[0].steps[1];
92
+ const filters = step2.apiBody.filters;
93
+ expect(filters).toHaveProperty('dateTo', '2025-02-28');
94
+ });
95
+ it('period filter absent by default', () => {
96
+ const bp = generateSupplierReconBlueprint();
97
+ const step2 = bp.phases[0].steps[1];
98
+ const filters = step2.apiBody.filters;
99
+ expect(filters).not.toHaveProperty('dateFrom');
100
+ expect(filters).not.toHaveProperty('dateTo');
101
+ });
102
+ });
103
+ describe('generateSupplierReconBlueprint (summary)', () => {
104
+ it('recipeReferences include standard-bill', () => {
105
+ const bp = generateSupplierReconBlueprint();
106
+ expect(bp.summary.recipeReferences).toContain('standard-bill');
107
+ });
108
+ it('apiCalls include key endpoints', () => {
109
+ const bp = generateSupplierReconBlueprint();
110
+ expect(bp.summary.apiCalls).toContain('POST /generate-reports/ap-aging');
111
+ expect(bp.summary.apiCalls).toContain('POST /bills/search');
112
+ expect(bp.summary.apiCalls).toContain('POST /bills');
113
+ expect(bp.summary.apiCalls).toContain('POST /journals');
114
+ });
115
+ });
@@ -0,0 +1,181 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseMonthPeriod, parseQuarterPeriod, parseYearPeriod, validateDateString, validateOverdueDays, JobValidationError, } from '../jobs/validate.js';
3
+ describe('parseMonthPeriod', () => {
4
+ it('parses "2025-01" → Jan 2025 with correct dates', () => {
5
+ const r = parseMonthPeriod('2025-01');
6
+ expect(r.type).toBe('month');
7
+ expect(r.year).toBe(2025);
8
+ expect(r.month).toBe(1);
9
+ expect(r.startDate).toBe('2025-01-01');
10
+ expect(r.endDate).toBe('2025-01-31');
11
+ expect(r.label).toBe('Jan 2025');
12
+ });
13
+ it('parses "2025-12" → Dec 2025 with correct dates', () => {
14
+ const r = parseMonthPeriod('2025-12');
15
+ expect(r.type).toBe('month');
16
+ expect(r.year).toBe(2025);
17
+ expect(r.month).toBe(12);
18
+ expect(r.startDate).toBe('2025-12-01');
19
+ expect(r.endDate).toBe('2025-12-31');
20
+ expect(r.label).toBe('Dec 2025');
21
+ });
22
+ it('handles February in a leap year', () => {
23
+ const r = parseMonthPeriod('2024-02');
24
+ expect(r.endDate).toBe('2024-02-29');
25
+ });
26
+ it('handles February in a non-leap year', () => {
27
+ const r = parseMonthPeriod('2025-02');
28
+ expect(r.endDate).toBe('2025-02-28');
29
+ });
30
+ it('rejects month 13 with JobValidationError', () => {
31
+ expect(() => parseMonthPeriod('2025-13')).toThrow(JobValidationError);
32
+ });
33
+ it('rejects non-date string "abc"', () => {
34
+ expect(() => parseMonthPeriod('abc')).toThrow(JobValidationError);
35
+ });
36
+ it('rejects single-digit month "2025-1"', () => {
37
+ expect(() => parseMonthPeriod('2025-1')).toThrow(JobValidationError);
38
+ });
39
+ it('rejects month 00', () => {
40
+ expect(() => parseMonthPeriod('2025-00')).toThrow(JobValidationError);
41
+ });
42
+ });
43
+ describe('parseQuarterPeriod', () => {
44
+ it('parses "2025-Q1" → Jan-Mar 2025', () => {
45
+ const r = parseQuarterPeriod('2025-Q1');
46
+ expect(r.type).toBe('quarter');
47
+ expect(r.year).toBe(2025);
48
+ expect(r.quarter).toBe(1);
49
+ expect(r.startDate).toBe('2025-01-01');
50
+ expect(r.endDate).toBe('2025-03-31');
51
+ expect(r.label).toBe('Q1 2025');
52
+ expect(r.months).toHaveLength(3);
53
+ expect(r.months[0].month).toBe(1);
54
+ expect(r.months[1].month).toBe(2);
55
+ expect(r.months[2].month).toBe(3);
56
+ });
57
+ it('parses "2025-Q4" → Oct-Dec 2025', () => {
58
+ const r = parseQuarterPeriod('2025-Q4');
59
+ expect(r.type).toBe('quarter');
60
+ expect(r.year).toBe(2025);
61
+ expect(r.quarter).toBe(4);
62
+ expect(r.startDate).toBe('2025-10-01');
63
+ expect(r.endDate).toBe('2025-12-31');
64
+ expect(r.label).toBe('Q4 2025');
65
+ expect(r.months).toHaveLength(3);
66
+ expect(r.months[0].month).toBe(10);
67
+ expect(r.months[1].month).toBe(11);
68
+ expect(r.months[2].month).toBe(12);
69
+ });
70
+ it('parses "2025-Q2" → Apr-Jun 2025', () => {
71
+ const r = parseQuarterPeriod('2025-Q2');
72
+ expect(r.startDate).toBe('2025-04-01');
73
+ expect(r.endDate).toBe('2025-06-30');
74
+ });
75
+ it('is case-insensitive for the Q prefix', () => {
76
+ const r = parseQuarterPeriod('2025-q3');
77
+ expect(r.quarter).toBe(3);
78
+ expect(r.startDate).toBe('2025-07-01');
79
+ });
80
+ it('rejects Q5 with JobValidationError', () => {
81
+ expect(() => parseQuarterPeriod('2025-Q5')).toThrow(JobValidationError);
82
+ });
83
+ it('rejects Q0 with JobValidationError', () => {
84
+ expect(() => parseQuarterPeriod('2025-q0')).toThrow(JobValidationError);
85
+ });
86
+ it('rejects invalid format "2025Q1"', () => {
87
+ expect(() => parseQuarterPeriod('2025Q1')).toThrow(JobValidationError);
88
+ });
89
+ it('each month in the quarter has correct start and end dates', () => {
90
+ const r = parseQuarterPeriod('2025-Q1');
91
+ expect(r.months[0].startDate).toBe('2025-01-01');
92
+ expect(r.months[0].endDate).toBe('2025-01-31');
93
+ expect(r.months[1].startDate).toBe('2025-02-01');
94
+ expect(r.months[1].endDate).toBe('2025-02-28');
95
+ expect(r.months[2].startDate).toBe('2025-03-01');
96
+ expect(r.months[2].endDate).toBe('2025-03-31');
97
+ });
98
+ });
99
+ describe('parseYearPeriod', () => {
100
+ it('parses "2025" → full year Jan-Dec with 4 quarters', () => {
101
+ const r = parseYearPeriod('2025');
102
+ expect(r.type).toBe('year');
103
+ expect(r.year).toBe(2025);
104
+ expect(r.startDate).toBe('2025-01-01');
105
+ expect(r.endDate).toBe('2025-12-31');
106
+ expect(r.label).toBe('FY2025');
107
+ expect(r.quarters).toHaveLength(4);
108
+ });
109
+ it('quarters span the correct months', () => {
110
+ const r = parseYearPeriod('2025');
111
+ expect(r.quarters[0].startDate).toBe('2025-01-01');
112
+ expect(r.quarters[0].endDate).toBe('2025-03-31');
113
+ expect(r.quarters[1].startDate).toBe('2025-04-01');
114
+ expect(r.quarters[1].endDate).toBe('2025-06-30');
115
+ expect(r.quarters[2].startDate).toBe('2025-07-01');
116
+ expect(r.quarters[2].endDate).toBe('2025-09-30');
117
+ expect(r.quarters[3].startDate).toBe('2025-10-01');
118
+ expect(r.quarters[3].endDate).toBe('2025-12-31');
119
+ });
120
+ it('each quarter has 3 months', () => {
121
+ const r = parseYearPeriod('2025');
122
+ for (const q of r.quarters) {
123
+ expect(q.months).toHaveLength(3);
124
+ }
125
+ });
126
+ it('rejects non-numeric string "abcd"', () => {
127
+ expect(() => parseYearPeriod('abcd')).toThrow(JobValidationError);
128
+ });
129
+ it('rejects partial year "25"', () => {
130
+ expect(() => parseYearPeriod('25')).toThrow(JobValidationError);
131
+ });
132
+ it('rejects YYYY-MM format "2025-01"', () => {
133
+ expect(() => parseYearPeriod('2025-01')).toThrow(JobValidationError);
134
+ });
135
+ });
136
+ describe('validateDateString', () => {
137
+ it('accepts a valid date "2025-06-15"', () => {
138
+ expect(() => validateDateString('2025-06-15', 'testDate')).not.toThrow();
139
+ });
140
+ it('accepts undefined (optional field)', () => {
141
+ expect(() => validateDateString(undefined, 'testDate')).not.toThrow();
142
+ });
143
+ it('rejects "not-a-date"', () => {
144
+ expect(() => validateDateString('not-a-date', 'testDate')).toThrow(JobValidationError);
145
+ });
146
+ it('rejects invalid month "2025-13-01"', () => {
147
+ expect(() => validateDateString('2025-13-01', 'testDate')).toThrow(JobValidationError);
148
+ });
149
+ it('rejects impossible calendar date "2025-02-31"', () => {
150
+ expect(() => validateDateString('2025-02-31', 'testDate')).toThrow(JobValidationError);
151
+ });
152
+ it('rejects impossible calendar date "2025-06-31"', () => {
153
+ expect(() => validateDateString('2025-06-31', 'testDate')).toThrow(JobValidationError);
154
+ });
155
+ it('rejects wrong format "06/15/2025"', () => {
156
+ expect(() => validateDateString('06/15/2025', 'testDate')).toThrow(JobValidationError);
157
+ });
158
+ it('error message includes the field name', () => {
159
+ expect(() => validateDateString('bad', 'myField')).toThrow(/myField/);
160
+ });
161
+ });
162
+ describe('validateOverdueDays', () => {
163
+ it('accepts a valid positive integer 30', () => {
164
+ expect(() => validateOverdueDays(30)).not.toThrow();
165
+ });
166
+ it('accepts 1 (minimum valid)', () => {
167
+ expect(() => validateOverdueDays(1)).not.toThrow();
168
+ });
169
+ it('rejects 0', () => {
170
+ expect(() => validateOverdueDays(0)).toThrow(JobValidationError);
171
+ });
172
+ it('rejects negative values', () => {
173
+ expect(() => validateOverdueDays(-1)).toThrow(JobValidationError);
174
+ });
175
+ it('rejects non-integer 1.5', () => {
176
+ expect(() => validateOverdueDays(1.5)).toThrow(JobValidationError);
177
+ });
178
+ it('rejects non-integer 0.5', () => {
179
+ expect(() => validateOverdueDays(0.5)).toThrow(JobValidationError);
180
+ });
181
+ });
@@ -0,0 +1,149 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generateYearEndBlueprint } from '../jobs/year-end.js';
3
+ import { JobValidationError } from '../jobs/validate.js';
4
+ describe('generateYearEndBlueprint', () => {
5
+ // Standalone mode (default)
6
+ describe('standalone mode', () => {
7
+ const base = { period: '2025' };
8
+ it('includes all quarterly phases plus annual extras and verification', () => {
9
+ const r = generateYearEndBlueprint(base);
10
+ // 4 quarters x 17 phases each (15 month-end + 2 quarterly) = 68
11
+ // + Phase 8 (annual extras) + Phase 9 (annual verification) = 70
12
+ expect(r.phases.length).toBeGreaterThan(0);
13
+ // Last two phases are annual extras and verification
14
+ const last = r.phases[r.phases.length - 1];
15
+ const secondLast = r.phases[r.phases.length - 2];
16
+ expect(secondLast.name).toBe('Phase 8: Annual Extras');
17
+ expect(last.name).toBe('Phase 9: Annual Verification');
18
+ });
19
+ it('has correct jobType', () => {
20
+ const r = generateYearEndBlueprint(base);
21
+ expect(r.jobType).toBe('year-end-close');
22
+ });
23
+ it('mode is standalone', () => {
24
+ const r = generateYearEndBlueprint(base);
25
+ expect(r.mode).toBe('standalone');
26
+ });
27
+ it('period label is "FY2025"', () => {
28
+ const r = generateYearEndBlueprint(base);
29
+ expect(r.period).toBe('FY2025');
30
+ });
31
+ it('currency defaults to "SGD"', () => {
32
+ const r = generateYearEndBlueprint(base);
33
+ expect(r.currency).toBe('SGD');
34
+ });
35
+ it('phase names are prefixed with quarter labels', () => {
36
+ const r = generateYearEndBlueprint(base);
37
+ // First batch of phases should contain Q1 prefix
38
+ expect(r.phases[0].name).toContain('Q1 2025');
39
+ });
40
+ it('annual extras has 7 steps', () => {
41
+ const r = generateYearEndBlueprint(base);
42
+ const extras = r.phases[r.phases.length - 2];
43
+ expect(extras.name).toBe('Phase 8: Annual Extras');
44
+ expect(extras.steps).toHaveLength(7);
45
+ });
46
+ it('annual verification has 3 steps', () => {
47
+ const r = generateYearEndBlueprint(base);
48
+ const verification = r.phases[r.phases.length - 1];
49
+ expect(verification.name).toBe('Phase 9: Annual Verification');
50
+ expect(verification.steps).toHaveLength(3);
51
+ });
52
+ it('annual extras ordering continues from last quarterly max order', () => {
53
+ const r = generateYearEndBlueprint(base);
54
+ // Each quarter ends with quarterly verification at order 26
55
+ // Annual extras starts at 27
56
+ const extras = r.phases[r.phases.length - 2];
57
+ expect(extras.steps[0].order).toBe(27);
58
+ });
59
+ it('annual verification follows annual extras ordering', () => {
60
+ const r = generateYearEndBlueprint(base);
61
+ const extras = r.phases[r.phases.length - 2];
62
+ const verification = r.phases[r.phases.length - 1];
63
+ const lastExtraOrder = extras.steps[extras.steps.length - 1].order;
64
+ expect(verification.steps[0].order).toBe(lastExtraOrder + 1);
65
+ });
66
+ it('summary totalSteps matches actual step count', () => {
67
+ const r = generateYearEndBlueprint(base);
68
+ const actual = r.phases.reduce((sum, p) => sum + p.steps.length, 0);
69
+ expect(r.summary.totalSteps).toBe(actual);
70
+ });
71
+ it('summary includes recipe references from monthly, quarterly, and annual steps', () => {
72
+ const r = generateYearEndBlueprint(base);
73
+ // Monthly recipe
74
+ expect(r.summary.recipeReferences).toContain('accrued-expenses');
75
+ // Quarterly recipe
76
+ expect(r.summary.recipeReferences).toContain('intercompany');
77
+ // Annual recipe
78
+ expect(r.summary.recipeReferences).toContain('dividend');
79
+ });
80
+ it('summary recipe references are sorted', () => {
81
+ const r = generateYearEndBlueprint(base);
82
+ const sorted = [...r.summary.recipeReferences].sort();
83
+ expect(r.summary.recipeReferences).toEqual(sorted);
84
+ });
85
+ it('annual extras API calls use full year dates', () => {
86
+ const r = generateYearEndBlueprint(base);
87
+ const extras = r.phases[r.phases.length - 2];
88
+ const gstStep = extras.steps.find(s => s.description.includes('GST'));
89
+ expect(gstStep).toBeDefined();
90
+ expect(gstStep.apiBody.startDate).toBe('2025-01-01');
91
+ expect(gstStep.apiBody.endDate).toBe('2025-12-31');
92
+ });
93
+ it('annual verification reports use full year dates', () => {
94
+ const r = generateYearEndBlueprint(base);
95
+ const verification = r.phases[r.phases.length - 1];
96
+ const trialBalance = verification.steps[0];
97
+ expect(trialBalance.apiBody.startDate).toBe('2025-01-01');
98
+ expect(trialBalance.apiBody.endDate).toBe('2025-12-31');
99
+ });
100
+ });
101
+ // Incremental mode
102
+ describe('incremental mode', () => {
103
+ const base = { period: '2025', incremental: true };
104
+ it('has only annual extras and verification (no quarter/month phases)', () => {
105
+ const r = generateYearEndBlueprint(base);
106
+ expect(r.phases).toHaveLength(2);
107
+ expect(r.phases[0].name).toBe('Phase 8: Annual Extras');
108
+ expect(r.phases[1].name).toBe('Phase 9: Annual Verification');
109
+ });
110
+ it('mode is incremental', () => {
111
+ const r = generateYearEndBlueprint(base);
112
+ expect(r.mode).toBe('incremental');
113
+ });
114
+ it('step ordering starts at 1 for extras', () => {
115
+ const r = generateYearEndBlueprint(base);
116
+ expect(r.phases[0].steps[0].order).toBe(1);
117
+ });
118
+ it('step ordering is continuous across extras and verification', () => {
119
+ const r = generateYearEndBlueprint(base);
120
+ const allOrders = r.phases.flatMap(p => p.steps.map(s => s.order));
121
+ for (let i = 0; i < allOrders.length; i++) {
122
+ expect(allOrders[i]).toBe(i + 1);
123
+ }
124
+ });
125
+ it('total steps = 7 extras + 3 verification = 10', () => {
126
+ const r = generateYearEndBlueprint(base);
127
+ expect(r.summary.totalSteps).toBe(10);
128
+ });
129
+ it('annual extras has 7 steps', () => {
130
+ const r = generateYearEndBlueprint(base);
131
+ expect(r.phases[0].steps).toHaveLength(7);
132
+ });
133
+ });
134
+ // Custom currency
135
+ it('custom currency "MYR" passes through', () => {
136
+ const r = generateYearEndBlueprint({ period: '2025', currency: 'MYR' });
137
+ expect(r.currency).toBe('MYR');
138
+ });
139
+ // Validation
140
+ it('invalid period throws JobValidationError', () => {
141
+ expect(() => generateYearEndBlueprint({ period: 'bad' })).toThrow(JobValidationError);
142
+ });
143
+ it('"2025-Q1" is not a valid year period', () => {
144
+ expect(() => generateYearEndBlueprint({ period: '2025-Q1' })).toThrow(JobValidationError);
145
+ });
146
+ it('"abcd" throws JobValidationError', () => {
147
+ expect(() => generateYearEndBlueprint({ period: 'abcd' })).toThrow(JobValidationError);
148
+ });
149
+ });
@@ -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
+ });