Once the accounts are mapped, a monthly close has no judgement left in it — which means no model belongs in it. What broke while building one, and what each failure teaches about scheduled jobs generally.
Once the accounts are mapped and the matching rules are known, the monthly version of reconciliation has no judgement left in it. Every step has a provable answer, which means it can run on a timer with no model involved at all.
The thing that made statements necessary during the catch-up was needing balances at a date already in the past. Going forward that problem disappears: every provider reports a current balance, and so does the ledger. Comparing two live figures needs no PDF, no transcription, and no interpretation.
What follows is what broke when we built it, because the failures are more instructive than the design.
The first version fetched from each provider on every run. It broke immediately on the aggregator, whose sync is cursor-based: once a transaction has been delivered it is never delivered again. A job asking "give me June" a second time gets nothing at all.
This is not a bug in the API. Cursor-based sync is the right design for keeping a copy current, and it is the wrong shape for answering questions about a fixed period. Those are different needs and want different structures.
Pulls append to a durable table keyed on the provider's own transaction id. Every downstream question is asked of that table, never of the network.
The simulation below runs the same close twice, both ways. The left column asks the provider directly; the right reads a store the pull writes into.
| Run | Direct from provider | From the store | Verdict |
|---|
Transaction counts are invented for illustration.
The properties a store buys are worth naming. Re-running a pull is free and idempotent. Re-running a close reads exactly what it read last time, so a report is reproducible — and a report you cannot reproduce is one you cannot argue with. A provider that restates a pending transaction once it settles updates one row instead of creating a second. And an outage degrades to "no new rows" rather than "the month is empty", which matters because the second failure looks exactly like a clean month.
One detail that is easy to skip: rows whose provider account has no home in the chart of accounts are stored but excluded from reconciliation. Storing them means a later mapping fix retroactively fixes every past month. Excluding them means an unmapped account cannot silently make a close look clean — which is precisely the bug we shipped and caught, and it is invisible without a count of what was dropped.
The same shape applies to any agent workflow consuming a paginated or streaming source: separate the ingestion cursor from the query surface, or you can only ever ask each question once.
During catch-up, the useful comparison is balances: what does the bank say this account holds, and what does the ledger say. For a monthly close it is the wrong question, because a balance carries every earlier error with it. An account that went wrong in March will report a variance every month afterwards, and the operator learns to ignore it.
The monthly question is narrower: did we capture this month? So the variance is the month's bank movement minus the month's book movement, and the test is that the unmatched rows account for the difference.
Balances answer "are the books right overall". Movements answer "did we capture this period". A monthly job wants the second, and mixing them produces a check that is always red.
Toggle between the two below. One error occurs in March. Watch how many months each mode calls broken.
| Month | Bank | Books | Variance | Status |
|---|
All figures here are invented for illustration.
Balance mode is not wrong — it is answering a different question, correctly. It is the right check when you are establishing a baseline, which is exactly what the catch-up phase was doing. It is the wrong check afterwards, because a monitor that reports the same failure every month is a monitor everyone has muted by June.
Movement mode also makes each month independently verifiable, and independence is what lets you fix history out of order. Correct March next quarter and March alone turns green; nothing else in the series was resting on it.
Card transactions post over several days. A close run on the 1st reports a variance that resolves itself by the 3rd — which teaches you to ignore the report, the worst possible outcome for a check. Running a few days into the month costs nothing and means a red result is worth reading.
Second, the job exits non-zero when an account fails to tie. That single detail is what makes it a monitor rather than a log: a scheduler surfaces the failure, and a green month needs no attention at all. A job that always exits zero and writes its findings to a file is a file nobody reads.
Third, and least obvious: the job never writes to the ledger. It reads both sides, compares, and reports. Posting stays behind a human approval step, which means a bug in the scheduled job can produce a wrong report but never a wrong book. That asymmetry is worth preserving even when the job has been correct for a year — a wrong report costs an hour, a wrong book can cost a filing.
Zero means every account tied. A distinct non-zero code means the reconciliation ran fine and at least one account did not tie. A generic failure code means the run itself broke — expired credentials, a provider outage, a network fault.
Collapsing the last two loses the distinction that matters operationally. "The books disagree" is accounting work for a person who knows the business. "The job could not run" is infrastructure work, often just a token refresh, and nothing about the books is yet known. Same alert, entirely different response.
Rows pulled per source, so a provider that quietly stopped returning data shows up as a zero rather than as a clean month. A per-account line with the count of transactions the ledger is missing and their sum. And, when an account fails, the unexplained residual as a number — because the size of a gap is the strongest hint about its cause. A round figure suggests a missed transfer; an odd few dollars suggests a fee or an interest charge.
The obvious way to validate a monthly close is to wait for next month. The better way is to run it over history you have already reconciled by hand, where you know the right answer.
Doing that surfaced four defects in about an hour, all of which would otherwise have shipped silently and produced confident, wrong reports:
The last one is the one worth repeating. The completeness check was converting card sign frames, and so was its caller. Each was individually reasonable; together they cancelled, and every card reported as broken by exactly twice its true value.
Doubling is the signature of a double negation. When a number is out by exactly 2×, stop looking for a missing transaction and start looking for two places that each think they own the sign.
The fix was not better arithmetic. It was deciding once, in one place, which direction means "money out" — and never converting again. Every subsequent bug of that family disappeared with it.
A useful property of reconciled history as test data: the expected answer is not a fixture someone wrote, it is a fact about the world. A fixture can be wrong in the same direction as the code — someone writes the fixture by running the code and pasting the output, and the test then guarantees only that the bug is stable. A bank statement cannot be wrong in your favour.
Running seven historical months rather than one also caught the period-scoped correction, which passed on the month it was written for and failed on the six around it. A single-month test would have shipped it.
A few choices that look like details and are not.
Re-fetch a window, not just the delta. Providers correct themselves. A pending charge settles at a different amount, a description gets enriched, a refund attaches to an earlier transaction. Re-fetching several weeks on every run and de-duplicating on the provider's id costs almost nothing and means those corrections land. A pull that only ever asks for new rows preserves the first version of every transaction forever.
Log every pull. Source, rows inserted, rows updated, window. When a month looks wrong six weeks later, the first question is always what the job actually saw, and the only reliable answer is a record written at the time.
Keep credentials out of the invocation. Secrets read from a file with restrictive permissions, never passed as arguments — a scheduled job's command line is visible in the process table and in logs. Same reasoning as not committing them, one layer down.
Let the schedule be dumb. A timer firing a shell script that calls one command is easy to reason about at seven in the morning. The complexity belongs in the code being tested, not in the thing that starts it.
A scheduled close is only half the loop. The other half is a person reading a red month and deciding what to do — usually not at a desk, and rarely at a convenient moment.
TermRoam keeps the working session alive between those moments. The job runs on the box; the report is waiting in the same session you were last in; you can pick it up from a phone, start an agent digging into the one account that failed, and come back to it later without losing the thread.
Next: categorizing what the close finds, which is the one step that genuinely needs judgement.