fix(sales-summaries): stop days falling out of balance #17
Reference in New Issue
Block a user
Delete Branch "sales-summary-balancing"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Four faults were leaving restaurant days out of balance — one in the data, three in the arithmetic.
Measured over ninety days on a restored copy of production (210 clients, 18,900 client-days), running the real nightly job twice: once with the fixes off, once with them on.
1,135 days repaired, none knocked out of balance, and not one already-balanced day altered — verified line by line (category, debit/credit side, amount to the cent, account), not just on each day's bottom line. Of the 123 days remaining, only 33 are above ten cents.
📊 Full report: https://claude.ai/code/artifact/58910b7e-45e0-40c9-956d-a6932d548dea
📋 Rollout plan:
docs/2026-08-15-sales-summary-rollout-plan.md— steps to execute, in orderNothing changes on deploy
Both behaviour changes sit behind per-client feature flags that are off by default:
summary-service-chargessummary-refund-only-returnsMerging and deploying changes no client's books until one is opted in. Removing a flag is the rollback and restores today's behaviour exactly.
The data fault
Ten Square locations were configured against two client records each. Sales orders scoped their identifier by client; refunds, card payments, payouts and cash-drawer shifts used the bare Square id. Those attributes are
:db.unique/identity, so both clients' imports resolved to a single entity and the last writer won — 3,387 refunds, 4,069 payouts and 2,628 cash-drawer shifts changed hands over time, across 19 client pairs of which only 10 are visible in today's configuration.Worse, one payment could belong to two orders.
:sales-order/chargesis:db/isComponent, so removing a voided order cascaded into payments the other client still needed. Seedocs/2026-08-15-remove-voided-orders-risk.md.Three fixes: client-scope the four key schemes; look the record up under both schemes so the change can deploy before the migration finishes; and a migration giving every order its own payment.
Run over the whole database: 19,040,785 orders walked, 9,100,314 payments re-keyed, 200,027 copied, ending with 17,047,142 payments scoped, none left to rename, none unscopable, and no payment owned by more than one order. Idempotent and resumable; about thirteen minutes.
The arithmetic faults
Refunded tips stayed on the books.
get-tipsummed tips by joining through:sales-order/charges, so a return-only order — which has no tender to join through — contributed nothing while its reversal sat unread on:sales-order/tip. The fix is additive rather than substitutive on purpose: where an order does have a tender, the tender is the correct source.Service charges were collected but never earned. Nothing read
:sales-order/service-chargeat all. Now credited for Square orders only, both signs.A refund on a day with no sales had nothing to offset it. Refunds are credited on the day the money goes back; the return that offsets them is read from that day's orders. When they fall on different days the credit has no debit anywhere.
This last one is the answer to why the duplicated restaurants looked so much worse than everyone else: 155 of the 158 days still failing on them had no sales orders at all — the summary was nothing but orphaned refunds and their fees. The refunds were claimed by whichever record imported them during the shared period; the orders went to the other. Nothing was double-counted and no money was lost; the two halves of each refund simply ended up on two sets of books.
It is not a duplicate-only problem, which is why it is fixed properly rather than papered over — the largest non-duplicate cluster,
NG4S, has the identical shape.get-returnsnow falls back to the day's refunded total, but only where the client recorded no sales orders whatsoever. That guard is the whole design: with no orders there is no order-derived return to double-count and no trading day can be moved. Days that did trade and still carry an unmatched refund are left alone deliberately — apportioning a return across a day that also sold is an accounting question, not a mechanical one.Supporting changes
schema.ednis built from an attribute incloud-migration-schema.edn, so every test fixture died in setup — very likely why sales summaries had no tests before thiscompare-sales-summariesmoved totest/cljasauto-ap.tools.*One correction worth reading
d/as-ofcannot be used to compare summary amounts.:ledger-mapped/amount,ledger-sideandaccountare all:db/noHistory, so superseded values are discarded — a recomputed summary reads back throughas-ofwith its categories intact and its amounts absent, which is indistinguishable from a legitimate all-zero balanced day. Every figure in the report comes from live captures taken straight after each pass. The tool's docstring now says so where someone will actually hit it.Needs a decision before rollout
What this does not fix
123 client-days over ninety days, $2,970.35:
Testing
lein test auto-ap.jobs.sales-summaries-test auto-ap.square.core3-test auto-ap.jobs.rekey-square-external-ids-test— 28 tests, 65 assertions, 0 failures.Beyond unit tests, the whole analysis was rebuilt from a fresh restore of the production backup and reproduced the previous run to the cent at the point the two are comparable — 279 days and $7,790.54, with not one client-day differing by half a penny.
🤖 Generated with Claude Code
sales-summaries-v2 recomputes every dirty summary, but nothing set the dirty flag on a schedule: mark-all-dirty was only ever called by hand from the comment block, and the job was registered in neither server.clj's INTEGREAT_JOB dispatch nor terraform/deploy.tf, so -main was dead code that could never run in production. Summaries were only recalculated when someone remembered to do it in the REPL, and POS data keeps arriving after a business day closes, so a summary computed once on the day was routinely wrong and stayed wrong. Add a daily job that marks the trailing 7 days dirty and recomputes them, leaving finished work alone. "Finished" is the condition the app already calls Balanced -- debits equal credits and every line is mapped to an account. Since that is derived rather than stored, a summary that later falls out of balance is picked up again on the next run. Extract the Balanced predicate into auto-ap.datomic.sales-summaries so the grid's pill and the job share one definition, rather than a background job requiring an SSR namespace. total-debits/total-credits resolve the ledger side from either a plain keyword or the {:db/ident ...} map a pull returns, and accepted? requires every item to declare a side: un-normalized pulled items otherwise sum to 0.0 on both sides, read as balanced, and get skipped silently and permanently. Also fix sales-summaries-v2 destroying user-entered line items. It filtered for :sales-summary-item/manual? to preserve them, but dirty-sales-summaries' index-pull selector never fetched :sales-summary/items, so manual-items was always empty. Because items is a component attribute upserted via [:reset-rels ...], every recompute deleted the hand-entered lines -- often the very lines that make a summary balance. Harmless while nothing ran on a schedule; destructive the moment this does. Register the job in the admin Background Jobs dropdown too, with a days field: schedules are prod-only, so the admin page is the only way to run it on staging. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Pull request closed