fix(sales-summaries): stop days falling out of balance

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): 1,258 days out of
balance and $69,560.10 becomes 123 days and $2,970.35, of which only 33
are above ten cents.

1,135 days repaired, none knocked out of balance, and not one
already-balanced day altered — verified line by line (category, side,
amount to the cent, account), not just on each day's bottom line.

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/charges is
:db/isComponent, so removing a voided order cascaded into payments the
other client still needed.

Fixes: client-scope the four key schemes; look the record up under both
schemes so the change deploys before the migration finishes; and a
migration that gives every order its own payment. Run over the whole
database that is 19,040,785 orders walked, 9,100,314 payments re-keyed
and 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-tip summed tips by joining
  through :sales-order/charges, so a return-only order — no tender to
  join through — contributed nothing while its reversal sat unread on
  :sales-order/tip. Additive, not substitutive: 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-charge. Now credited for Square orders only, both
  signs, behind summary-service-charges.

- 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. get-returns now falls back to the day's
  refunded total, but only where the client recorded no sales orders at
  all — with no orders there is no order-derived return to double-count
  and no trading day can be moved. Behind summary-refund-only-returns.

Both flags are off by default, so deploying this changes nothing until a
client is opted in. docs/2026-08-15-sales-summary-rollout-plan.md has the
steps.

SUPPORTING

- Install schema attributes before the tuples that compose them. A tuple
  in schema.edn is built from an attribute in cloud-migration-schema.edn,
  so every test fixture died in setup — very likely why sales summaries
  had no tests before this.
- Log each day's imbalance and its suspect lines.
- Bound the dirty-summary scan to one client: 1,321 ms to 5.6 ms.
- compare-sales-summaries lives in test/clj as auto-ap.tools.* — it is a
  verification harness, not part of the running application. Its
  docstring now warns that d/as-of cannot be used to compare summary
  amounts: :ledger-mapped/amount, ledger-side and account are
  :db/noHistory, so a recomputed summary reads back with its amounts
  absent and looks like a legitimate balanced day.

28 tests, 65 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 19:19:04 -07:00
parent 943bc18842
commit f8ef7918ef
12 changed files with 2248 additions and 44 deletions

View File

@@ -0,0 +1,153 @@
(ns auto-ap.tools.compare-sales-summaries
"Compares sales summaries between two points in the same database.
A verification tool, not part of the running application: it lives on the test/dev classpath so
nothing in production can depend on it. Load it from a REPL when auditing a recompute.
The question this exists to answer is narrower than \"did the totals improve\": it is *which
days changed, and were any of them already balanced*. A day that was balanced before and still
balances after can still have had its line amounts move, and that is a real change to the
books even though no red turns green. Counting only balanced/unbalanced transitions would hide
it entirely.
DO NOT USE `compare-against` — OR ANY `d/as-of` DATABASE — TO COMPARE AMOUNTS. The obvious
reading is that Datomic keeps every past value, so a recompute can be audited against what was
there before with no snapshot. That does not hold here: `:ledger-mapped/amount`,
`:ledger-mapped/ledger-side` and `:ledger-mapped/account` are all `:db/noHistory true`, so
superseded values are discarded rather than retained. A summary that has since been recomputed
reads back through `as-of` with its categories intact and its amounts *absent* — which is
indistinguishable from a legitimate all-zero day, and quietly turns every rewritten summary
into a false \"was balanced, still balances\".
To compare amounts, capture `summaries-in` from a live `(d/db conn)` immediately after each
run, keep the two captures outside the database, and diff those. `compare-window` is safe when
both arguments are live database values; only the historical read is unsound. Categories and
which-days-changed do survive `as-of`, since `:sales-summary-item/category` retains history."
(:require
[auto-ap.datomic :refer [conn]]
[auto-ap.datomic.sales-summaries :as d-ss]
[clj-time.coerce :as c]
[datomic.api :as dc]))
(def item-read
[:sales-summary-item/category
:sales-summary-item/manual?
:ledger-mapped/amount
{:ledger-mapped/ledger-side [:db/ident]}
{:ledger-mapped/account [:account/numeric-code]}])
(defn- cents
"Amounts are doubles carrying float noise, so compare them at the cent — the unit the books are
actually kept in. Without this, 182.87000000000003 and 182.87 read as a change."
[x]
(Math/round (* 100.0 (double (or x 0.0)))))
(defn- line
"One item reduced to what a reader would call \"the number\": category, side, amount, account."
[item]
{:category (:sales-summary-item/category item)
:side (get-in item [:ledger-mapped/ledger-side :db/ident])
:cents (cents (:ledger-mapped/amount item))
:account (get-in item [:ledger-mapped/account :account/numeric-code])})
(defn summaries-in
"`{[client-code date] {:lines … :imbalance … :balanced?}}` for every summary in `[start end)`.
Keyed by client code and date rather than entity id so the two sides line up even if an entity
were recreated between the points being compared."
[db start end]
(->> (dc/q {:find [(list 'pull '?s [:sales-summary/date
{:sales-summary/client [:client/code]}
{:sales-summary/items item-read}])]
:in '[$ ?start ?end]
:where '[[?s :sales-summary/date ?d]
[(>= ?d ?start)]
[(< ?d ?end)]]}
db (c/to-date start) (c/to-date end))
(map first)
(reduce (fn [acc s]
(let [items (map d-ss/<-pulled-item (:sales-summary/items s))]
(assoc acc
[(get-in s [:sales-summary/client :client/code]) (:sales-summary/date s)]
{:lines (frequencies (map line (:sales-summary/items s)))
:imbalance (d-ss/imbalance items)
:balanced? (d-ss/balanced? items)})))
{})))
(defn- classify
"How one client-day differs. `:numbers-changed` is the interesting one — the lines themselves
moved, whether or not the day's balance status did."
[before after]
(cond
(nil? before) :added
(nil? after) :removed
(= (:lines before) (:lines after)) :identical
:else :numbers-changed))
(defn compare-window
"Compares every summary in `[start end)` between two database values.
Returns per-day rows plus the tallies worth reporting, including the one that is easy to miss:
days that were **already balanced** and whose numbers moved anyway."
[before-db after-db start end]
(let [before (summaries-in before-db start end)
after (summaries-in after-db start end)
rows (for [k (distinct (concat (keys before) (keys after)))
:let [b (get before k) a (get after k)]]
{:client (first k)
:date (second k)
:change (classify b a)
:was-balanced? (:balanced? b)
:now-balanced? (:balanced? a)
:before-imbalance (:imbalance b)
:after-imbalance (:imbalance a)
:lines-before (:lines b)
:lines-after (:lines a)})
rows (vec rows)
changed (filter #(= :numbers-changed (:change %)) rows)]
{:rows rows
:tally {:compared (count rows)
:identical (count (filter #(= :identical (:change %)) rows))
:numbers-changed (count changed)
:added (count (filter #(= :added (:change %)) rows))
:removed (count (filter #(= :removed (:change %)) rows))}
:balance-transitions
{:unbalanced->balanced (count (filter #(and (false? (:was-balanced? %)) (true? (:now-balanced? %))) rows))
:balanced->unbalanced (count (filter #(and (true? (:was-balanced? %)) (false? (:now-balanced? %))) rows))
:stayed-balanced (count (filter #(and (true? (:was-balanced? %)) (true? (:now-balanced? %))) rows))
:stayed-unbalanced (count (filter #(and (false? (:was-balanced? %)) (false? (:now-balanced? %))) rows))}
:previously-balanced-and-changed
(->> changed (filter :was-balanced?) vec)}))
(defn line-diff
"Which categories actually moved on one row, as `{category [before-cents after-cents]}`. For
reading a handful of rows by hand once the tallies point at them."
[row]
(let [by-cat (fn [lines] (reduce (fn [m [l n]] (assoc m (:category l) (* n (:cents l)))) {} lines))
b (by-cat (:lines-before row))
a (by-cat (:lines-after row))]
(->> (distinct (concat (keys b) (keys a)))
(keep (fn [cat]
(let [x (get b cat 0) y (get a cat 0)]
(when (not= x y) [cat [(/ x 100.0) (/ y 100.0)]]))))
(into {}))))
(defn compare-against
"Compare the current database against its own past value at basis `t`.
UNSOUND FOR AMOUNTS — see the namespace docstring. The amount, side and account attributes are
`:db/noHistory`, so any summary rewritten since `t` reads back with no amounts and appears
balanced. Kept only for comparing categories and identifying which days changed."
[t start end]
(let [db (dc/db conn)]
(compare-window (dc/as-of db t) db start end)))
(comment
;; the restore point, i.e. production's own summaries before any of this work
(def result (compare-against 209608347
(clj-time.core/date-time 2026 7 15)
(clj-time.core/date-time 2026 8 14)))
(:tally result)
(:balance-transitions result)
(count (:previously-balanced-and-changed result))
(map line-diff (take 3 (:previously-balanced-and-changed result))))