jaz-cli 2.8.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 (45) 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 +58 -1
  4. package/assets/skills/jobs/references/sg-tax/add-backs-guide.md +354 -0
  5. package/assets/skills/jobs/references/sg-tax/capital-allowances-guide.md +343 -0
  6. package/assets/skills/jobs/references/sg-tax/data-extraction.md +408 -0
  7. package/assets/skills/jobs/references/sg-tax/enhanced-deductions.md +248 -0
  8. package/assets/skills/jobs/references/sg-tax/exemptions-and-rebates.md +197 -0
  9. package/assets/skills/jobs/references/sg-tax/form-cs-fields.md +191 -0
  10. package/assets/skills/jobs/references/sg-tax/ifrs16-tax-adjustment.md +194 -0
  11. package/assets/skills/jobs/references/sg-tax/losses-and-carry-forwards.md +269 -0
  12. package/assets/skills/jobs/references/sg-tax/overview.md +207 -0
  13. package/assets/skills/jobs/references/sg-tax/wizard-workflow.md +391 -0
  14. package/assets/skills/transaction-recipes/SKILL.md +1 -1
  15. package/dist/__tests__/jobs-audit-prep.test.js +3 -3
  16. package/dist/__tests__/jobs-bank-recon.test.js +5 -5
  17. package/dist/__tests__/jobs-credit-control.test.js +1 -1
  18. package/dist/__tests__/jobs-fa-review.test.js +1 -1
  19. package/dist/__tests__/jobs-payment-run.test.js +3 -3
  20. package/dist/__tests__/jobs-supplier-recon.test.js +6 -6
  21. package/dist/__tests__/tax-sg-capital-allowances.test.js +389 -0
  22. package/dist/__tests__/tax-sg-exemptions.test.js +232 -0
  23. package/dist/__tests__/tax-sg-form-cs.test.js +687 -0
  24. package/dist/__tests__/tax-validate.test.js +208 -0
  25. package/dist/commands/init.js +7 -2
  26. package/dist/commands/jobs.js +1 -1
  27. package/dist/commands/tax.js +195 -0
  28. package/dist/index.js +2 -0
  29. package/dist/jobs/audit-prep.js +4 -4
  30. package/dist/jobs/bank-recon.js +4 -4
  31. package/dist/jobs/credit-control.js +5 -5
  32. package/dist/jobs/fa-review.js +4 -4
  33. package/dist/jobs/gst-vat.js +3 -3
  34. package/dist/jobs/payment-run.js +4 -4
  35. package/dist/jobs/supplier-recon.js +3 -3
  36. package/dist/tax/format.js +18 -0
  37. package/dist/tax/sg/capital-allowances.js +160 -0
  38. package/dist/tax/sg/constants.js +63 -0
  39. package/dist/tax/sg/exemptions.js +76 -0
  40. package/dist/tax/sg/form-cs.js +349 -0
  41. package/dist/tax/sg/format-sg.js +134 -0
  42. package/dist/tax/types.js +9 -0
  43. package/dist/tax/validate.js +124 -0
  44. package/dist/utils/template.js +1 -1
  45. package/package.json +1 -1
@@ -0,0 +1,687 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { computeFormCs } from '../tax/sg/form-cs.js';
3
+ // ── Helper factory ──────────────────────────────────────────────
4
+ function makeInput(overrides = {}) {
5
+ return {
6
+ ya: 2026,
7
+ basisPeriodStart: '2025-01-01',
8
+ basisPeriodEnd: '2025-12-31',
9
+ revenue: 500_000,
10
+ accountingProfit: 100_000,
11
+ addBacks: {
12
+ depreciation: 0,
13
+ amortization: 0,
14
+ rouDepreciation: 0,
15
+ leaseInterest: 0,
16
+ generalProvisions: 0,
17
+ donations: 0,
18
+ entertainment: 0,
19
+ penalties: 0,
20
+ privateCar: 0,
21
+ capitalExpOnPnl: 0,
22
+ unrealizedFxLoss: 0,
23
+ otherNonDeductible: 0,
24
+ },
25
+ deductions: {
26
+ actualLeasePayments: 0,
27
+ unrealizedFxGain: 0,
28
+ exemptDividends: 0,
29
+ exemptIncome: 0,
30
+ otherDeductions: 0,
31
+ },
32
+ capitalAllowances: {
33
+ currentYearClaim: 0,
34
+ balanceBroughtForward: 0,
35
+ },
36
+ enhancedDeductions: {
37
+ rdExpenditure: 0,
38
+ rdMultiplier: 2.5,
39
+ ipRegistration: 0,
40
+ ipMultiplier: 2.0,
41
+ donations250Base: 0,
42
+ s14qRenovation: 0,
43
+ },
44
+ losses: { broughtForward: 0 },
45
+ donationsCarryForward: { broughtForward: 0 },
46
+ exemptionType: 'pte',
47
+ ...overrides,
48
+ };
49
+ }
50
+ /** Merge nested addBacks while keeping all other defaults. */
51
+ function withAddBacks(partial, base) {
52
+ const input = makeInput(base);
53
+ return { ...input, addBacks: { ...input.addBacks, ...partial } };
54
+ }
55
+ /** Merge nested deductions while keeping all other defaults. */
56
+ function withDeductions(partial, base) {
57
+ const input = makeInput(base);
58
+ return { ...input, deductions: { ...input.deductions, ...partial } };
59
+ }
60
+ // ── Form type determination ─────────────────────────────────────
61
+ describe('Form type determination', () => {
62
+ it('1. revenue $200K -> formType C-S Lite', () => {
63
+ const r = computeFormCs(makeInput({ revenue: 200_000 }));
64
+ expect(r.formType).toBe('C-S Lite');
65
+ expect(r.eligible).toBe(true);
66
+ });
67
+ it('2. revenue $500K -> formType C-S', () => {
68
+ const r = computeFormCs(makeInput({ revenue: 500_000 }));
69
+ expect(r.formType).toBe('C-S');
70
+ expect(r.eligible).toBe(true);
71
+ });
72
+ it('3. revenue $5M -> formType C-S, eligible', () => {
73
+ const r = computeFormCs(makeInput({ revenue: 5_000_000 }));
74
+ expect(r.formType).toBe('C-S');
75
+ expect(r.eligible).toBe(true);
76
+ });
77
+ it('4. revenue $5.1M -> not eligible', () => {
78
+ const r = computeFormCs(makeInput({ revenue: 5_100_000 }));
79
+ expect(r.formType).toBe('C-S');
80
+ expect(r.eligible).toBe(false);
81
+ });
82
+ });
83
+ // ── Basic computation — simple profitable company ───────────────
84
+ describe('Basic computation — simple profitable company', () => {
85
+ it('5. profit $100K, PTE — chargeableIncome, exempt, taxable, grossTax, rebate', () => {
86
+ const r = computeFormCs(makeInput());
87
+ expect(r.chargeableIncome).toBeCloseTo(100_000, 2);
88
+ // PTE: 10K×75% = 7,500 + 90K×50% = 45,000 → 52,500 exempt
89
+ expect(r.exemptAmount).toBeCloseTo(52_500, 2);
90
+ expect(r.taxableIncome).toBeCloseTo(47_500, 2);
91
+ // 47,500 × 17% = 8,075
92
+ expect(r.grossTax).toBeCloseTo(8_075, 2);
93
+ // YA 2026: 40% capped at 40K → 8,075 × 0.40 = 3,230
94
+ expect(r.citRebate).toBeCloseTo(3_230, 2);
95
+ expect(r.netTaxPayable).toBeCloseTo(4_845, 2);
96
+ });
97
+ it('6. profit $100K, SUTE — exempt $75K, taxable $25K', () => {
98
+ const r = computeFormCs(makeInput({ exemptionType: 'sute' }));
99
+ // SUTE: 100K × 75% = 75,000 exempt
100
+ expect(r.exemptAmount).toBeCloseTo(75_000, 2);
101
+ expect(r.taxableIncome).toBeCloseTo(25_000, 2);
102
+ // 25,000 × 17% = 4,250
103
+ expect(r.grossTax).toBeCloseTo(4_250, 2);
104
+ });
105
+ });
106
+ // ── Add-backs ───────────────────────────────────────────────────
107
+ describe('Add-backs', () => {
108
+ it('7. profit $100K + depreciation $15K -> adjustedProfit $115K', () => {
109
+ const input = withAddBacks({ depreciation: 15_000 });
110
+ const r = computeFormCs(input);
111
+ expect(r.totalAddBacks).toBeCloseTo(15_000, 2);
112
+ expect(r.adjustedProfit).toBeCloseTo(115_000, 2);
113
+ });
114
+ it('8. all add-back fields populated -> totalAddBacks is sum of all', () => {
115
+ const input = withAddBacks({
116
+ depreciation: 10_000,
117
+ amortization: 5_000,
118
+ rouDepreciation: 3_000,
119
+ leaseInterest: 2_000,
120
+ generalProvisions: 4_000,
121
+ donations: 1_000,
122
+ entertainment: 500,
123
+ penalties: 300,
124
+ privateCar: 200,
125
+ capitalExpOnPnl: 1_500,
126
+ unrealizedFxLoss: 800,
127
+ otherNonDeductible: 100,
128
+ });
129
+ const r = computeFormCs(input);
130
+ const expected = 10_000 + 5_000 + 3_000 + 2_000 + 4_000 + 1_000 + 500 + 300 + 200 + 1_500 + 800 + 100;
131
+ expect(r.totalAddBacks).toBeCloseTo(expected, 2);
132
+ expect(r.adjustedProfit).toBeCloseTo(100_000 + expected, 2);
133
+ });
134
+ it('9. only non-zero add-backs appear in schedule', () => {
135
+ const input = withAddBacks({ depreciation: 5_000, entertainment: 2_000 });
136
+ const r = computeFormCs(input);
137
+ const subItems = r.schedule.filter(s => s.indent === 1 && s.label !== '');
138
+ const labels = subItems.map(s => s.label);
139
+ expect(labels).toContain('Depreciation');
140
+ expect(labels).toContain('Entertainment');
141
+ expect(labels).not.toContain('Amortization');
142
+ expect(labels).not.toContain('Penalties & fines');
143
+ });
144
+ });
145
+ // ── Deductions ──────────────────────────────────────────────────
146
+ describe('Deductions', () => {
147
+ it('10. deductions (actual lease $5K, unrealizedFxGain $2K) -> adjustedProfit $93K', () => {
148
+ const input = withDeductions({ actualLeasePayments: 5_000, unrealizedFxGain: 2_000 });
149
+ const r = computeFormCs(input);
150
+ expect(r.totalDeductions).toBeCloseTo(7_000, 2);
151
+ expect(r.adjustedProfit).toBeCloseTo(93_000, 2);
152
+ });
153
+ it('11. add-backs AND deductions combined', () => {
154
+ let input = withAddBacks({ depreciation: 20_000 });
155
+ input = { ...input, deductions: { ...input.deductions, actualLeasePayments: 10_000 } };
156
+ const r = computeFormCs(input);
157
+ // 100K + 20K - 10K = 110K
158
+ expect(r.adjustedProfit).toBeCloseTo(110_000, 2);
159
+ });
160
+ });
161
+ // ── Capital allowances (set-off order) ──────────────────────────
162
+ describe('Capital allowances', () => {
163
+ it('12. adjusted profit $100K, current CA $30K -> remaining $70K', () => {
164
+ const input = makeInput({
165
+ capitalAllowances: { currentYearClaim: 30_000, balanceBroughtForward: 0 },
166
+ });
167
+ const r = computeFormCs(input);
168
+ expect(r.capitalAllowanceClaim).toBeCloseTo(30_000, 2);
169
+ expect(r.chargeableIncomeBeforeLosses).toBeCloseTo(70_000, 2);
170
+ });
171
+ it('13. adjusted profit $100K, current CA $30K + unabsorbed b/f CA $20K -> remaining $50K', () => {
172
+ const input = makeInput({
173
+ capitalAllowances: { currentYearClaim: 30_000, balanceBroughtForward: 20_000 },
174
+ });
175
+ const r = computeFormCs(input);
176
+ expect(r.capitalAllowanceClaim).toBeCloseTo(50_000, 2);
177
+ expect(r.chargeableIncomeBeforeLosses).toBeCloseTo(50_000, 2);
178
+ });
179
+ it('14. adjusted profit $50K, current CA $80K -> claim only $50K, carry forward $30K', () => {
180
+ const input = makeInput({
181
+ accountingProfit: 50_000,
182
+ capitalAllowances: { currentYearClaim: 80_000, balanceBroughtForward: 0 },
183
+ });
184
+ const r = computeFormCs(input);
185
+ expect(r.capitalAllowanceClaim).toBeCloseTo(50_000, 2);
186
+ expect(r.chargeableIncomeBeforeLosses).toBeCloseTo(0, 2);
187
+ expect(r.unabsorbedCapitalAllowances).toBeCloseTo(30_000, 2);
188
+ });
189
+ it('15. adjusted profit $0 (zero), CA $30K -> claim $0, carry forward $30K', () => {
190
+ const input = makeInput({
191
+ accountingProfit: 0,
192
+ capitalAllowances: { currentYearClaim: 30_000, balanceBroughtForward: 0 },
193
+ });
194
+ const r = computeFormCs(input);
195
+ expect(r.capitalAllowanceClaim).toBeCloseTo(0, 2);
196
+ expect(r.unabsorbedCapitalAllowances).toBeCloseTo(30_000, 2);
197
+ });
198
+ it('16. negative adjusted profit: no CA claimed, full CA carried forward', () => {
199
+ // profit = -20K, addBacks = 0, deductions = 0 -> adjustedProfit = -20K
200
+ const input = makeInput({
201
+ accountingProfit: -20_000,
202
+ capitalAllowances: { currentYearClaim: 30_000, balanceBroughtForward: 10_000 },
203
+ });
204
+ const r = computeFormCs(input);
205
+ expect(r.capitalAllowanceClaim).toBeCloseTo(0, 2);
206
+ expect(r.unabsorbedCapitalAllowances).toBeCloseTo(40_000, 2);
207
+ });
208
+ });
209
+ // ── Enhanced deductions ─────────────────────────────────────────
210
+ describe('Enhanced deductions', () => {
211
+ it('17. R&D expenditure $10K at 2.5x -> enhanced portion = $15K', () => {
212
+ const input = makeInput({
213
+ enhancedDeductions: {
214
+ rdExpenditure: 10_000,
215
+ rdMultiplier: 2.5,
216
+ ipRegistration: 0,
217
+ ipMultiplier: 2.0,
218
+ donations250Base: 0,
219
+ s14qRenovation: 0,
220
+ },
221
+ });
222
+ const r = computeFormCs(input);
223
+ // Enhanced R&D = 10K × (2.5 - 1) = 15K
224
+ expect(r.enhancedDeductionTotal).toBeCloseTo(15_000, 2);
225
+ // CI before losses = 100K - 0 (CA) - 15K (enhanced) = 85K
226
+ expect(r.chargeableIncomeBeforeLosses).toBeCloseTo(85_000, 2);
227
+ });
228
+ it('18. R&D expenditure $10K at 4.0x -> enhanced portion = $30K', () => {
229
+ const input = makeInput({
230
+ enhancedDeductions: {
231
+ rdExpenditure: 10_000,
232
+ rdMultiplier: 4.0,
233
+ ipRegistration: 0,
234
+ ipMultiplier: 2.0,
235
+ donations250Base: 0,
236
+ s14qRenovation: 0,
237
+ },
238
+ });
239
+ const r = computeFormCs(input);
240
+ expect(r.enhancedDeductionTotal).toBeCloseTo(30_000, 2);
241
+ });
242
+ it('19. IP registration $5K at 2.0x -> enhanced = $5K', () => {
243
+ const input = makeInput({
244
+ enhancedDeductions: {
245
+ rdExpenditure: 0,
246
+ rdMultiplier: 2.5,
247
+ ipRegistration: 5_000,
248
+ ipMultiplier: 2.0,
249
+ donations250Base: 0,
250
+ s14qRenovation: 0,
251
+ },
252
+ });
253
+ const r = computeFormCs(input);
254
+ // IP enhanced = 5K × (2.0 - 1) = 5K
255
+ expect(r.enhancedDeductionTotal).toBeCloseTo(5_000, 2);
256
+ });
257
+ it('20. donations 250%: base $10K -> enhanced = $15K', () => {
258
+ const input = makeInput({
259
+ enhancedDeductions: {
260
+ rdExpenditure: 0,
261
+ rdMultiplier: 2.5,
262
+ ipRegistration: 0,
263
+ ipMultiplier: 2.0,
264
+ donations250Base: 10_000,
265
+ s14qRenovation: 0,
266
+ },
267
+ });
268
+ const r = computeFormCs(input);
269
+ // Donation enhanced = 10K × (2.5 - 1) = 15K
270
+ expect(r.enhancedDeductionTotal).toBeCloseTo(15_000, 2);
271
+ });
272
+ it('21. S14Q renovation $20K -> enhanced = $20K', () => {
273
+ const input = makeInput({
274
+ enhancedDeductions: {
275
+ rdExpenditure: 0,
276
+ rdMultiplier: 2.5,
277
+ ipRegistration: 0,
278
+ ipMultiplier: 2.0,
279
+ donations250Base: 0,
280
+ s14qRenovation: 20_000,
281
+ },
282
+ });
283
+ const r = computeFormCs(input);
284
+ expect(r.enhancedDeductionTotal).toBeCloseTo(20_000, 2);
285
+ });
286
+ it('22. enhanced claims capped at remaining CI', () => {
287
+ // profit $30K, CA $10K -> remaining $20K, but enhanced total = $30K
288
+ const input = makeInput({
289
+ accountingProfit: 30_000,
290
+ capitalAllowances: { currentYearClaim: 10_000, balanceBroughtForward: 0 },
291
+ enhancedDeductions: {
292
+ rdExpenditure: 20_000,
293
+ rdMultiplier: 2.5,
294
+ ipRegistration: 0,
295
+ ipMultiplier: 2.0,
296
+ donations250Base: 0,
297
+ s14qRenovation: 0,
298
+ },
299
+ });
300
+ const r = computeFormCs(input);
301
+ // Adjusted profit = 30K, after CA = 20K, enhanced total = 20K×(2.5-1) = 30K
302
+ // Capped at 20K
303
+ expect(r.enhancedDeductionTotal).toBeCloseTo(20_000, 2);
304
+ // chargeableIncomeBeforeLosses floored at 0 because remainingCI went to 0
305
+ // then prior CA = 0 claimed
306
+ expect(r.chargeableIncomeBeforeLosses).toBeCloseTo(0, 2);
307
+ });
308
+ });
309
+ // ── Loss relief ─────────────────────────────────────────────────
310
+ describe('Loss relief', () => {
311
+ it('23. CI before losses $80K, losses b/f $50K -> lossRelief $50K, CI $30K', () => {
312
+ // adjustedProfit = $80K → chargeableIncomeBeforeLosses = $80K
313
+ const input = makeInput({
314
+ accountingProfit: 80_000,
315
+ losses: { broughtForward: 50_000 },
316
+ });
317
+ const r = computeFormCs(input);
318
+ expect(r.chargeableIncomeBeforeLosses).toBeCloseTo(80_000, 2);
319
+ expect(r.lossRelief).toBeCloseTo(50_000, 2);
320
+ expect(r.chargeableIncome).toBeCloseTo(30_000, 2);
321
+ });
322
+ it('24. CI before losses $30K, losses b/f $50K -> lossRelief $30K', () => {
323
+ const input = makeInput({
324
+ accountingProfit: 30_000,
325
+ losses: { broughtForward: 50_000 },
326
+ });
327
+ const r = computeFormCs(input);
328
+ expect(r.lossRelief).toBeCloseTo(30_000, 2);
329
+ expect(r.chargeableIncome).toBeCloseTo(0, 2);
330
+ expect(r.unabsorbedLosses).toBeCloseTo(20_000, 2);
331
+ });
332
+ it('25. CI before losses $0 -> no loss relief, full carry forward', () => {
333
+ const input = makeInput({
334
+ accountingProfit: 0,
335
+ losses: { broughtForward: 50_000 },
336
+ });
337
+ const r = computeFormCs(input);
338
+ expect(r.lossRelief).toBeCloseTo(0, 2);
339
+ expect(r.unabsorbedLosses).toBeCloseTo(50_000, 2);
340
+ });
341
+ });
342
+ // ── Donation relief ─────────────────────────────────────────────
343
+ describe('Donation relief', () => {
344
+ it('26. CI after losses $50K, donations base $10K (x250% = $25K) -> relief $25K', () => {
345
+ const input = makeInput({
346
+ accountingProfit: 50_000,
347
+ enhancedDeductions: {
348
+ rdExpenditure: 0,
349
+ rdMultiplier: 2.5,
350
+ ipRegistration: 0,
351
+ ipMultiplier: 2.0,
352
+ donations250Base: 10_000,
353
+ s14qRenovation: 0,
354
+ },
355
+ });
356
+ const r = computeFormCs(input);
357
+ // Enhanced deduction from donations: 10K × (2.5-1) = 15K
358
+ // Adjusted profit = 50K, after enhanced = 50K - 15K = 35K (chargeableIncomeBeforeLosses)
359
+ // ciAfterLosses = 35K
360
+ // Donation relief: current donation 250% = 10K × 2.5 = 25K
361
+ // Relief = min(25K, 35K) = 25K
362
+ expect(r.donationRelief).toBeCloseTo(25_000, 2);
363
+ // chargeableIncome = 35K - 25K = 10K
364
+ expect(r.chargeableIncome).toBeCloseTo(10_000, 2);
365
+ });
366
+ it('27. CI after losses $20K, donations available $30K -> relief capped at $20K', () => {
367
+ const input = makeInput({
368
+ accountingProfit: 20_000,
369
+ enhancedDeductions: {
370
+ rdExpenditure: 0,
371
+ rdMultiplier: 2.5,
372
+ ipRegistration: 0,
373
+ ipMultiplier: 2.0,
374
+ donations250Base: 12_000, // 12K × 2.5 = 30K
375
+ s14qRenovation: 0,
376
+ },
377
+ });
378
+ const r = computeFormCs(input);
379
+ // Enhanced deduction: 12K × (2.5-1) = 18K, capped at 20K remaining = 18K
380
+ // chargeableIncomeBeforeLosses = 20K - 18K = 2K
381
+ // Donation relief: 12K × 2.5 = 30K, capped at 2K
382
+ expect(r.donationRelief).toBeCloseTo(2_000, 2);
383
+ expect(r.chargeableIncome).toBeCloseTo(0, 2);
384
+ });
385
+ it('28. prior donations b/f $5K + current $25K -> uses current first', () => {
386
+ const input = makeInput({
387
+ accountingProfit: 100_000,
388
+ enhancedDeductions: {
389
+ rdExpenditure: 0,
390
+ rdMultiplier: 2.5,
391
+ ipRegistration: 0,
392
+ ipMultiplier: 2.0,
393
+ donations250Base: 10_000, // 10K × 2.5 = 25K
394
+ s14qRenovation: 0,
395
+ },
396
+ donationsCarryForward: { broughtForward: 5_000 },
397
+ });
398
+ const r = computeFormCs(input);
399
+ // Enhanced deduction: 10K × (2.5-1) = 15K
400
+ // Adjusted profit = 100K, after enhanced = 85K
401
+ // Donation relief: current = 10K × 2.5 = 25K, + prior = 5K = 30K total
402
+ // CI after losses = 85K, donation relief = min(30K, 85K) = 30K
403
+ expect(r.donationRelief).toBeCloseTo(30_000, 2);
404
+ // Chargeable income = 85K - 30K = 55K
405
+ expect(r.chargeableIncome).toBeCloseTo(55_000, 2);
406
+ // All prior donations used
407
+ expect(r.unabsorbedDonations).toBeCloseTo(0, 2);
408
+ });
409
+ });
410
+ // ── Chargeable income floor ─────────────────────────────────────
411
+ describe('Chargeable income floor', () => {
412
+ it('29. all reliefs exceed income -> chargeableIncome = 0', () => {
413
+ const input = makeInput({
414
+ accountingProfit: 10_000,
415
+ capitalAllowances: { currentYearClaim: 5_000, balanceBroughtForward: 0 },
416
+ losses: { broughtForward: 20_000 },
417
+ });
418
+ const r = computeFormCs(input);
419
+ // Adjusted profit = 10K, CA = 5K, CI before losses = 5K
420
+ // Losses = 20K but capped at 5K
421
+ expect(r.chargeableIncome).toBeCloseTo(0, 2);
422
+ expect(r.grossTax).toBeCloseTo(0, 2);
423
+ expect(r.netTaxPayable).toBeCloseTo(0, 2);
424
+ });
425
+ });
426
+ // ── Tax computation ─────────────────────────────────────────────
427
+ describe('Tax computation', () => {
428
+ it('30. CI $200K, PTE -> exempt $102,500, taxable $97,500, grossTax $16,575', () => {
429
+ const input = makeInput({ accountingProfit: 200_000 });
430
+ const r = computeFormCs(input);
431
+ expect(r.chargeableIncome).toBeCloseTo(200_000, 2);
432
+ // PTE: 10K×75% = 7,500 + 190K×50% = 95,000 → 102,500
433
+ expect(r.exemptAmount).toBeCloseTo(102_500, 2);
434
+ expect(r.taxableIncome).toBeCloseTo(97_500, 2);
435
+ expect(r.grossTax).toBeCloseTo(16_575, 2);
436
+ });
437
+ it('31. CI $200K, SUTE -> exempt $125,000, taxable $75,000, grossTax $12,750', () => {
438
+ const input = makeInput({ accountingProfit: 200_000, exemptionType: 'sute' });
439
+ const r = computeFormCs(input);
440
+ expect(r.exemptAmount).toBeCloseTo(125_000, 2);
441
+ expect(r.taxableIncome).toBeCloseTo(75_000, 2);
442
+ expect(r.grossTax).toBeCloseTo(12_750, 2);
443
+ });
444
+ it('32. CI $200K, none -> exempt $0, taxable $200K, grossTax $34,000', () => {
445
+ const input = makeInput({ accountingProfit: 200_000, exemptionType: 'none' });
446
+ const r = computeFormCs(input);
447
+ expect(r.exemptAmount).toBeCloseTo(0, 2);
448
+ expect(r.taxableIncome).toBeCloseTo(200_000, 2);
449
+ expect(r.grossTax).toBeCloseTo(34_000, 2);
450
+ });
451
+ });
452
+ // ── CIT rebate integration ──────────────────────────────────────
453
+ describe('CIT rebate integration', () => {
454
+ it('33. YA 2026, grossTax $10,000 -> rebate $4,000 (40%)', () => {
455
+ // Need taxableIncome to produce grossTax ~ $10K: $10K / 0.17 = 58,823.53
456
+ // But easier: set CI high with 'none' exemption so taxable = CI
457
+ // $10K / 0.17 = 58,823.5294... Use exact: accountingProfit where taxable = profit
458
+ // With none exemption: grossTax = profit × 0.17
459
+ // Want grossTax = 10K → profit = 10K / 0.17 ≈ 58823.53 (not exact)
460
+ // Simpler: just verify the rebate in an end-to-end scenario
461
+ // Profit $100K PTE → grossTax = $8,075 → rebate = $8,075 × 0.40 = $3,230
462
+ const r = computeFormCs(makeInput({ ya: 2026 }));
463
+ expect(r.grossTax).toBeCloseTo(8_075, 2);
464
+ expect(r.citRebate).toBeCloseTo(3_230, 2);
465
+ });
466
+ it('34. YA 2024, grossTax $100K -> rebate $40,000 (50%, capped)', () => {
467
+ // profit $1M, none exemption → taxable = $1M, grossTax = $170K
468
+ // rebate = min(170K × 0.50, 40K) = 40K
469
+ const input = makeInput({
470
+ ya: 2024,
471
+ accountingProfit: 1_000_000,
472
+ exemptionType: 'none',
473
+ });
474
+ const r = computeFormCs(input);
475
+ expect(r.grossTax).toBeCloseTo(170_000, 2);
476
+ expect(r.citRebate).toBeCloseTo(40_000, 2);
477
+ });
478
+ it('35. YA 2021, grossTax $10,000 -> rebate $0', () => {
479
+ const input = makeInput({ ya: 2021, exemptionType: 'none' });
480
+ const r = computeFormCs(input);
481
+ expect(r.citRebate).toBeCloseTo(0, 2);
482
+ });
483
+ });
484
+ // ── Net tax payable ─────────────────────────────────────────────
485
+ describe('Net tax payable', () => {
486
+ it('36. gross tax $8,075 minus rebate -> final net tax', () => {
487
+ const r = computeFormCs(makeInput());
488
+ // grossTax = 8,075, rebate = 3,230
489
+ expect(r.netTaxPayable).toBeCloseTo(4_845, 2);
490
+ });
491
+ });
492
+ // ── Carry-forwards ──────────────────────────────────────────────
493
+ describe('Carry-forwards', () => {
494
+ it('37. current year loss: adjusted profit negative -> adds to unabsorbedLosses', () => {
495
+ const input = makeInput({
496
+ accountingProfit: -50_000,
497
+ losses: { broughtForward: 10_000 },
498
+ });
499
+ const r = computeFormCs(input);
500
+ // adjustedProfit = -50K (loss), currentYearLoss = 50K
501
+ // unabsorbedLosses = 10K (b/f, none used) + 50K (current) = 60K
502
+ expect(r.unabsorbedLosses).toBeCloseTo(60_000, 2);
503
+ });
504
+ it('38. excess CA -> unabsorbedCapitalAllowances', () => {
505
+ const input = makeInput({
506
+ accountingProfit: 20_000,
507
+ capitalAllowances: { currentYearClaim: 50_000, balanceBroughtForward: 15_000 },
508
+ });
509
+ const r = computeFormCs(input);
510
+ // Adjusted profit = 20K, current CA claim = 20K, excess = 30K
511
+ // Prior CA claim = 0 (remainingCI = 0), excess = 15K
512
+ // Total unabsorbed CA = 30K + 15K = 45K
513
+ expect(r.unabsorbedCapitalAllowances).toBeCloseTo(45_000, 2);
514
+ });
515
+ it('39. excess donations -> unabsorbedDonations', () => {
516
+ const input = makeInput({
517
+ accountingProfit: 10_000,
518
+ donationsCarryForward: { broughtForward: 20_000 },
519
+ enhancedDeductions: {
520
+ rdExpenditure: 0,
521
+ rdMultiplier: 2.5,
522
+ ipRegistration: 0,
523
+ ipMultiplier: 2.0,
524
+ donations250Base: 0,
525
+ s14qRenovation: 0,
526
+ },
527
+ });
528
+ const r = computeFormCs(input);
529
+ // CI = 10K, donation available = 0 (current) + 20K (b/f) = 20K
530
+ // Relief = min(20K, 10K) = 10K
531
+ // Current donation used = min(0, 10K) = 0
532
+ // Prior donation used = 10K - 0 = 10K
533
+ // Unabsorbed = 20K - 10K = 10K
534
+ expect(r.donationRelief).toBeCloseTo(10_000, 2);
535
+ expect(r.unabsorbedDonations).toBeCloseTo(10_000, 2);
536
+ });
537
+ it('40. no excess -> all carry-forwards zero', () => {
538
+ const r = computeFormCs(makeInput());
539
+ expect(r.unabsorbedLosses).toBeCloseTo(0, 2);
540
+ expect(r.unabsorbedCapitalAllowances).toBeCloseTo(0, 2);
541
+ expect(r.unabsorbedDonations).toBeCloseTo(0, 2);
542
+ });
543
+ });
544
+ // ── Full end-to-end scenarios ───────────────────────────────────
545
+ describe('Full end-to-end scenarios', () => {
546
+ it('41. simple SMB: revenue $800K, profit $120K, depreciation + entertainment, PTE, YA 2026', () => {
547
+ let input = withAddBacks({ depreciation: 15_000, entertainment: 3_000 }, { revenue: 800_000, accountingProfit: 120_000 });
548
+ const r = computeFormCs(input);
549
+ expect(r.formType).toBe('C-S');
550
+ expect(r.eligible).toBe(true);
551
+ // Add-backs: 15K + 3K = 18K
552
+ expect(r.totalAddBacks).toBeCloseTo(18_000, 2);
553
+ // Adjusted profit: 120K + 18K = 138K
554
+ expect(r.adjustedProfit).toBeCloseTo(138_000, 2);
555
+ // No CA, no enhanced, no losses, no donations
556
+ expect(r.chargeableIncome).toBeCloseTo(138_000, 2);
557
+ // PTE: 10K×75% = 7,500 + 128K×50% = 64,000 → 71,500 exempt
558
+ // (128K because min(128K, 190K) for second band)
559
+ expect(r.exemptAmount).toBeCloseTo(71_500, 2);
560
+ // Taxable: 138K - 71,500 = 66,500
561
+ expect(r.taxableIncome).toBeCloseTo(66_500, 2);
562
+ // Gross tax: 66,500 × 0.17 = 11,305
563
+ expect(r.grossTax).toBeCloseTo(11_305, 2);
564
+ // Rebate YA 2026: 11,305 × 0.40 = 4,522
565
+ expect(r.citRebate).toBeCloseTo(4_522, 2);
566
+ // Net tax: 11,305 - 4,522 = 6,783
567
+ expect(r.netTaxPayable).toBeCloseTo(6_783, 2);
568
+ });
569
+ it('42. loss-making company: revenue $300K, loss -$50K, depreciation $10K', () => {
570
+ let input = withAddBacks({ depreciation: 10_000 }, { revenue: 300_000, accountingProfit: -50_000 });
571
+ const r = computeFormCs(input);
572
+ // Adjusted profit: -50K + 10K = -40K
573
+ expect(r.adjustedProfit).toBeCloseTo(-40_000, 2);
574
+ // No CA claimed, no enhanced claimed
575
+ expect(r.capitalAllowanceClaim).toBeCloseTo(0, 2);
576
+ expect(r.chargeableIncomeBeforeLosses).toBeCloseTo(0, 2);
577
+ expect(r.chargeableIncome).toBeCloseTo(0, 2);
578
+ expect(r.grossTax).toBeCloseTo(0, 2);
579
+ expect(r.netTaxPayable).toBeCloseTo(0, 2);
580
+ // Current year loss = 40K
581
+ expect(r.unabsorbedLosses).toBeCloseTo(40_000, 2);
582
+ });
583
+ it('43. company with IFRS 16: ROU depr + lease interest add-back, actual payments deduction', () => {
584
+ let input = withAddBacks({ rouDepreciation: 20_000, leaseInterest: 5_000 }, { accountingProfit: 200_000 });
585
+ input = { ...input, deductions: { ...input.deductions, actualLeasePayments: 22_000 } };
586
+ const r = computeFormCs(input);
587
+ // Add-backs: 20K + 5K = 25K
588
+ expect(r.totalAddBacks).toBeCloseTo(25_000, 2);
589
+ // Deductions: 22K
590
+ expect(r.totalDeductions).toBeCloseTo(22_000, 2);
591
+ // Adjusted profit: 200K + 25K - 22K = 203K (net IFRS 16 adjustment = +3K)
592
+ expect(r.adjustedProfit).toBeCloseTo(203_000, 2);
593
+ });
594
+ it('44. full reliefs: profit $100K, depreciation $10K, CA $40K, losses b/f $30K, PTE, YA 2025', () => {
595
+ let input = withAddBacks({ depreciation: 10_000 }, {
596
+ ya: 2025,
597
+ accountingProfit: 100_000,
598
+ capitalAllowances: { currentYearClaim: 40_000, balanceBroughtForward: 0 },
599
+ losses: { broughtForward: 30_000 },
600
+ });
601
+ const r = computeFormCs(input);
602
+ // Adjusted profit: 100K + 10K = 110K
603
+ expect(r.adjustedProfit).toBeCloseTo(110_000, 2);
604
+ // CA: 40K claimed
605
+ expect(r.capitalAllowanceClaim).toBeCloseTo(40_000, 2);
606
+ // CI before losses: 110K - 40K = 70K
607
+ expect(r.chargeableIncomeBeforeLosses).toBeCloseTo(70_000, 2);
608
+ // Loss relief: min(30K, 70K) = 30K
609
+ expect(r.lossRelief).toBeCloseTo(30_000, 2);
610
+ // Chargeable income: 70K - 30K = 40K
611
+ expect(r.chargeableIncome).toBeCloseTo(40_000, 2);
612
+ // PTE: 10K×75% = 7,500 + 30K×50% = 15,000 → 22,500 exempt
613
+ expect(r.exemptAmount).toBeCloseTo(22_500, 2);
614
+ // Taxable: 40K - 22,500 = 17,500
615
+ expect(r.taxableIncome).toBeCloseTo(17_500, 2);
616
+ // Gross tax: 17,500 × 0.17 = 2,975
617
+ expect(r.grossTax).toBeCloseTo(2_975, 2);
618
+ // YA 2025 rebate: 50% capped at 40K → 2,975 × 0.50 = 1,487.50
619
+ expect(r.citRebate).toBeCloseTo(1_487.50, 2);
620
+ // Net tax: 2,975 - 1,487.50 = 1,487.50
621
+ expect(r.netTaxPayable).toBeCloseTo(1_487.50, 2);
622
+ });
623
+ });
624
+ // ── Output structure ────────────────────────────────────────────
625
+ describe('Output structure', () => {
626
+ it('45. schedule array has entries', () => {
627
+ const r = computeFormCs(makeInput());
628
+ expect(r.schedule.length).toBeGreaterThan(0);
629
+ for (const entry of r.schedule) {
630
+ expect(entry).toHaveProperty('label');
631
+ expect(entry).toHaveProperty('amount');
632
+ }
633
+ });
634
+ it('46. formFields for C-S has boxes 1-18 (IRAS spec)', () => {
635
+ const r = computeFormCs(makeInput({ revenue: 500_000 }));
636
+ expect(r.formType).toBe('C-S');
637
+ const boxes = r.formFields.map(f => f.box).sort((a, b) => a - b);
638
+ expect(boxes).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18]);
639
+ });
640
+ it('47. formFields for C-S Lite has boxes 1-6 (IRAS spec)', () => {
641
+ const r = computeFormCs(makeInput({ revenue: 100_000 }));
642
+ expect(r.formType).toBe('C-S Lite');
643
+ const boxes = r.formFields.map(f => f.box).sort((a, b) => a - b);
644
+ expect(boxes).toEqual([1, 2, 3, 4, 5, 6]);
645
+ });
646
+ it('48. workings is a non-empty string', () => {
647
+ const r = computeFormCs(makeInput());
648
+ expect(typeof r.workings).toBe('string');
649
+ expect(r.workings.length).toBeGreaterThan(0);
650
+ expect(r.workings).toContain('TAX COMPUTATION');
651
+ expect(r.workings).toContain('NET TAX PAYABLE');
652
+ });
653
+ it('49. type is sg-form-cs', () => {
654
+ const r = computeFormCs(makeInput());
655
+ expect(r.type).toBe('sg-form-cs');
656
+ });
657
+ });
658
+ // ── Rounding ────────────────────────────────────────────────────
659
+ describe('Rounding', () => {
660
+ it('50. all amounts rounded to 2dp', () => {
661
+ const round2 = (n) => Math.round(n * 100) / 100;
662
+ // Use a scenario that produces fractional values
663
+ const input = makeInput({
664
+ accountingProfit: 33_333,
665
+ ya: 2025,
666
+ });
667
+ const r = computeFormCs(input);
668
+ expect(round2(r.accountingProfit)).toBe(r.accountingProfit);
669
+ expect(round2(r.totalAddBacks)).toBe(r.totalAddBacks);
670
+ expect(round2(r.totalDeductions)).toBe(r.totalDeductions);
671
+ expect(round2(r.adjustedProfit)).toBe(r.adjustedProfit);
672
+ expect(round2(r.capitalAllowanceClaim)).toBe(r.capitalAllowanceClaim);
673
+ expect(round2(r.enhancedDeductionTotal)).toBe(r.enhancedDeductionTotal);
674
+ expect(round2(r.chargeableIncomeBeforeLosses)).toBe(r.chargeableIncomeBeforeLosses);
675
+ expect(round2(r.lossRelief)).toBe(r.lossRelief);
676
+ expect(round2(r.donationRelief)).toBe(r.donationRelief);
677
+ expect(round2(r.chargeableIncome)).toBe(r.chargeableIncome);
678
+ expect(round2(r.exemptAmount)).toBe(r.exemptAmount);
679
+ expect(round2(r.taxableIncome)).toBe(r.taxableIncome);
680
+ expect(round2(r.grossTax)).toBe(r.grossTax);
681
+ expect(round2(r.citRebate)).toBe(r.citRebate);
682
+ expect(round2(r.netTaxPayable)).toBe(r.netTaxPayable);
683
+ expect(round2(r.unabsorbedLosses)).toBe(r.unabsorbedLosses);
684
+ expect(round2(r.unabsorbedCapitalAllowances)).toBe(r.unabsorbedCapitalAllowances);
685
+ expect(round2(r.unabsorbedDonations)).toBe(r.unabsorbedDonations);
686
+ });
687
+ });