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,153 @@
1
+ /**
2
+ * Year-end close blueprint generator.
3
+ * Builds on quarter-end phases with additional annual review and audit steps.
4
+ * Supports standalone (full) and incremental (annual extras only) modes.
5
+ */
6
+ import { parseYearPeriod } from './validate.js';
7
+ import { generateQuarterEndBlueprint } from './quarter-end.js';
8
+ import { buildSummary } from './types.js';
9
+ /**
10
+ * Build the annual extras phase (Phase 8).
11
+ * Year-end only steps: final depreciation, true-ups, dividends, retained
12
+ * earnings rollover, full-year GST reconciliation, audit prep, and final lock.
13
+ */
14
+ function buildAnnualExtrasPhase(startDate, endDate, startOrder) {
15
+ let order = startOrder;
16
+ const step = (partial) => ({
17
+ order: ++order,
18
+ ...partial,
19
+ });
20
+ return {
21
+ name: 'Phase 8: Annual Extras',
22
+ description: 'Year-end specific adjustments, filings, and audit preparation.',
23
+ steps: [
24
+ step({
25
+ description: 'Final depreciation run for all fixed assets',
26
+ category: 'verify',
27
+ recipeRef: 'declining-balance',
28
+ calcCommand: 'jaz calc depreciation',
29
+ verification: 'NBV per register matches GL balances',
30
+ notes: 'Reconcile fixed asset register to general ledger',
31
+ }),
32
+ step({
33
+ description: 'Year-end true-ups (leave, bonus, provisions)',
34
+ category: 'adjust',
35
+ recipeRef: 'employee-accruals',
36
+ notes: 'True-up leave accrual to actual entitlement, bonus accrual to board-approved pool, and provision estimates to year-end reassessment',
37
+ }),
38
+ step({
39
+ description: 'Dividend declaration and payment',
40
+ category: 'adjust',
41
+ recipeRef: 'dividend',
42
+ conditional: 'If declaring dividends',
43
+ notes: 'Requires board resolution. Record declaration (DR Retained Earnings, CR Dividends Payable) then payment separately.',
44
+ }),
45
+ step({
46
+ description: 'Retained earnings rollover verification',
47
+ category: 'verify',
48
+ notes: 'Platform-managed. Verify via equity movement report that opening retained earnings equals prior year closing balance plus current year net income.',
49
+ }),
50
+ step({
51
+ description: 'Final GST/VAT reconciliation for the full year',
52
+ category: 'verify',
53
+ apiCall: 'POST /generate-reports/vat-ledger',
54
+ apiBody: { startDate, endDate },
55
+ verification: 'Full-year VAT ledger reconciles to quarterly filings',
56
+ notes: 'Sum of quarterly GST submissions should match full-year ledger totals',
57
+ }),
58
+ step({
59
+ description: 'Audit preparation and documentation',
60
+ category: 'export',
61
+ notes: 'See audit-prep job for full detail. Prepare trial balance, aged schedules, bank confirmations, and supporting journals for auditor.',
62
+ }),
63
+ step({
64
+ description: `Set final lock date to ${endDate}`,
65
+ category: 'lock',
66
+ notes: `Lock the full financial year by setting the accounting lock date to ${endDate}. No further postings to this year after lock.`,
67
+ }),
68
+ ],
69
+ };
70
+ }
71
+ /**
72
+ * Build the annual verification phase (Phase 9).
73
+ * Full-year financial reports for board and statutory filing.
74
+ */
75
+ function buildAnnualVerificationPhase(startDate, endDate, label, startOrder) {
76
+ let order = startOrder;
77
+ const step = (partial) => ({
78
+ order: ++order,
79
+ ...partial,
80
+ });
81
+ return {
82
+ name: 'Phase 9: Annual Verification',
83
+ description: `Full-year financial report review for ${label}.`,
84
+ steps: [
85
+ step({
86
+ description: `Review trial balance for ${label}`,
87
+ category: 'report',
88
+ apiCall: 'POST /generate-reports/trial-balance',
89
+ apiBody: { startDate, endDate },
90
+ verification: 'Total debits equal total credits',
91
+ }),
92
+ step({
93
+ description: `Generate P&L for ${label}`,
94
+ category: 'report',
95
+ apiCall: 'POST /generate-reports/profit-and-loss',
96
+ apiBody: { primarySnapshotDate: endDate, secondarySnapshotDate: startDate },
97
+ }),
98
+ step({
99
+ description: `Generate balance sheet as at ${endDate}`,
100
+ category: 'report',
101
+ apiCall: 'POST /generate-reports/balance-sheet',
102
+ apiBody: { primarySnapshotDate: endDate },
103
+ verification: 'Assets = Liabilities + Equity',
104
+ }),
105
+ ],
106
+ };
107
+ }
108
+ /**
109
+ * Generate a year-end close blueprint.
110
+ *
111
+ * @param opts.period Year in YYYY format (e.g. "2025")
112
+ * @param opts.currency Optional base currency code (e.g. "SGD")
113
+ * @param opts.incremental If true, generate only the annual extras (skip quarter/month phases)
114
+ */
115
+ export function generateYearEndBlueprint(opts) {
116
+ const parsed = parseYearPeriod(opts.period);
117
+ const mode = opts.incremental ? 'incremental' : 'standalone';
118
+ const phases = [];
119
+ if (!opts.incremental) {
120
+ // Standalone: include quarter-end phases for each of the 4 quarters
121
+ for (const quarter of parsed.quarters) {
122
+ const qBp = generateQuarterEndBlueprint({
123
+ period: `${parsed.year}-Q${quarter.quarter}`,
124
+ currency: opts.currency,
125
+ incremental: false,
126
+ });
127
+ // Prefix each phase name with the quarter label for clarity
128
+ for (const phase of qBp.phases) {
129
+ if (!phase.name.startsWith(quarter.label)) {
130
+ phase.name = `${quarter.label} — ${phase.name}`;
131
+ }
132
+ phases.push(phase);
133
+ }
134
+ }
135
+ }
136
+ // Count the highest step order from existing phases
137
+ const maxOrder = phases.reduce((max, phase) => phase.steps.reduce((m, s) => Math.max(m, s.order), max), 0);
138
+ // Add annual extras (Phase 8)
139
+ const extras = buildAnnualExtrasPhase(parsed.startDate, parsed.endDate, maxOrder);
140
+ phases.push(extras);
141
+ // Add annual verification (Phase 9)
142
+ const lastExtraOrder = extras.steps.reduce((max, s) => Math.max(max, s.order), 0);
143
+ const verification = buildAnnualVerificationPhase(parsed.startDate, parsed.endDate, parsed.label, lastExtraOrder);
144
+ phases.push(verification);
145
+ return {
146
+ jobType: 'year-end-close',
147
+ period: parsed.label,
148
+ currency: opts.currency ?? 'SGD',
149
+ mode,
150
+ phases,
151
+ summary: buildSummary(phases),
152
+ };
153
+ }
@@ -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
+ }