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
@@ -23,7 +23,7 @@ export function generatePaymentRunBlueprint(opts = {}) {
23
23
  category: 'verify',
24
24
  apiCall: 'POST /bills/search',
25
25
  apiBody: {
26
- filters: {
26
+ filter: {
27
27
  status: ['APPROVED'],
28
28
  ...dueDateFilter,
29
29
  },
@@ -36,7 +36,7 @@ export function generatePaymentRunBlueprint(opts = {}) {
36
36
  category: 'verify',
37
37
  apiCall: 'POST /bills/search',
38
38
  apiBody: {
39
- filters: {
39
+ filter: {
40
40
  status: ['APPROVED'],
41
41
  ...dueDateFilter,
42
42
  },
@@ -66,7 +66,7 @@ export function generatePaymentRunBlueprint(opts = {}) {
66
66
  order: 4,
67
67
  description: 'Generate AP aging report',
68
68
  category: 'report',
69
- apiCall: 'POST /generate-reports/ap-aging',
69
+ apiCall: 'POST /generate-reports/ap-report',
70
70
  notes: 'Current, 1-30, 31-60, 61-90, 90+ day buckets',
71
71
  verification: 'AP aging total ties to unpaid bills total',
72
72
  },
@@ -175,7 +175,7 @@ export function generatePaymentRunBlueprint(opts = {}) {
175
175
  order: 9,
176
176
  description: 'Verify AP aging after payment run',
177
177
  category: 'verify',
178
- apiCall: 'POST /generate-reports/ap-aging',
178
+ apiCall: 'POST /generate-reports/ap-report',
179
179
  verification: 'AP aging reduced by total payments made. No unexpected outstanding items.',
180
180
  },
181
181
  {
@@ -26,7 +26,7 @@ export function generateSupplierReconBlueprint(opts = {}) {
26
26
  order: 1,
27
27
  description: 'Generate AP aging report',
28
28
  category: 'report',
29
- apiCall: 'POST /generate-reports/ap-aging',
29
+ apiCall: 'POST /generate-reports/ap-report',
30
30
  notes: opts.supplier
31
31
  ? `Filtered to supplier: ${opts.supplier}`
32
32
  : 'Full AP aging — filter to target supplier(s) for comparison',
@@ -38,7 +38,7 @@ export function generateSupplierReconBlueprint(opts = {}) {
38
38
  category: 'verify',
39
39
  apiCall: 'POST /bills/search',
40
40
  apiBody: {
41
- filters: {
41
+ filter: {
42
42
  ...supplierFilter,
43
43
  ...periodFilter,
44
44
  },
@@ -112,7 +112,7 @@ export function generateSupplierReconBlueprint(opts = {}) {
112
112
  order: 7,
113
113
  description: 'Re-check AP aging for supplier after resolution',
114
114
  category: 'verify',
115
- apiCall: 'POST /generate-reports/ap-aging',
115
+ apiCall: 'POST /generate-reports/ap-report',
116
116
  notes: opts.supplier
117
117
  ? `Verify ${opts.supplier} balance matches their statement closing balance`
118
118
  : 'Verify each reconciled supplier balance matches their statement',
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Top-level dispatch for tax result formatting.
3
+ * Routes to country-specific formatters.
4
+ */
5
+ import { printFormCsResult, printCaResult } from './sg/format-sg.js';
6
+ export function printTaxResult(result) {
7
+ switch (result.type) {
8
+ case 'sg-form-cs': return printFormCsResult(result);
9
+ case 'sg-capital-allowance': return printCaResult(result);
10
+ default: {
11
+ const _exhaustive = result;
12
+ throw new Error(`Unknown tax result type: ${_exhaustive.type}`);
13
+ }
14
+ }
15
+ }
16
+ export function printTaxJson(result) {
17
+ console.log(JSON.stringify(result, null, 2));
18
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Singapore capital allowance schedule calculator.
3
+ * Computes per-asset CA claims for a given Year of Assessment.
4
+ */
5
+ import { round2 } from '../types.js';
6
+ import { validateCaInput } from '../validate.js';
7
+ import { CA_RATES, LOW_VALUE_YA_CAP, S14Q_BLOCK_CAP } from './constants.js';
8
+ /**
9
+ * Compute CA for a single asset in the given YA.
10
+ * Returns the current year claim (may be zero if fully claimed).
11
+ */
12
+ function computeAssetCa(asset) {
13
+ const rate = CA_RATES[asset.category];
14
+ if (!rate) {
15
+ // Shouldn't happen after validation, but defensive
16
+ return {
17
+ description: asset.description,
18
+ cost: asset.cost,
19
+ category: asset.category,
20
+ section: 'Unknown',
21
+ totalClaimedPrior: asset.priorYearsClaimed,
22
+ currentYearClaim: 0,
23
+ totalClaimedToDate: asset.priorYearsClaimed,
24
+ remainingUnabsorbed: round2(asset.cost - asset.priorYearsClaimed),
25
+ };
26
+ }
27
+ // IP category: use custom write-off years if provided
28
+ let annualRate = rate.annualRate;
29
+ if (asset.category === 'ip' && asset.ipWriteOffYears) {
30
+ annualRate = 1 / asset.ipWriteOffYears;
31
+ }
32
+ const unclaimed = round2(asset.cost - asset.priorYearsClaimed);
33
+ if (unclaimed <= 0) {
34
+ return {
35
+ description: asset.description,
36
+ cost: asset.cost,
37
+ category: asset.category,
38
+ section: rate.section,
39
+ totalClaimedPrior: asset.priorYearsClaimed,
40
+ currentYearClaim: 0,
41
+ totalClaimedToDate: asset.cost,
42
+ remainingUnabsorbed: 0,
43
+ };
44
+ }
45
+ // Annual claim = cost × annual rate, capped at remaining unclaimed
46
+ const rawClaim = round2(asset.cost * annualRate);
47
+ const currentYearClaim = round2(Math.min(rawClaim, unclaimed));
48
+ const totalClaimedToDate = round2(asset.priorYearsClaimed + currentYearClaim);
49
+ const remainingUnabsorbed = round2(asset.cost - totalClaimedToDate);
50
+ return {
51
+ description: asset.description,
52
+ cost: asset.cost,
53
+ category: asset.category,
54
+ section: rate.section,
55
+ totalClaimedPrior: asset.priorYearsClaimed,
56
+ currentYearClaim,
57
+ totalClaimedToDate,
58
+ remainingUnabsorbed,
59
+ };
60
+ }
61
+ /**
62
+ * Compute the full capital allowance schedule for a set of assets.
63
+ *
64
+ * Applies:
65
+ * - Per-asset CA computation using IRAS rates
66
+ * - Low-value asset $30K/YA total cap (S19A(2))
67
+ * - S14Q renovation $300K per 3-year block cap
68
+ * - Unabsorbed CA brought forward added to total available
69
+ */
70
+ export function computeCapitalAllowances(input) {
71
+ validateCaInput(input);
72
+ const currency = input.currency ?? 'SGD';
73
+ // Step 1: Compute raw per-asset claims
74
+ const rawRows = input.assets.map(a => computeAssetCa(a));
75
+ // Step 2: Apply low-value YA cap ($30K total for all low-value assets)
76
+ let lowValueTotal = 0;
77
+ let lowValueCount = 0;
78
+ let lowValueCapped = false;
79
+ const rows = rawRows.map(row => {
80
+ if (row.category === 'low-value' && row.currentYearClaim > 0) {
81
+ lowValueCount++;
82
+ const available = round2(LOW_VALUE_YA_CAP - lowValueTotal);
83
+ if (available <= 0) {
84
+ lowValueCapped = true;
85
+ return { ...row, currentYearClaim: 0, totalClaimedToDate: row.totalClaimedPrior, remainingUnabsorbed: round2(row.cost - row.totalClaimedPrior) };
86
+ }
87
+ const cappedClaim = round2(Math.min(row.currentYearClaim, available));
88
+ if (cappedClaim < row.currentYearClaim)
89
+ lowValueCapped = true;
90
+ lowValueTotal = round2(lowValueTotal + cappedClaim);
91
+ return {
92
+ ...row,
93
+ currentYearClaim: cappedClaim,
94
+ totalClaimedToDate: round2(row.totalClaimedPrior + cappedClaim),
95
+ remainingUnabsorbed: round2(row.cost - row.totalClaimedPrior - cappedClaim),
96
+ };
97
+ }
98
+ if (row.category === 'low-value')
99
+ lowValueCount++;
100
+ return row;
101
+ });
102
+ // Step 3: Apply S14Q renovation cap ($300K per 3-year block)
103
+ // Simplified: we cap total renovation claims at $300K, leaving detailed
104
+ // 3-year block tracking to the AI agent (which has multi-year context).
105
+ let renovationTotal = 0;
106
+ for (let i = 0; i < rows.length; i++) {
107
+ const row = rows[i];
108
+ if (row.category === 'renovation' && row.currentYearClaim > 0) {
109
+ const available = round2(S14Q_BLOCK_CAP - renovationTotal);
110
+ if (available <= 0) {
111
+ rows[i] = { ...row, currentYearClaim: 0, totalClaimedToDate: row.totalClaimedPrior, remainingUnabsorbed: round2(row.cost - row.totalClaimedPrior) };
112
+ continue;
113
+ }
114
+ const cappedClaim = round2(Math.min(row.currentYearClaim, available));
115
+ renovationTotal = round2(renovationTotal + cappedClaim);
116
+ if (cappedClaim < row.currentYearClaim) {
117
+ rows[i] = {
118
+ ...row,
119
+ currentYearClaim: cappedClaim,
120
+ totalClaimedToDate: round2(row.totalClaimedPrior + cappedClaim),
121
+ remainingUnabsorbed: round2(row.cost - row.totalClaimedPrior - cappedClaim),
122
+ };
123
+ }
124
+ }
125
+ }
126
+ // Step 4: Totals
127
+ const totalCurrentYearClaim = round2(rows.reduce((sum, r) => sum + r.currentYearClaim, 0));
128
+ const totalAvailable = round2(totalCurrentYearClaim + input.unabsorbedBroughtForward);
129
+ // Step 5: Build workings
130
+ const lines = [
131
+ `Capital Allowance Schedule — YA ${input.ya}`,
132
+ '',
133
+ ];
134
+ for (const row of rows) {
135
+ lines.push(`${row.description} (${row.section}): Cost ${currency} ${row.cost.toLocaleString('en-US', { minimumFractionDigits: 2 })} → ` +
136
+ `Claim ${currency} ${row.currentYearClaim.toLocaleString('en-US', { minimumFractionDigits: 2 })} ` +
137
+ `(${row.totalClaimedToDate.toLocaleString('en-US', { minimumFractionDigits: 2 })} to date, ` +
138
+ `${row.remainingUnabsorbed.toLocaleString('en-US', { minimumFractionDigits: 2 })} remaining)`);
139
+ }
140
+ if (lowValueCapped) {
141
+ lines.push(`\nNote: Low-value asset claims capped at ${currency} ${LOW_VALUE_YA_CAP.toLocaleString()} per YA`);
142
+ }
143
+ lines.push('');
144
+ lines.push(`Current year CA: ${currency} ${totalCurrentYearClaim.toLocaleString('en-US', { minimumFractionDigits: 2 })}`);
145
+ lines.push(`Unabsorbed b/f: ${currency} ${input.unabsorbedBroughtForward.toLocaleString('en-US', { minimumFractionDigits: 2 })}`);
146
+ lines.push(`Total available: ${currency} ${totalAvailable.toLocaleString('en-US', { minimumFractionDigits: 2 })}`);
147
+ return {
148
+ type: 'sg-capital-allowance',
149
+ ya: input.ya,
150
+ currency,
151
+ assets: rows,
152
+ totalCurrentYearClaim,
153
+ lowValueCount,
154
+ lowValueTotal,
155
+ lowValueCapped,
156
+ unabsorbedBroughtForward: input.unabsorbedBroughtForward,
157
+ totalAvailable,
158
+ workings: lines.join('\n'),
159
+ };
160
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Singapore corporate income tax constants.
3
+ * All magic numbers live here — no hard-coded rates in computation logic.
4
+ */
5
+ /** Standard corporate income tax rate. */
6
+ export const SG_CIT_RATE = 0.17;
7
+ /** Revenue threshold for Form C-S eligibility. */
8
+ export const FORM_CS_REVENUE_LIMIT = 5_000_000;
9
+ /** Revenue threshold for Form C-S Lite eligibility. */
10
+ export const FORM_CS_LITE_REVENUE_LIMIT = 200_000;
11
+ /**
12
+ * Start-Up Tax Exemption (SUTE) — for first 3 YAs of newly incorporated companies.
13
+ * Applied in order: first band fills before second band.
14
+ */
15
+ export const SUTE_BANDS = [
16
+ { threshold: 100_000, exemptionRate: 0.75 },
17
+ { threshold: 100_000, exemptionRate: 0.50 },
18
+ ];
19
+ /**
20
+ * Partial Tax Exemption (PTE) — for all companies not on SUTE.
21
+ * Applied in order: first band fills before second band.
22
+ */
23
+ export const PTE_BANDS = [
24
+ { threshold: 10_000, exemptionRate: 0.75 },
25
+ { threshold: 190_000, exemptionRate: 0.50 },
26
+ ];
27
+ /**
28
+ * CIT rebate schedule by Year of Assessment.
29
+ * Rebate = min(grossTax * rate, cap).
30
+ */
31
+ export const CIT_REBATE_SCHEDULE = {
32
+ 2020: { rate: 0.25, cap: 15_000 },
33
+ 2021: { rate: 0.00, cap: 0 }, // COVID relief was different
34
+ 2022: { rate: 0.00, cap: 0 },
35
+ 2023: { rate: 0.00, cap: 0 },
36
+ 2024: { rate: 0.50, cap: 40_000 },
37
+ 2025: { rate: 0.50, cap: 40_000 },
38
+ 2026: { rate: 0.40, cap: 40_000 },
39
+ };
40
+ /**
41
+ * Capital allowance rates by asset category.
42
+ */
43
+ export const CA_RATES = {
44
+ computer: { section: 'S19A(1)', annualRate: 1.00, years: 1 },
45
+ automation: { section: 'S19A(1)', annualRate: 1.00, years: 1 },
46
+ 'low-value': { section: 'S19A(2)', annualRate: 1.00, years: 1 },
47
+ general: { section: 'S19', annualRate: 1 / 3, years: 3 },
48
+ ip: { section: 'S19B', annualRate: 0.20, years: 5 }, // default 5-year
49
+ renovation: { section: 'S14Q', annualRate: 1 / 3, years: 3 },
50
+ };
51
+ /** Low-value asset per-YA total cap. */
52
+ export const LOW_VALUE_YA_CAP = 30_000;
53
+ /** Low-value asset per-item cost ceiling. */
54
+ export const LOW_VALUE_ITEM_LIMIT = 5_000;
55
+ /** S14Q renovation per-3-year-block cap. */
56
+ export const S14Q_BLOCK_CAP = 300_000;
57
+ /** Donation tax deduction multiplier for approved IPC donations. */
58
+ export const DONATION_MULTIPLIER = 2.50;
59
+ /** Month names for date formatting. */
60
+ export const MONTH_NAMES = [
61
+ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
62
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
63
+ ];
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Singapore corporate income tax exemptions and rebates.
3
+ * Pure functions — no side effects, no I/O.
4
+ */
5
+ import { round2 } from '../types.js';
6
+ import { SG_CIT_RATE, SUTE_BANDS, PTE_BANDS, CIT_REBATE_SCHEDULE, } from './constants.js';
7
+ /**
8
+ * Apply banded exemption (SUTE or PTE).
9
+ * Each band fills in order — excess flows to the next band.
10
+ * Returns the total exempt amount (deducted from chargeable income before applying 17%).
11
+ */
12
+ function applyBands(chargeableIncome, bands) {
13
+ let remaining = chargeableIncome;
14
+ let totalExempt = 0;
15
+ for (const band of bands) {
16
+ if (remaining <= 0)
17
+ break;
18
+ const inBand = Math.min(remaining, band.threshold);
19
+ totalExempt = round2(totalExempt + round2(inBand * band.exemptionRate));
20
+ remaining = round2(remaining - inBand);
21
+ }
22
+ return totalExempt;
23
+ }
24
+ /**
25
+ * Compute tax exemption for a given chargeable income.
26
+ *
27
+ * @param chargeableIncome — must be >= 0 (losses already absorbed upstream)
28
+ * @param type — 'sute' | 'pte' | 'none'
29
+ * @returns ExemptionResult with exempt amount, taxable income, and effective rate
30
+ */
31
+ export function computeExemption(chargeableIncome, type) {
32
+ if (chargeableIncome <= 0) {
33
+ return {
34
+ type,
35
+ chargeableIncome: 0,
36
+ exemptAmount: 0,
37
+ taxableIncome: 0,
38
+ effectiveRate: 0,
39
+ };
40
+ }
41
+ let exemptAmount;
42
+ switch (type) {
43
+ case 'sute':
44
+ exemptAmount = applyBands(chargeableIncome, SUTE_BANDS);
45
+ break;
46
+ case 'pte':
47
+ exemptAmount = applyBands(chargeableIncome, PTE_BANDS);
48
+ break;
49
+ case 'none':
50
+ exemptAmount = 0;
51
+ break;
52
+ }
53
+ const taxableIncome = round2(chargeableIncome - exemptAmount);
54
+ const grossTax = round2(taxableIncome * SG_CIT_RATE);
55
+ const effectiveRate = chargeableIncome > 0
56
+ ? round2((grossTax / chargeableIncome) * 10000) / 10000 // 4dp for rate
57
+ : 0;
58
+ return {
59
+ type,
60
+ chargeableIncome,
61
+ exemptAmount,
62
+ taxableIncome,
63
+ effectiveRate,
64
+ };
65
+ }
66
+ /**
67
+ * Compute CIT rebate for a given YA.
68
+ * Rebate = min(grossTax * rebateRate, rebateCap).
69
+ * Returns 0 if no rebate schedule exists for the YA.
70
+ */
71
+ export function computeCitRebate(grossTax, ya) {
72
+ const schedule = CIT_REBATE_SCHEDULE[ya];
73
+ if (!schedule || grossTax <= 0)
74
+ return 0;
75
+ return round2(Math.min(grossTax * schedule.rate, schedule.cap));
76
+ }