@seethruhead/cra-payroll 0.7.0 → 0.8.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/README.md CHANGED
@@ -1,15 +1,36 @@
1
- # @seethruhead/cra-payroll
1
+ # cra-payroll
2
2
 
3
3
  Calculate Canadian payroll deductions using CRA's [Payroll Deductions Online Calculator (PDOC)](https://apps.cra-arc.gc.ca/ebci/rhpd/beta/entry).
4
4
 
5
- Automates the CRA wizard via Puppeteer and returns your net pay, taxes, CPP, EI, and RRSP breakdown — per paycheck, monthly, or annually.
5
+ Automates the CRA wizard via Playwright and returns your net pay, taxes, CPP, EI, and RRSP breakdown — per paycheck, monthly, or annually.
6
+
7
+ ## Download
8
+
9
+ Grab the latest binary for your platform from [**Releases**](https://github.com/SeeThruHead/cra-payroll/releases):
10
+
11
+ | Platform | File |
12
+ |----------|------|
13
+ | macOS (Apple Silicon) | `cra-payroll-darwin-arm64` |
14
+ | macOS (Intel) | `cra-payroll-darwin-x64` |
15
+ | Linux (x64) | `cra-payroll-linux-x64` |
16
+
17
+ ```bash
18
+ # Example: macOS Apple Silicon
19
+ curl -L -o cra-payroll https://github.com/SeeThruHead/cra-payroll/releases/latest/download/cra-payroll-darwin-arm64
20
+ chmod +x cra-payroll
21
+
22
+ # Remove macOS quarantine flag (unsigned binary)
23
+ xattr -d com.apple.quarantine cra-payroll
24
+
25
+ sudo mv cra-payroll /usr/local/bin/
26
+ ```
6
27
 
7
28
  > **Requires Google Chrome** — uses your system Chrome, no extra browser install needed.
8
29
 
9
- ## Install
30
+ Or use the one-liner:
10
31
 
11
32
  ```bash
12
- npm install -g @seethruhead/cra-payroll
33
+ curl -fsSL https://raw.githubusercontent.com/SeeThruHead/cra-payroll/main/install.sh | bash
13
34
  ```
14
35
 
15
36
  ## Usage
@@ -22,7 +43,7 @@ cra-payroll
22
43
  cra-payroll --salary 120000 --province "British Columbia"
23
44
 
24
45
  # Per-paycheck table for the year (tracks CPP/EI maxout)
25
- cra-payroll --salary 263000 --table
46
+ cra-payroll --salary 150000 --table
26
47
 
27
48
  # Annual totals
28
49
  cra-payroll --salary 100000 --annual
@@ -31,48 +52,59 @@ cra-payroll --salary 100000 --annual
31
52
  cra-payroll --salary 100000 --monthly
32
53
 
33
54
  # Combine them
34
- cra-payroll --salary 263000 --table --annual --monthly
55
+ cra-payroll --salary 150000 --table --annual --monthly
56
+
57
+ # Verbose logging
58
+ cra-payroll -v --salary 100000
59
+
60
+ # Check version
61
+ cra-payroll --version
62
+
63
+ # Self-update to latest release
64
+ cra-payroll --update
35
65
  ```
36
66
 
37
67
  ### Example output (`--table`)
38
68
 
39
69
  ```
40
- Per-Paycheck Table (2026)
70
+ 📊 Per-Paycheck Table (2026)
41
71
  ══════════════════════════════════════════════════════════════════════════════════════════════
42
72
  # │ Gross │ Fed Tax │ Prov Tax │ CPP │ EI │ Net Pay │ Cum CPP/EI
43
73
  ──────────────────────────────────────────────────────────────────────────────────────────────
44
- 1 │ 10,958.332,232.82 1,431.13669.42178.626,446.34848.04
45
- 2 │ 10,958.332,232.82 1,431.13669.42178.626,446.34 1,696.08
74
+ 1 │ 6,250.001,028.37 612.45382.51102.084,124.59484.59
75
+ 2 │ 6,250.001,028.37 612.45382.51102.084,124.59 969.18
46
76
  ... │ ... │ ... │ ... │ ... │ ... │ ... │ ...
47
- 7 │ 10,958.332,232.82 1,431.13213.9351.357,029.10 │ 5,353.52 ← partial
48
- 8 │ 10,958.332,232.82 1,431.13 │ 0.00 │ 0.00 │ 7,294.38 │ 5,353.52 ✓ maxed
77
+ 11 6,250.001,028.37 612.45112.5019.374,477.31 │ 5,353.52 ← partial
78
+ 12 6,250.001,028.37 612.45 │ 0.00 │ 0.00 │ 4,609.18 │ 5,353.52 ✓ maxed
49
79
  ... │ ... │ ... │ ... │ ... │ ... │ ... │ ...
50
80
  ```
51
81
 
52
82
  ## Config
53
83
 
54
- Create `~/.config/cra-payroll.json`:
84
+ Config is loaded from the first file found:
85
+ 1. `--config <path>`
86
+ 2. `./config.json`
87
+ 3. `~/.config/cra-payroll.json`
88
+ 4. `~/.cra-payroll.json`
89
+
90
+ CLI args override config file values. Missing values are prompted interactively.
55
91
 
56
92
  ```json
57
93
  {
58
94
  "province": "Ontario",
59
95
  "annualSalary": 100000,
60
96
  "payPeriod": "Semi-monthly (24 pay periods a year)",
61
- "year": 2026,
62
97
  "rrspEmployeePercent": 4,
63
98
  "rrspEmployerPercent": 4
64
99
  }
65
100
  ```
66
101
 
67
- CLI args override config file values. Missing values are prompted interactively.
68
-
69
102
  ### Options
70
103
 
71
104
  | Option | CLI flag | Config key | Default |
72
105
  |--------|----------|------------|---------|
73
106
  | Province | `-p`, `--province` | `province` | `Ontario` |
74
107
  | Annual salary | `-s`, `--salary` | `annualSalary` | _(prompted)_ |
75
- | Tax year | `-y`, `--year` | `year` | current year |
76
108
  | Pay period | `--pay-period` | `payPeriod` | `Semi-monthly (24)` |
77
109
  | Employee RRSP % | `--rrsp-employee` | `rrspEmployeePercent` | `4` |
78
110
  | Employer RRSP % | `--rrsp-employer` | `rrspEmployerPercent` | `4` |
@@ -82,20 +114,69 @@ CLI args override config file values. Missing values are prompted interactively.
82
114
  | Annual totals | `-a`, `--annual` | — | `false` |
83
115
  | Monthly averages | `-m`, `--monthly` | — | `false` |
84
116
  | Verbose | `-v`, `--verbose` | — | `false` |
117
+ | Self-update | `--update` | — | — |
118
+ | Show version | `--version` | — | — |
85
119
  | Headless | `--headless` | — | `false` |
86
120
  | Config path | `-c`, `--config` | — | — |
87
121
 
122
+ ### 2026 CPP/EI Maximums (used for `--table`)
123
+
124
+ | | Amount |
125
+ |---|---|
126
+ | CPP max contribution | $4,230.45 |
127
+ | CPP2 max (additional) | $416.00 |
128
+ | EI max premium | $1,123.07 |
129
+
130
+ ## Run from source
131
+
132
+ If you'd rather not download a binary, you can clone and run directly. You'll need [Google Chrome](https://www.google.com/chrome/) installed.
133
+
134
+ ### With Bun
135
+
136
+ ```bash
137
+ git clone https://github.com/SeeThruHead/cra-payroll.git
138
+ cd cra-payroll
139
+ bun install
140
+ bun run dev -- --salary 100000
141
+ bun run dev -- --salary 150000 --table
142
+ ```
143
+
144
+ ### With Node
145
+
146
+ ```bash
147
+ git clone https://github.com/SeeThruHead/cra-payroll.git
148
+ cd cra-payroll
149
+ npm install
150
+ npx tsx src/cli.ts --salary 100000
151
+ npx tsx src/cli.ts --salary 150000 --table
152
+ ```
153
+
154
+ ## Development
155
+
156
+ ```bash
157
+ # Run directly
158
+ bun run dev -- --salary 100000
159
+
160
+ # Build standalone binary
161
+ bun run build
162
+
163
+ # Unit tests (fast, no browser)
164
+ bun test
165
+
166
+ # Integration tests (hits CRA, needs Chrome, may be flaky)
167
+ bun run test:integration
168
+
169
+ # All tests
170
+ bun run test:all
171
+ ```
172
+
88
173
  ## How it works
89
174
 
90
175
  1. Launches your system Chrome via Puppeteer (headed by default — CRA blocks headless)
91
176
  2. Fills out the PDOC wizard: province, pay period, salary, RRSP contributions
92
177
  3. Sets CPP/EI status and hits Calculate
93
178
  4. Scrapes the results page for taxes, deductions, and net pay
94
- 5. For `--table` mode: runs twice (with/without CPP/EI) and simulates each paycheck using the annual maximums
95
-
96
- ## Standalone binary
97
-
98
- If you prefer a standalone binary without Node.js, see the [GitHub releases](https://github.com/SeeThruHead/cra-payroll/releases).
179
+ 5. For `--table` mode: runs twice (with/without CPP/EI) and simulates each paycheck using the 2026 maximums
99
180
 
100
181
  ## License
101
182
 
@@ -69212,8 +69212,8 @@ var init_LaunchOptions = __esm(() => {
69212
69212
 
69213
69213
  // src/cli.ts
69214
69214
  import { parseArgs } from "util";
69215
- import { resolve as resolve7 } from "path";
69216
- import { existsSync as existsSync5, readFileSync as readFileSync5, fstatSync } from "fs";
69215
+ import { resolve as resolve8 } from "path";
69216
+ import { existsSync as existsSync6, readFileSync as readFileSync6, fstatSync } from "fs";
69217
69217
  import { createInterface as createInterface2 } from "readline";
69218
69218
 
69219
69219
  // node_modules/neverthrow/dist/index.cjs.js
@@ -79630,7 +79630,7 @@ import { existsSync as existsSync4, renameSync, unlinkSync, chmodSync } from "fs
79630
79630
  // package.json
79631
79631
  var package_default = {
79632
79632
  name: "@seethruhead/cra-payroll",
79633
- version: "0.7.0",
79633
+ version: "0.8.0",
79634
79634
  description: "Calculate Canadian payroll deductions using CRA's Payroll Deductions Online Calculator",
79635
79635
  type: "module",
79636
79636
  bin: {
@@ -80171,6 +80171,241 @@ var buildJsonOutput = (mode, config2, data) => {
80171
80171
  }
80172
80172
  };
80173
80173
 
80174
+ // src/rrsp-optimizer.ts
80175
+ import { resolve as resolve7 } from "path";
80176
+ import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "fs";
80177
+ import { createHash as createHash2 } from "crypto";
80178
+ var PROVINCE_CODES = {
80179
+ Alberta: "ab",
80180
+ "British Columbia": "bc",
80181
+ Manitoba: "mb",
80182
+ "New Brunswick": "nb",
80183
+ "Newfoundland and Labrador": "nl",
80184
+ Newfoundland: "nl",
80185
+ "Nova Scotia": "ns",
80186
+ Ontario: "on",
80187
+ "Prince Edward Island": "pe",
80188
+ Quebec: "qc",
80189
+ Saskatchewan: "sk",
80190
+ "Northwest Territories": "nt",
80191
+ Nunavut: "nu",
80192
+ Yukon: "yt"
80193
+ };
80194
+ var resolveProvinceCode = (province) => {
80195
+ if (Object.values(PROVINCE_CODES).includes(province.toLowerCase()))
80196
+ return $ok(province.toLowerCase());
80197
+ const code = PROVINCE_CODES[province];
80198
+ if (code)
80199
+ return $ok(code);
80200
+ return $err(`Unknown province "${province}". Supported: ${Object.keys(PROVINCE_CODES).join(", ")}`);
80201
+ };
80202
+ var RRSP_URL = "https://www.rrspcontribution.ca/submit";
80203
+ var buildFormBody = (config2, provinceCode) => {
80204
+ const params = new URLSearchParams;
80205
+ params.set("year", String(config2.year));
80206
+ params.set("province", provinceCode);
80207
+ params.set("income", String(config2.income));
80208
+ params.set("rrsp_room", String(config2.rrspRoom));
80209
+ params.set("num_kids_5_and_younger", String(config2.numKids5AndYounger));
80210
+ params.set("num_kids_6_and_older", String(config2.numKids6AndOlder));
80211
+ if (config2.hasSpouse)
80212
+ params.set("spouse", "y");
80213
+ if (config2.spouseIncome !== null)
80214
+ params.set("spouse_income", String(config2.spouseIncome));
80215
+ return params.toString();
80216
+ };
80217
+ var fetchResults = async (config2) => {
80218
+ const provinceCode = resolveProvinceCode(config2.province);
80219
+ if (provinceCode.isErr())
80220
+ return $err(provinceCode.error);
80221
+ const body = buildFormBody(config2, provinceCode.value);
80222
+ log(`RRSP optimizer POST: ${body}`);
80223
+ try {
80224
+ const response = await fetch(RRSP_URL, {
80225
+ method: "POST",
80226
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
80227
+ body
80228
+ });
80229
+ if (!response.ok)
80230
+ return $err(`HTTP ${response.status}: ${response.statusText}`);
80231
+ return $ok(await response.text());
80232
+ } catch (e2) {
80233
+ return $err(`Network error: ${e2.message}`);
80234
+ }
80235
+ };
80236
+ var parseRecommendation = (html) => {
80237
+ const contMatch = html.match(/contribute\s+\$\s*([\d,]+)/i);
80238
+ const savMatch = html.match(/savings\s+of\s+\$\s*([\d,]+)/i);
80239
+ const pctMatch = html.match(/([\d.]+)\s*%\s*<\/b>\s*of your contribution/i);
80240
+ if (!contMatch)
80241
+ return $err("Could not parse recommended contribution from response");
80242
+ if (!savMatch)
80243
+ return $err("Could not parse savings amount from response");
80244
+ const contribution = parseFloat(contMatch[1].replace(/,/g, ""));
80245
+ const savings = parseFloat(savMatch[1].replace(/,/g, ""));
80246
+ const savingsPercent = pctMatch ? parseFloat(pctMatch[1]) / 100 : savings / contribution;
80247
+ return $ok({ contribution, savings, savingsPercent });
80248
+ };
80249
+ var parseStepsTable = (html) => {
80250
+ const steps = [];
80251
+ const rowRegex = /<tr>\s*(?:<td>\s*(.*?)\s*<\/td>\s*){6}<\/tr>/gs;
80252
+ const tableMatch = html.match(/<table class="table table-striped">([\s\S]*?)<\/table>/);
80253
+ if (!tableMatch)
80254
+ return steps;
80255
+ const tableHtml = tableMatch[1];
80256
+ const rows = tableHtml.split(/<tr>/g).slice(2);
80257
+ for (const row of rows) {
80258
+ const cells = [...row.matchAll(/<td>([\s\S]*?)<\/td>/g)].map((m) => m[1].trim());
80259
+ if (cells.length < 6)
80260
+ continue;
80261
+ steps.push({
80262
+ step: parseInt(cells[0], 10),
80263
+ stepSize: parseFloat(cells[1].replace(/[$,]/g, "")),
80264
+ cumulativeContribution: parseFloat(cells[2].replace(/[$,]/g, "")),
80265
+ effectiveTaxRate: parseFloat(cells[3].replace(/%/, "")) / 100,
80266
+ cumulativeSavings: parseFloat(cells[4].replace(/[$,]/g, "")),
80267
+ event: cells[5].replace(/=&gt;/g, "→").replace(/<[^>]*>/g, "").trim()
80268
+ });
80269
+ }
80270
+ return steps;
80271
+ };
80272
+ var parseBenefits = (html) => {
80273
+ const section = html.match(/government benefits and tax credits are included:[\s\S]*?<ul[^>]*>([\s\S]*?)<\/ul>/);
80274
+ if (!section)
80275
+ return [];
80276
+ return [...section[1].matchAll(/<li[^>]*>([\s\S]*?)<\/li>/g)].map((m) => m[1].trim()).filter(Boolean);
80277
+ };
80278
+ var parseHtml = (html) => {
80279
+ const rec = parseRecommendation(html);
80280
+ if (rec.isErr())
80281
+ return $err(rec.error);
80282
+ const steps = parseStepsTable(html);
80283
+ const benefits = parseBenefits(html);
80284
+ return $ok({
80285
+ recommendedContribution: rec.value.contribution,
80286
+ totalSavings: rec.value.savings,
80287
+ savingsPercent: rec.value.savingsPercent,
80288
+ steps,
80289
+ benefitsIncluded: benefits
80290
+ });
80291
+ };
80292
+ var CACHE_DIR = resolve7(process.env.HOME || "~", ".config", "cra-payroll", "cache", "rrsp");
80293
+ var cacheKey2 = (config2) => {
80294
+ const data = JSON.stringify({
80295
+ year: config2.year,
80296
+ province: config2.province,
80297
+ income: config2.income,
80298
+ rrspRoom: config2.rrspRoom,
80299
+ numKids5AndYounger: config2.numKids5AndYounger,
80300
+ numKids6AndOlder: config2.numKids6AndOlder,
80301
+ hasSpouse: config2.hasSpouse,
80302
+ spouseIncome: config2.spouseIncome
80303
+ });
80304
+ return createHash2("sha256").update(data).digest("hex").slice(0, 16);
80305
+ };
80306
+ var cachePath2 = (config2) => resolve7(CACHE_DIR, `${cacheKey2(config2)}.json`);
80307
+ var readCache2 = (config2) => {
80308
+ const path12 = cachePath2(config2);
80309
+ if (!existsSync5(path12))
80310
+ return null;
80311
+ try {
80312
+ const data = JSON.parse(readFileSync5(path12, "utf-8"));
80313
+ if (typeof data.recommendedContribution === "number")
80314
+ return data;
80315
+ return null;
80316
+ } catch {
80317
+ return null;
80318
+ }
80319
+ };
80320
+ var writeCache2 = (config2, result) => {
80321
+ try {
80322
+ if (!existsSync5(CACHE_DIR))
80323
+ mkdirSync3(CACHE_DIR, { recursive: true });
80324
+ writeFileSync2(cachePath2(config2), JSON.stringify(result, null, 2));
80325
+ } catch (e2) {
80326
+ log(`RRSP cache write failed: ${e2.message}`);
80327
+ }
80328
+ };
80329
+ var rawOptimize = async (config2) => {
80330
+ const html = await fetchResults(config2);
80331
+ if (html.isErr())
80332
+ return $err(html.error);
80333
+ return parseHtml(html.value);
80334
+ };
80335
+ var cachedOptimize = async (config2) => {
80336
+ const cached = readCache2(config2);
80337
+ if (cached) {
80338
+ log(`RRSP cache hit: ${cachePath2(config2)}`);
80339
+ return $ok(cached);
80340
+ }
80341
+ log("RRSP cache miss, hitting rrspcontribution.ca...");
80342
+ const result = await rawOptimize(config2);
80343
+ if (result.isOk())
80344
+ writeCache2(config2, result.value);
80345
+ return result;
80346
+ };
80347
+ var rrspOptimizerService = { optimize: cachedOptimize };
80348
+ var rrspOptimizerServiceNoCache = { optimize: rawOptimize };
80349
+
80350
+ // src/views/rrsp.ts
80351
+ var f2 = (n5) => money(n5).padStart(10);
80352
+ var W3 = 80;
80353
+ var renderRrspAdvice = (config2, result) => {
80354
+ const parts = [];
80355
+ parts.push("");
80356
+ parts.push(`RRSP Contribution Optimizer (via rrspcontribution.ca)`);
80357
+ parts.push(line("─", W3));
80358
+ parts.push(`Province: ${config2.province}`);
80359
+ parts.push(`Year: ${config2.year}`);
80360
+ parts.push(`Income: $${money(config2.income)}`);
80361
+ parts.push(`RRSP Room: $${money(config2.rrspRoom)}`);
80362
+ if (config2.numKids5AndYounger > 0)
80363
+ parts.push(`Kids ≤5: ${config2.numKids5AndYounger}`);
80364
+ if (config2.numKids6AndOlder > 0)
80365
+ parts.push(`Kids 6–17: ${config2.numKids6AndOlder}`);
80366
+ if (config2.hasSpouse)
80367
+ parts.push(`Spouse Income: ${config2.spouseIncome !== null ? `$${money(config2.spouseIncome)}` : "N/A"}`);
80368
+ parts.push(line("─", W3));
80369
+ parts.push("");
80370
+ parts.push(`\uD83D\uDCA1 Recommendation`);
80371
+ parts.push(line("═", W3));
80372
+ parts.push(` Contribute: $${f2(result.recommendedContribution)}`);
80373
+ parts.push(` Tax Savings: $${f2(result.totalSavings)} (${pct(result.savingsPercent * 100)} of contribution)`);
80374
+ parts.push(line("═", W3));
80375
+ if (result.steps.length > 0) {
80376
+ parts.push("");
80377
+ parts.push(`Marginal Effective Tax Rate Breakdown`);
80378
+ parts.push(line("─", W3));
80379
+ const hStep = "Step".padStart(4);
80380
+ const hSize = "Step Size".padStart(12);
80381
+ const hCum = "Cumulative".padStart(12);
80382
+ const hRate = "METR".padStart(7);
80383
+ const hSav = "Savings".padStart(12);
80384
+ const hEvt = "Event";
80385
+ parts.push(`${hStep} ${hSize} ${hCum} ${hRate} ${hSav} ${hEvt}`);
80386
+ parts.push(line("─", W3));
80387
+ for (const s of result.steps) {
80388
+ const step = String(s.step).padStart(4);
80389
+ const size = `$${money(s.stepSize)}`.padStart(12);
80390
+ const cum = `$${money(s.cumulativeContribution)}`.padStart(12);
80391
+ const rate = pct(s.effectiveTaxRate * 100).padStart(7);
80392
+ const sav = `$${money(s.cumulativeSavings)}`.padStart(12);
80393
+ parts.push(`${step} ${size} ${cum} ${rate} ${sav} ${s.event}`);
80394
+ }
80395
+ parts.push(line("─", W3));
80396
+ }
80397
+ if (result.benefitsIncluded.length > 0) {
80398
+ parts.push("");
80399
+ parts.push("Includes impact of:");
80400
+ for (const b of result.benefitsIncluded) {
80401
+ parts.push(` • ${b}`);
80402
+ }
80403
+ }
80404
+ parts.push("");
80405
+ return parts.join(`
80406
+ `);
80407
+ };
80408
+
80174
80409
  // src/cli.ts
80175
80410
  var PROVINCES = [
80176
80411
  "Alberta",
@@ -80219,6 +80454,11 @@ try {
80219
80454
  monthly: { type: "boolean", short: "m", default: false },
80220
80455
  json: { type: "boolean", default: false },
80221
80456
  "no-cache": { type: "boolean", default: false },
80457
+ "rrsp-room": { type: "string" },
80458
+ "num-kids-young": { type: "string" },
80459
+ "num-kids-older": { type: "string" },
80460
+ spouse: { type: "boolean", default: false },
80461
+ "spouse-income": { type: "string" },
80222
80462
  update: { type: "boolean", default: false },
80223
80463
  version: { type: "boolean", default: false },
80224
80464
  headless: { type: "boolean", default: false },
@@ -80263,7 +80503,12 @@ if (values.help) {
80263
80503
  -a, --annual Show annualized totals
80264
80504
  -m, --monthly Show monthly averages
80265
80505
  --json Output results as JSON (works with -t, -M, -a, -m, or single)
80266
- --no-cache Skip cache and force a fresh CRA lookup
80506
+ --no-cache Skip cache and force a fresh lookup
80507
+ --rrsp-room <amount> RRSP contribution room (enables RRSP advice automatically)
80508
+ --num-kids-young <n> Number of kids 5 and younger (for RRSP advice, default: 0)
80509
+ --num-kids-older <n> Number of kids 6–17 (for RRSP advice, default: 0)
80510
+ --spouse Have a spouse (for RRSP advice)
80511
+ --spouse-income <amount> Spouse's income (for RRSP advice)
80267
80512
  --headless Run browser headless (may be blocked by CRA)
80268
80513
  --update Self-update to the latest release
80269
80514
  --version Show current version
@@ -80279,7 +80524,8 @@ if (values.help) {
80279
80524
  "rrspMatchPercent": 4,
80280
80525
  "rrspUnmatchedPercent": 0,
80281
80526
  "cppMaxedOut": false,
80282
- "eiMaxedOut": false
80527
+ "eiMaxedOut": false,
80528
+ "rrspRoom": 50000
80283
80529
  }
80284
80530
 
80285
80531
  You can pipe a config file via stdin, pass one with --config, or place
@@ -80358,14 +80604,14 @@ var readStdinConfig = async () => {
80358
80604
  };
80359
80605
  var readFileConfig = (configFlag) => {
80360
80606
  const configPaths = [
80361
- configFlag ? resolve7(configFlag) : "",
80362
- resolve7("config.json"),
80363
- resolve7(process.env.HOME || "~", ".config", "cra-payroll.json"),
80364
- resolve7(process.env.HOME || "~", ".cra-payroll.json")
80607
+ configFlag ? resolve8(configFlag) : "",
80608
+ resolve8("config.json"),
80609
+ resolve8(process.env.HOME || "~", ".config", "cra-payroll.json"),
80610
+ resolve8(process.env.HOME || "~", ".cra-payroll.json")
80365
80611
  ].filter(Boolean);
80366
80612
  for (const p of configPaths) {
80367
- if (p && existsSync5(p)) {
80368
- return JSON.parse(readFileSync5(p, "utf-8"));
80613
+ if (p && existsSync6(p)) {
80614
+ return JSON.parse(readFileSync6(p, "utf-8"));
80369
80615
  }
80370
80616
  }
80371
80617
  return {};
@@ -80381,57 +80627,83 @@ var loadFileConfig = async (configFlag, isPiped) => {
80381
80627
  try {
80382
80628
  const config2 = readFileConfig(configFlag);
80383
80629
  if (configFlag && Object.keys(config2).length === 0) {
80384
- return $err(`Config file not found: ${resolve7(configFlag)}`);
80630
+ return $err(`Config file not found: ${resolve8(configFlag)}`);
80385
80631
  }
80386
80632
  return $ok(config2);
80387
80633
  } catch (e2) {
80388
80634
  return $err(`Failed to parse config file: ${e2.message}`);
80389
80635
  }
80390
80636
  };
80391
- var resolveField = async (label, cliVal, fileVal, defaultVal, promptFn, isPiped) => {
80392
- if (cliVal !== undefined)
80393
- return $ok(cliVal);
80394
- if (fileVal !== undefined)
80395
- return $ok(fileVal);
80396
- if (promptFn && !isPiped)
80397
- return $ok(await promptFn());
80398
- if (defaultVal !== undefined)
80399
- return $ok(defaultVal);
80400
- return $err(`${label} is required (pass via config or --${label})`);
80637
+ var DEFAULTS = {
80638
+ province: "Ontario",
80639
+ annualSalary: 0,
80640
+ payPeriod: "Semi-monthly (24 pay periods a year)",
80641
+ year: new Date().getFullYear(),
80642
+ rrspMatchPercent: 4,
80643
+ rrspUnmatchedPercent: 0,
80644
+ cppMaxedOut: false,
80645
+ eiMaxedOut: false
80646
+ };
80647
+ var cliToConfig = (vals) => {
80648
+ const cfg = {};
80649
+ if (vals.province !== undefined)
80650
+ cfg.province = vals.province;
80651
+ if (vals.salary !== undefined)
80652
+ cfg.annualSalary = parseFloat(vals.salary);
80653
+ if (vals["pay-period"] !== undefined)
80654
+ cfg.payPeriod = vals["pay-period"];
80655
+ if (vals.year !== undefined)
80656
+ cfg.year = parseInt(vals.year, 10);
80657
+ if (vals["rrsp-match"] !== undefined)
80658
+ cfg.rrspMatchPercent = parseFloat(vals["rrsp-match"]);
80659
+ if (vals["rrsp-unmatched"] !== undefined)
80660
+ cfg.rrspUnmatchedPercent = parseFloat(vals["rrsp-unmatched"]);
80661
+ if (vals["cpp-maxed"])
80662
+ cfg.cppMaxedOut = true;
80663
+ if (vals["ei-maxed"])
80664
+ cfg.eiMaxedOut = true;
80665
+ if (vals["rrsp-room"] !== undefined)
80666
+ cfg.rrspRoom = parseFloat(vals["rrsp-room"]);
80667
+ if (vals["num-kids-young"] !== undefined)
80668
+ cfg.numKids5AndYounger = parseInt(vals["num-kids-young"], 10);
80669
+ if (vals["num-kids-older"] !== undefined)
80670
+ cfg.numKids6AndOlder = parseInt(vals["num-kids-older"], 10);
80671
+ if (vals.spouse)
80672
+ cfg.hasSpouse = true;
80673
+ if (vals["spouse-income"] !== undefined)
80674
+ cfg.spouseIncome = parseFloat(vals["spouse-income"]);
80675
+ return cfg;
80676
+ };
80677
+ var promptMissing = async (merged, isPiped) => {
80678
+ let { province, annualSalary, payPeriod, year } = merged;
80679
+ if (!province && !isPiped) {
80680
+ province = await promptChoice("Province of employment:", PROVINCES, "Ontario");
80681
+ }
80682
+ if (!province)
80683
+ return $err("province is required (pass via config or --province)");
80684
+ if (!annualSalary && !isPiped) {
80685
+ annualSalary = await promptNumber("Annual salary ($)");
80686
+ }
80687
+ if (!annualSalary)
80688
+ return $err("salary is required (pass via config or --salary)");
80689
+ if (!payPeriod && !isPiped) {
80690
+ payPeriod = await promptChoice("Pay period:", PAY_PERIODS2, "Semi-monthly (24 pay periods a year)");
80691
+ }
80692
+ if (!payPeriod)
80693
+ return $err("pay-period is required (pass via config or --pay-period)");
80694
+ if (!year && !isPiped) {
80695
+ year = await promptNumber("Tax year", new Date().getFullYear());
80696
+ }
80697
+ if (!year)
80698
+ return $err("year is required (pass via config or --year)");
80699
+ return $ok({ ...merged, province, annualSalary, payPeriod, year });
80401
80700
  };
80402
80701
  var resolveConfig = async (vals, fileConfig, isPiped) => {
80403
- const province = await resolveField("province", vals.province, fileConfig.province, "Ontario", () => promptChoice("Province of employment:", PROVINCES, "Ontario"), isPiped);
80404
- if (province.isErr())
80405
- return $err(province.error);
80406
- const year = await resolveField("year", vals.year !== undefined ? parseInt(vals.year, 10) : undefined, fileConfig.year, new Date().getFullYear(), () => promptNumber("Tax year", new Date().getFullYear()), isPiped);
80407
- if (year.isErr())
80408
- return $err(year.error);
80409
- const salary = await resolveField("salary", vals.salary !== undefined ? parseFloat(vals.salary) : undefined, fileConfig.annualSalary, undefined, () => promptNumber("Annual salary ($)"), isPiped);
80410
- if (salary.isErr())
80411
- return $err(salary.error);
80412
- const payPeriod = await resolveField("pay-period", vals["pay-period"], fileConfig.payPeriod, "Semi-monthly (24 pay periods a year)", () => promptChoice("Pay period:", PAY_PERIODS2, "Semi-monthly (24 pay periods a year)"), isPiped);
80413
- if (payPeriod.isErr())
80414
- return $err(payPeriod.error);
80415
- const rrspMatch = await resolveField("rrsp-match", vals["rrsp-match"] !== undefined ? parseFloat(vals["rrsp-match"]) : undefined, fileConfig.rrspMatchPercent, 4, () => promptNumber("RRSP match % (employee + employer both contribute)", 4), isPiped);
80416
- if (rrspMatch.isErr())
80417
- return $err(rrspMatch.error);
80418
- const rrspUnmatched = await resolveField("rrsp-unmatched", vals["rrsp-unmatched"] !== undefined ? parseFloat(vals["rrsp-unmatched"]) : undefined, fileConfig.rrspUnmatchedPercent, 0, () => promptNumber("Additional unmatched employee RRSP %", 0), isPiped);
80419
- if (rrspUnmatched.isErr())
80420
- return $err(rrspUnmatched.error);
80421
- const cppMaxedOut = vals["cpp-maxed"] === true ? true : fileConfig.cppMaxedOut ?? false;
80422
- const eiMaxedOut = vals["ei-maxed"] === true ? true : fileConfig.eiMaxedOut ?? false;
80423
- return $ok({
80424
- province: province.value,
80425
- annualSalary: salary.value,
80426
- payPeriod: payPeriod.value,
80427
- year: year.value,
80428
- rrspMatchPercent: rrspMatch.value,
80429
- rrspUnmatchedPercent: rrspUnmatched.value,
80430
- cppMaxedOut,
80431
- eiMaxedOut
80432
- });
80702
+ const cliConfig = cliToConfig(vals);
80703
+ const merged = { ...DEFAULTS, ...fileConfig, ...cliConfig };
80704
+ return promptMissing(merged, isPiped);
80433
80705
  };
80434
- var runYearlyMode = async (config2, headless, svc, flags) => {
80706
+ var runYearlyMode = async (config2, headless, svc, flags, rrspAdvice) => {
80435
80707
  const yearlyResult = await calculateYearly(svc, config2, headless);
80436
80708
  if (yearlyResult.isErr()) {
80437
80709
  console.error(`Error: ${yearlyResult.error}`);
@@ -80443,7 +80715,10 @@ var runYearlyMode = async (config2, headless, svc, flags) => {
80443
80715
  if (flags.json) {
80444
80716
  const monthlyData = flags.monthTable ? groupByMonth(yearly, config2.year, config2.payPeriod, periodsPerYear) : undefined;
80445
80717
  const mode = flags.monthTable ? "month-table" : flags.table ? "table" : flags.annual ? "annual" : "monthly";
80446
- console.log(JSON.stringify(buildJsonOutput(mode, config2, { yearly, monthly: monthlyData }), null, 2));
80718
+ const output2 = buildJsonOutput(mode, config2, { yearly, monthly: monthlyData });
80719
+ if (rrspAdvice)
80720
+ output2.rrspAdvice = rrspAdvice.result;
80721
+ console.log(JSON.stringify(output2, null, 2));
80447
80722
  return;
80448
80723
  }
80449
80724
  if (flags.table)
@@ -80456,18 +80731,46 @@ var runYearlyMode = async (config2, headless, svc, flags) => {
80456
80731
  console.log(renderAnnual(yearly.totals));
80457
80732
  if (flags.monthly)
80458
80733
  console.log(renderMonthly(yearly.totals));
80734
+ if (rrspAdvice)
80735
+ console.log(renderRrspAdvice(rrspAdvice.config, rrspAdvice.result));
80459
80736
  };
80460
- var runSingleMode = async (config2, headless, svc, json) => {
80737
+ var runSingleMode = async (config2, headless, svc, json, rrspAdvice) => {
80461
80738
  const calcResult = await svc.calculate(config2, headless);
80462
80739
  if (calcResult.isErr()) {
80463
80740
  console.error(`Error: ${calcResult.error}`);
80464
80741
  process.exit(1);
80465
80742
  }
80466
80743
  if (json) {
80467
- console.log(JSON.stringify(buildJsonOutput("single", config2, { single: calcResult.value }), null, 2));
80744
+ const output2 = buildJsonOutput("single", config2, { single: calcResult.value });
80745
+ if (rrspAdvice)
80746
+ output2.rrspAdvice = rrspAdvice.result;
80747
+ console.log(JSON.stringify(output2, null, 2));
80468
80748
  return;
80469
80749
  }
80470
80750
  console.log(renderSingleResult(calcResult.value));
80751
+ if (rrspAdvice)
80752
+ console.log(renderRrspAdvice(rrspAdvice.config, rrspAdvice.result));
80753
+ };
80754
+ var fetchRrspAdvice = async (config2, noCache) => {
80755
+ if (!config2.rrspRoom || config2.rrspRoom <= 0)
80756
+ return null;
80757
+ const rrspSvc = noCache ? rrspOptimizerServiceNoCache : rrspOptimizerService;
80758
+ const rrspConfig = {
80759
+ year: config2.year,
80760
+ province: config2.province,
80761
+ income: config2.annualSalary,
80762
+ rrspRoom: config2.rrspRoom,
80763
+ numKids5AndYounger: config2.numKids5AndYounger ?? 0,
80764
+ numKids6AndOlder: config2.numKids6AndOlder ?? 0,
80765
+ hasSpouse: config2.hasSpouse ?? false,
80766
+ spouseIncome: config2.spouseIncome ?? null
80767
+ };
80768
+ const result = await rrspSvc.optimize(rrspConfig);
80769
+ if (result.isErr()) {
80770
+ console.error(`⚠️ RRSP advice unavailable: ${result.error}`);
80771
+ return null;
80772
+ }
80773
+ return { config: rrspConfig, result: result.value };
80471
80774
  };
80472
80775
  var showUpdateNag = async () => {
80473
80776
  if (!isCompiledBinary)
@@ -80504,10 +80807,13 @@ var wantJson = values.json ?? false;
80504
80807
  if (!wantJson) {
80505
80808
  console.log(renderConfig(config2, !wantTable && !wantMonthTable && !wantAnnual && !wantMonthly));
80506
80809
  }
80810
+ var rrspAdvicePromise = config2.rrspRoom ? (!wantJson && console.error("Fetching RRSP advice from rrspcontribution.ca..."), fetchRrspAdvice(config2, values["no-cache"] ?? false)) : Promise.resolve(null);
80507
80811
  if (wantTable || wantMonthTable || wantAnnual || wantMonthly) {
80508
- await runYearlyMode(config2, headless, service, { table: wantTable, monthTable: wantMonthTable, annual: wantAnnual, monthly: wantMonthly, json: wantJson });
80812
+ const rrspAdvice = await rrspAdvicePromise;
80813
+ await runYearlyMode(config2, headless, service, { table: wantTable, monthTable: wantMonthTable, annual: wantAnnual, monthly: wantMonthly, json: wantJson }, rrspAdvice);
80509
80814
  } else {
80510
- await runSingleMode(config2, headless, service, wantJson);
80815
+ const rrspAdvice = await rrspAdvicePromise;
80816
+ await runSingleMode(config2, headless, service, wantJson, rrspAdvice);
80511
80817
  }
80512
80818
  if (!wantJson)
80513
80819
  await showUpdateNag();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seethruhead/cra-payroll",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Calculate Canadian payroll deductions using CRA's Payroll Deductions Online Calculator",
5
5
  "type": "module",
6
6
  "bin": {
package/README.md.bak DELETED
@@ -1,183 +0,0 @@
1
- # cra-payroll
2
-
3
- Calculate Canadian payroll deductions using CRA's [Payroll Deductions Online Calculator (PDOC)](https://apps.cra-arc.gc.ca/ebci/rhpd/beta/entry).
4
-
5
- Automates the CRA wizard via Playwright and returns your net pay, taxes, CPP, EI, and RRSP breakdown — per paycheck, monthly, or annually.
6
-
7
- ## Download
8
-
9
- Grab the latest binary for your platform from [**Releases**](https://github.com/SeeThruHead/cra-payroll/releases):
10
-
11
- | Platform | File |
12
- |----------|------|
13
- | macOS (Apple Silicon) | `cra-payroll-darwin-arm64` |
14
- | macOS (Intel) | `cra-payroll-darwin-x64` |
15
- | Linux (x64) | `cra-payroll-linux-x64` |
16
-
17
- ```bash
18
- # Example: macOS Apple Silicon
19
- curl -L -o cra-payroll https://github.com/SeeThruHead/cra-payroll/releases/latest/download/cra-payroll-darwin-arm64
20
- chmod +x cra-payroll
21
-
22
- # Remove macOS quarantine flag (unsigned binary)
23
- xattr -d com.apple.quarantine cra-payroll
24
-
25
- sudo mv cra-payroll /usr/local/bin/
26
- ```
27
-
28
- > **Requires Google Chrome** — uses your system Chrome, no extra browser install needed.
29
-
30
- Or use the one-liner:
31
-
32
- ```bash
33
- curl -fsSL https://raw.githubusercontent.com/SeeThruHead/cra-payroll/main/install.sh | bash
34
- ```
35
-
36
- ## Usage
37
-
38
- ```bash
39
- # Interactive — prompts for missing values
40
- cra-payroll
41
-
42
- # CLI args
43
- cra-payroll --salary 120000 --province "British Columbia"
44
-
45
- # Per-paycheck table for the year (tracks CPP/EI maxout)
46
- cra-payroll --salary 263000 --table
47
-
48
- # Annual totals
49
- cra-payroll --salary 100000 --annual
50
-
51
- # Monthly averages
52
- cra-payroll --salary 100000 --monthly
53
-
54
- # Combine them
55
- cra-payroll --salary 263000 --table --annual --monthly
56
-
57
- # Verbose logging
58
- cra-payroll -v --salary 100000
59
-
60
- # Check version
61
- cra-payroll --version
62
-
63
- # Self-update to latest release
64
- cra-payroll --update
65
- ```
66
-
67
- ### Example output (`--table`)
68
-
69
- ```
70
- 📊 Per-Paycheck Table (2026)
71
- ══════════════════════════════════════════════════════════════════════════════════════════════
72
- # │ Gross │ Fed Tax │ Prov Tax │ CPP │ EI │ Net Pay │ Cum CPP/EI
73
- ──────────────────────────────────────────────────────────────────────────────────────────────
74
- 1 │ 10,958.33 │ 2,232.82 │ 1,431.13 │ 669.42 │ 178.62 │ 6,446.34 │ 848.04
75
- 2 │ 10,958.33 │ 2,232.82 │ 1,431.13 │ 669.42 │ 178.62 │ 6,446.34 │ 1,696.08
76
- ... │ ... │ ... │ ... │ ... │ ... │ ... │ ...
77
- 7 │ 10,958.33 │ 2,232.82 │ 1,431.13 │ 213.93 │ 51.35 │ 7,029.10 │ 5,353.52 ← partial
78
- 8 │ 10,958.33 │ 2,232.82 │ 1,431.13 │ 0.00 │ 0.00 │ 7,294.38 │ 5,353.52 ✓ maxed
79
- ... │ ... │ ... │ ... │ ... │ ... │ ... │ ...
80
- ```
81
-
82
- ## Config
83
-
84
- Config is loaded from the first file found:
85
- 1. `--config <path>`
86
- 2. `./config.json`
87
- 3. `~/.config/cra-payroll.json`
88
- 4. `~/.cra-payroll.json`
89
-
90
- CLI args override config file values. Missing values are prompted interactively.
91
-
92
- ```json
93
- {
94
- "province": "Ontario",
95
- "annualSalary": 100000,
96
- "payPeriod": "Semi-monthly (24 pay periods a year)",
97
- "rrspEmployeePercent": 4,
98
- "rrspEmployerPercent": 4
99
- }
100
- ```
101
-
102
- ### Options
103
-
104
- | Option | CLI flag | Config key | Default |
105
- |--------|----------|------------|---------|
106
- | Province | `-p`, `--province` | `province` | `Ontario` |
107
- | Annual salary | `-s`, `--salary` | `annualSalary` | _(prompted)_ |
108
- | Pay period | `--pay-period` | `payPeriod` | `Semi-monthly (24)` |
109
- | Employee RRSP % | `--rrsp-employee` | `rrspEmployeePercent` | `4` |
110
- | Employer RRSP % | `--rrsp-employer` | `rrspEmployerPercent` | `4` |
111
- | CPP maxed | `--cpp-maxed` | `cppMaxedOut` | `false` |
112
- | EI maxed | `--ei-maxed` | `eiMaxedOut` | `false` |
113
- | Yearly table | `-t`, `--table` | — | `false` |
114
- | Annual totals | `-a`, `--annual` | — | `false` |
115
- | Monthly averages | `-m`, `--monthly` | — | `false` |
116
- | Verbose | `-v`, `--verbose` | — | `false` |
117
- | Self-update | `--update` | — | — |
118
- | Show version | `--version` | — | — |
119
- | Headless | `--headless` | — | `false` |
120
- | Config path | `-c`, `--config` | — | — |
121
-
122
- ### 2026 CPP/EI Maximums (used for `--table`)
123
-
124
- | | Amount |
125
- |---|---|
126
- | CPP max contribution | $4,230.45 |
127
- | CPP2 max (additional) | $416.00 |
128
- | EI max premium | $1,123.07 |
129
-
130
- ## Run from source
131
-
132
- If you'd rather not download a binary, you can clone and run directly. You'll need [Google Chrome](https://www.google.com/chrome/) installed.
133
-
134
- ### With Bun
135
-
136
- ```bash
137
- git clone https://github.com/SeeThruHead/cra-payroll.git
138
- cd cra-payroll
139
- bun install
140
- bun run dev -- --salary 100000
141
- bun run dev -- --salary 263000 --table
142
- ```
143
-
144
- ### With Node
145
-
146
- ```bash
147
- git clone https://github.com/SeeThruHead/cra-payroll.git
148
- cd cra-payroll
149
- npm install
150
- npx tsx src/cli.ts --salary 100000
151
- npx tsx src/cli.ts --salary 263000 --table
152
- ```
153
-
154
- ## Development
155
-
156
- ```bash
157
- # Run directly
158
- bun run dev -- --salary 100000
159
-
160
- # Build standalone binary
161
- bun run build
162
-
163
- # Unit tests (fast, no browser)
164
- bun test
165
-
166
- # Integration tests (hits CRA, needs Chrome, may be flaky)
167
- bun run test:integration
168
-
169
- # All tests
170
- bun run test:all
171
- ```
172
-
173
- ## How it works
174
-
175
- 1. Launches your system Chrome via Puppeteer (headed by default — CRA blocks headless)
176
- 2. Fills out the PDOC wizard: province, pay period, salary, RRSP contributions
177
- 3. Sets CPP/EI status and hits Calculate
178
- 4. Scrapes the results page for taxes, deductions, and net pay
179
- 5. For `--table` mode: runs twice (with/without CPP/EI) and simulates each paycheck using the 2026 maximums
180
-
181
- ## License
182
-
183
- MIT