From 7e4ff93b68acc7cf0bf95f25a48ec29683ba0404 Mon Sep 17 00:00:00 2001 From: Bryce Date: Sat, 15 Aug 2026 09:06:54 -0700 Subject: [PATCH] feat(sales-summaries): add a summary comparison tool, and report 30-day impact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compare-sales-summaries diffs summaries between two points in the same database using as-of, so a recompute can be audited against exactly what was there before without a snapshot or scratch copy. It answers a question that balance-status counting hides: a day can stay balanced while its line amounts move, which is a real change to the books even though no red turns green. Amounts are compared at the cent so floating-point noise does not read as a change. Run over 2026-07-15 to 08-13 against production's own summaries at basis-t 209608347: out of balance -> balanced 309 balanced -> out of balance 0 balanced -> balanced, numbers moved 237 no summary in production 4,058 Broken down by fix, neither calculation change touches a healthy day: R1 fixes 86 with 0 unbalanced and 0 balanced-day edits, R2 fixes 276 on the same terms. Every one of the 237 balanced-but-changed days comes from the data work, which is also the only step that unbalances anything — 19 days, all of them absorbed by R1 and R2, which is why the end-to-end count is zero. The 4,058 missing summaries include eight consecutive days, Jul 30 to Aug 6, missing for all 210 clients — the coverage hole the plan predicted from the scheduler's seven-day lookback, confirmed here independently from the data. Co-Authored-By: Claude Opus 5 --- .../2026-08-15-thirty-day-reconciliation.html | 420 ++++++++++++++++++ .../auto_ap/jobs/compare_sales_summaries.clj | 135 ++++++ 2 files changed, 555 insertions(+) create mode 100644 docs/2026-08-15-thirty-day-reconciliation.html create mode 100644 src/clj/auto_ap/jobs/compare_sales_summaries.clj diff --git a/docs/2026-08-15-thirty-day-reconciliation.html b/docs/2026-08-15-thirty-day-reconciliation.html new file mode 100644 index 00000000..d4888054 --- /dev/null +++ b/docs/2026-08-15-thirty-day-reconciliation.html @@ -0,0 +1,420 @@ +Thirty-Day Reconciliation + + +
+ +
+
Sales summaries · restored production backup
+

Thirty-Day Reconciliation

+

What the deduplication work and the two calculation fixes actually did to the books, measured day by day against production's own summaries.

+
+ Window 2026-07-15 → 2026-08-13 + Client-days 3,040 with activity + Compared against basis-t 209608347 +
+
+ +
+
+
+ Baseline + $22,527.40 + 398 days out of balance · 86.00% clean +
+ +
+ After fixes + $405.66 + 67 days out of balance · 97.64% clean +
+
+ +
+
331client-days brought into balance
+
0days knocked out of balance
+
237already-balanced days whose numbers moved
+
98.2%of the dollar variance removed
+
+ +
+

Both arms are computed on the same deduplicated data, so this isolates what the calculation fixes are worth. Figures exclude the ten deactivated duplicate clients, which the plan says to exclude from reporting; including them the shape is identical (432 → 70 days, $25,622.98 → $1,548.64).

+
+
+ +
+

Was anything already balanced disturbed?

+
+

This is the question that matters most for the books, and it has two halves. No day that balanced under production went out of balance — that count is zero. But 237 client-days that were balanced had their line amounts change anyway, and a balance-only view would hide every one of them.

+

None of those 237 came from the calculation fixes. Both fixes are provably inert on balanced days — see the decomposition below. They came from the data being corrected underneath.

+
+ +
+ + + + + + + + + + + + +
Outcome, production → finalClient-daysReading
Out of balance → balanced309the intended repair
Balanced → out of balance0nothing was broken
Balanced → balanced, numbers changed237amounts moved, balance held
Balanced → balanced, identical1,652untouched
Still out of balance44residual, see below
Production summaries in window2,242of 6,300 client-day slots
+
+ +

What moved on those 237 days

+
+ + + + + + + +
PatternDaysCause
Every category 0.00 → real values~100production held an all-zero summary for a day that had orders
Card Payments ↔ Fees reallocation~135the payout arrived, so the processing fee is now known and booked separately
Service Charges line appears13R2, on days that were already out of balance
+
+
+

The second pattern is worth reading carefully: Card Payments falls by exactly what Fees gains, so the day stays balanced while the split between the two lines changes. That is a data-freshness effect from re-importing, not a change in how anything is calculated.

+
+
+ +
+

Impact by underlying fix

+
+

Applied in sequence, each measured against the state before it. Percentages of a 3,040 client-day population.

+
+ +
+

Data

+
Deduplication — Phase 0, re-key, re-import
+
+
+ + + + + + + + + +
OutcomeClient-days
Unchanged1,581
Numbers changed547
Out of balance → balanced56
Balanced → out of balance19
Balanced → balanced, numbers changed237
+
+
+

The data work is the only step that unbalances anything: 19 days go out of balance purely from correcting the data, mostly where a refund that had been sitting under the twin client now lands on the right one. The two calculation fixes then absorb all 19, which is why the end-to-end figure is zero.

+
+ +
+

R1

+
Tips on untendered orders
+
+
+ + + + + + + + + + +
OutcomeClient-daysShare
Unchanged2,94196.7%
Out of balance → balanced862.8%
Balanced → out of balance0
Balanced → balanced, numbers changed0
Still out of balance, but closer130.4%
Days touched · dollars moved99$973.79
+
+ +
+

R2

+
Square service charges, both signs
+
+
+ + + + + + + + + + +
OutcomeClient-daysShare
Unchanged2,76090.8%
Out of balance → balanced2769.1%
Balanced → out of balance0
Balanced → balanced, numbers changed0
Still out of balance, but closer40.1%
Days touched · dollars moved280$23,729.45
+
+ +
+ Both calculation fixes are inert on healthy days +

Across 3,040 client-days, neither R1 nor R2 changed a single number on a day that was already balanced. Every day they touched was already out of balance. That is the strongest available evidence that they cannot quietly restate correct books — and it is why R2 carries the larger dollar figure without carrying larger risk.

+
+
+ +
+

Days production never had

+
+

Of 6,300 client-day slots in the window, production held a summary for only 2,242. The remaining 4,058 had none at all — nothing to compare against, and nothing an accountant could have looked at.

+

They are not evenly spread. Eight consecutive days show 210 missing summaries each, which is every client in the database:

+
+
+ + + + + + + +
DatesMissing per dayReading
Jul 15 – Jul 29117clients with no summary that day
Jul 30 – Aug 6210every client — a total coverage hole
Aug 7 – Aug 1389clients with no summary that day
+
+
+ The coverage hole is real and self-confirming +

The plan predicted a global gap at 2026-07-30 → 08-06 from reading the scheduler, which only looks back seven days and so can never backfill a hole older than that. This measurement found the same eight days independently, from the data. Every one of those 1,680 client-days now has a summary.

+
+
+ +
+

What is left

+
+

Five client-days above the ten-cent materiality threshold, totalling $404.04. Everything else — 62 days — comes to $1.62, with the largest single day at nine cents.

+
+
+ + + + + + + + + +
ClientDateVarianceExplanation
NGBK2026-08-06+$299.42refunds now held by both twins — a re-key consequence
NGDA2026-08-01−$50.00auto-gratuity booked as a service charge
NGEB2026-08-10−$25.00ezCater fee semantics predicted
NGEB2026-07-29−$20.00ezCater fee semantics predicted
NGPS2026-08-12+$9.62unexplained predicted
+
+
+

The ten-cent threshold separates register rounding from real variance with nothing sitting near the boundary — the largest sub-threshold day is 9.00¢ and the smallest material one is $9.62, two orders of magnitude apart.

+
+
+ +
+

How this was measured

+
+
    +
  • Run against a production backup restored to basis-t 209608347, newest transaction 2026-08-14 22:52. Nothing in production was touched.
  • +
  • "Before" is production's own stored summaries, read via as-of — not a re-simulation of them. Datomic keeps every past value, so the comparison is against exactly what was there.
  • +
  • A day counts as out of balance when debits minus credits is at least half a cent. Line amounts are compared at the cent, so floating-point noise does not read as a change.
  • +
  • R1 and R2 only ever add credits, so baseline is derived as fixed + untendered tip + service charges. That identity was checked against a from-scratch baseline recomputation on 20 random client-days and agreed on every one.
  • +
  • All 14,458 summaries in the database were recomputed through the real job, not a test harness.
  • +
+
+
+ Two defects still open before this ships +

Re-keying a charge that has two parent orders duplicates the tender, because the other client's import then matches neither key and creates a second charge which cardinality-many appends. The migration must split shared charges first. Separately, remove-voided-orders retracts orders, and 63% of the contended clients' charges have two parents, so a retraction there deletes the other client's payment.

+
+
+ + + +
diff --git a/src/clj/auto_ap/jobs/compare_sales_summaries.clj b/src/clj/auto_ap/jobs/compare_sales_summaries.clj new file mode 100644 index 00000000..24f8aba3 --- /dev/null +++ b/src/clj/auto_ap/jobs/compare_sales_summaries.clj @@ -0,0 +1,135 @@ +(ns auto-ap.jobs.compare-sales-summaries + "Compares sales summaries between two points in the same database. + + Datomic keeps every past value, so a recompute can be audited against exactly what was there + before by reading `(d/as-of db t)` for some earlier `t` — no snapshot or scratch copy needed. + + 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." + (: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 + "Convenience: compare the current database against its own past value at basis `t`." + [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))))