jaz-cli 2.7.0 → 2.9.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 (68) hide show
  1. package/assets/skills/api/SKILL.md +1 -1
  2. package/assets/skills/conversion/SKILL.md +1 -1
  3. package/assets/skills/jobs/SKILL.md +161 -0
  4. package/assets/skills/jobs/references/audit-prep.md +319 -0
  5. package/assets/skills/jobs/references/bank-recon.md +234 -0
  6. package/assets/skills/jobs/references/building-blocks.md +135 -0
  7. package/assets/skills/jobs/references/credit-control.md +273 -0
  8. package/assets/skills/jobs/references/fa-review.md +267 -0
  9. package/assets/skills/jobs/references/gst-vat-filing.md +250 -0
  10. package/assets/skills/jobs/references/month-end-close.md +308 -0
  11. package/assets/skills/jobs/references/payment-run.md +246 -0
  12. package/assets/skills/jobs/references/quarter-end-close.md +268 -0
  13. package/assets/skills/jobs/references/sg-tax/add-backs-guide.md +354 -0
  14. package/assets/skills/jobs/references/sg-tax/capital-allowances-guide.md +343 -0
  15. package/assets/skills/jobs/references/sg-tax/data-extraction.md +408 -0
  16. package/assets/skills/jobs/references/sg-tax/enhanced-deductions.md +248 -0
  17. package/assets/skills/jobs/references/sg-tax/exemptions-and-rebates.md +197 -0
  18. package/assets/skills/jobs/references/sg-tax/form-cs-fields.md +191 -0
  19. package/assets/skills/jobs/references/sg-tax/ifrs16-tax-adjustment.md +194 -0
  20. package/assets/skills/jobs/references/sg-tax/losses-and-carry-forwards.md +269 -0
  21. package/assets/skills/jobs/references/sg-tax/overview.md +207 -0
  22. package/assets/skills/jobs/references/sg-tax/wizard-workflow.md +391 -0
  23. package/assets/skills/jobs/references/supplier-recon.md +330 -0
  24. package/assets/skills/jobs/references/year-end-close.md +341 -0
  25. package/assets/skills/transaction-recipes/SKILL.md +1 -1
  26. package/dist/__tests__/jobs-audit-prep.test.js +125 -0
  27. package/dist/__tests__/jobs-bank-recon.test.js +108 -0
  28. package/dist/__tests__/jobs-credit-control.test.js +98 -0
  29. package/dist/__tests__/jobs-fa-review.test.js +104 -0
  30. package/dist/__tests__/jobs-gst-vat.test.js +113 -0
  31. package/dist/__tests__/jobs-month-end.test.js +162 -0
  32. package/dist/__tests__/jobs-payment-run.test.js +106 -0
  33. package/dist/__tests__/jobs-quarter-end.test.js +155 -0
  34. package/dist/__tests__/jobs-supplier-recon.test.js +115 -0
  35. package/dist/__tests__/jobs-validate.test.js +181 -0
  36. package/dist/__tests__/jobs-year-end.test.js +149 -0
  37. package/dist/__tests__/tax-sg-capital-allowances.test.js +389 -0
  38. package/dist/__tests__/tax-sg-exemptions.test.js +232 -0
  39. package/dist/__tests__/tax-sg-form-cs.test.js +687 -0
  40. package/dist/__tests__/tax-validate.test.js +208 -0
  41. package/dist/commands/init.js +7 -2
  42. package/dist/commands/jobs.js +184 -0
  43. package/dist/commands/tax.js +195 -0
  44. package/dist/index.js +4 -0
  45. package/dist/jobs/audit-prep.js +211 -0
  46. package/dist/jobs/bank-recon.js +163 -0
  47. package/dist/jobs/credit-control.js +126 -0
  48. package/dist/jobs/fa-review.js +121 -0
  49. package/dist/jobs/format.js +102 -0
  50. package/dist/jobs/gst-vat.js +187 -0
  51. package/dist/jobs/month-end.js +232 -0
  52. package/dist/jobs/payment-run.js +199 -0
  53. package/dist/jobs/quarter-end.js +135 -0
  54. package/dist/jobs/supplier-recon.js +132 -0
  55. package/dist/jobs/types.js +36 -0
  56. package/dist/jobs/validate.js +115 -0
  57. package/dist/jobs/year-end.js +153 -0
  58. package/dist/tax/format.js +18 -0
  59. package/dist/tax/sg/capital-allowances.js +160 -0
  60. package/dist/tax/sg/constants.js +63 -0
  61. package/dist/tax/sg/exemptions.js +76 -0
  62. package/dist/tax/sg/form-cs.js +349 -0
  63. package/dist/tax/sg/format-sg.js +134 -0
  64. package/dist/tax/types.js +9 -0
  65. package/dist/tax/validate.js +124 -0
  66. package/dist/types/index.js +2 -1
  67. package/dist/utils/template.js +1 -1
  68. package/package.json +1 -1
@@ -0,0 +1,389 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { computeCapitalAllowances } from '../tax/sg/capital-allowances.js';
3
+ // ── Helpers ─────────────────────────────────────────────────────
4
+ /** Build a minimal valid input — override as needed. */
5
+ function makeInput(overrides) {
6
+ return {
7
+ ya: 2026,
8
+ unabsorbedBroughtForward: 0,
9
+ ...overrides,
10
+ };
11
+ }
12
+ /** Shorthand to build a single-asset input and compute. */
13
+ function computeSingle(asset, opts = {}) {
14
+ return computeCapitalAllowances(makeInput({ assets: [asset], ...opts }));
15
+ }
16
+ // ── Basic per-category claims ───────────────────────────────────
17
+ describe('computeCapitalAllowances — per-category claims', () => {
18
+ it('computer (S19A(1)): $3,000 → 100% claim in year 1', () => {
19
+ const r = computeSingle({
20
+ description: 'MacBook Pro',
21
+ cost: 3000,
22
+ acquisitionDate: '2025-03-01',
23
+ category: 'computer',
24
+ priorYearsClaimed: 0,
25
+ });
26
+ expect(r.assets[0].currentYearClaim).toBeCloseTo(3000, 2);
27
+ expect(r.assets[0].remainingUnabsorbed).toBeCloseTo(0, 2);
28
+ expect(r.assets[0].section).toBe('S19A(1)');
29
+ });
30
+ it('automation (S19A(1)): $10,000 → 100% claim', () => {
31
+ const r = computeSingle({
32
+ description: 'Robotic arm',
33
+ cost: 10000,
34
+ acquisitionDate: '2025-06-15',
35
+ category: 'automation',
36
+ priorYearsClaimed: 0,
37
+ });
38
+ expect(r.assets[0].currentYearClaim).toBeCloseTo(10000, 2);
39
+ expect(r.assets[0].remainingUnabsorbed).toBeCloseTo(0, 2);
40
+ expect(r.assets[0].section).toBe('S19A(1)');
41
+ });
42
+ it('low-value (S19A(2)): $4,500 → 100% claim', () => {
43
+ const r = computeSingle({
44
+ description: 'Office chair',
45
+ cost: 4500,
46
+ acquisitionDate: '2025-01-10',
47
+ category: 'low-value',
48
+ priorYearsClaimed: 0,
49
+ });
50
+ expect(r.assets[0].currentYearClaim).toBeCloseTo(4500, 2);
51
+ expect(r.assets[0].remainingUnabsorbed).toBeCloseTo(0, 2);
52
+ expect(r.assets[0].section).toBe('S19A(2)');
53
+ });
54
+ it('general (S19): $30,000, no prior → $10,000 claim (33.33%)', () => {
55
+ const r = computeSingle({
56
+ description: 'Office furniture set',
57
+ cost: 30000,
58
+ acquisitionDate: '2024-01-01',
59
+ category: 'general',
60
+ priorYearsClaimed: 0,
61
+ });
62
+ expect(r.assets[0].currentYearClaim).toBeCloseTo(10000, 2);
63
+ expect(r.assets[0].remainingUnabsorbed).toBeCloseTo(20000, 2);
64
+ expect(r.assets[0].section).toBe('S19');
65
+ });
66
+ it('general (S19): $30,000, $10K prior → $10,000 (second year)', () => {
67
+ const r = computeSingle({
68
+ description: 'Office furniture set',
69
+ cost: 30000,
70
+ acquisitionDate: '2023-01-01',
71
+ category: 'general',
72
+ priorYearsClaimed: 10000,
73
+ });
74
+ expect(r.assets[0].currentYearClaim).toBeCloseTo(10000, 2);
75
+ expect(r.assets[0].totalClaimedToDate).toBeCloseTo(20000, 2);
76
+ expect(r.assets[0].remainingUnabsorbed).toBeCloseTo(10000, 2);
77
+ });
78
+ it('general (S19): $30,000, $20K prior → $10,000 (final year, closes to zero)', () => {
79
+ const r = computeSingle({
80
+ description: 'Office furniture set',
81
+ cost: 30000,
82
+ acquisitionDate: '2022-01-01',
83
+ category: 'general',
84
+ priorYearsClaimed: 20000,
85
+ });
86
+ expect(r.assets[0].currentYearClaim).toBeCloseTo(10000, 2);
87
+ expect(r.assets[0].totalClaimedToDate).toBeCloseTo(30000, 2);
88
+ expect(r.assets[0].remainingUnabsorbed).toBeCloseTo(0, 2);
89
+ });
90
+ it('IP (S19B): $50,000, default 5-year → $10,000 claim', () => {
91
+ const r = computeSingle({
92
+ description: 'Patent registration',
93
+ cost: 50000,
94
+ acquisitionDate: '2025-04-01',
95
+ category: 'ip',
96
+ priorYearsClaimed: 0,
97
+ });
98
+ expect(r.assets[0].currentYearClaim).toBeCloseTo(10000, 2);
99
+ expect(r.assets[0].remainingUnabsorbed).toBeCloseTo(40000, 2);
100
+ expect(r.assets[0].section).toBe('S19B');
101
+ });
102
+ it('IP (S19B): $50,000, ipWriteOffYears=10 → $5,000 claim', () => {
103
+ const r = computeSingle({
104
+ description: 'Trademark',
105
+ cost: 50000,
106
+ acquisitionDate: '2025-04-01',
107
+ category: 'ip',
108
+ priorYearsClaimed: 0,
109
+ ipWriteOffYears: 10,
110
+ });
111
+ expect(r.assets[0].currentYearClaim).toBeCloseTo(5000, 2);
112
+ expect(r.assets[0].remainingUnabsorbed).toBeCloseTo(45000, 2);
113
+ });
114
+ it('IP (S19B): $50,000, ipWriteOffYears=15 → $3,333.33 claim', () => {
115
+ const r = computeSingle({
116
+ description: 'Copyright',
117
+ cost: 50000,
118
+ acquisitionDate: '2025-04-01',
119
+ category: 'ip',
120
+ priorYearsClaimed: 0,
121
+ ipWriteOffYears: 15,
122
+ });
123
+ expect(r.assets[0].currentYearClaim).toBeCloseTo(3333.33, 2);
124
+ expect(r.assets[0].remainingUnabsorbed).toBeCloseTo(46666.67, 2);
125
+ });
126
+ it('renovation (S14Q): $90,000 → $30,000 claim (33.33%)', () => {
127
+ const r = computeSingle({
128
+ description: 'Office renovation',
129
+ cost: 90000,
130
+ acquisitionDate: '2025-02-01',
131
+ category: 'renovation',
132
+ priorYearsClaimed: 0,
133
+ });
134
+ expect(r.assets[0].currentYearClaim).toBeCloseTo(30000, 2);
135
+ expect(r.assets[0].remainingUnabsorbed).toBeCloseTo(60000, 2);
136
+ expect(r.assets[0].section).toBe('S14Q');
137
+ });
138
+ it('fully claimed asset ($20K cost, $20K prior) → $0 claim', () => {
139
+ const r = computeSingle({
140
+ description: 'Old laptop',
141
+ cost: 20000,
142
+ acquisitionDate: '2022-01-01',
143
+ category: 'general',
144
+ priorYearsClaimed: 20000,
145
+ });
146
+ expect(r.assets[0].currentYearClaim).toBeCloseTo(0, 2);
147
+ expect(r.assets[0].totalClaimedToDate).toBeCloseTo(20000, 2);
148
+ expect(r.assets[0].remainingUnabsorbed).toBeCloseTo(0, 2);
149
+ });
150
+ });
151
+ // ── Low-value $30K/YA cap ───────────────────────────────────────
152
+ describe('computeCapitalAllowances — low-value $30K/YA cap', () => {
153
+ it('7 assets at $5,000 each → first 6 fully claimed ($30K), 7th gets $0', () => {
154
+ const assets = Array.from({ length: 7 }, (_, i) => ({
155
+ description: `Low-value item ${i + 1}`,
156
+ cost: 5000,
157
+ acquisitionDate: '2025-01-15',
158
+ category: 'low-value',
159
+ priorYearsClaimed: 0,
160
+ }));
161
+ const r = computeCapitalAllowances(makeInput({ assets }));
162
+ // First 6 assets: each claims $5,000 (cumulative $30K)
163
+ for (let i = 0; i < 6; i++) {
164
+ expect(r.assets[i].currentYearClaim).toBeCloseTo(5000, 2);
165
+ }
166
+ // 7th asset: $0 (cap exhausted)
167
+ expect(r.assets[6].currentYearClaim).toBeCloseTo(0, 2);
168
+ expect(r.lowValueTotal).toBeCloseTo(30000, 2);
169
+ expect(r.totalCurrentYearClaim).toBeCloseTo(30000, 2);
170
+ });
171
+ it('lowValueCapped flag = true when cap triggers', () => {
172
+ const assets = Array.from({ length: 7 }, (_, i) => ({
173
+ description: `Item ${i + 1}`,
174
+ cost: 5000,
175
+ acquisitionDate: '2025-01-15',
176
+ category: 'low-value',
177
+ priorYearsClaimed: 0,
178
+ }));
179
+ const r = computeCapitalAllowances(makeInput({ assets }));
180
+ expect(r.lowValueCapped).toBe(true);
181
+ });
182
+ it('cap only applies to low-value — general assets are unaffected', () => {
183
+ const assets = [
184
+ // 6 low-value assets consuming the $30K cap
185
+ ...Array.from({ length: 6 }, (_, i) => ({
186
+ description: `Low-value ${i + 1}`,
187
+ cost: 5000,
188
+ acquisitionDate: '2025-01-15',
189
+ category: 'low-value',
190
+ priorYearsClaimed: 0,
191
+ })),
192
+ // 1 general asset — should not be capped
193
+ {
194
+ description: 'Server rack',
195
+ cost: 30000,
196
+ acquisitionDate: '2025-03-01',
197
+ category: 'general',
198
+ priorYearsClaimed: 0,
199
+ },
200
+ ];
201
+ const r = computeCapitalAllowances(makeInput({ assets }));
202
+ // Low-value: 6 x $5K = $30K
203
+ expect(r.lowValueTotal).toBeCloseTo(30000, 2);
204
+ // General: 33.33% x $30K = $10K — unaffected by low-value cap
205
+ expect(r.assets[6].currentYearClaim).toBeCloseTo(10000, 2);
206
+ expect(r.totalCurrentYearClaim).toBeCloseTo(40000, 2);
207
+ });
208
+ });
209
+ // ── S14Q renovation $300K cap ───────────────────────────────────
210
+ describe('computeCapitalAllowances — S14Q renovation cap', () => {
211
+ it('$900K renovation → raw annual $300K, at S14Q cap exactly', () => {
212
+ const r = computeSingle({
213
+ description: 'Major office renovation',
214
+ cost: 900000,
215
+ acquisitionDate: '2025-01-01',
216
+ category: 'renovation',
217
+ priorYearsClaimed: 0,
218
+ });
219
+ // 33.33% x $900K = $300K; S14Q cap is $300K — exactly at cap
220
+ expect(r.assets[0].currentYearClaim).toBeCloseTo(300000, 2);
221
+ });
222
+ it('$1,200,000 renovation → raw annual $400K, capped at $300K', () => {
223
+ const r = computeSingle({
224
+ description: 'HQ full renovation',
225
+ cost: 1200000,
226
+ acquisitionDate: '2025-01-01',
227
+ category: 'renovation',
228
+ priorYearsClaimed: 0,
229
+ });
230
+ // 33.33% x $1.2M = $400K; S14Q cap = $300K → capped
231
+ expect(r.assets[0].currentYearClaim).toBeCloseTo(300000, 2);
232
+ // Remaining: $1.2M - $300K = $900K
233
+ expect(r.assets[0].remainingUnabsorbed).toBeCloseTo(900000, 2);
234
+ });
235
+ });
236
+ // ── Unabsorbed CA brought forward ───────────────────────────────
237
+ describe('computeCapitalAllowances — unabsorbed CA', () => {
238
+ it('current year $20K general claim + $5K unabsorbed b/f → totalAvailable correct', () => {
239
+ const r = computeSingle({
240
+ description: 'Printer',
241
+ cost: 20000,
242
+ acquisitionDate: '2025-01-01',
243
+ category: 'general',
244
+ priorYearsClaimed: 0,
245
+ }, { unabsorbedBroughtForward: 5000 });
246
+ // General: 33.33% x $20K = $6,666.67
247
+ const expectedClaim = 6666.67;
248
+ expect(r.totalCurrentYearClaim).toBeCloseTo(expectedClaim, 2);
249
+ expect(r.totalAvailable).toBeCloseTo(expectedClaim + 5000, 2);
250
+ expect(r.unabsorbedBroughtForward).toBe(5000);
251
+ });
252
+ it('fully-claimed asset + $10K unabsorbed → totalAvailable $10K', () => {
253
+ const r = computeSingle({
254
+ description: 'Old scanner',
255
+ cost: 10000,
256
+ acquisitionDate: '2022-01-01',
257
+ category: 'computer',
258
+ priorYearsClaimed: 10000,
259
+ }, { unabsorbedBroughtForward: 10000 });
260
+ expect(r.totalCurrentYearClaim).toBeCloseTo(0, 2);
261
+ expect(r.totalAvailable).toBeCloseTo(10000, 2);
262
+ });
263
+ });
264
+ // ── Multiple mixed assets ───────────────────────────────────────
265
+ describe('computeCapitalAllowances — mixed asset portfolio', () => {
266
+ it('computer $5K + general $30K + low-value $3K → total $18K', () => {
267
+ const assets = [
268
+ {
269
+ description: 'Laptop',
270
+ cost: 5000,
271
+ acquisitionDate: '2025-02-01',
272
+ category: 'computer',
273
+ priorYearsClaimed: 0,
274
+ },
275
+ {
276
+ description: 'Warehouse shelving',
277
+ cost: 30000,
278
+ acquisitionDate: '2024-07-01',
279
+ category: 'general',
280
+ priorYearsClaimed: 0,
281
+ },
282
+ {
283
+ description: 'Desk lamp',
284
+ cost: 3000,
285
+ acquisitionDate: '2025-05-01',
286
+ category: 'low-value',
287
+ priorYearsClaimed: 0,
288
+ },
289
+ ];
290
+ const r = computeCapitalAllowances(makeInput({ assets }));
291
+ expect(r.assets[0].currentYearClaim).toBeCloseTo(5000, 2); // computer 100%
292
+ expect(r.assets[1].currentYearClaim).toBeCloseTo(10000, 2); // general 33.33%
293
+ expect(r.assets[2].currentYearClaim).toBeCloseTo(3000, 2); // low-value 100%
294
+ expect(r.totalCurrentYearClaim).toBeCloseTo(18000, 2);
295
+ });
296
+ it('all six categories with realistic amounts', () => {
297
+ const assets = [
298
+ { description: 'Server', cost: 8000, acquisitionDate: '2025-01-01', category: 'computer', priorYearsClaimed: 0 },
299
+ { description: 'Conveyor belt', cost: 15000, acquisitionDate: '2025-03-01', category: 'automation', priorYearsClaimed: 0 },
300
+ { description: 'Monitor arm', cost: 500, acquisitionDate: '2025-04-01', category: 'low-value', priorYearsClaimed: 0 },
301
+ { description: 'Factory equipment', cost: 60000, acquisitionDate: '2024-01-01', category: 'general', priorYearsClaimed: 0 },
302
+ { description: 'Software license', cost: 25000, acquisitionDate: '2025-06-01', category: 'ip', priorYearsClaimed: 0 },
303
+ { description: 'Retail fit-out', cost: 150000, acquisitionDate: '2025-01-15', category: 'renovation', priorYearsClaimed: 0 },
304
+ ];
305
+ const r = computeCapitalAllowances(makeInput({ assets }));
306
+ expect(r.assets[0].currentYearClaim).toBeCloseTo(8000, 2); // computer 100%
307
+ expect(r.assets[1].currentYearClaim).toBeCloseTo(15000, 2); // automation 100%
308
+ expect(r.assets[2].currentYearClaim).toBeCloseTo(500, 2); // low-value 100%
309
+ expect(r.assets[3].currentYearClaim).toBeCloseTo(20000, 2); // general 33.33% x 60K
310
+ expect(r.assets[4].currentYearClaim).toBeCloseTo(5000, 2); // IP 20% x 25K
311
+ expect(r.assets[5].currentYearClaim).toBeCloseTo(50000, 2); // renovation 33.33% x 150K
312
+ const expectedTotal = 8000 + 15000 + 500 + 20000 + 5000 + 50000;
313
+ expect(r.totalCurrentYearClaim).toBeCloseTo(expectedTotal, 2);
314
+ });
315
+ });
316
+ // ── Output structure ────────────────────────────────────────────
317
+ describe('computeCapitalAllowances — output structure', () => {
318
+ const singleAssetResult = () => computeSingle({
319
+ description: 'Test laptop',
320
+ cost: 3000,
321
+ acquisitionDate: '2025-01-01',
322
+ category: 'computer',
323
+ priorYearsClaimed: 0,
324
+ });
325
+ it('type is "sg-capital-allowance"', () => {
326
+ const r = singleAssetResult();
327
+ expect(r.type).toBe('sg-capital-allowance');
328
+ });
329
+ it('workings string contains currency amounts', () => {
330
+ const r = singleAssetResult();
331
+ expect(r.workings).toContain('SGD');
332
+ expect(r.workings).toContain('3,000.00');
333
+ expect(r.workings).toContain('Capital Allowance Schedule');
334
+ });
335
+ it('assets array length matches input', () => {
336
+ const assets = [
337
+ { description: 'A', cost: 1000, acquisitionDate: '2025-01-01', category: 'computer', priorYearsClaimed: 0 },
338
+ { description: 'B', cost: 2000, acquisitionDate: '2025-02-01', category: 'computer', priorYearsClaimed: 0 },
339
+ { description: 'C', cost: 3000, acquisitionDate: '2025-03-01', category: 'general', priorYearsClaimed: 0 },
340
+ ];
341
+ const r = computeCapitalAllowances(makeInput({ assets }));
342
+ expect(r.assets).toHaveLength(3);
343
+ });
344
+ it('lowValueCount counts all low-value assets (including zero-claim)', () => {
345
+ const assets = [
346
+ { description: 'LV1', cost: 5000, acquisitionDate: '2025-01-01', category: 'low-value', priorYearsClaimed: 0 },
347
+ { description: 'LV2', cost: 3000, acquisitionDate: '2025-01-01', category: 'low-value', priorYearsClaimed: 0 },
348
+ { description: 'Gen', cost: 9000, acquisitionDate: '2025-01-01', category: 'general', priorYearsClaimed: 0 },
349
+ ];
350
+ const r = computeCapitalAllowances(makeInput({ assets }));
351
+ expect(r.lowValueCount).toBe(2);
352
+ });
353
+ });
354
+ // ── Edge cases ──────────────────────────────────────────────────
355
+ describe('computeCapitalAllowances — edge cases', () => {
356
+ it('partial final year claim for general asset near cost', () => {
357
+ // $25K cost, 33.33% annual = $8,333.33/yr
358
+ // After 2 years: $16,666.67 claimed, remaining $8,333.33
359
+ // Year 3: raw = $8,333.33, unclaimed = $8,333.33 → claims exactly the remainder
360
+ const r = computeSingle({
361
+ description: 'Office partition',
362
+ cost: 25000,
363
+ acquisitionDate: '2022-01-01',
364
+ category: 'general',
365
+ priorYearsClaimed: 16666.67,
366
+ });
367
+ expect(r.assets[0].currentYearClaim).toBeCloseTo(8333.33, 2);
368
+ expect(r.assets[0].remainingUnabsorbed).toBeCloseTo(0, 2);
369
+ expect(r.assets[0].totalClaimedToDate).toBeCloseTo(25000, 2);
370
+ });
371
+ it('all amounts are 2dp (round2 invariant)', () => {
372
+ const round2 = (n) => Math.round(n * 100) / 100;
373
+ const r = computeSingle({
374
+ description: 'IP asset',
375
+ cost: 50000,
376
+ acquisitionDate: '2025-01-01',
377
+ category: 'ip',
378
+ priorYearsClaimed: 0,
379
+ ipWriteOffYears: 15,
380
+ });
381
+ for (const row of r.assets) {
382
+ expect(round2(row.currentYearClaim)).toBe(row.currentYearClaim);
383
+ expect(round2(row.totalClaimedToDate)).toBe(row.totalClaimedToDate);
384
+ expect(round2(row.remainingUnabsorbed)).toBe(row.remainingUnabsorbed);
385
+ }
386
+ expect(round2(r.totalCurrentYearClaim)).toBe(r.totalCurrentYearClaim);
387
+ expect(round2(r.totalAvailable)).toBe(r.totalAvailable);
388
+ });
389
+ });
@@ -0,0 +1,232 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { computeExemption, computeCitRebate } from '../tax/sg/exemptions.js';
3
+ import { SG_CIT_RATE } from '../tax/sg/constants.js';
4
+ // ── computeExemption ────────────────────────────────────────────────
5
+ describe('computeExemption', () => {
6
+ const round2 = (n) => Math.round(n * 100) / 100;
7
+ // ── SUTE (Start-Up Tax Exemption) ─────────────────────────────────
8
+ describe('SUTE', () => {
9
+ it('$0 income → all zeros', () => {
10
+ const r = computeExemption(0, 'sute');
11
+ expect(r.type).toBe('sute');
12
+ expect(r.chargeableIncome).toBe(0);
13
+ expect(r.exemptAmount).toBe(0);
14
+ expect(r.taxableIncome).toBe(0);
15
+ expect(r.effectiveRate).toBe(0);
16
+ });
17
+ it('$100,000 → exempt $75,000 (75% of first $100K)', () => {
18
+ const r = computeExemption(100_000, 'sute');
19
+ expect(r.exemptAmount).toBeCloseTo(75_000, 2);
20
+ expect(r.taxableIncome).toBeCloseTo(25_000, 2);
21
+ });
22
+ it('$150,000 → exempt $75,000 + $25,000 = $100,000', () => {
23
+ const r = computeExemption(150_000, 'sute');
24
+ // First band: 100K x 75% = 75K. Second band: 50K x 50% = 25K.
25
+ expect(r.exemptAmount).toBeCloseTo(100_000, 2);
26
+ expect(r.taxableIncome).toBeCloseTo(50_000, 2);
27
+ });
28
+ it('$200,000 → exempt $75,000 + $50,000 = $125,000 (both bands full)', () => {
29
+ const r = computeExemption(200_000, 'sute');
30
+ // First band: 100K x 75% = 75K. Second band: 100K x 50% = 50K.
31
+ expect(r.exemptAmount).toBeCloseTo(125_000, 2);
32
+ expect(r.taxableIncome).toBeCloseTo(75_000, 2);
33
+ });
34
+ it('$300,000 → still $125,000 (excess beyond bands gets no exemption)', () => {
35
+ const r = computeExemption(300_000, 'sute');
36
+ expect(r.exemptAmount).toBeCloseTo(125_000, 2);
37
+ expect(r.taxableIncome).toBeCloseTo(175_000, 2);
38
+ });
39
+ it('$50,000 → exempt $37,500 (75% x $50K, first band partially filled)', () => {
40
+ const r = computeExemption(50_000, 'sute');
41
+ expect(r.exemptAmount).toBeCloseTo(37_500, 2);
42
+ expect(r.taxableIncome).toBeCloseTo(12_500, 2);
43
+ });
44
+ it('$1 → tiny numbers work correctly', () => {
45
+ const r = computeExemption(1, 'sute');
46
+ expect(r.exemptAmount).toBeCloseTo(0.75, 2);
47
+ expect(r.taxableIncome).toBeCloseTo(0.25, 2);
48
+ expect(r.chargeableIncome).toBe(1);
49
+ });
50
+ it('exact at total band boundary: $200,000 → exempt $125,000', () => {
51
+ const r = computeExemption(200_000, 'sute');
52
+ expect(r.exemptAmount).toBeCloseTo(125_000, 2);
53
+ expect(r.taxableIncome).toBeCloseTo(75_000, 2);
54
+ });
55
+ it('known benchmark: SUTE on $200K effective rate', () => {
56
+ // taxable = 200K - 125K = 75K
57
+ // grossTax = 75K x 0.17 = 12,750
58
+ // effectiveRate = 12,750 / 200,000 = 0.06375
59
+ const r = computeExemption(200_000, 'sute');
60
+ expect(r.effectiveRate).toBeCloseTo(0.06375, 4);
61
+ expect(r.effectiveRate).toBeLessThan(SG_CIT_RATE);
62
+ });
63
+ });
64
+ // ── PTE (Partial Tax Exemption) ───────────────────────────────────
65
+ describe('PTE', () => {
66
+ it('$0 income → all zeros', () => {
67
+ const r = computeExemption(0, 'pte');
68
+ expect(r.type).toBe('pte');
69
+ expect(r.chargeableIncome).toBe(0);
70
+ expect(r.exemptAmount).toBe(0);
71
+ expect(r.taxableIncome).toBe(0);
72
+ expect(r.effectiveRate).toBe(0);
73
+ });
74
+ it('$10,000 → exempt $7,500 (75% of first $10K)', () => {
75
+ const r = computeExemption(10_000, 'pte');
76
+ expect(r.exemptAmount).toBeCloseTo(7_500, 2);
77
+ expect(r.taxableIncome).toBeCloseTo(2_500, 2);
78
+ });
79
+ it('$100,000 → exempt $7,500 + $45,000 = $52,500', () => {
80
+ const r = computeExemption(100_000, 'pte');
81
+ // First band: 10K x 75% = 7.5K. Second band: 90K x 50% = 45K.
82
+ expect(r.exemptAmount).toBeCloseTo(52_500, 2);
83
+ expect(r.taxableIncome).toBeCloseTo(47_500, 2);
84
+ });
85
+ it('$200,000 → exempt $7,500 + $95,000 = $102,500 (both bands full)', () => {
86
+ const r = computeExemption(200_000, 'pte');
87
+ // First band: 10K x 75% = 7.5K. Second band: 190K x 50% = 95K.
88
+ expect(r.exemptAmount).toBeCloseTo(102_500, 2);
89
+ expect(r.taxableIncome).toBeCloseTo(97_500, 2);
90
+ });
91
+ it('$500,000 → still $102,500 (excess beyond bands)', () => {
92
+ const r = computeExemption(500_000, 'pte');
93
+ expect(r.exemptAmount).toBeCloseTo(102_500, 2);
94
+ expect(r.taxableIncome).toBeCloseTo(397_500, 2);
95
+ });
96
+ it('$5,000 → exempt $3,750 (first band partially filled)', () => {
97
+ const r = computeExemption(5_000, 'pte');
98
+ expect(r.exemptAmount).toBeCloseTo(3_750, 2);
99
+ expect(r.taxableIncome).toBeCloseTo(1_250, 2);
100
+ });
101
+ it('exact at first band boundary: $10,000 → exempt $7,500', () => {
102
+ const r = computeExemption(10_000, 'pte');
103
+ expect(r.exemptAmount).toBeCloseTo(7_500, 2);
104
+ expect(r.taxableIncome).toBeCloseTo(2_500, 2);
105
+ });
106
+ it('known benchmark: PTE on $200K effective rate', () => {
107
+ // taxable = 200K - 102.5K = 97.5K
108
+ // grossTax = 97.5K x 0.17 = 16,575
109
+ // effectiveRate = 16,575 / 200,000 = 0.082875 → rounds to 0.0829 (4dp)
110
+ const r = computeExemption(200_000, 'pte');
111
+ expect(r.effectiveRate).toBeCloseTo(0.0829, 4);
112
+ expect(r.effectiveRate).toBeLessThan(SG_CIT_RATE);
113
+ });
114
+ });
115
+ // ── No Exemption ──────────────────────────────────────────────────
116
+ describe('none', () => {
117
+ it('$200,000 → exempt $0, taxable = $200,000', () => {
118
+ const r = computeExemption(200_000, 'none');
119
+ expect(r.type).toBe('none');
120
+ expect(r.exemptAmount).toBe(0);
121
+ expect(r.taxableIncome).toBeCloseTo(200_000, 2);
122
+ expect(r.effectiveRate).toBe(SG_CIT_RATE);
123
+ });
124
+ it('$0 income → all zeros', () => {
125
+ const r = computeExemption(0, 'none');
126
+ expect(r.chargeableIncome).toBe(0);
127
+ expect(r.exemptAmount).toBe(0);
128
+ expect(r.taxableIncome).toBe(0);
129
+ expect(r.effectiveRate).toBe(0);
130
+ });
131
+ });
132
+ // ── Edge cases ────────────────────────────────────────────────────
133
+ describe('edge cases', () => {
134
+ it('negative chargeable income → all zeros', () => {
135
+ const r = computeExemption(-50_000, 'sute');
136
+ expect(r.chargeableIncome).toBe(0);
137
+ expect(r.exemptAmount).toBe(0);
138
+ expect(r.taxableIncome).toBe(0);
139
+ expect(r.effectiveRate).toBe(0);
140
+ });
141
+ });
142
+ // ── Invariants ────────────────────────────────────────────────────
143
+ describe('invariants', () => {
144
+ const cases = [
145
+ [0, 'sute'], [100, 'pte'], [10_000, 'sute'], [100_000, 'pte'],
146
+ [200_000, 'sute'], [500_000, 'none'], [1_000_000, 'pte'],
147
+ ];
148
+ it.each(cases)('taxableIncome = chargeableIncome - exemptAmount (%d, %s)', (ci, type) => {
149
+ const r = computeExemption(ci, type);
150
+ expect(r.taxableIncome).toBe(round2(r.chargeableIncome - r.exemptAmount));
151
+ });
152
+ it.each(cases)('all amounts are 2dp (%d, %s)', (ci, type) => {
153
+ const r = computeExemption(ci, type);
154
+ expect(round2(r.exemptAmount)).toBe(r.exemptAmount);
155
+ expect(round2(r.taxableIncome)).toBe(r.taxableIncome);
156
+ });
157
+ it.each(cases)('effective rate <= 17%% (%d, %s)', (ci, type) => {
158
+ const r = computeExemption(ci, type);
159
+ expect(r.effectiveRate).toBeLessThanOrEqual(SG_CIT_RATE);
160
+ });
161
+ it('SUTE effective rate < PTE effective rate (same income above both caps)', () => {
162
+ const sute = computeExemption(500_000, 'sute');
163
+ const pte = computeExemption(500_000, 'pte');
164
+ expect(sute.effectiveRate).toBeLessThan(pte.effectiveRate);
165
+ });
166
+ it('PTE effective rate < no exemption (same income above cap)', () => {
167
+ const pte = computeExemption(500_000, 'pte');
168
+ const none = computeExemption(500_000, 'none');
169
+ expect(pte.effectiveRate).toBeLessThan(none.effectiveRate);
170
+ });
171
+ });
172
+ });
173
+ // ── computeCitRebate ────────────────────────────────────────────────
174
+ describe('computeCitRebate', () => {
175
+ const round2 = (n) => Math.round(n * 100) / 100;
176
+ // ── YA 2024 (50%, cap $40K) ───────────────────────────────────────
177
+ describe('YA 2024', () => {
178
+ it('grossTax $10,000 → rebate $5,000 (50% x $10K)', () => {
179
+ expect(computeCitRebate(10_000, 2024)).toBeCloseTo(5_000, 2);
180
+ });
181
+ it('grossTax $100,000 → rebate $40,000 (50% x $100K = $50K, capped at $40K)', () => {
182
+ expect(computeCitRebate(100_000, 2024)).toBeCloseTo(40_000, 2);
183
+ });
184
+ });
185
+ // ── YA 2025 (same as 2024) ────────────────────────────────────────
186
+ describe('YA 2025', () => {
187
+ it('same rates as YA 2024', () => {
188
+ expect(computeCitRebate(10_000, 2025)).toBeCloseTo(5_000, 2);
189
+ expect(computeCitRebate(100_000, 2025)).toBeCloseTo(40_000, 2);
190
+ });
191
+ });
192
+ // ── YA 2026 (40%, cap $40K) ───────────────────────────────────────
193
+ describe('YA 2026', () => {
194
+ it('grossTax $10,000 → rebate $4,000 (40% x $10K)', () => {
195
+ expect(computeCitRebate(10_000, 2026)).toBeCloseTo(4_000, 2);
196
+ });
197
+ it('grossTax $200,000 → rebate $40,000 (40% x $200K = $80K, capped at $40K)', () => {
198
+ expect(computeCitRebate(200_000, 2026)).toBeCloseTo(40_000, 2);
199
+ });
200
+ });
201
+ // ── YA 2020 (25%, cap $15K) ───────────────────────────────────────
202
+ describe('YA 2020', () => {
203
+ it('grossTax $10,000 → rebate $2,500 (25% x $10K)', () => {
204
+ expect(computeCitRebate(10_000, 2020)).toBeCloseTo(2_500, 2);
205
+ });
206
+ it('grossTax $100,000 → rebate $15,000 (cap)', () => {
207
+ expect(computeCitRebate(100_000, 2020)).toBeCloseTo(15_000, 2);
208
+ });
209
+ });
210
+ // ── YA 2021 (0% — no rebate) ─────────────────────────────────────
211
+ describe('YA 2021', () => {
212
+ it('grossTax $10,000 → rebate $0 (no rebate)', () => {
213
+ expect(computeCitRebate(10_000, 2021)).toBe(0);
214
+ });
215
+ });
216
+ // ── Edge cases ────────────────────────────────────────────────────
217
+ describe('edge cases', () => {
218
+ it('YA with no schedule entry (e.g. 2030) → $0', () => {
219
+ expect(computeCitRebate(10_000, 2030)).toBe(0);
220
+ });
221
+ it('grossTax $0 → rebate $0', () => {
222
+ expect(computeCitRebate(0, 2024)).toBe(0);
223
+ });
224
+ it('negative grossTax → rebate $0', () => {
225
+ expect(computeCitRebate(-1_000, 2024)).toBe(0);
226
+ });
227
+ it('result is always 2dp', () => {
228
+ const rebate = computeCitRebate(33_333, 2024);
229
+ expect(round2(rebate)).toBe(rebate);
230
+ });
231
+ });
232
+ });