Paychecks Page¶
Route: /paychecks (paychecks.html)
Nav label: Paychecks
Last Updated: 2026-09-17
What It Does¶
Records pay statements: gross pay, and every tax and deduction taken out before the net lands in
checking. bank_transactions only sees the deposit. This page is the layer above it, and it is what
lets the Spending page Sankey start at gross pay.
For the selected year it shows:
| Section | Content |
|---|---|
| KPI strip | Gross pay, taxes (with effective rate), benefits, retirement, take-home, stub coverage |
| Where gross pay goes | Every tax and deduction bucket as a share of gross, grouped, plus take-home |
| By employer | Gross, taxes, benefits, retirement, other, net and tax rate per employer |
| Payroll deposits with no stub | Deposits no saved stub explains, each with an Add stub button |
| Pay stubs | Every stub, with its linked deposit. Click a row to edit |
Stubs are filed by pay date for this page. The Sankey files them by their deposit's date instead.
Getting Stubs In¶
Upload stubs (or drop PDFs on the drop zone) sends the files to POST /api/paystubs/parse. That
reads them and writes nothing. Each stub found comes back as a draft in Review uploaded stubs:
| Status | Meaning | Selected by default |
|---|---|---|
| Balanced | Gross less taxes and deductions equals net, to the cent | yes |
| Off by $X | The lines do not add up. Review before saving | no |
| Pick employer | The layout was read but the employer was not recognised | no |
| Already saved | The same statement is in the database | no, and cannot be |
| Duplicate in upload | The same statement appeared twice in this upload | no |
Save selected posts each checked draft. Review opens the draft in the editor first.
A report PDF holding many stubs is fine: every stub page becomes its own draft. The uploaded file is
kept on the app's /data volume under paystubs/<sha256>.pdf, and a saved stub's editor links to it
as View PDF. One file can back many stubs, so deleting a stub never deletes the file.
Add manually opens an empty editor. Add stub on a deposit with no stub opens one prefilled with the employer, date and net amount.
Layouts read¶
| Parser | Employer | Recognised by | Notes |
|---|---|---|---|
prismhr |
BravoLt | "Statement of Earnings" and "Voucher Id" | EARNINGS, TAXES, DEDUCTIONS side by side. Every other page is an "Employee Pay Details" page and is skipped |
optomi |
Optomi | "Earnings Statement" and "Voucher Number" | Earnings and Taxes on the left, Deductions and Direct Deposits on the right |
adp |
Priority Payment | "Earnings Statement" and "Advice number" | ADP. Statutory (taxes), then voluntary deductions; employer HSA and GTL under Other Benefits |
generic |
anything else | a Net Pay and a Gross label | Keyword scan of flowed text. Every draft carries a warning to check each line |
The named layouts are read by word position, not flowed text: their columns interleave in
extracted text, so a number is placed under the header whose right edge is nearest. BravoLt's stub
never prints the employer name; it is recognised by its PrismHR company id, CN453685, the same code
its ACH deposits carry.
ADP's text layer has no thousands or decimal separators: $6,923.08 extracts as $6 923 08. Digit
groups within 10pt of each other are one amount and the last group is the cents. The first line
under Statutory that is not a tax starts the voluntary deductions, since the stub prints no header
there.
The Editor¶
One modal serves drafts, new stubs and saved stubs.
- Header fields: employer, pay date, period, voucher, gross, net, notes
- One row per line: kind, bucket, the label as printed, amount, YTD
- A running balance: gross less taxes less deductions, against net
| Kind | In gross? | In the Sankey? | Bucket |
|---|---|---|---|
| Earning | yes | no | Regular, Overtime, Bonus, Holiday, PTO, Other Earnings |
| Tax | taken out | yes, under Taxes | Federal Income Tax, Social Security, Medicare, State Income Tax, Local Income Tax, Other Tax |
| Deduction | taken out | yes, under Benefits, Retirement or Other Deductions | see below |
| Employer-paid | no | no | a benefit or retirement bucket (employer medical, 401(k) match) |
| Deposit | no | no | the account's last four digits |
Deduction buckets: Benefits is Medical, Dental, Vision, HSA, FSA, Life & AD&D, Disability, Other Benefit. Retirement is 401(k), Roth 401(k), 401(k) Loan. Other Deductions is Stock Purchase, Garnishment, Charity, Other Deduction.
The taxonomy lives in paystubs.py and nowhere else. Labels are mapped onto it by ordered regex
rules (TAX_RULES, DEDUCTION_RULES), first match wins. A section-aware parser knows which column a
label sat in; the generic parser does not, so it never reads a bare MED as medical insurance
(PrismHR's MED EE is the Medicare tax).
An unbalanced stub can be saved. In the Sankey, a positive gap draws as Other Deduction so the employer node still adds up to gross.
Linking a Stub to Its Deposit¶
A stub only reaches the Sankey once it is linked to a payroll deposit (paystubs.bank_transaction_id).
Automatic matching needs all three:
- the same employer, as
vw_income_depositsnames the deposit - the exact amount: an itemised deposit line (checked against the account last4), or net pay
- a deposit date from 3 days before to 5 days after the pay date
Assignment is best-first across every candidate pair, nearest date first, so two biweekly stubs with identical net pay each get their own deposit.
Matching runs when either side arrives: after a stub is saved, and after any statement is ingested
(the scanner CronJob and POST /api/statements/upload). Match deposits runs it on demand.
The editor's deposit row offers a manual choice among the employer's deposits within two weeks:
| Button | Effect | link_source |
|---|---|---|
| Link | links the chosen deposit | manual |
| No deposit | unlinks, and automatic matching leaves the stub alone | manual, no deposit |
| Auto | clears the choice and re-runs matching | auto or empty |
A manual link must be a payroll deposit from the stub's own employer. The Sankey draws a stub's deductions on the node its deposit flows out of, so a cross-employer link is refused. Editing a saved stub drops an automatic link and re-matches; a manual one is kept.
Data¶
Tables are created by ansible-heezy/roles/heezy-postgres-schema/files/schema.sql, applied by the
heezy-postgres-schema playbook on any change to that role (and again at the end of the big-boi
playbook). Until it has run, the page shows a banner and uploads can be read but not saved.
| Table | Row |
|---|---|
paystubs |
One pay statement: employer, pay date, period, gross, net, voucher, linked deposit, source file |
paystub_lines |
One printed line: kind, bucket, label, amount, YTD |
(employer, voucher)is unique, and so isbank_transaction_id- A statement with no voucher is a duplicate when employer, pay date, gross and net all match
bank_transaction_idhas no foreign key.bank_transactionsbelongs ton8n, andheezy_app, which creates these tables, has noREFERENCESgrant on it. A link whose deposit is gone is cleared before matching runs
API¶
| Endpoint | Purpose |
|---|---|
GET /api/paystubs/meta |
Employers, years with pay data, the bucket taxonomy and group colours, ready |
GET /api/paystubs?year= |
Stubs with lines, per-employer and per-bucket totals, deposits with no stub |
GET /api/paystubs/<id> |
One stub, plus candidates for manual linking |
POST /api/paystubs/parse |
Multipart files. Drafts only, no database writes |
POST /api/paystubs |
Save a draft. 409 with duplicate_of when it is already saved |
PUT /api/paystubs/<id> |
Replace a stub and its lines |
DELETE /api/paystubs/<id> |
Delete a stub. Lines go with it |
POST /api/paystubs/<id>/link |
{bank_transaction_id}, {bank_transaction_id: null} or {auto: true} |
POST /api/paystubs/match |
Run automatic matching now |
GET /api/paystubs/<id>/file |
The stored PDF |
Code: paystubs.py (taxonomy, totals, matching, queries), paystub_parser.py (PDF reading),
routes in app.py. Tests: tests/test_paystubs.py, on synthetic layouts only.