The Cron Job That Deleted Payroll
ENGINEERING
AUGUST 29, 2026

The Cron Job That Deleted Payroll

8 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.

The quickest place to see a quiet failure in your own business is your website. The free check loads it the way a customer's phone would and reports the errors that generate no complaint — a minute, no account.

Most of what I do is this: find the jobs that run unattended at 3am and make them fail loudly instead of quietly. If you have automation touching payroll, invoicing or anything that moves money, tell me what you are running — and if it turns out your jobs are safe, that is what I will tell you.

SHARE
8 min read
Read More

MORE ARTICLES

MSUFCU turned its own website off. That was the right call.
Engineering
Engineering

MSUFCU turned its own website off. That was the right call.

A PHP flaw became unauthenticated remote code execution with no workaround, so banks and governments went dark on purpose. What happened in Lansing, and why.

I gave an AI agent a wallet and a spending limit
Engineering
Engineering

I gave an AI agent a wallet and a spending limit

An autonomous client paid for its own API call in stablecoins. The payment was the easy half — the interesting half is the record of what it was refused.

The demo worked because you built it on your own data
Engineering
Engineering

The demo worked because you built it on your own data

The most expensive failure in applied AI is the one that looks like a success. Here is the test that catches it, and why it has to be able to fail.

How to read the arithmetic on a views guarantee
Engineering
Engineering

How to read the arithmetic on a views guarantee

A real offer promises 10 million views in 90 days or you do not pay. Here is the division that tells you what you are actually buying.