index / all work

Case study / persadpay

Building PersadPay: payroll where a rounding error is a compliance incident

PersadPay runs my family's actual household payroll: weekly paystubs, statutory federal and New York employment taxes, and the year-end paper trail of W-2, W-3, Schedule H, NYS-45, and 1040-ES. Employing someone in your home makes you a real employer with real filing obligations, and the spreadsheet-and-hope approach does not produce defensible numbers. It was built in sixteen days across fourteen numbered phases in May 2026, for exactly three users, and every dollar it computes is a dollar the IRS or New York State can ask about.

role
Solo: product, design, build, ship
timeline
16 days, 14 phases, May 2026
status
Live at persadpay.com, running real payroll

The stakes fit in one sentence

Hiring help in your own home quietly makes you an employer, with the tax math, the withholding, and the quarterly filings that word implies. PersadPay exists because every number it produces is one the IRS or New York State can ask my family about, which changes what "good enough" means for a side project.

Honest scope, stated plainly: this is fixed-rate employment-tax math, Social Security, Medicare, FUTA, New York SUTA, and the optional SDI and PFL coverages, applied stub by stub with wage-base caps. It does not compute progressive income-tax withholding; those amounts are inputs. The engine's job is to get the employer obligations exactly right and to generate the paper trail: W-2, W-3, Schedule H, NYS-45, 1040-ES, and the year-end packet.

A PersadPay stub preview computed live from typed inputs: 10 hours at 25 dollars an hour makes 250 dollars gross, employee FICA deductions of 15.50 and 3.63, net pay 230.87, and employer-side FICA, FUTA, and SUTA lines below.
fig. 01  the live preview on the new-stub screen, computed from synthetic inputs typed for this screenshot. Both sides of the ledger, before anything is saved.

The decisions

decision 01 / 05

Rates live in the database, math lives in a pure function

The tax calculation is a pure function taking wages and a rates row, nothing else. Rates for each year live in a Postgres table, and the code cites its sources inline: IRS Publication 926 for the household employee rules, New York DOL and DFS for SUTA, SDI, and PFL.

Purity is what makes the 34 unit tests meaningful: they assert exact cent values, hand-verified against the 2026 rate tables, for the real pay scenarios and for the nasty ones where year-to-date wages cross a wage-base cap in the middle of a single stub.

Tradeoff taken: statutory rates require a human once a year: verify against the IRS and New York publications, insert the new row. I accepted the ceremony because a hardcoded rate is a silent lie the following January, but the fallback behavior is the weakest part of the design; see the last section.

decision 02 / 05

Withhold FICA from dollar one

IRS rules say household-employee FICA only applies once annual wages cross 3,000 dollars, which invites a tempting simplification: skip withholding until the threshold. But a weekly babysitter always crosses it, so PersadPay withholds from the first stub and reconciles the threshold where the IRS actually checks it, on the year-end Schedule H.

Tradeoff taken: if the employee left before crossing the threshold, I would owe a manual refund of everything withheld, about 190 dollars across the first six weeks of a year. I took the over-collection risk because the alternative, withholding nothing and then owing double mid-year, fails in the direction that hurts.

decision 03 / 05

Respect the float, or it will teach you to

JavaScript rounds 1.005 to 1.00 unless you make it not, so the engine's rounding helper nudges by machine epsilon before rounding, and cap remainders are pre-rounded so a 411.91 dollar annual cap minus 411.81 withheld never becomes 0.0999 repeating. There is a unit test whose entire job is asserting that round(1.005) equals 1.01.

Tradeoff taken: money stays a floating-point number rounded to cents at every boundary, backed by fixed-point columns in Postgres, instead of integer cents everywhere. Discipline instead of representation: cheaper to build, and one test still needs a tolerance assertion as the reminder of what I traded.

decision 04 / 05

Enforce the roles three separate times

Every one of the fifteen tables has row-level security enabled. The request proxy checks the session, forces TOTP enrollment, and refuses admin routes to non-admins. Then every admin page and API route re-verifies the role server-side anyway. There is no public sign-up; the three accounts were created by hand, and multi-factor is not optional for any of them.

One deliberate absence: the app never stores a Social Security number. The W-2 prints with a blank for the admin to hand-write.

Tradeoff taken: the same authorization rule exists in three places, which is three chances to drift. I keep all three anyway: for payroll data, the failure mode of a single missed check is someone else's wages on screen, and each layer has independently caught a mistake the others would have allowed.

decision 05 / 05

Store the stub's numbers, never recompute them

Every tax component is persisted on the stub at generation time, and the filings sum those stored values rather than recomputing history with current code. A rate fix next year can never silently rewrite January's stubs, and an append-only audit log written by database triggers records who changed what.

The filings also respect an easy-to-miss federal quirk: 1040-ES quarters are the IRS's three, two, three, four month fiscal periods, not calendar quarters, while NYS-45 uses calendar quarters. The code models both and says why.

Tradeoff taken: summing stored values assumes every line item shares the same taxability, which is true for this household and untrue in general. The simplification is documented where it lives, so future-me inherits a known limit instead of a surprise.

The numbers

Before / after: one synthetic 250-dollar stub through the engine
Measurestatutory ratethis stub
Social Security, employee side6.2%$15.50
Medicare, employee side1.45%$3.63
FUTA, employer side0.6% to $7,000$1.50
NY SUTA, employer side4.1% to $17,600$10.25
Net pay$250.00 gross$230.87

Sixteen days, 33 ordered migrations, 15 tables all under row-level security, six generated filing artifacts, and 34 unit tests asserting exact cents. Before launch the project ran a multi-agent audit of itself, and the findings report is checked into the repo, which is why the next section can be specific.

What I would do differently

  • Fail loudly on a missing tax year. If a year's rates are absent, the engine falls back to the latest populated year with only a console warning. That is the polite version of computing taxes with last year's numbers. It should refuse.
  • Keep test fixtures on one source of truth. The pre-launch audit caught that I had launched with the wrong New York SUTA wage base, and the fix updated the engine tests but left the stale figure in a filings test fixture. Two fixtures, one fact, and the audit residue is still visible in the repo.
  • Honor the retention rule in the schema. New York requires payroll records kept for six years, and the app still allows a hard delete of a stub. Soft delete with the audit trail is the design the domain was asking for.