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.
- package/assets/skills/api/SKILL.md +1 -1
- package/assets/skills/conversion/SKILL.md +1 -1
- package/assets/skills/jobs/SKILL.md +161 -0
- package/assets/skills/jobs/references/audit-prep.md +319 -0
- package/assets/skills/jobs/references/bank-recon.md +234 -0
- package/assets/skills/jobs/references/building-blocks.md +135 -0
- package/assets/skills/jobs/references/credit-control.md +273 -0
- package/assets/skills/jobs/references/fa-review.md +267 -0
- package/assets/skills/jobs/references/gst-vat-filing.md +250 -0
- package/assets/skills/jobs/references/month-end-close.md +308 -0
- package/assets/skills/jobs/references/payment-run.md +246 -0
- package/assets/skills/jobs/references/quarter-end-close.md +268 -0
- package/assets/skills/jobs/references/sg-tax/add-backs-guide.md +354 -0
- package/assets/skills/jobs/references/sg-tax/capital-allowances-guide.md +343 -0
- package/assets/skills/jobs/references/sg-tax/data-extraction.md +408 -0
- package/assets/skills/jobs/references/sg-tax/enhanced-deductions.md +248 -0
- package/assets/skills/jobs/references/sg-tax/exemptions-and-rebates.md +197 -0
- package/assets/skills/jobs/references/sg-tax/form-cs-fields.md +191 -0
- package/assets/skills/jobs/references/sg-tax/ifrs16-tax-adjustment.md +194 -0
- package/assets/skills/jobs/references/sg-tax/losses-and-carry-forwards.md +269 -0
- package/assets/skills/jobs/references/sg-tax/overview.md +207 -0
- package/assets/skills/jobs/references/sg-tax/wizard-workflow.md +391 -0
- package/assets/skills/jobs/references/supplier-recon.md +330 -0
- package/assets/skills/jobs/references/year-end-close.md +341 -0
- package/assets/skills/transaction-recipes/SKILL.md +1 -1
- package/dist/__tests__/jobs-audit-prep.test.js +125 -0
- package/dist/__tests__/jobs-bank-recon.test.js +108 -0
- package/dist/__tests__/jobs-credit-control.test.js +98 -0
- package/dist/__tests__/jobs-fa-review.test.js +104 -0
- package/dist/__tests__/jobs-gst-vat.test.js +113 -0
- package/dist/__tests__/jobs-month-end.test.js +162 -0
- package/dist/__tests__/jobs-payment-run.test.js +106 -0
- package/dist/__tests__/jobs-quarter-end.test.js +155 -0
- package/dist/__tests__/jobs-supplier-recon.test.js +115 -0
- package/dist/__tests__/jobs-validate.test.js +181 -0
- package/dist/__tests__/jobs-year-end.test.js +149 -0
- package/dist/__tests__/tax-sg-capital-allowances.test.js +389 -0
- package/dist/__tests__/tax-sg-exemptions.test.js +232 -0
- package/dist/__tests__/tax-sg-form-cs.test.js +687 -0
- package/dist/__tests__/tax-validate.test.js +208 -0
- package/dist/commands/init.js +7 -2
- package/dist/commands/jobs.js +184 -0
- package/dist/commands/tax.js +195 -0
- package/dist/index.js +4 -0
- package/dist/jobs/audit-prep.js +211 -0
- package/dist/jobs/bank-recon.js +163 -0
- package/dist/jobs/credit-control.js +126 -0
- package/dist/jobs/fa-review.js +121 -0
- package/dist/jobs/format.js +102 -0
- package/dist/jobs/gst-vat.js +187 -0
- package/dist/jobs/month-end.js +232 -0
- package/dist/jobs/payment-run.js +199 -0
- package/dist/jobs/quarter-end.js +135 -0
- package/dist/jobs/supplier-recon.js +132 -0
- package/dist/jobs/types.js +36 -0
- package/dist/jobs/validate.js +115 -0
- package/dist/jobs/year-end.js +153 -0
- package/dist/tax/format.js +18 -0
- package/dist/tax/sg/capital-allowances.js +160 -0
- package/dist/tax/sg/constants.js +63 -0
- package/dist/tax/sg/exemptions.js +76 -0
- package/dist/tax/sg/form-cs.js +349 -0
- package/dist/tax/sg/format-sg.js +134 -0
- package/dist/tax/types.js +9 -0
- package/dist/tax/validate.js +124 -0
- package/dist/types/index.js +2 -1
- package/dist/utils/template.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Singapore Form C-S / C-S Lite corporate income tax computation engine.
|
|
3
|
+
*
|
|
4
|
+
* Implements the full IRAS tax computation pipeline:
|
|
5
|
+
* accounting profit → add-backs → deductions → adjusted profit →
|
|
6
|
+
* capital allowances → enhanced deductions → loss relief → donation relief →
|
|
7
|
+
* chargeable income → exemption → 17% → CIT rebate → net tax payable
|
|
8
|
+
*
|
|
9
|
+
* Set-off order follows IRAS rules (current CA → enhanced → unabsorbed CA →
|
|
10
|
+
* losses → donations, each capped at available chargeable income).
|
|
11
|
+
*/
|
|
12
|
+
import { round2 } from '../types.js';
|
|
13
|
+
import { validateFormCsInput } from '../validate.js';
|
|
14
|
+
import { SG_CIT_RATE, FORM_CS_REVENUE_LIMIT, FORM_CS_LITE_REVENUE_LIMIT, DONATION_MULTIPLIER, MONTH_NAMES, } from './constants.js';
|
|
15
|
+
import { computeExemption, computeCitRebate } from './exemptions.js';
|
|
16
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
17
|
+
function formatBasisPeriod(start, end) {
|
|
18
|
+
const s = new Date(start + 'T00:00:00Z');
|
|
19
|
+
const e = new Date(end + 'T00:00:00Z');
|
|
20
|
+
const fmt = (d) => `${String(d.getUTCDate()).padStart(2, '0')} ${MONTH_NAMES[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
|
|
21
|
+
return `${fmt(s)} to ${fmt(e)}`;
|
|
22
|
+
}
|
|
23
|
+
function line(label, amount, ref, indent) {
|
|
24
|
+
return { label, amount: round2(amount), reference: ref, indent: indent ?? 0 };
|
|
25
|
+
}
|
|
26
|
+
// ── Main computation ──────────────────────────────────────────────
|
|
27
|
+
export function computeFormCs(input) {
|
|
28
|
+
validateFormCsInput(input);
|
|
29
|
+
const currency = input.currency ?? 'SGD';
|
|
30
|
+
const ya = input.ya;
|
|
31
|
+
const basisPeriod = formatBasisPeriod(input.basisPeriodStart, input.basisPeriodEnd);
|
|
32
|
+
// ── Form type determination ──
|
|
33
|
+
const eligible = input.revenue <= FORM_CS_REVENUE_LIMIT;
|
|
34
|
+
const formType = input.revenue <= FORM_CS_LITE_REVENUE_LIMIT ? 'C-S Lite' : 'C-S';
|
|
35
|
+
const schedule = [];
|
|
36
|
+
// ── Step 1: Accounting profit ──
|
|
37
|
+
const accountingProfit = round2(input.accountingProfit);
|
|
38
|
+
schedule.push(line('Net profit/(loss) per accounts', accountingProfit, 'P&L'));
|
|
39
|
+
// ── Step 2: Add-backs ──
|
|
40
|
+
const ab = input.addBacks;
|
|
41
|
+
const addBackItems = [
|
|
42
|
+
{ label: 'Depreciation', amount: ab.depreciation, ref: 'S19 — accounting depr always added back' },
|
|
43
|
+
{ label: 'Amortization', amount: ab.amortization, ref: 'Intangible amortization' },
|
|
44
|
+
{ label: 'ROU depreciation (IFRS 16)', amount: ab.rouDepreciation, ref: 'IFRS 16 ROU' },
|
|
45
|
+
{ label: 'Lease interest (IFRS 16)', amount: ab.leaseInterest, ref: 'IFRS 16 lease liability' },
|
|
46
|
+
{ label: 'General provisions', amount: ab.generalProvisions, ref: 'ECL, warranty, restructuring' },
|
|
47
|
+
{ label: 'Donations', amount: ab.donations, ref: 'IPC — claim 250% separately' },
|
|
48
|
+
{ label: 'Entertainment', amount: ab.entertainment, ref: 'Non-deductible portion' },
|
|
49
|
+
{ label: 'Penalties & fines', amount: ab.penalties, ref: 'Not deductible' },
|
|
50
|
+
{ label: 'Private car expenses', amount: ab.privateCar, ref: 'S-plated vehicles' },
|
|
51
|
+
{ label: 'Capital expenditure on P&L', amount: ab.capitalExpOnPnl, ref: 'Capital items expensed' },
|
|
52
|
+
{ label: 'Unrealized FX losses', amount: ab.unrealizedFxLoss, ref: 'Not crystallized' },
|
|
53
|
+
{ label: 'Other non-deductible', amount: ab.otherNonDeductible, ref: ab.otherDescription },
|
|
54
|
+
];
|
|
55
|
+
const totalAddBacks = round2(addBackItems.reduce((s, i) => s + i.amount, 0));
|
|
56
|
+
schedule.push(line('Add: Non-deductible / non-taxable items', totalAddBacks));
|
|
57
|
+
for (const item of addBackItems) {
|
|
58
|
+
if (item.amount > 0) {
|
|
59
|
+
schedule.push(line(item.label, item.amount, item.ref, 1));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// ── Step 3: Deductions ──
|
|
63
|
+
const ded = input.deductions;
|
|
64
|
+
const deductionItems = [
|
|
65
|
+
{ label: 'Actual lease payments (IFRS 16 reversal)', amount: ded.actualLeasePayments, ref: 'Operating lease payments' },
|
|
66
|
+
{ label: 'Unrealized FX gains', amount: ded.unrealizedFxGain, ref: 'Not crystallized' },
|
|
67
|
+
{ label: 'Exempt dividends', amount: ded.exemptDividends, ref: 'SG one-tier' },
|
|
68
|
+
{ label: 'Exempt income', amount: ded.exemptIncome, ref: 'Not taxable' },
|
|
69
|
+
{ label: 'Other deductions', amount: ded.otherDeductions, ref: ded.otherDescription },
|
|
70
|
+
];
|
|
71
|
+
const totalDeductions = round2(deductionItems.reduce((s, i) => s + i.amount, 0));
|
|
72
|
+
schedule.push(line('Less: Further deductions', totalDeductions));
|
|
73
|
+
for (const item of deductionItems) {
|
|
74
|
+
if (item.amount > 0) {
|
|
75
|
+
schedule.push(line(item.label, item.amount, item.ref, 1));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// ── Step 4: Adjusted profit ──
|
|
79
|
+
const adjustedProfit = round2(accountingProfit + totalAddBacks - totalDeductions);
|
|
80
|
+
schedule.push(line('Adjusted profit/(loss)', adjustedProfit, 'Box 1'));
|
|
81
|
+
// ── Step 5: Capital allowances (IRAS set-off order) ──
|
|
82
|
+
// Current year CA first, then unabsorbed brought forward
|
|
83
|
+
const ca = input.capitalAllowances;
|
|
84
|
+
// If adjusted profit is negative, CA cannot reduce it further — carry all forward
|
|
85
|
+
let remainingCI = adjustedProfit;
|
|
86
|
+
// Current year CA
|
|
87
|
+
const currentCaClaim = remainingCI > 0
|
|
88
|
+
? round2(Math.min(ca.currentYearClaim, remainingCI))
|
|
89
|
+
: 0;
|
|
90
|
+
const currentCaExcess = round2(ca.currentYearClaim - currentCaClaim);
|
|
91
|
+
if (currentCaClaim > 0) {
|
|
92
|
+
schedule.push(line('Less: Current year capital allowances', currentCaClaim, 'S19/S19A'));
|
|
93
|
+
}
|
|
94
|
+
remainingCI = round2(remainingCI - currentCaClaim);
|
|
95
|
+
// ── Step 6: Enhanced deductions ──
|
|
96
|
+
const ed = input.enhancedDeductions;
|
|
97
|
+
// R&D: qualifying expenditure × (multiplier - 1) = the enhanced portion
|
|
98
|
+
// (The base 100% is already in the P&L as an expense)
|
|
99
|
+
const rdEnhanced = round2(ed.rdExpenditure * Math.max(0, ed.rdMultiplier - 1));
|
|
100
|
+
const ipEnhanced = round2(ed.ipRegistration * Math.max(0, ed.ipMultiplier - 1));
|
|
101
|
+
const donationEnhanced = round2(ed.donations250Base * (DONATION_MULTIPLIER - 1));
|
|
102
|
+
// S14Q: net uplift only (base already claimed via CA)
|
|
103
|
+
const s14qEnhanced = ed.s14qRenovation;
|
|
104
|
+
const enhancedTotal = round2(rdEnhanced + ipEnhanced + donationEnhanced + s14qEnhanced);
|
|
105
|
+
const enhancedClaim = remainingCI > 0
|
|
106
|
+
? round2(Math.min(enhancedTotal, remainingCI))
|
|
107
|
+
: 0;
|
|
108
|
+
const enhancedExcess = round2(enhancedTotal - enhancedClaim);
|
|
109
|
+
if (enhancedClaim > 0) {
|
|
110
|
+
schedule.push(line('Less: Enhanced deductions', enhancedClaim));
|
|
111
|
+
if (rdEnhanced > 0)
|
|
112
|
+
schedule.push(line(`R&D (${ed.rdMultiplier * 100}% − 100% base)`, rdEnhanced, 'S14C/14E', 1));
|
|
113
|
+
if (ipEnhanced > 0)
|
|
114
|
+
schedule.push(line(`IP registration (${ed.ipMultiplier * 100}% − 100% base)`, ipEnhanced, 'S14A', 1));
|
|
115
|
+
if (donationEnhanced > 0)
|
|
116
|
+
schedule.push(line(`Donations (${DONATION_MULTIPLIER * 100}% − 100% base)`, donationEnhanced, 'S37', 1));
|
|
117
|
+
if (s14qEnhanced > 0)
|
|
118
|
+
schedule.push(line('S14Q renovation (net uplift)', s14qEnhanced, 'S14Q', 1));
|
|
119
|
+
}
|
|
120
|
+
remainingCI = round2(remainingCI - enhancedClaim);
|
|
121
|
+
// Unabsorbed CA from prior years
|
|
122
|
+
const priorCaClaim = remainingCI > 0
|
|
123
|
+
? round2(Math.min(ca.balanceBroughtForward, remainingCI))
|
|
124
|
+
: 0;
|
|
125
|
+
const priorCaExcess = round2(ca.balanceBroughtForward - priorCaClaim);
|
|
126
|
+
if (priorCaClaim > 0) {
|
|
127
|
+
schedule.push(line('Less: Unabsorbed CA brought forward', priorCaClaim, 'Prior YA CA'));
|
|
128
|
+
}
|
|
129
|
+
remainingCI = round2(remainingCI - priorCaClaim);
|
|
130
|
+
const capitalAllowanceClaim = round2(currentCaClaim + priorCaClaim);
|
|
131
|
+
const chargeableIncomeBeforeLosses = round2(Math.max(0, remainingCI));
|
|
132
|
+
schedule.push(line('Chargeable income before loss relief', chargeableIncomeBeforeLosses));
|
|
133
|
+
// ── Step 7: Loss relief ──
|
|
134
|
+
const lossRelief = chargeableIncomeBeforeLosses > 0
|
|
135
|
+
? round2(Math.min(input.losses.broughtForward, chargeableIncomeBeforeLosses))
|
|
136
|
+
: 0;
|
|
137
|
+
const unabsorbedLossesAfter = round2(input.losses.broughtForward - lossRelief);
|
|
138
|
+
if (lossRelief > 0) {
|
|
139
|
+
schedule.push(line('Less: Unabsorbed trade losses', lossRelief, 'Prior YA losses'));
|
|
140
|
+
}
|
|
141
|
+
let ciAfterLosses = round2(chargeableIncomeBeforeLosses - lossRelief);
|
|
142
|
+
// ── Step 8: Donation relief ──
|
|
143
|
+
// Current year donations at 250%
|
|
144
|
+
const currentDonation250 = round2(ed.donations250Base * DONATION_MULTIPLIER);
|
|
145
|
+
const totalDonationAvailable = round2(currentDonation250 + input.donationsCarryForward.broughtForward);
|
|
146
|
+
const donationRelief = ciAfterLosses > 0
|
|
147
|
+
? round2(Math.min(totalDonationAvailable, ciAfterLosses))
|
|
148
|
+
: 0;
|
|
149
|
+
// Determine how much came from current year vs brought forward
|
|
150
|
+
const currentDonationUsed = round2(Math.min(currentDonation250, donationRelief));
|
|
151
|
+
const priorDonationUsed = round2(donationRelief - currentDonationUsed);
|
|
152
|
+
const unabsorbedDonations = round2(input.donationsCarryForward.broughtForward - priorDonationUsed);
|
|
153
|
+
if (donationRelief > 0) {
|
|
154
|
+
schedule.push(line('Less: Donation deductions', donationRelief));
|
|
155
|
+
if (currentDonationUsed > 0)
|
|
156
|
+
schedule.push(line(`Current year (${DONATION_MULTIPLIER * 100}%)`, currentDonationUsed, 'S37', 1));
|
|
157
|
+
if (priorDonationUsed > 0)
|
|
158
|
+
schedule.push(line('Brought forward', priorDonationUsed, 'Prior YA', 1));
|
|
159
|
+
}
|
|
160
|
+
// ── Step 9: Chargeable income ──
|
|
161
|
+
const chargeableIncome = round2(Math.max(0, ciAfterLosses - donationRelief));
|
|
162
|
+
schedule.push(line('Chargeable income', chargeableIncome, 'Box 2'));
|
|
163
|
+
// ── Step 10: Tax exemption ──
|
|
164
|
+
const exemption = computeExemption(chargeableIncome, input.exemptionType);
|
|
165
|
+
const exemptAmount = exemption.exemptAmount;
|
|
166
|
+
const taxableIncome = exemption.taxableIncome;
|
|
167
|
+
if (exemptAmount > 0) {
|
|
168
|
+
const exemptionLabel = input.exemptionType === 'sute'
|
|
169
|
+
? 'Start-Up Tax Exemption (SUTE)'
|
|
170
|
+
: 'Partial Tax Exemption (PTE)';
|
|
171
|
+
schedule.push(line(`Less: ${exemptionLabel}`, exemptAmount));
|
|
172
|
+
}
|
|
173
|
+
schedule.push(line('Taxable income', taxableIncome));
|
|
174
|
+
// ── Step 11: Gross tax ──
|
|
175
|
+
const grossTax = round2(taxableIncome * SG_CIT_RATE);
|
|
176
|
+
schedule.push(line(`Tax @ ${SG_CIT_RATE * 100}%`, grossTax, 'Box 6'));
|
|
177
|
+
// ── Step 12: CIT rebate ──
|
|
178
|
+
const citRebate = computeCitRebate(grossTax, ya);
|
|
179
|
+
if (citRebate > 0) {
|
|
180
|
+
schedule.push(line('Less: CIT rebate', citRebate, `YA ${ya} rebate`));
|
|
181
|
+
}
|
|
182
|
+
// ── Step 13: Net tax payable ──
|
|
183
|
+
const netTaxPayable = round2(Math.max(0, grossTax - citRebate));
|
|
184
|
+
schedule.push(line('Net tax payable', netTaxPayable, 'Box 7'));
|
|
185
|
+
// ── Carry-forwards ──
|
|
186
|
+
// Unabsorbed CA = excess from current year + excess from prior + enhanced excess
|
|
187
|
+
const unabsorbedCapitalAllowances = round2(currentCaExcess + priorCaExcess);
|
|
188
|
+
// If adjusted profit was negative, the loss is a current-year loss that adds to carry-forward
|
|
189
|
+
const currentYearLoss = adjustedProfit < 0 ? round2(Math.abs(adjustedProfit)) : 0;
|
|
190
|
+
const unabsorbedLosses = round2(unabsorbedLossesAfter + currentYearLoss);
|
|
191
|
+
// ── Form C-S field mapping ──
|
|
192
|
+
const formFields = buildFormFields(input, {
|
|
193
|
+
adjustedProfit,
|
|
194
|
+
chargeableIncome,
|
|
195
|
+
capitalAllowanceClaim,
|
|
196
|
+
enhancedDeductionTotal: enhancedClaim,
|
|
197
|
+
lossRelief,
|
|
198
|
+
donationRelief,
|
|
199
|
+
grossTax,
|
|
200
|
+
citRebate,
|
|
201
|
+
netTaxPayable,
|
|
202
|
+
formType,
|
|
203
|
+
basisPeriod,
|
|
204
|
+
exemptAmount,
|
|
205
|
+
taxableIncome,
|
|
206
|
+
currentYearLoss,
|
|
207
|
+
unabsorbedLosses,
|
|
208
|
+
unabsorbedCapitalAllowances,
|
|
209
|
+
unabsorbedDonations,
|
|
210
|
+
});
|
|
211
|
+
// ── Workings text ──
|
|
212
|
+
const workings = buildWorkings({
|
|
213
|
+
ya, currency, basisPeriod, formType, eligible,
|
|
214
|
+
accountingProfit, totalAddBacks, totalDeductions, adjustedProfit,
|
|
215
|
+
capitalAllowanceClaim, currentCaClaim, priorCaClaim,
|
|
216
|
+
enhancedDeductionTotal: enhancedClaim,
|
|
217
|
+
chargeableIncomeBeforeLosses, lossRelief, donationRelief,
|
|
218
|
+
chargeableIncome, exemptAmount, taxableIncome,
|
|
219
|
+
grossTax, citRebate, netTaxPayable,
|
|
220
|
+
exemptionType: input.exemptionType,
|
|
221
|
+
unabsorbedLosses, unabsorbedCapitalAllowances, unabsorbedDonations,
|
|
222
|
+
});
|
|
223
|
+
return {
|
|
224
|
+
type: 'sg-form-cs',
|
|
225
|
+
ya,
|
|
226
|
+
currency,
|
|
227
|
+
basisPeriod,
|
|
228
|
+
formType,
|
|
229
|
+
eligible,
|
|
230
|
+
schedule,
|
|
231
|
+
accountingProfit,
|
|
232
|
+
totalAddBacks,
|
|
233
|
+
totalDeductions,
|
|
234
|
+
adjustedProfit,
|
|
235
|
+
capitalAllowanceClaim,
|
|
236
|
+
enhancedDeductionTotal: enhancedClaim,
|
|
237
|
+
chargeableIncomeBeforeLosses,
|
|
238
|
+
lossRelief,
|
|
239
|
+
donationRelief,
|
|
240
|
+
chargeableIncome,
|
|
241
|
+
exemptAmount,
|
|
242
|
+
taxableIncome,
|
|
243
|
+
grossTax,
|
|
244
|
+
citRebate,
|
|
245
|
+
netTaxPayable,
|
|
246
|
+
unabsorbedLosses,
|
|
247
|
+
unabsorbedCapitalAllowances,
|
|
248
|
+
unabsorbedDonations,
|
|
249
|
+
formFields,
|
|
250
|
+
workings,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Build Form C-S (18 boxes) or C-S Lite (6 boxes) field mapping
|
|
255
|
+
* per IRAS specification. Box numbers match the actual IRAS form.
|
|
256
|
+
*/
|
|
257
|
+
function buildFormFields(input, f) {
|
|
258
|
+
if (f.formType === 'C-S Lite') {
|
|
259
|
+
// C-S Lite: 6 fields only
|
|
260
|
+
return [
|
|
261
|
+
{ box: 1, label: 'Total Revenue', value: input.revenue, source: 'P&L total revenue' },
|
|
262
|
+
{ box: 2, label: 'Adjusted Profit / Loss', value: f.adjustedProfit, source: 'Accounting profit + add-backs − deductions' },
|
|
263
|
+
{ box: 3, label: 'Chargeable Income', value: f.chargeableIncome, source: 'After all reliefs' },
|
|
264
|
+
{ box: 4, label: 'Exempt Amount', value: f.exemptAmount, source: `${input.exemptionType.toUpperCase()} exemption` },
|
|
265
|
+
{ box: 5, label: 'Tax Payable', value: f.grossTax, source: `Taxable income × ${SG_CIT_RATE * 100}%` },
|
|
266
|
+
{ box: 6, label: 'Net Tax Payable', value: f.netTaxPayable, source: 'Gross tax − CIT rebate' },
|
|
267
|
+
];
|
|
268
|
+
}
|
|
269
|
+
// Form C-S: 18 fields matching IRAS layout
|
|
270
|
+
return [
|
|
271
|
+
// Section A: Revenue and Adjusted Profit
|
|
272
|
+
{ box: 1, label: 'Adjusted Profit / Loss', value: f.adjustedProfit, source: 'Accounting profit + add-backs − deductions' },
|
|
273
|
+
{ box: 2, label: 'Total Revenue', value: input.revenue, source: 'P&L total revenue' },
|
|
274
|
+
// Section B: Capital Allowances
|
|
275
|
+
{ box: 3, label: 'Capital Allowances Claimed', value: f.capitalAllowanceClaim, source: 'Current + prior YA CA' },
|
|
276
|
+
{ box: 4, label: 'Unabsorbed CA b/f', value: input.capitalAllowances.balanceBroughtForward, source: 'Prior YA carry-forward' },
|
|
277
|
+
{ box: 5, label: 'Unabsorbed CA c/f', value: f.unabsorbedCapitalAllowances, source: 'b/f + current available − utilized' },
|
|
278
|
+
// Section C: Losses
|
|
279
|
+
{ box: 6, label: 'Current Year Unabsorbed Losses', value: f.currentYearLoss, source: 'Only if Box 1 is a loss' },
|
|
280
|
+
{ box: 7, label: 'Unabsorbed Losses b/f', value: input.losses.broughtForward, source: 'Prior YA carry-forward' },
|
|
281
|
+
{ box: 8, label: 'Unabsorbed Losses c/f', value: f.unabsorbedLosses, source: 'b/f + current year loss − utilized' },
|
|
282
|
+
// Section D: Donations
|
|
283
|
+
{ box: 9, label: 'Qualifying Donations (250%)', value: f.donationRelief, source: 'IPC donations at 250%' },
|
|
284
|
+
{ box: 10, label: 'Unabsorbed Donations b/f', value: input.donationsCarryForward.broughtForward, source: 'Prior YA carry-forward (max 5 years)' },
|
|
285
|
+
{ box: 11, label: 'Unabsorbed Donations c/f', value: f.unabsorbedDonations, source: 'b/f + current qualifying − utilized' },
|
|
286
|
+
// Section E: Chargeable Income and Tax
|
|
287
|
+
{ box: 12, label: 'Chargeable Income', value: f.chargeableIncome, source: 'After all reliefs, floored at 0' },
|
|
288
|
+
{ box: 13, label: 'Exempt Amount', value: f.exemptAmount, source: `${input.exemptionType.toUpperCase()} exemption` },
|
|
289
|
+
{ box: 14, label: 'Taxable Income', value: f.taxableIncome, source: 'Chargeable income − exempt amount' },
|
|
290
|
+
{ box: 15, label: 'Gross Tax', value: f.grossTax, source: `Taxable income × ${SG_CIT_RATE * 100}%` },
|
|
291
|
+
{ box: 16, label: 'CIT Rebate', value: f.citRebate, source: `YA ${input.ya} rebate schedule` },
|
|
292
|
+
{ box: 17, label: 'Net Tax Payable', value: f.netTaxPayable, source: 'Gross tax − CIT rebate' },
|
|
293
|
+
{ box: 18, label: 'Claiming SUTE?', value: input.exemptionType === 'sute', source: 'Exemption election' },
|
|
294
|
+
];
|
|
295
|
+
}
|
|
296
|
+
function fmtAmt(n, ccy) {
|
|
297
|
+
return `${ccy} ${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
298
|
+
}
|
|
299
|
+
function buildWorkings(w) {
|
|
300
|
+
const lines = [
|
|
301
|
+
`TAX COMPUTATION — YA ${w.ya}`,
|
|
302
|
+
`${w.formType} | Basis period: ${w.basisPeriod}`,
|
|
303
|
+
`${w.eligible ? '✓ Eligible for Form C-S filing' : '✗ Exceeds C-S revenue threshold'}`,
|
|
304
|
+
'',
|
|
305
|
+
`Net profit per accounts: ${fmtAmt(w.accountingProfit, w.currency)}`,
|
|
306
|
+
`Add: Non-deductible items: ${fmtAmt(w.totalAddBacks, w.currency)}`,
|
|
307
|
+
`Less: Further deductions: (${fmtAmt(w.totalDeductions, w.currency)})`,
|
|
308
|
+
` ─────────────────`,
|
|
309
|
+
`Adjusted profit/(loss): ${fmtAmt(w.adjustedProfit, w.currency)}`,
|
|
310
|
+
'',
|
|
311
|
+
`Less: Capital allowances: (${fmtAmt(w.capitalAllowanceClaim, w.currency)})`,
|
|
312
|
+
];
|
|
313
|
+
if (w.currentCaClaim > 0)
|
|
314
|
+
lines.push(` Current year CA: (${fmtAmt(w.currentCaClaim, w.currency)})`);
|
|
315
|
+
if (w.priorCaClaim > 0)
|
|
316
|
+
lines.push(` Unabsorbed CA b/f: (${fmtAmt(w.priorCaClaim, w.currency)})`);
|
|
317
|
+
if (w.enhancedDeductionTotal > 0) {
|
|
318
|
+
lines.push(`Less: Enhanced deductions: (${fmtAmt(w.enhancedDeductionTotal, w.currency)})`);
|
|
319
|
+
}
|
|
320
|
+
lines.push(` ─────────────────`);
|
|
321
|
+
lines.push(`Chargeable income before losses: ${fmtAmt(w.chargeableIncomeBeforeLosses, w.currency)}`);
|
|
322
|
+
if (w.lossRelief > 0)
|
|
323
|
+
lines.push(`Less: Trade loss relief: (${fmtAmt(w.lossRelief, w.currency)})`);
|
|
324
|
+
if (w.donationRelief > 0)
|
|
325
|
+
lines.push(`Less: Donation relief: (${fmtAmt(w.donationRelief, w.currency)})`);
|
|
326
|
+
lines.push(` ─────────────────`);
|
|
327
|
+
lines.push(`Chargeable income: ${fmtAmt(w.chargeableIncome, w.currency)}`);
|
|
328
|
+
if (w.exemptAmount > 0) {
|
|
329
|
+
lines.push(`Less: ${w.exemptionType.toUpperCase()} exemption: (${fmtAmt(w.exemptAmount, w.currency)})`);
|
|
330
|
+
lines.push(`Taxable income: ${fmtAmt(w.taxableIncome, w.currency)}`);
|
|
331
|
+
}
|
|
332
|
+
lines.push('');
|
|
333
|
+
lines.push(`Tax @ 17%: ${fmtAmt(w.grossTax, w.currency)}`);
|
|
334
|
+
if (w.citRebate > 0)
|
|
335
|
+
lines.push(`Less: CIT rebate (YA ${w.ya}): (${fmtAmt(w.citRebate, w.currency)})`);
|
|
336
|
+
lines.push(` ═════════════════`);
|
|
337
|
+
lines.push(`NET TAX PAYABLE: ${fmtAmt(w.netTaxPayable, w.currency)}`);
|
|
338
|
+
if (w.unabsorbedLosses > 0 || w.unabsorbedCapitalAllowances > 0 || w.unabsorbedDonations > 0) {
|
|
339
|
+
lines.push('');
|
|
340
|
+
lines.push('Carry-forwards to next YA:');
|
|
341
|
+
if (w.unabsorbedLosses > 0)
|
|
342
|
+
lines.push(` Unabsorbed trade losses: ${fmtAmt(w.unabsorbedLosses, w.currency)}`);
|
|
343
|
+
if (w.unabsorbedCapitalAllowances > 0)
|
|
344
|
+
lines.push(` Unabsorbed capital allowances: ${fmtAmt(w.unabsorbedCapitalAllowances, w.currency)}`);
|
|
345
|
+
if (w.unabsorbedDonations > 0)
|
|
346
|
+
lines.push(` Unabsorbed donations: ${fmtAmt(w.unabsorbedDonations, w.currency)}`);
|
|
347
|
+
}
|
|
348
|
+
return lines.join('\n');
|
|
349
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Singapore tax result formatters.
|
|
3
|
+
* Professional-grade tax computation schedule and CA schedule output.
|
|
4
|
+
*/
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
const fmt = (n) => n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
7
|
+
const fmtR = (n) => fmt(Math.abs(n)).padStart(16);
|
|
8
|
+
const line = (w) => chalk.dim('─'.repeat(w));
|
|
9
|
+
const dline = (w) => chalk.dim('═'.repeat(w));
|
|
10
|
+
// ── Form C-S / C-S Lite ──────────────────────────────────────────
|
|
11
|
+
export function printFormCsResult(result) {
|
|
12
|
+
const W = 80;
|
|
13
|
+
const ccy = result.currency;
|
|
14
|
+
console.log();
|
|
15
|
+
console.log(chalk.bold(`SINGAPORE CORPORATE INCOME TAX COMPUTATION — YA ${result.ya}`));
|
|
16
|
+
console.log(line(W));
|
|
17
|
+
console.log(` Form type: ${chalk.bold(result.formType)}${result.eligible ? chalk.green(' ✓ Eligible') : chalk.red(' ✗ Not eligible')}`);
|
|
18
|
+
console.log(` Basis period: ${result.basisPeriod}`);
|
|
19
|
+
console.log(` Currency: ${ccy}`);
|
|
20
|
+
console.log(line(W));
|
|
21
|
+
// Tax computation schedule
|
|
22
|
+
console.log();
|
|
23
|
+
console.log(chalk.bold('TAX COMPUTATION SCHEDULE'));
|
|
24
|
+
console.log(line(W));
|
|
25
|
+
for (const entry of result.schedule) {
|
|
26
|
+
printScheduleLine(entry, ccy, W);
|
|
27
|
+
}
|
|
28
|
+
console.log(dline(W));
|
|
29
|
+
// Carry-forwards
|
|
30
|
+
if (result.unabsorbedLosses > 0 || result.unabsorbedCapitalAllowances > 0 || result.unabsorbedDonations > 0) {
|
|
31
|
+
console.log();
|
|
32
|
+
console.log(chalk.bold('CARRY-FORWARDS TO NEXT YA'));
|
|
33
|
+
console.log(line(W));
|
|
34
|
+
if (result.unabsorbedLosses > 0) {
|
|
35
|
+
console.log(` Unabsorbed trade losses: ${ccy} ${fmtR(result.unabsorbedLosses)}`);
|
|
36
|
+
}
|
|
37
|
+
if (result.unabsorbedCapitalAllowances > 0) {
|
|
38
|
+
console.log(` Unabsorbed capital allowances: ${ccy} ${fmtR(result.unabsorbedCapitalAllowances)}`);
|
|
39
|
+
}
|
|
40
|
+
if (result.unabsorbedDonations > 0) {
|
|
41
|
+
console.log(` Unabsorbed donations: ${ccy} ${fmtR(result.unabsorbedDonations)}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Form C-S field mapping
|
|
45
|
+
console.log();
|
|
46
|
+
console.log(chalk.bold(`FORM ${result.formType} FIELD MAPPING`));
|
|
47
|
+
console.log(line(W));
|
|
48
|
+
for (const f of result.formFields) {
|
|
49
|
+
const val = typeof f.value === 'number' ? `${ccy} ${fmt(f.value)}` : String(f.value);
|
|
50
|
+
console.log(` Box ${String(f.box).padStart(2)}: ${f.label.padEnd(35)} ${val}`);
|
|
51
|
+
console.log(chalk.dim(` ${f.source}`));
|
|
52
|
+
}
|
|
53
|
+
// Workings
|
|
54
|
+
console.log();
|
|
55
|
+
console.log(chalk.bold('WORKINGS'));
|
|
56
|
+
console.log(line(W));
|
|
57
|
+
for (const l of result.workings.split('\n')) {
|
|
58
|
+
console.log(` ${l}`);
|
|
59
|
+
}
|
|
60
|
+
console.log();
|
|
61
|
+
}
|
|
62
|
+
function printScheduleLine(entry, ccy, _w) {
|
|
63
|
+
const indent = entry.indent ?? 0;
|
|
64
|
+
const prefix = ' '.repeat(indent + 1);
|
|
65
|
+
const label = entry.label;
|
|
66
|
+
const ref = entry.reference ? chalk.dim(` (${entry.reference})`) : '';
|
|
67
|
+
// Main lines are bold, sub-items are dim
|
|
68
|
+
if (indent === 0) {
|
|
69
|
+
const isSubtotal = label.includes('Adjusted profit') || label.includes('Chargeable income') ||
|
|
70
|
+
label.includes('Taxable income') || label.includes('Net tax payable') ||
|
|
71
|
+
label.includes('Tax @');
|
|
72
|
+
const formatted = `${prefix}${label.padEnd(45 - indent * 2)}${ccy} ${fmtR(entry.amount)}${ref}`;
|
|
73
|
+
if (isSubtotal) {
|
|
74
|
+
console.log(chalk.bold(formatted));
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
console.log(formatted);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
console.log(chalk.dim(`${prefix}${label.padEnd(43 - indent * 2)}${ccy} ${fmtR(entry.amount)}${ref}`));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// ── Capital Allowance Schedule ────────────────────────────────────
|
|
85
|
+
export function printCaResult(result) {
|
|
86
|
+
const W = 110;
|
|
87
|
+
const ccy = result.currency;
|
|
88
|
+
console.log();
|
|
89
|
+
console.log(chalk.bold(`CAPITAL ALLOWANCE SCHEDULE — YA ${result.ya}`));
|
|
90
|
+
console.log(line(W));
|
|
91
|
+
// Header row
|
|
92
|
+
const header = [
|
|
93
|
+
'Description'.padEnd(25),
|
|
94
|
+
'Section'.padEnd(10),
|
|
95
|
+
'Cost'.padStart(14),
|
|
96
|
+
'Prior Claimed'.padStart(14),
|
|
97
|
+
'Current YA'.padStart(14),
|
|
98
|
+
'Total Claimed'.padStart(14),
|
|
99
|
+
'Remaining'.padStart(14),
|
|
100
|
+
].join(' ');
|
|
101
|
+
console.log(chalk.dim(header));
|
|
102
|
+
console.log(line(W));
|
|
103
|
+
for (const row of result.assets) {
|
|
104
|
+
const cols = [
|
|
105
|
+
row.description.slice(0, 25).padEnd(25),
|
|
106
|
+
row.section.padEnd(10),
|
|
107
|
+
fmt(row.cost).padStart(14),
|
|
108
|
+
fmt(row.totalClaimedPrior).padStart(14),
|
|
109
|
+
fmt(row.currentYearClaim).padStart(14),
|
|
110
|
+
fmt(row.totalClaimedToDate).padStart(14),
|
|
111
|
+
fmt(row.remainingUnabsorbed).padStart(14),
|
|
112
|
+
].join(' ');
|
|
113
|
+
console.log(cols);
|
|
114
|
+
}
|
|
115
|
+
console.log(line(W));
|
|
116
|
+
// Totals
|
|
117
|
+
console.log();
|
|
118
|
+
console.log(` Current year CA: ${ccy} ${fmt(result.totalCurrentYearClaim)}`);
|
|
119
|
+
console.log(` Unabsorbed CA b/f: ${ccy} ${fmt(result.unabsorbedBroughtForward)}`);
|
|
120
|
+
console.log(chalk.bold(` Total CA available: ${ccy} ${fmt(result.totalAvailable)}`));
|
|
121
|
+
if (result.lowValueCapped) {
|
|
122
|
+
console.log();
|
|
123
|
+
console.log(chalk.yellow(` Note: Low-value asset claims capped at ${ccy} 30,000.00 per YA`));
|
|
124
|
+
console.log(chalk.yellow(` (${result.lowValueCount} low-value assets, total ${ccy} ${fmt(result.lowValueTotal)} claimed)`));
|
|
125
|
+
}
|
|
126
|
+
// Workings
|
|
127
|
+
console.log();
|
|
128
|
+
console.log(chalk.bold('WORKINGS'));
|
|
129
|
+
console.log(line(W));
|
|
130
|
+
for (const l of result.workings.split('\n')) {
|
|
131
|
+
console.log(` ${l}`);
|
|
132
|
+
}
|
|
133
|
+
console.log();
|
|
134
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for jaz tax — Singapore Form C-S corporate income tax computation.
|
|
3
|
+
*/
|
|
4
|
+
import { round2 } from '../calc/types.js';
|
|
5
|
+
export { round2 };
|
|
6
|
+
export const ASSET_CATEGORIES = [
|
|
7
|
+
'computer', 'automation', 'low-value', 'general', 'ip', 'renovation',
|
|
8
|
+
];
|
|
9
|
+
export const EXEMPTION_TYPES = ['sute', 'pte', 'none'];
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input validation for jaz tax computations.
|
|
3
|
+
* Throws TaxValidationError with user-friendly messages.
|
|
4
|
+
*/
|
|
5
|
+
import { EXEMPTION_TYPES, ASSET_CATEGORIES } from './types.js';
|
|
6
|
+
export class TaxValidationError extends Error {
|
|
7
|
+
constructor(message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = 'TaxValidationError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function validateYa(ya) {
|
|
13
|
+
if (!Number.isFinite(ya) || !Number.isInteger(ya)) {
|
|
14
|
+
throw new TaxValidationError(`Year of Assessment must be an integer (got ${ya})`);
|
|
15
|
+
}
|
|
16
|
+
if (ya < 2020 || ya > 2100) {
|
|
17
|
+
throw new TaxValidationError(`Year of Assessment must be between 2020 and 2100 (got ${ya})`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function validateNonNegative(value, name) {
|
|
21
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
22
|
+
throw new TaxValidationError(`${name} must be zero or positive (got ${value})`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function validatePositive(value, name) {
|
|
26
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
27
|
+
throw new TaxValidationError(`${name} must be a positive number (got ${value})`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function validateExemptionType(type) {
|
|
31
|
+
if (!EXEMPTION_TYPES.includes(type)) {
|
|
32
|
+
throw new TaxValidationError(`Invalid exemption type: "${type}". Must be one of: ${EXEMPTION_TYPES.join(', ')}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function validateAssetCategory(cat) {
|
|
36
|
+
if (!ASSET_CATEGORIES.includes(cat)) {
|
|
37
|
+
throw new TaxValidationError(`Invalid asset category: "${cat}". Must be one of: ${ASSET_CATEGORIES.join(', ')}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function validateDateFormat(date, name) {
|
|
41
|
+
const m = date.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
42
|
+
if (!m) {
|
|
43
|
+
throw new TaxValidationError(`${name} must be YYYY-MM-DD format (got "${date}")`);
|
|
44
|
+
}
|
|
45
|
+
const y = Number(m[1]);
|
|
46
|
+
const mo = Number(m[2]);
|
|
47
|
+
const da = Number(m[3]);
|
|
48
|
+
const d = new Date(Date.UTC(y, mo - 1, da));
|
|
49
|
+
if (d.getUTCFullYear() !== y || d.getUTCMonth() !== mo - 1 || d.getUTCDate() !== da) {
|
|
50
|
+
throw new TaxValidationError(`Invalid ${name}: "${date}"`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Validate the full SgFormCsInput structure. */
|
|
54
|
+
export function validateFormCsInput(input) {
|
|
55
|
+
validateYa(input.ya);
|
|
56
|
+
validateDateFormat(input.basisPeriodStart, 'basisPeriodStart');
|
|
57
|
+
validateDateFormat(input.basisPeriodEnd, 'basisPeriodEnd');
|
|
58
|
+
if (input.basisPeriodStart > input.basisPeriodEnd) {
|
|
59
|
+
throw new TaxValidationError(`basisPeriodStart (${input.basisPeriodStart}) must be on or before basisPeriodEnd (${input.basisPeriodEnd})`);
|
|
60
|
+
}
|
|
61
|
+
validateNonNegative(input.revenue, 'revenue');
|
|
62
|
+
// accountingProfit can be negative (loss)
|
|
63
|
+
// Add-backs — all must be non-negative
|
|
64
|
+
const ab = input.addBacks;
|
|
65
|
+
validateNonNegative(ab.depreciation, 'addBacks.depreciation');
|
|
66
|
+
validateNonNegative(ab.amortization, 'addBacks.amortization');
|
|
67
|
+
validateNonNegative(ab.rouDepreciation, 'addBacks.rouDepreciation');
|
|
68
|
+
validateNonNegative(ab.leaseInterest, 'addBacks.leaseInterest');
|
|
69
|
+
validateNonNegative(ab.generalProvisions, 'addBacks.generalProvisions');
|
|
70
|
+
validateNonNegative(ab.donations, 'addBacks.donations');
|
|
71
|
+
validateNonNegative(ab.entertainment, 'addBacks.entertainment');
|
|
72
|
+
validateNonNegative(ab.penalties, 'addBacks.penalties');
|
|
73
|
+
validateNonNegative(ab.privateCar, 'addBacks.privateCar');
|
|
74
|
+
validateNonNegative(ab.capitalExpOnPnl, 'addBacks.capitalExpOnPnl');
|
|
75
|
+
validateNonNegative(ab.unrealizedFxLoss, 'addBacks.unrealizedFxLoss');
|
|
76
|
+
validateNonNegative(ab.otherNonDeductible, 'addBacks.otherNonDeductible');
|
|
77
|
+
// Deductions — all must be non-negative
|
|
78
|
+
const ded = input.deductions;
|
|
79
|
+
validateNonNegative(ded.actualLeasePayments, 'deductions.actualLeasePayments');
|
|
80
|
+
validateNonNegative(ded.unrealizedFxGain, 'deductions.unrealizedFxGain');
|
|
81
|
+
validateNonNegative(ded.exemptDividends, 'deductions.exemptDividends');
|
|
82
|
+
validateNonNegative(ded.exemptIncome, 'deductions.exemptIncome');
|
|
83
|
+
validateNonNegative(ded.otherDeductions, 'deductions.otherDeductions');
|
|
84
|
+
// Capital allowances
|
|
85
|
+
validateNonNegative(input.capitalAllowances.currentYearClaim, 'capitalAllowances.currentYearClaim');
|
|
86
|
+
validateNonNegative(input.capitalAllowances.balanceBroughtForward, 'capitalAllowances.balanceBroughtForward');
|
|
87
|
+
// Enhanced deductions
|
|
88
|
+
const ed = input.enhancedDeductions;
|
|
89
|
+
validateNonNegative(ed.rdExpenditure, 'enhancedDeductions.rdExpenditure');
|
|
90
|
+
validateNonNegative(ed.ipRegistration, 'enhancedDeductions.ipRegistration');
|
|
91
|
+
validateNonNegative(ed.donations250Base, 'enhancedDeductions.donations250Base');
|
|
92
|
+
validateNonNegative(ed.s14qRenovation, 'enhancedDeductions.s14qRenovation');
|
|
93
|
+
// Carry-forwards
|
|
94
|
+
validateNonNegative(input.losses.broughtForward, 'losses.broughtForward');
|
|
95
|
+
validateNonNegative(input.donationsCarryForward.broughtForward, 'donationsCarryForward.broughtForward');
|
|
96
|
+
validateExemptionType(input.exemptionType);
|
|
97
|
+
}
|
|
98
|
+
/** Validate the SgCapitalAllowanceInput structure. */
|
|
99
|
+
export function validateCaInput(input) {
|
|
100
|
+
validateYa(input.ya);
|
|
101
|
+
validateNonNegative(input.unabsorbedBroughtForward, 'unabsorbedBroughtForward');
|
|
102
|
+
if (!Array.isArray(input.assets) || input.assets.length === 0) {
|
|
103
|
+
throw new TaxValidationError('At least one asset is required');
|
|
104
|
+
}
|
|
105
|
+
for (let i = 0; i < input.assets.length; i++) {
|
|
106
|
+
const asset = input.assets[i];
|
|
107
|
+
validatePositive(asset.cost, `assets[${i}].cost`);
|
|
108
|
+
validateDateFormat(asset.acquisitionDate, `assets[${i}].acquisitionDate`);
|
|
109
|
+
validateAssetCategory(asset.category);
|
|
110
|
+
validateNonNegative(asset.priorYearsClaimed, `assets[${i}].priorYearsClaimed`);
|
|
111
|
+
if (asset.priorYearsClaimed > asset.cost) {
|
|
112
|
+
throw new TaxValidationError(`assets[${i}].priorYearsClaimed (${asset.priorYearsClaimed}) exceeds cost (${asset.cost})`);
|
|
113
|
+
}
|
|
114
|
+
if (asset.category === 'ip' && asset.ipWriteOffYears != null) {
|
|
115
|
+
const valid = [5, 10, 15];
|
|
116
|
+
if (!valid.includes(asset.ipWriteOffYears)) {
|
|
117
|
+
throw new TaxValidationError(`assets[${i}].ipWriteOffYears must be 5, 10, or 15 (got ${asset.ipWriteOffYears})`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (asset.category === 'low-value' && asset.cost > 5000) {
|
|
121
|
+
throw new TaxValidationError(`assets[${i}] is 'low-value' but cost (${asset.cost}) exceeds $5,000 threshold`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
package/dist/types/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
export const SKILL_TYPES = ['api', 'conversion', 'transaction-recipes', 'all'];
|
|
1
|
+
export const SKILL_TYPES = ['api', 'conversion', 'transaction-recipes', 'jobs', 'all'];
|
|
2
2
|
export const SKILL_DESCRIPTIONS = {
|
|
3
3
|
api: 'Jaz/Juan REST API reference — 55 rules, endpoint catalog, error catalog, field mapping',
|
|
4
4
|
conversion: 'Data conversion pipeline — Xero, QuickBooks, Sage, Excel migration to Jaz',
|
|
5
5
|
'transaction-recipes': 'Complex accounting recipes — prepaid, deferred revenue, loans, IFRS 16, depreciation',
|
|
6
|
+
jobs: 'Accounting job blueprints — month/quarter/year-end close + 7 ad-hoc operational workflows',
|
|
6
7
|
};
|
package/dist/utils/template.js
CHANGED
|
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
5
5
|
// From dist/utils/template.js -> ../../assets (two levels up to cli/, then assets/)
|
|
6
6
|
const ASSETS_DIR = join(__dirname, '..', '..', 'assets');
|
|
7
|
-
const SKILLS = ['api', 'conversion', 'transaction-recipes'];
|
|
7
|
+
const SKILLS = ['api', 'conversion', 'transaction-recipes', 'jobs'];
|
|
8
8
|
async function exists(path) {
|
|
9
9
|
try {
|
|
10
10
|
await access(path);
|