@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 +102 -21
- package/dist/cra-payroll.js +364 -58
- package/package.json +1 -1
- package/README.md.bak +0 -183
package/README.md
CHANGED
|
@@ -1,15 +1,36 @@
|
|
|
1
|
-
#
|
|
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
|
|
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
|
-
|
|
30
|
+
Or use the one-liner:
|
|
10
31
|
|
|
11
32
|
```bash
|
|
12
|
-
|
|
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
|
|
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
|
|
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 │
|
|
45
|
-
2 │
|
|
74
|
+
1 │ 6,250.00 │ 1,028.37 │ 612.45 │ 382.51 │ 102.08 │ 4,124.59 │ 484.59
|
|
75
|
+
2 │ 6,250.00 │ 1,028.37 │ 612.45 │ 382.51 │ 102.08 │ 4,124.59 │ 969.18
|
|
46
76
|
... │ ... │ ... │ ... │ ... │ ... │ ... │ ...
|
|
47
|
-
|
|
48
|
-
|
|
77
|
+
11 │ 6,250.00 │ 1,028.37 │ 612.45 │ 112.50 │ 19.37 │ 4,477.31 │ 5,353.52 ← partial
|
|
78
|
+
12 │ 6,250.00 │ 1,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
|
-
|
|
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
|
|
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
|
|
package/dist/cra-payroll.js
CHANGED
|
@@ -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
|
|
69216
|
-
import { existsSync as
|
|
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.
|
|
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(/=>/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
|
|
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 ?
|
|
80362
|
-
|
|
80363
|
-
|
|
80364
|
-
|
|
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 &&
|
|
80368
|
-
return JSON.parse(
|
|
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: ${
|
|
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
|
|
80392
|
-
|
|
80393
|
-
|
|
80394
|
-
|
|
80395
|
-
|
|
80396
|
-
|
|
80397
|
-
|
|
80398
|
-
|
|
80399
|
-
|
|
80400
|
-
|
|
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
|
|
80404
|
-
|
|
80405
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|