Skip to content

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_deposits names 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 is bank_transaction_id
  • A statement with no voucher is a duplicate when employer, pay date, gross and net all match
  • bank_transaction_id has no foreign key. bank_transactions belongs to n8n, and heezy_app, which creates these tables, has no REFERENCES grant 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.