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.
- 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/jobs/SKILL.md +104 -0
- package/assets/skills/jobs/references/audit-prep.md +319 -0
- package/assets/skills/jobs/references/bank-recon.md +234 -0
- package/assets/skills/jobs/references/building-blocks.md +135 -0
- package/assets/skills/jobs/references/credit-control.md +273 -0
- package/assets/skills/jobs/references/fa-review.md +267 -0
- package/assets/skills/jobs/references/gst-vat-filing.md +250 -0
- package/assets/skills/jobs/references/month-end-close.md +308 -0
- package/assets/skills/jobs/references/payment-run.md +246 -0
- package/assets/skills/jobs/references/quarter-end-close.md +268 -0
- package/assets/skills/jobs/references/supplier-recon.md +330 -0
- package/assets/skills/jobs/references/year-end-close.md +341 -0
- 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__/jobs-audit-prep.test.js +125 -0
- package/dist/__tests__/jobs-bank-recon.test.js +108 -0
- package/dist/__tests__/jobs-credit-control.test.js +98 -0
- package/dist/__tests__/jobs-fa-review.test.js +104 -0
- package/dist/__tests__/jobs-gst-vat.test.js +113 -0
- package/dist/__tests__/jobs-month-end.test.js +162 -0
- package/dist/__tests__/jobs-payment-run.test.js +106 -0
- package/dist/__tests__/jobs-quarter-end.test.js +155 -0
- package/dist/__tests__/jobs-supplier-recon.test.js +115 -0
- package/dist/__tests__/jobs-validate.test.js +181 -0
- package/dist/__tests__/jobs-year-end.test.js +149 -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/dist/commands/jobs.js +184 -0
- package/dist/index.js +2 -0
- package/dist/jobs/audit-prep.js +211 -0
- package/dist/jobs/bank-recon.js +163 -0
- package/dist/jobs/credit-control.js +126 -0
- package/dist/jobs/fa-review.js +121 -0
- package/dist/jobs/format.js +102 -0
- package/dist/jobs/gst-vat.js +187 -0
- package/dist/jobs/month-end.js +232 -0
- package/dist/jobs/payment-run.js +199 -0
- package/dist/jobs/quarter-end.js +135 -0
- package/dist/jobs/supplier-recon.js +132 -0
- package/dist/jobs/types.js +36 -0
- package/dist/jobs/validate.js +115 -0
- package/dist/jobs/year-end.js +153 -0
- package/dist/types/index.js +2 -1
- package/package.json +5 -2
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { calculateFixedDeposit } from '../calc/fixed-deposit.js';
|
|
3
|
+
import { CalcValidationError } from '../calc/validate.js';
|
|
4
|
+
describe('calculateFixedDeposit (simple interest)', () => {
|
|
5
|
+
const base = { principal: 100000, annualRate: 3.5, termMonths: 12 };
|
|
6
|
+
it('maturity = principal + simple interest', () => {
|
|
7
|
+
const r = calculateFixedDeposit(base);
|
|
8
|
+
// 100000 * 3.5% * 12/12 = 3500
|
|
9
|
+
expect(r.maturityValue).toBe(103500);
|
|
10
|
+
});
|
|
11
|
+
it('totalInterest = maturity - principal', () => {
|
|
12
|
+
const r = calculateFixedDeposit(base);
|
|
13
|
+
expect(r.totalInterest).toBe(3500);
|
|
14
|
+
});
|
|
15
|
+
it('schedule has termMonths periods', () => {
|
|
16
|
+
const r = calculateFixedDeposit(base);
|
|
17
|
+
expect(r.schedule).toHaveLength(12);
|
|
18
|
+
});
|
|
19
|
+
it('equal monthly accrual (except final rounding)', () => {
|
|
20
|
+
const r = calculateFixedDeposit(base);
|
|
21
|
+
const monthlyAccrual = Math.round(3500 / 12 * 100) / 100; // 291.67
|
|
22
|
+
for (let i = 0; i < 11; i++) {
|
|
23
|
+
expect(r.schedule[i].interest).toBe(monthlyAccrual);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
it('total accrued interest = totalInterest exactly', () => {
|
|
27
|
+
const r = calculateFixedDeposit(base);
|
|
28
|
+
const totalAccrued = r.schedule.reduce((s, row) => s + row.interest, 0);
|
|
29
|
+
expect(Math.round(totalAccrued * 100) / 100).toBe(3500);
|
|
30
|
+
});
|
|
31
|
+
it('opening balance stays at principal (simple interest)', () => {
|
|
32
|
+
const r = calculateFixedDeposit(base);
|
|
33
|
+
for (const row of r.schedule) {
|
|
34
|
+
expect(row.openingBalance).toBe(100000);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
it('every journal entry is balanced', () => {
|
|
38
|
+
const r = calculateFixedDeposit(base);
|
|
39
|
+
for (const row of r.schedule) {
|
|
40
|
+
const debits = row.journal.lines.reduce((s, l) => s + l.debit, 0);
|
|
41
|
+
const credits = row.journal.lines.reduce((s, l) => s + l.credit, 0);
|
|
42
|
+
expect(debits).toBe(credits);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
it('accrual journals use Accrued Interest / Interest Income', () => {
|
|
46
|
+
const r = calculateFixedDeposit(base);
|
|
47
|
+
expect(r.schedule[0].journal.lines[0].account).toBe('Accrued Interest Receivable');
|
|
48
|
+
expect(r.schedule[0].journal.lines[1].account).toBe('Interest Income');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe('calculateFixedDeposit (compound quarterly)', () => {
|
|
52
|
+
const base = { principal: 100000, annualRate: 4, termMonths: 12, compounding: 'quarterly' };
|
|
53
|
+
it('schedule has 4 periods (quarterly)', () => {
|
|
54
|
+
const r = calculateFixedDeposit(base);
|
|
55
|
+
expect(r.schedule).toHaveLength(4);
|
|
56
|
+
});
|
|
57
|
+
it('maturity > simple interest equivalent', () => {
|
|
58
|
+
const simple = calculateFixedDeposit({ principal: 100000, annualRate: 4, termMonths: 12 });
|
|
59
|
+
const compound = calculateFixedDeposit(base);
|
|
60
|
+
expect(compound.maturityValue).toBeGreaterThan(simple.maturityValue);
|
|
61
|
+
});
|
|
62
|
+
it('final closing balance = maturity value', () => {
|
|
63
|
+
const r = calculateFixedDeposit(base);
|
|
64
|
+
expect(r.schedule[r.schedule.length - 1].closingBalance).toBe(r.maturityValue);
|
|
65
|
+
});
|
|
66
|
+
it('opening balance = previous closing balance', () => {
|
|
67
|
+
const r = calculateFixedDeposit(base);
|
|
68
|
+
for (let i = 1; i < r.schedule.length; i++) {
|
|
69
|
+
expect(r.schedule[i].openingBalance).toBe(r.schedule[i - 1].closingBalance);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
it('total accrued = totalInterest', () => {
|
|
73
|
+
const r = calculateFixedDeposit(base);
|
|
74
|
+
const totalAccrued = r.schedule.reduce((s, row) => s + row.interest, 0);
|
|
75
|
+
expect(Math.round(totalAccrued * 100) / 100).toBe(r.totalInterest);
|
|
76
|
+
});
|
|
77
|
+
it('every journal entry is balanced', () => {
|
|
78
|
+
const r = calculateFixedDeposit(base);
|
|
79
|
+
for (const row of r.schedule) {
|
|
80
|
+
const debits = row.journal.lines.reduce((s, l) => s + l.debit, 0);
|
|
81
|
+
const credits = row.journal.lines.reduce((s, l) => s + l.credit, 0);
|
|
82
|
+
expect(debits).toBe(credits);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
describe('calculateFixedDeposit (compound monthly)', () => {
|
|
87
|
+
it('monthly compound: 12 periods', () => {
|
|
88
|
+
const r = calculateFixedDeposit({
|
|
89
|
+
principal: 100000, annualRate: 4, termMonths: 12, compounding: 'monthly',
|
|
90
|
+
});
|
|
91
|
+
expect(r.schedule).toHaveLength(12);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe('calculateFixedDeposit (compound annually)', () => {
|
|
95
|
+
it('annual compound: 1 period for 12 months', () => {
|
|
96
|
+
const r = calculateFixedDeposit({
|
|
97
|
+
principal: 100000, annualRate: 4, termMonths: 12, compounding: 'annually',
|
|
98
|
+
});
|
|
99
|
+
expect(r.schedule).toHaveLength(1);
|
|
100
|
+
expect(r.maturityValue).toBe(104000); // 100000 * 1.04
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
describe('calculateFixedDeposit (placement/maturity journals)', () => {
|
|
104
|
+
const base = { principal: 100000, annualRate: 3.5, termMonths: 12 };
|
|
105
|
+
it('placement journal: debit Fixed Deposit, credit Cash', () => {
|
|
106
|
+
const r = calculateFixedDeposit(base);
|
|
107
|
+
expect(r.placementJournal.lines[0].account).toBe('Fixed Deposit');
|
|
108
|
+
expect(r.placementJournal.lines[0].debit).toBe(100000);
|
|
109
|
+
expect(r.placementJournal.lines[1].account).toBe('Cash / Bank Account');
|
|
110
|
+
expect(r.placementJournal.lines[1].credit).toBe(100000);
|
|
111
|
+
});
|
|
112
|
+
it('maturity journal: debit Cash, credit FD + Accrued Interest', () => {
|
|
113
|
+
const r = calculateFixedDeposit(base);
|
|
114
|
+
expect(r.maturityJournal.lines[0].account).toBe('Cash / Bank Account');
|
|
115
|
+
expect(r.maturityJournal.lines[0].debit).toBe(103500);
|
|
116
|
+
expect(r.maturityJournal.lines[1].account).toBe('Fixed Deposit');
|
|
117
|
+
expect(r.maturityJournal.lines[1].credit).toBe(100000);
|
|
118
|
+
expect(r.maturityJournal.lines[2].account).toBe('Accrued Interest Receivable');
|
|
119
|
+
expect(r.maturityJournal.lines[2].credit).toBe(3500);
|
|
120
|
+
});
|
|
121
|
+
it('placement journal is balanced', () => {
|
|
122
|
+
const r = calculateFixedDeposit(base);
|
|
123
|
+
const debits = r.placementJournal.lines.reduce((s, l) => s + l.debit, 0);
|
|
124
|
+
const credits = r.placementJournal.lines.reduce((s, l) => s + l.credit, 0);
|
|
125
|
+
expect(debits).toBe(credits);
|
|
126
|
+
});
|
|
127
|
+
it('maturity journal is balanced', () => {
|
|
128
|
+
const r = calculateFixedDeposit(base);
|
|
129
|
+
const debits = r.maturityJournal.lines.reduce((s, l) => s + l.debit, 0);
|
|
130
|
+
const credits = r.maturityJournal.lines.reduce((s, l) => s + l.credit, 0);
|
|
131
|
+
expect(debits).toBe(credits);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
describe('calculateFixedDeposit (blueprint)', () => {
|
|
135
|
+
it('blueprint present when startDate given', () => {
|
|
136
|
+
const r = calculateFixedDeposit({ principal: 100000, annualRate: 3.5, termMonths: 12, startDate: '2025-01-01' });
|
|
137
|
+
expect(r.blueprint).not.toBeNull();
|
|
138
|
+
expect(r.blueprint.capsuleDescription).toBeTruthy();
|
|
139
|
+
});
|
|
140
|
+
it('blueprint null without startDate', () => {
|
|
141
|
+
const r = calculateFixedDeposit({ principal: 100000, annualRate: 3.5, termMonths: 12 });
|
|
142
|
+
expect(r.blueprint).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
it('step 1 = cash-out (placement)', () => {
|
|
145
|
+
const r = calculateFixedDeposit({ principal: 100000, annualRate: 3.5, termMonths: 12, startDate: '2025-01-01' });
|
|
146
|
+
expect(r.blueprint.steps[0].action).toBe('cash-out');
|
|
147
|
+
});
|
|
148
|
+
it('last step = cash-in (maturity)', () => {
|
|
149
|
+
const r = calculateFixedDeposit({ principal: 100000, annualRate: 3.5, termMonths: 12, startDate: '2025-01-01' });
|
|
150
|
+
const lastStep = r.blueprint.steps[r.blueprint.steps.length - 1];
|
|
151
|
+
expect(lastStep.action).toBe('cash-in');
|
|
152
|
+
});
|
|
153
|
+
it('step count = 1 placement + N accruals + 1 maturity', () => {
|
|
154
|
+
const r = calculateFixedDeposit({ principal: 100000, annualRate: 3.5, termMonths: 12, startDate: '2025-01-01' });
|
|
155
|
+
expect(r.blueprint.steps).toHaveLength(14); // 1 + 12 + 1
|
|
156
|
+
});
|
|
157
|
+
it('compound quarterly: step count uses compound periods', () => {
|
|
158
|
+
const r = calculateFixedDeposit({
|
|
159
|
+
principal: 100000, annualRate: 4, termMonths: 12,
|
|
160
|
+
compounding: 'quarterly', startDate: '2025-01-01',
|
|
161
|
+
});
|
|
162
|
+
expect(r.blueprint.steps).toHaveLength(6); // 1 + 4 + 1
|
|
163
|
+
});
|
|
164
|
+
it('capsuleType is Fixed Deposit', () => {
|
|
165
|
+
const r = calculateFixedDeposit({ principal: 100000, annualRate: 3.5, termMonths: 12, startDate: '2025-01-01' });
|
|
166
|
+
expect(r.blueprint.capsuleType).toBe('Fixed Deposit');
|
|
167
|
+
});
|
|
168
|
+
it('capsuleDescription contains IFRS 9', () => {
|
|
169
|
+
const r = calculateFixedDeposit({ principal: 100000, annualRate: 3.5, termMonths: 12, startDate: '2025-01-01' });
|
|
170
|
+
expect(r.blueprint.capsuleDescription).toContain('IFRS 9');
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
describe('calculateFixedDeposit (currency)', () => {
|
|
174
|
+
it('currency passes through', () => {
|
|
175
|
+
const r = calculateFixedDeposit({ principal: 100000, annualRate: 3.5, termMonths: 12, currency: 'SGD' });
|
|
176
|
+
expect(r.currency).toBe('SGD');
|
|
177
|
+
});
|
|
178
|
+
it('currency null when not provided', () => {
|
|
179
|
+
const r = calculateFixedDeposit({ principal: 100000, annualRate: 3.5, termMonths: 12 });
|
|
180
|
+
expect(r.currency).toBeNull();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
describe('calculateFixedDeposit (validation)', () => {
|
|
184
|
+
it('rejects zero principal', () => {
|
|
185
|
+
expect(() => calculateFixedDeposit({ principal: 0, annualRate: 3.5, termMonths: 12 })).toThrow(CalcValidationError);
|
|
186
|
+
});
|
|
187
|
+
it('rejects negative rate', () => {
|
|
188
|
+
expect(() => calculateFixedDeposit({ principal: 100000, annualRate: -1, termMonths: 12 })).toThrow(CalcValidationError);
|
|
189
|
+
});
|
|
190
|
+
it('rejects zero term', () => {
|
|
191
|
+
expect(() => calculateFixedDeposit({ principal: 100000, annualRate: 3.5, termMonths: 0 })).toThrow(CalcValidationError);
|
|
192
|
+
});
|
|
193
|
+
it('rejects non-multiple term for quarterly compounding', () => {
|
|
194
|
+
expect(() => calculateFixedDeposit({
|
|
195
|
+
principal: 100000, annualRate: 4, termMonths: 7, compounding: 'quarterly',
|
|
196
|
+
})).toThrow(CalcValidationError);
|
|
197
|
+
});
|
|
198
|
+
it('rejects non-multiple term for annual compounding', () => {
|
|
199
|
+
expect(() => calculateFixedDeposit({
|
|
200
|
+
principal: 100000, annualRate: 4, termMonths: 18, compounding: 'annually',
|
|
201
|
+
})).toThrow(CalcValidationError);
|
|
202
|
+
});
|
|
203
|
+
it('allows any term for monthly compounding', () => {
|
|
204
|
+
const r = calculateFixedDeposit({
|
|
205
|
+
principal: 100000, annualRate: 4, termMonths: 7, compounding: 'monthly',
|
|
206
|
+
});
|
|
207
|
+
expect(r.schedule).toHaveLength(7);
|
|
208
|
+
});
|
|
209
|
+
it('rejects invalid date format', () => {
|
|
210
|
+
expect(() => calculateFixedDeposit({
|
|
211
|
+
principal: 100000, annualRate: 3.5, termMonths: 12, startDate: '01-01-2025',
|
|
212
|
+
})).toThrow(CalcValidationError);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { calculateFxReval } from '../calc/fx-reval.js';
|
|
3
|
+
import { CalcValidationError } from '../calc/validate.js';
|
|
4
|
+
describe('calculateFxReval (gain)', () => {
|
|
5
|
+
const base = { amount: 50000, bookRate: 1.35, closingRate: 1.38 };
|
|
6
|
+
it('bookValue = amount * bookRate', () => {
|
|
7
|
+
const r = calculateFxReval(base);
|
|
8
|
+
expect(r.bookValue).toBe(67500); // 50000 * 1.35
|
|
9
|
+
});
|
|
10
|
+
it('closingValue = amount * closingRate', () => {
|
|
11
|
+
const r = calculateFxReval(base);
|
|
12
|
+
expect(r.closingValue).toBe(69000); // 50000 * 1.38
|
|
13
|
+
});
|
|
14
|
+
it('gain = closingValue - bookValue', () => {
|
|
15
|
+
const r = calculateFxReval(base);
|
|
16
|
+
expect(r.gainOrLoss).toBe(1500);
|
|
17
|
+
expect(r.isGain).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
it('journal debits Monetary Item, credits FX Unrealized Gain', () => {
|
|
20
|
+
const r = calculateFxReval(base);
|
|
21
|
+
expect(r.journal.lines[0].account).toContain('Monetary Item');
|
|
22
|
+
expect(r.journal.lines[0].debit).toBe(1500);
|
|
23
|
+
expect(r.journal.lines[1].account).toBe('FX Unrealized Gain');
|
|
24
|
+
expect(r.journal.lines[1].credit).toBe(1500);
|
|
25
|
+
});
|
|
26
|
+
it('journal is balanced', () => {
|
|
27
|
+
const r = calculateFxReval(base);
|
|
28
|
+
const debits = r.journal.lines.reduce((s, l) => s + l.debit, 0);
|
|
29
|
+
const credits = r.journal.lines.reduce((s, l) => s + l.credit, 0);
|
|
30
|
+
expect(debits).toBe(credits);
|
|
31
|
+
});
|
|
32
|
+
it('reversal is exact negative of revaluation', () => {
|
|
33
|
+
const r = calculateFxReval(base);
|
|
34
|
+
for (let i = 0; i < r.journal.lines.length; i++) {
|
|
35
|
+
expect(r.reversalJournal.lines[i].debit).toBe(r.journal.lines[i].credit);
|
|
36
|
+
expect(r.reversalJournal.lines[i].credit).toBe(r.journal.lines[i].debit);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
it('reversal journal is balanced', () => {
|
|
40
|
+
const r = calculateFxReval(base);
|
|
41
|
+
const debits = r.reversalJournal.lines.reduce((s, l) => s + l.debit, 0);
|
|
42
|
+
const credits = r.reversalJournal.lines.reduce((s, l) => s + l.credit, 0);
|
|
43
|
+
expect(debits).toBe(credits);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
describe('calculateFxReval (loss)', () => {
|
|
47
|
+
const base = { amount: 50000, bookRate: 1.38, closingRate: 1.35 };
|
|
48
|
+
it('loss is negative gainOrLoss', () => {
|
|
49
|
+
const r = calculateFxReval(base);
|
|
50
|
+
expect(r.gainOrLoss).toBe(-1500);
|
|
51
|
+
expect(r.isGain).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
it('journal debits FX Unrealized Loss, credits Monetary Item', () => {
|
|
54
|
+
const r = calculateFxReval(base);
|
|
55
|
+
expect(r.journal.lines[0].account).toBe('FX Unrealized Loss');
|
|
56
|
+
expect(r.journal.lines[0].debit).toBe(1500);
|
|
57
|
+
expect(r.journal.lines[1].account).toContain('Monetary Item');
|
|
58
|
+
expect(r.journal.lines[1].credit).toBe(1500);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe('calculateFxReval (zero movement)', () => {
|
|
62
|
+
it('zero gain/loss when rates equal', () => {
|
|
63
|
+
const r = calculateFxReval({ amount: 50000, bookRate: 1.35, closingRate: 1.35 });
|
|
64
|
+
expect(r.gainOrLoss).toBe(0);
|
|
65
|
+
expect(r.isGain).toBe(true); // >= 0 treated as gain
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
describe('calculateFxReval (blueprint)', () => {
|
|
69
|
+
it('blueprint always present (no startDate needed)', () => {
|
|
70
|
+
const r = calculateFxReval({ amount: 50000, bookRate: 1.35, closingRate: 1.38 });
|
|
71
|
+
expect(r.blueprint).not.toBeNull();
|
|
72
|
+
expect(r.blueprint.capsuleDescription).toBeTruthy();
|
|
73
|
+
});
|
|
74
|
+
it('blueprint has 2 steps (reval + reversal)', () => {
|
|
75
|
+
const r = calculateFxReval({ amount: 50000, bookRate: 1.35, closingRate: 1.38 });
|
|
76
|
+
expect(r.blueprint.steps).toHaveLength(2);
|
|
77
|
+
});
|
|
78
|
+
it('both steps are journal type', () => {
|
|
79
|
+
const r = calculateFxReval({ amount: 50000, bookRate: 1.35, closingRate: 1.38 });
|
|
80
|
+
for (const step of r.blueprint.steps) {
|
|
81
|
+
expect(step.action).toBe('journal');
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
it('capsuleType is FX Revaluation', () => {
|
|
85
|
+
const r = calculateFxReval({ amount: 50000, bookRate: 1.35, closingRate: 1.38 });
|
|
86
|
+
expect(r.blueprint.capsuleType).toBe('FX Revaluation');
|
|
87
|
+
});
|
|
88
|
+
it('capsuleDescription contains IAS 21', () => {
|
|
89
|
+
const r = calculateFxReval({ amount: 50000, bookRate: 1.35, closingRate: 1.38 });
|
|
90
|
+
expect(r.blueprint.capsuleDescription).toContain('IAS 21');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
describe('calculateFxReval (defaults + passthrough)', () => {
|
|
94
|
+
it('defaults to USD/SGD', () => {
|
|
95
|
+
const r = calculateFxReval({ amount: 1000, bookRate: 1.3, closingRate: 1.4 });
|
|
96
|
+
expect(r.currency).toBe('USD');
|
|
97
|
+
expect(r.inputs.baseCurrency).toBe('SGD');
|
|
98
|
+
});
|
|
99
|
+
it('custom currency passes through', () => {
|
|
100
|
+
const r = calculateFxReval({ amount: 1000, bookRate: 1.3, closingRate: 1.4, currency: 'EUR', baseCurrency: 'PHP' });
|
|
101
|
+
expect(r.currency).toBe('EUR');
|
|
102
|
+
expect(r.inputs.baseCurrency).toBe('PHP');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
describe('calculateFxReval (validation)', () => {
|
|
106
|
+
it('rejects zero amount', () => {
|
|
107
|
+
expect(() => calculateFxReval({ amount: 0, bookRate: 1.35, closingRate: 1.38 })).toThrow(CalcValidationError);
|
|
108
|
+
});
|
|
109
|
+
it('rejects negative bookRate', () => {
|
|
110
|
+
expect(() => calculateFxReval({ amount: 50000, bookRate: -1, closingRate: 1.38 })).toThrow(CalcValidationError);
|
|
111
|
+
});
|
|
112
|
+
it('rejects zero closingRate', () => {
|
|
113
|
+
expect(() => calculateFxReval({ amount: 50000, bookRate: 1.35, closingRate: 0 })).toThrow(CalcValidationError);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { generateAuditPrepBlueprint } from '../jobs/audit-prep.js';
|
|
3
|
+
import { JobValidationError } from '../jobs/validate.js';
|
|
4
|
+
describe('generateAuditPrepBlueprint (basic)', () => {
|
|
5
|
+
it('produces a valid JobBlueprint with correct jobType', () => {
|
|
6
|
+
const bp = generateAuditPrepBlueprint({ period: '2025' });
|
|
7
|
+
expect(bp.jobType).toBe('audit-prep');
|
|
8
|
+
});
|
|
9
|
+
it('has 4 phases and 14 total steps', () => {
|
|
10
|
+
const bp = generateAuditPrepBlueprint({ period: '2025' });
|
|
11
|
+
expect(bp.phases).toHaveLength(4);
|
|
12
|
+
expect(bp.summary.totalSteps).toBe(14);
|
|
13
|
+
});
|
|
14
|
+
it('phase names are correct', () => {
|
|
15
|
+
const bp = generateAuditPrepBlueprint({ period: '2025' });
|
|
16
|
+
expect(bp.phases.map(p => p.name)).toEqual([
|
|
17
|
+
'Financial Statements',
|
|
18
|
+
'Supporting Schedules',
|
|
19
|
+
'Reconciliations',
|
|
20
|
+
'Export & Compile Audit Pack',
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
it('step categories match expectations', () => {
|
|
24
|
+
const bp = generateAuditPrepBlueprint({ period: '2025' });
|
|
25
|
+
const categories = bp.phases.flatMap(p => p.steps.map(s => s.category));
|
|
26
|
+
expect(categories).toEqual([
|
|
27
|
+
'report', // 1 — trial balance
|
|
28
|
+
'report', // 2 — balance sheet
|
|
29
|
+
'report', // 3 — P&L
|
|
30
|
+
'report', // 4 — cash flow
|
|
31
|
+
'report', // 5 — equity movement
|
|
32
|
+
'report', // 6 — AR aging
|
|
33
|
+
'report', // 7 — AP aging
|
|
34
|
+
'report', // 8 — fixed assets
|
|
35
|
+
'report', // 9 — tax ledger
|
|
36
|
+
'verify', // 10 — bank reconciliation
|
|
37
|
+
'verify', // 11 — intercompany
|
|
38
|
+
'verify', // 12 — loan schedule
|
|
39
|
+
'export', // 13 — export data
|
|
40
|
+
'report', // 14 — compile audit pack
|
|
41
|
+
]);
|
|
42
|
+
});
|
|
43
|
+
it('summary has non-empty apiCalls', () => {
|
|
44
|
+
const bp = generateAuditPrepBlueprint({ period: '2025' });
|
|
45
|
+
expect(bp.summary.apiCalls.length).toBeGreaterThan(0);
|
|
46
|
+
});
|
|
47
|
+
it('mode is standalone', () => {
|
|
48
|
+
const bp = generateAuditPrepBlueprint({ period: '2025' });
|
|
49
|
+
expect(bp.mode).toBe('standalone');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
describe('generateAuditPrepBlueprint (defaults)', () => {
|
|
53
|
+
it('default currency is SGD', () => {
|
|
54
|
+
const bp = generateAuditPrepBlueprint({ period: '2025' });
|
|
55
|
+
expect(bp.currency).toBe('SGD');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
describe('generateAuditPrepBlueprint (options)', () => {
|
|
59
|
+
it('custom currency works', () => {
|
|
60
|
+
const bp = generateAuditPrepBlueprint({ period: '2025', currency: 'PHP' });
|
|
61
|
+
expect(bp.currency).toBe('PHP');
|
|
62
|
+
});
|
|
63
|
+
it('full year period produces FY label', () => {
|
|
64
|
+
const bp = generateAuditPrepBlueprint({ period: '2025' });
|
|
65
|
+
expect(bp.period).toBe('FY2025');
|
|
66
|
+
});
|
|
67
|
+
it('full year period uses Jan 1 to Dec 31', () => {
|
|
68
|
+
const bp = generateAuditPrepBlueprint({ period: '2025' });
|
|
69
|
+
const step1 = bp.phases[0].steps[0];
|
|
70
|
+
const body = step1.apiBody;
|
|
71
|
+
expect(body.startDate).toBe('2025-01-01');
|
|
72
|
+
expect(body.endDate).toBe('2025-12-31');
|
|
73
|
+
});
|
|
74
|
+
it('quarter period produces Q label', () => {
|
|
75
|
+
const bp = generateAuditPrepBlueprint({ period: '2025-Q3' });
|
|
76
|
+
expect(bp.period).toBe('Q3 2025');
|
|
77
|
+
});
|
|
78
|
+
it('quarter period uses correct date range', () => {
|
|
79
|
+
const bp = generateAuditPrepBlueprint({ period: '2025-Q3' });
|
|
80
|
+
const step1 = bp.phases[0].steps[0];
|
|
81
|
+
const body = step1.apiBody;
|
|
82
|
+
expect(body.startDate).toBe('2025-07-01');
|
|
83
|
+
expect(body.endDate).toBe('2025-09-30');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
describe('generateAuditPrepBlueprint (validation)', () => {
|
|
87
|
+
it('invalid period throws JobValidationError', () => {
|
|
88
|
+
expect(() => generateAuditPrepBlueprint({ period: '2025-06' }))
|
|
89
|
+
.toThrow(JobValidationError);
|
|
90
|
+
});
|
|
91
|
+
it('invalid quarter throws JobValidationError', () => {
|
|
92
|
+
expect(() => generateAuditPrepBlueprint({ period: '2025-Q5' }))
|
|
93
|
+
.toThrow(JobValidationError);
|
|
94
|
+
});
|
|
95
|
+
it('malformed period throws JobValidationError', () => {
|
|
96
|
+
expect(() => generateAuditPrepBlueprint({ period: 'FY2025' }))
|
|
97
|
+
.toThrow(JobValidationError);
|
|
98
|
+
});
|
|
99
|
+
it('empty period throws JobValidationError', () => {
|
|
100
|
+
expect(() => generateAuditPrepBlueprint({ period: '' }))
|
|
101
|
+
.toThrow(JobValidationError);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
describe('generateAuditPrepBlueprint (summary)', () => {
|
|
105
|
+
it('recipeReferences include bank-reconciliation', () => {
|
|
106
|
+
const bp = generateAuditPrepBlueprint({ period: '2025' });
|
|
107
|
+
expect(bp.summary.recipeReferences).toContain('bank-reconciliation');
|
|
108
|
+
});
|
|
109
|
+
it('calcReferences include jaz calc loan', () => {
|
|
110
|
+
const bp = generateAuditPrepBlueprint({ period: '2025' });
|
|
111
|
+
expect(bp.summary.calcReferences).toContain('jaz calc loan');
|
|
112
|
+
});
|
|
113
|
+
it('apiCalls include key report endpoints', () => {
|
|
114
|
+
const bp = generateAuditPrepBlueprint({ period: '2025' });
|
|
115
|
+
expect(bp.summary.apiCalls).toContain('POST /generate-reports/trial-balance');
|
|
116
|
+
expect(bp.summary.apiCalls).toContain('POST /generate-reports/balance-sheet');
|
|
117
|
+
expect(bp.summary.apiCalls).toContain('POST /generate-reports/profit-and-loss');
|
|
118
|
+
expect(bp.summary.apiCalls).toContain('POST /generate-reports/cashflow');
|
|
119
|
+
expect(bp.summary.apiCalls).toContain('POST /generate-reports/equity-movement');
|
|
120
|
+
expect(bp.summary.apiCalls).toContain('POST /generate-reports/ar-aging');
|
|
121
|
+
expect(bp.summary.apiCalls).toContain('POST /generate-reports/ap-aging');
|
|
122
|
+
expect(bp.summary.apiCalls).toContain('POST /generate-reports/fixed-assets');
|
|
123
|
+
expect(bp.summary.apiCalls).toContain('POST /generate-reports/vat-ledger');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { generateBankReconBlueprint } from '../jobs/bank-recon.js';
|
|
3
|
+
describe('generateBankReconBlueprint (basic)', () => {
|
|
4
|
+
it('produces a valid JobBlueprint with correct jobType', () => {
|
|
5
|
+
const bp = generateBankReconBlueprint();
|
|
6
|
+
expect(bp.jobType).toBe('bank-recon');
|
|
7
|
+
});
|
|
8
|
+
it('has 4 phases and 10 total steps', () => {
|
|
9
|
+
const bp = generateBankReconBlueprint();
|
|
10
|
+
expect(bp.phases).toHaveLength(4);
|
|
11
|
+
expect(bp.summary.totalSteps).toBe(10);
|
|
12
|
+
});
|
|
13
|
+
it('phase names are correct', () => {
|
|
14
|
+
const bp = generateBankReconBlueprint();
|
|
15
|
+
expect(bp.phases.map(p => p.name)).toEqual([
|
|
16
|
+
'Identify Bank Accounts',
|
|
17
|
+
'Pull Unreconciled Records',
|
|
18
|
+
'Resolve Unreconciled Items',
|
|
19
|
+
'Verification',
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
it('step categories match expectations', () => {
|
|
23
|
+
const bp = generateBankReconBlueprint();
|
|
24
|
+
const categories = bp.phases.flatMap(p => p.steps.map(s => s.category));
|
|
25
|
+
expect(categories).toEqual([
|
|
26
|
+
'verify', // 1 — list bank accounts
|
|
27
|
+
'verify', // 2 — search unreconciled
|
|
28
|
+
'verify', // 3 — check duplicates
|
|
29
|
+
'resolve', // 4 — match bank records
|
|
30
|
+
'resolve', // 5 — create transactions (import)
|
|
31
|
+
'resolve', // 6 — create cash journals
|
|
32
|
+
'review', // 7 — flag remaining items
|
|
33
|
+
'verify', // 8 — re-check unreconciled count
|
|
34
|
+
'verify', // 9 — review bank balance
|
|
35
|
+
'report', // 10 — generate reconciliation report
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
it('summary has non-empty apiCalls', () => {
|
|
39
|
+
const bp = generateBankReconBlueprint();
|
|
40
|
+
expect(bp.summary.apiCalls.length).toBeGreaterThan(0);
|
|
41
|
+
});
|
|
42
|
+
it('mode is standalone', () => {
|
|
43
|
+
const bp = generateBankReconBlueprint();
|
|
44
|
+
expect(bp.mode).toBe('standalone');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
describe('generateBankReconBlueprint (defaults)', () => {
|
|
48
|
+
it('default currency is SGD', () => {
|
|
49
|
+
const bp = generateBankReconBlueprint();
|
|
50
|
+
expect(bp.currency).toBe('SGD');
|
|
51
|
+
});
|
|
52
|
+
it('default period is current', () => {
|
|
53
|
+
const bp = generateBankReconBlueprint();
|
|
54
|
+
expect(bp.period).toBe('current');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
describe('generateBankReconBlueprint (options)', () => {
|
|
58
|
+
it('custom currency works', () => {
|
|
59
|
+
const bp = generateBankReconBlueprint({ currency: 'PHP' });
|
|
60
|
+
expect(bp.currency).toBe('PHP');
|
|
61
|
+
});
|
|
62
|
+
it('account filter is applied', () => {
|
|
63
|
+
const bp = generateBankReconBlueprint({ account: 'DBS Current' });
|
|
64
|
+
const step1 = bp.phases[0].steps[0];
|
|
65
|
+
expect(step1.apiBody).toBeDefined();
|
|
66
|
+
expect(step1.apiBody.filters).toHaveProperty('accountName', 'DBS Current');
|
|
67
|
+
expect(step1.notes).toContain('DBS Current');
|
|
68
|
+
});
|
|
69
|
+
it('account filter absent by default', () => {
|
|
70
|
+
const bp = generateBankReconBlueprint();
|
|
71
|
+
const step1 = bp.phases[0].steps[0];
|
|
72
|
+
const filters = step1.apiBody.filters;
|
|
73
|
+
expect(filters).not.toHaveProperty('accountName');
|
|
74
|
+
});
|
|
75
|
+
it('period filter is applied to unreconciled search with correct end-of-month', () => {
|
|
76
|
+
const bp = generateBankReconBlueprint({ period: '2025-06' });
|
|
77
|
+
expect(bp.period).toBe('2025-06');
|
|
78
|
+
const step2 = bp.phases[1].steps[0];
|
|
79
|
+
const filters = step2.apiBody.filters;
|
|
80
|
+
expect(filters).toHaveProperty('valueDateFrom', '2025-06-01');
|
|
81
|
+
expect(filters).toHaveProperty('valueDateTo', '2025-06-30');
|
|
82
|
+
});
|
|
83
|
+
it('period filter handles February correctly', () => {
|
|
84
|
+
const bp = generateBankReconBlueprint({ period: '2025-02' });
|
|
85
|
+
const step2 = bp.phases[1].steps[0];
|
|
86
|
+
const filters = step2.apiBody.filters;
|
|
87
|
+
expect(filters).toHaveProperty('valueDateTo', '2025-02-28');
|
|
88
|
+
});
|
|
89
|
+
it('period filter handles leap year February', () => {
|
|
90
|
+
const bp = generateBankReconBlueprint({ period: '2024-02' });
|
|
91
|
+
const step2 = bp.phases[1].steps[0];
|
|
92
|
+
const filters = step2.apiBody.filters;
|
|
93
|
+
expect(filters).toHaveProperty('valueDateTo', '2024-02-29');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
describe('generateBankReconBlueprint (summary)', () => {
|
|
97
|
+
it('recipeReferences include bank-reconciliation and cash-receipt', () => {
|
|
98
|
+
const bp = generateBankReconBlueprint();
|
|
99
|
+
expect(bp.summary.recipeReferences).toContain('bank-reconciliation');
|
|
100
|
+
expect(bp.summary.recipeReferences).toContain('cash-receipt');
|
|
101
|
+
});
|
|
102
|
+
it('apiCalls include key endpoints', () => {
|
|
103
|
+
const bp = generateBankReconBlueprint();
|
|
104
|
+
expect(bp.summary.apiCalls).toContain('POST /chart-of-accounts/search');
|
|
105
|
+
expect(bp.summary.apiCalls).toContain('POST /bank-records/search');
|
|
106
|
+
expect(bp.summary.apiCalls).toContain('POST /journals');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { generateCreditControlBlueprint } from '../jobs/credit-control.js';
|
|
3
|
+
import { JobValidationError } from '../jobs/validate.js';
|
|
4
|
+
describe('generateCreditControlBlueprint (basic)', () => {
|
|
5
|
+
it('produces a valid JobBlueprint with correct jobType', () => {
|
|
6
|
+
const bp = generateCreditControlBlueprint();
|
|
7
|
+
expect(bp.jobType).toBe('credit-control');
|
|
8
|
+
});
|
|
9
|
+
it('has 4 phases and 7 total steps', () => {
|
|
10
|
+
const bp = generateCreditControlBlueprint();
|
|
11
|
+
expect(bp.phases).toHaveLength(4);
|
|
12
|
+
expect(bp.summary.totalSteps).toBe(7);
|
|
13
|
+
});
|
|
14
|
+
it('phase names are correct', () => {
|
|
15
|
+
const bp = generateCreditControlBlueprint();
|
|
16
|
+
expect(bp.phases.map(p => p.name)).toEqual([
|
|
17
|
+
'AR Analysis',
|
|
18
|
+
'Chase List',
|
|
19
|
+
'Bad Debt Assessment',
|
|
20
|
+
'Verification',
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
it('step categories match expectations', () => {
|
|
24
|
+
const bp = generateCreditControlBlueprint();
|
|
25
|
+
const categories = bp.phases.flatMap(p => p.steps.map(s => s.category));
|
|
26
|
+
expect(categories).toEqual([
|
|
27
|
+
'report', // 1 — AR aging report
|
|
28
|
+
'verify', // 2 — identify overdue invoices
|
|
29
|
+
'review', // 3 — group by customer priority
|
|
30
|
+
'report', // 4 — follow-up action list
|
|
31
|
+
'review', // 5 — identify doubtful debts
|
|
32
|
+
'value', // 6 — calculate ECL provision
|
|
33
|
+
'verify', // 7 — review AR aging after actions
|
|
34
|
+
]);
|
|
35
|
+
});
|
|
36
|
+
it('summary has non-empty apiCalls', () => {
|
|
37
|
+
const bp = generateCreditControlBlueprint();
|
|
38
|
+
expect(bp.summary.apiCalls.length).toBeGreaterThan(0);
|
|
39
|
+
});
|
|
40
|
+
it('mode is standalone', () => {
|
|
41
|
+
const bp = generateCreditControlBlueprint();
|
|
42
|
+
expect(bp.mode).toBe('standalone');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
describe('generateCreditControlBlueprint (defaults)', () => {
|
|
46
|
+
it('default currency is SGD', () => {
|
|
47
|
+
const bp = generateCreditControlBlueprint();
|
|
48
|
+
expect(bp.currency).toBe('SGD');
|
|
49
|
+
});
|
|
50
|
+
it('default overdueDays is 30', () => {
|
|
51
|
+
const bp = generateCreditControlBlueprint();
|
|
52
|
+
expect(bp.period).toBe('Overdue > 30 days');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
describe('generateCreditControlBlueprint (options)', () => {
|
|
56
|
+
it('custom currency works', () => {
|
|
57
|
+
const bp = generateCreditControlBlueprint({ currency: 'PHP' });
|
|
58
|
+
expect(bp.currency).toBe('PHP');
|
|
59
|
+
});
|
|
60
|
+
it('custom overdueDays works', () => {
|
|
61
|
+
const bp = generateCreditControlBlueprint({ overdueDays: 60 });
|
|
62
|
+
expect(bp.period).toBe('Overdue > 60 days');
|
|
63
|
+
});
|
|
64
|
+
it('overdueDays is reflected in step description', () => {
|
|
65
|
+
const bp = generateCreditControlBlueprint({ overdueDays: 90 });
|
|
66
|
+
const step2 = bp.phases[0].steps[1];
|
|
67
|
+
expect(step2.description).toContain('90');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe('generateCreditControlBlueprint (validation)', () => {
|
|
71
|
+
it('zero overdueDays throws JobValidationError', () => {
|
|
72
|
+
expect(() => generateCreditControlBlueprint({ overdueDays: 0 }))
|
|
73
|
+
.toThrow(JobValidationError);
|
|
74
|
+
});
|
|
75
|
+
it('negative overdueDays throws JobValidationError', () => {
|
|
76
|
+
expect(() => generateCreditControlBlueprint({ overdueDays: -10 }))
|
|
77
|
+
.toThrow(JobValidationError);
|
|
78
|
+
});
|
|
79
|
+
it('fractional overdueDays throws JobValidationError', () => {
|
|
80
|
+
expect(() => generateCreditControlBlueprint({ overdueDays: 30.5 }))
|
|
81
|
+
.toThrow(JobValidationError);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
describe('generateCreditControlBlueprint (summary)', () => {
|
|
85
|
+
it('recipeReferences include bad-debt-provision', () => {
|
|
86
|
+
const bp = generateCreditControlBlueprint();
|
|
87
|
+
expect(bp.summary.recipeReferences).toContain('bad-debt-provision');
|
|
88
|
+
});
|
|
89
|
+
it('calcReferences include jaz calc ecl', () => {
|
|
90
|
+
const bp = generateCreditControlBlueprint();
|
|
91
|
+
expect(bp.summary.calcReferences).toContain('jaz calc ecl');
|
|
92
|
+
});
|
|
93
|
+
it('apiCalls include key endpoints', () => {
|
|
94
|
+
const bp = generateCreditControlBlueprint();
|
|
95
|
+
expect(bp.summary.apiCalls).toContain('POST /generate-reports/ar-aging');
|
|
96
|
+
expect(bp.summary.apiCalls).toContain('POST /invoices/search');
|
|
97
|
+
});
|
|
98
|
+
});
|