The Cron Job That Deleted Payroll
ENGINEERING
AUGUST 29, 2026

The Cron Job That Deleted Payroll

7 min read
BACK TO BLOG

I own a handyman company and wrote every line of software that runs it. $216K invoiced, 55,000 lines, one engineer — and everything that broke along the way, including the week of payroll a cleanup job quietly deleted.

I own a home-services company in Michigan. A crew, trucks, real customers who call and complain. I am also its only engineer — every piece of software that runs it is mine, and it has invoiced $216,291.

One Monday morning, a cron job I wrote quietly deleted two men's pay.

The Monday morning cron

Week 29 — the week of 13 July. Two of my guys cut seven yards between them. Each was owed $87.50.

Mowing work orders in my system are generated ahead of the season and cleaned up afterwards. A scheduled job runs Monday at 6am and deletes work orders whose scheduled date has passed. They are checklist items, stale ones clutter the board, and I wrote it that way on purpose.

What I had not thought about is that somewhere between generation and cleanup, a work order stops being a checklist item. The moment a crew member's name and their pay get recorded against it, that row is a pay record. My cleanup job could not tell the difference, because I had never told it there was one.

It deleted the week. Seven yards, two men, and the only surviving trace of what they were owed was a note in a file I keep for the AI agents that work on this codebase. I rebuilt the records by hand from that note — and the Monday 6am cron would have deleted them a second time if I had not also fixed the job first.

The fix is four lines: keep any work order carrying a payout or a paid-contractor field, regardless of date. The lesson is not four lines. The lesson is that a data model has lifecycle states you did not declare, and a deletion job is where you find out.

What this actually is

Haslett Handyman is a real home-services business. It is not a side project, a demo, or a SaaS built for imaginary users.

I am the owner and I am also the only engineer. Everything that runs the business — lead intake, quoting, scheduling, work orders, invoicing, crew payouts, bookkeeping — is software I wrote and operate.

The machineCount
Invoiced$216,291
Collected$191,983
Bank transactions auto-categorized3,235
Admin hours removed~140
Lines of code (application + automation)55,035
Python automation modules83
Scheduled jobs running unattended~35
Engineers1

The application is AWS-native: Lambda behind API Gateway, DynamoDB single-table, Cognito for auth, Terraform for all of it. A React web app for the office and a React Native app the crew use in the field. That part is unremarkable — it is the boring, correct choice and I would make it again.

The interesting half is the automation layer: 83 Python modules that run on a schedule on a Mac Mini in my house. They read bank transactions, generate invoices in QuickBooks, chase quotes that went quiet, reconcile work orders against payments, calculate crew payouts, and send real email to real customers.

Nearly every module refuses to do anything until you pass it --commit. That convention was not foresight. It is scar tissue.

The watcher that ran into the void

A job that watches for new bid opportunities had been loaded and firing every twelve hours for months. Working perfectly. Finding bids.

It sent every one of them to a hard-coded Gmail address. The CRM reads a different mailbox entirely. Every bid it ever found landed somewhere nobody was looking.

Nothing errored. Logs were clean. The job reported success every twelve hours for months, because it had succeeded — at the thing I told it to do, which was not the thing I wanted. A cron job that runs and does nothing useful is indistinguishable from one that works, unless you go and check the far end. I now check the far end.

The page footer that ate $66,438.97

My bookkeeping importer reads PDF bank statements. A function called is_cash_withdrawal() decided a line was a cash withdrawal if the text "statement of account" appeared anywhere in it.

The importer appended the statement's own page footer — "…Statement of Account Jan 1 2026 to Jan 31 2026" — to every single line it imported.

Fifty deposits worth $66,438.97 were stored as the literal string "Deposit." The payer was right there in the PDF and got thrown away. For months my books reported a business that collected fine and could not say what any individual job cost.

I had built an operating score to tell me how healthy the business was. Its cash pillar read 58.4 out of 100. When I found this bug and re-imported, the score dropped — and the drop was the finding, not a regression. The number had been reporting on data it could not see.

The review gate I nearly shipped

This is the one I am least comfortable writing down.

I wrote a module to ask happy customers for Google reviews. It filtered the ask list: anyone with a callback, a go-back, a dispute, or a write-off was dropped before the message went out. The message itself said "if anything wasn't right, tell me instead."

That is review gating. It violates Google's policy in both of the forms Google names it, and for a business whose Google listing is the top of the funnel, the penalty is removal of the entire profile.

It never sent a single message — there was no scheduled job wired to it. But it was one cron entry away from running. I found it, removed the gate, and rewrote eligibility to turn only on whose customer it is and whether the work is actually finished. Everyone gets asked or nobody does.

I did not build it to cheat. I built it because filtering felt like politeness, and I did not think about it hard enough. That is how most of this happens.

What actually transfers

  • Silence is not success. Three of the four failures here produced no error, no alert, and clean logs. The expensive bugs in automation are not crashes — they are jobs cheerfully doing the wrong thing on schedule.
  • Deletion is where undeclared state surfaces. Every row in your system has a lifecycle. You have declared some of it. A cleanup job finds the rest, in production, on a Monday.
  • Dry-run by default, commit by flag. Every module that touches money or sends email does nothing until explicitly told to. This costs one keystroke and has saved me more than everything else combined.
  • Instrument the far end, not the job. "Did it run" is the wrong question. "Did anything arrive where a human will see it" is the right one.
  • Your metrics can be confidently wrong. A score computed from data with a silent gap will report a healthy business. When a fix makes a number worse, that is usually the number becoming honest.
  • Ethical bugs look like features while you are writing them. The review gate was the most dangerous thing in this codebase and it was also the most reasonable-seeming.

Why I think this is worth reading

Most software is written for users the author has never met, to solve problems the author has never had. I get to do the opposite. When the invoicing is wrong, a customer calls me. When payroll is wrong, I hand cash to someone who is standing in my driveway and knows exactly what he is owed.

That loop is brutally short and it does not accept excuses. There is no product manager between me and the consequence, no sprint boundary to hide behind, no "we'll pick it up next quarter." The bug that deleted payroll was found by the men whose pay it deleted.

I do not think this makes me a better engineer than anyone reading this. I think it means I have been taught a narrower and more specific set of lessons than most people get, and the ones above are the ones I would want if I were starting over.

The industry is currently arguing about whether AI agents can do real work. I do not have an opinion worth publishing on that. What I have is a Mac Mini in my house running thirty-five scheduled jobs that bill real customers and pay real people, and a list of everything that went wrong while I got there.

SHARE
7 min read
Read More

MORE ARTICLES

Two ECS failures that produce no useful error
Engineering
Engineering

Two ECS failures that produce no useful error

A readiness probe blocked by the task's own IAM role, and readonlyRootFilesystem silently killing ECS Exec. Both look like broken infrastructure.

How do you know when your AI feature is wrong?
Engineering
Engineering

How do you know when your AI feature is wrong?

Most teams ship an AI feature and have no answer past spot-checking. What an evaluation harness actually contains, and the failure it usually misses.

Don't put a model where you need a reproducible answer
Engineering
Engineering

Don't put a model where you need a reproducible answer

Three systems where I deliberately chose regex and a lookup table over an LLM, and the two questions that decide which one a problem needs.

CLAUDE.md as production infrastructure
Engineering
Engineering

CLAUDE.md as production infrastructure

What a coding agent's context file has to contain when the repos it touches are live, and the day I found the file preventing disasters had no backup.