Recomputed every summary in the database — 14,458 client-days spanning 2024-04-01 to 2026-08-14 — through sales-summaries-v2, zero failures, none left dirty. Compared the fixes against baseline over the last 30 days (2026-07-15 to 2026-08-13), both arms on the same deduplicated data so this isolates what the calculation fixes are worth on top of the dedup work. Excluding the deactivated twins, 2,842 client-days: baseline 398 days off, 86.00% clean, $22,527.40, 336 material fixed 67 days off, 97.64% clean, $405.66, 5 material 331 client-days fixed, 0 regressed — not one balanced day stopped balancing. Of the $405.66 left, $399 sits in five material days, three of which the plan predicted; the remaining 62 days total $1.62 with the largest at 9.00c, so the 10c threshold separates rounding from real variance with nothing near the boundary. Baseline was derived as fixed + untendered tip + service charges, since R1 and R2 only add credits; the identity was verified against a from-scratch baseline recomputation on 20 random client-days. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
394 lines
18 KiB
Markdown
394 lines
18 KiB
Markdown
---
|
||
title: Sales Summary Balance — validation against a restored production backup
|
||
type: validation
|
||
date: 2026-08-15
|
||
status: measured
|
||
---
|
||
|
||
# Sales Summary Balance — validation against a restored production backup
|
||
|
||
Measures the two calculation fixes (R1 reversed tips, R2 Square service charges) against real
|
||
production data, and checks them against the predictions in
|
||
[`docs/plans/2026-08-13-fix-sales-summary-balance-plan.md`](plans/2026-08-13-fix-sales-summary-balance-plan.md).
|
||
|
||
**Headline: the fixes behave as predicted.** R2 clears 307 client-days with zero regressions.
|
||
After both fixes, the entire remaining dollar error outside the contended clients is $73.98,
|
||
and all but six client-days of it is sub-10¢ register rounding.
|
||
|
||
---
|
||
|
||
## 1. What was measured against
|
||
|
||
| | |
|
||
|---|---|
|
||
| Database | `datomic:dev://localhost:4337/integreat-prod-restore` |
|
||
| Restored from | `s3://integreat-backups/`, restore point **209608347** |
|
||
| Basis-t | 209608347 |
|
||
| Newest transaction | **2026-08-14 22:52 local** |
|
||
| Clients | 210 (106 with orders in the window) |
|
||
| Refunds / charges / orders | 50,986 / 16,545,495 / 19,039,575 |
|
||
| Window | 2026-07-13 → 2026-08-14 |
|
||
| Population | **3,342 client-days** |
|
||
|
||
The earlier restore in this directory was **stale** — the backup had written no root since
|
||
2025-03-10 even though segments kept uploading daily through 2026-07-12. A fresh production
|
||
backup on 2026-08-14 produced root 209608347, which is what this measures. The staleness was a
|
||
production backup failure of roughly 17 months, and is worth treating as its own issue.
|
||
|
||
The plan's population was 2,314 client-days because it counted existing summary entities. This
|
||
counts every client-day that actually has orders, which is the fuller denominator; percentages
|
||
are comparable, absolute counts are not.
|
||
|
||
## 2. Method
|
||
|
||
Each client-day is composed exactly as `sales-summaries-v2` composes it — the same
|
||
`get-sales`, `get-payment-items`, `get-refund-items`, `get-discounts`, `get-fees`, `get-tax`,
|
||
`get-returns` — and its imbalance taken as `d-ss/imbalance`, i.e. debits minus credits. Three
|
||
variants differ only in the tip and service-charge terms:
|
||
|
||
- **Baseline** — tips from tenders only, no service-charge credit. What production does today.
|
||
- **R1** — tips additive: tendered tips plus tips on orders carrying no tender.
|
||
- **R2** — R1 plus Square service charges, both signs.
|
||
|
||
Nothing was transacted. A day counts as off when `|imbalance| >= $0.005`, i.e. it does not
|
||
round to zero.
|
||
|
||
## 3. Results
|
||
|
||
| Stage | Days off | Clean | Total \|Δ\| |
|
||
|---|---|---|---|
|
||
| Baseline (production today) | 569 | 82.97% | $32,921.40 |
|
||
| + R1 additive tip | 493 | 85.25% | $32,437.67 |
|
||
| **+ R2 Square service charges** | **186** | **94.43%** | **$5,166.49** |
|
||
|
||
The plan predicted 82.7% clean at baseline including duplicates. Measured: **82.97%**.
|
||
|
||
### Fixed / regressed — the plan's pre-merge gate
|
||
|
||
| Change | Fixed | Regressed |
|
||
|---|---|---|
|
||
| R1 vs baseline | 78 | **2** |
|
||
| R2 vs R1 | 307 | **0** |
|
||
|
||
R2's gate — "days going balanced → unbalanced must be 0, or each explained" — **passes
|
||
outright**.
|
||
|
||
R1's two regressions are both days that balanced only because two errors cancelled, exactly the
|
||
mechanism the plan anticipated:
|
||
|
||
| Client | Date | Before | After | Untendered tip |
|
||
|---|---|---|---|---|
|
||
| NGCD | 2026-08-08 | $0.00 | +$11.80 | −$11.80 |
|
||
| NGDG | 2026-08-05 | $0.00 | +$10.00 | −$10.00 |
|
||
|
||
Both clients are halves of duplicate Square-location pairs (NGCD/NGCC, NGDG/NGDU), so these are
|
||
expected to resolve under Phase 0 rather than needing a change to R1.
|
||
|
||
## 4. Where the remaining error lives
|
||
|
||
Splitting the post-R2 residual by whether the client shares a Square location with another
|
||
client:
|
||
|
||
| Group | Client-days | Days off | Total \|Δ\| |
|
||
|---|---|---|---|
|
||
| 10 duplicate pairs (20 clients) | 546 | 112 | **$5,092.51** |
|
||
| Everyone else (96 clients) | 2,796 | 74 | **$73.98** |
|
||
|
||
**98.6% of the remaining dollar error sits in the contended clients** — which is Phase 0's to
|
||
fix, not a calculation defect. This is the plan's central claim, and it holds.
|
||
|
||
### The $73.98 residual, in full
|
||
|
||
| Client | Days | Total \|Δ\| | |
|
||
|---|---|---|---|
|
||
| PNSP | 30 | $0.91 | register rounding |
|
||
| PNLP | 26 | $0.77 | register rounding |
|
||
| GLAD | 12 | $0.20 | register rounding |
|
||
| NGEB | 2 | $45.00 | ezCater fee semantics (plan §15.4) |
|
||
| NGPS | 1 | $9.62 | unexplained |
|
||
| NGGB | 1 | $7.59 | partial final day |
|
||
| NGSC | 1 | $5.02 | partial final day |
|
||
| NGTY | 1 | $4.87 | partial final day |
|
||
|
||
Applying Phase 6's 10¢ materiality threshold leaves **6 material client-days**. The largest
|
||
sub-threshold day is **9.00¢**, so 10¢ separates rounding noise from real variance with no day
|
||
sitting near the boundary — the threshold is empirically justified, not arbitrary.
|
||
|
||
Three of the six material days — NGGB, NGSC, NGTY — all fall on **2026-08-14**, the day the
|
||
backup was taken at 22:52, and no other date carries more than one material day. They are
|
||
partial-day artifacts of the backup cut, not defects.
|
||
|
||
That leaves exactly the residual the plan predicted, to the cent:
|
||
|
||
- **NGEB 2026-08-10 −$25.00** and **NGEB 2026-07-29 −$20.00** — predicted
|
||
- **NGPS 2026-08-12 +$9.62** — predicted, still unexplained
|
||
|
||
## 5. Confirmed independently: the dropped refund line
|
||
|
||
The plan reports NGCD 2026-07-23 missing a $71.94 refund line because the refund belonged to
|
||
NGCC at recompute time. That client-day measures **+$71.94** out of balance here, and neither R1
|
||
nor R2 moves it — correct, since it is a duplicate-client defect rather than a calculation one.
|
||
|
||
## 6. Amendment to Phase 1 — charges that cannot be scoped
|
||
|
||
The re-key pre-flight was run against the restore.
|
||
|
||
**Refunds — clean.** All 50,986 carry both a client code and a location; 0 unscopable, 0
|
||
collisions. Collisions are in fact structurally impossible: the old key is `:db.unique/identity`
|
||
and the new key is a deterministic function of that same id plus client and location, so two
|
||
distinct entities cannot converge. The run confirms the reasoning.
|
||
|
||
**Charges — not clean.** Of 16,545,495 charges, **2,122,161 (12.8%) have neither
|
||
`:charge/client` nor `:charge/location`**, so they cannot be scoped from the entity itself.
|
||
A 5,000-entity sample profiles them as:
|
||
|
||
| | Count | Share |
|
||
|---|---|---|
|
||
| Referenced only by an expected-deposit, no total/type/date | 4,897 | 98% |
|
||
| Referenced by a sales order, real tender records | 290 | 5.8% |
|
||
| Orphaned — reachable from nothing | **0** | 0% |
|
||
|
||
These are stubs created by the payout path, which asserts `{:charge/external-id ...}` alone and
|
||
lets unique-identity upsert bring a bare entity into existence.
|
||
|
||
**Consequence:** Phase 1 cannot read scoping off the charge. The migration must derive client and
|
||
location from the referencing order or expected-deposit — roughly 123 K charges via orders and
|
||
2.0 M via deposits, extrapolated. Nothing is unreachable, so the phase remains feasible, but the
|
||
step as written in §4.2 would strand 12.8% of charges on the legacy scheme.
|
||
|
||
The payout call site in `square.core3` needs the same treatment: it constructs
|
||
`(str "square/charge/" payment-id)` and would keep minting legacy-scheme stubs after the cutover.
|
||
It has `client` and `location` in scope already, so the fix is local.
|
||
|
||
## 7. Not yet run
|
||
|
||
- **Phase 0** itself — deactivating the duplicate client per location, then re-measuring. The
|
||
numbers above isolate the contended clients rather than deactivating them.
|
||
- **Charges referenced by more than one order** (§3.3), the gate on any historical cleanup.
|
||
- The account for service charges is **49000 Service Income**, chosen to make measurement
|
||
possible. It does not affect balance at all — only whether a day can reach `accepted?` — so
|
||
every number here is independent of that choice.
|
||
|
||
|
||
---
|
||
|
||
# Part 2 — executed end to end on the restore
|
||
|
||
Part 1 measured what the fixes *would* do without writing anything. This part actually ran them:
|
||
Phase 0, the re-key migration, a live Square import, and a full recompute through the real job.
|
||
All windows re-cut to end **2026-08-13**, since the backup was taken mid-evening on the 14th and
|
||
that partial day was polluting the comparison.
|
||
|
||
## What was executed, in order
|
||
|
||
| Step | Result |
|
||
|---|---|
|
||
| Phase 0 — deactivate the newer twin at all 10 shared locations | 10 datoms retracted; no location shared by two clients any more |
|
||
| Phase 1 — re-key refunds | 50,986 re-keyed in 1.9 s, 0 collisions, 0 unscopable |
|
||
| Phase 1 — re-key contended clients' charges | 56,829 re-keyed, 0 collisions |
|
||
| **Count-unchanged assertion** | refunds 50,986 → 50,986, charges 16,545,495 → 16,545,495 |
|
||
| Live Square import, 10 surviving clients | all succeeded |
|
||
| Recompute | 6,720 client-days through `sales-summaries-v2` |
|
||
|
||
The count-unchanged assertion is the one that matters: 107,815 entities were re-keyed and **not a
|
||
single entity was created or destroyed**, which is what proves the expand step resolved legacy
|
||
keys rather than duplicating everything under the new scheme.
|
||
|
||
## Deduplication works on an ongoing basis
|
||
|
||
- **The deactivated twins received nothing.** Across all 10, orders and refunds were byte-identical
|
||
before and after a live import. Phase 0 holds.
|
||
- **Refunds stopped alternating and became two stable copies.** 98 of NGCC's 125 refunds are now
|
||
also held by NGCD as separate entities. Each client owns its own; neither can take the other's.
|
||
This is the intended behaviour of client-scoped keys, and it is what makes the data
|
||
reproducible — but note it double counts at the group level, which is why Phase 0 (one client
|
||
per location) is the actual fix and the re-key is the safety net.
|
||
|
||
### The dropped refund line, fixed
|
||
|
||
NGCD 2026-07-23 was out of balance by exactly **+$71.94** with no refund line, because the refund
|
||
belonged to NGCC at recompute time. After the full pipeline the summary carries a **"Card Refunds"
|
||
line of $71.94** and the day's imbalance is **9.1e-13 — zero**.
|
||
|
||
## Results
|
||
|
||
Reportable population excludes the 10 deactivated twins, which the plan says to exclude from
|
||
reporting.
|
||
|
||
| | Client-days | Days off | Clean | Total \|Δ\| |
|
||
|---|---|---|---|---|
|
||
| Before (production code, nothing applied) | 3,236 | 542 | 83.25% | $30,982.11 |
|
||
| **After (everything applied)** | 3,030 | **71** | **97.66%** | **$405.79** |
|
||
|
||
A **98.7% reduction in dollar error**. With the 10¢ materiality threshold, **5 material days
|
||
remain**:
|
||
|
||
| Client | Date | Δ | |
|
||
|---|---|---|---|
|
||
| NGBK | 2026-08-06 | +$299.42 | new — see below |
|
||
| NGDA | 2026-08-01 | −$50.00 | the known auto-gratuity-as-service-charge order |
|
||
| NGEB | 2026-08-10 | −$25.00 | ezCater fee semantics, predicted |
|
||
| NGEB | 2026-07-29 | −$20.00 | ezCater fee semantics, predicted |
|
||
| NGPS | 2026-08-12 | +$9.62 | predicted, still unexplained |
|
||
|
||
The three predicted residuals reproduced exactly. NGBK 08-06 is new and appeared only after the
|
||
import: NGBK went from 5 refunds to 105, because it now holds its own copies of refunds that had
|
||
been sitting under its twin NGBR. That is the double-counting consequence of two clean copies, and
|
||
it is an argument for Phase 0 being the real fix rather than the re-key.
|
||
|
||
## Two defects found by executing rather than simulating
|
||
|
||
### 1. Re-keying a shared charge duplicates the tender
|
||
|
||
**Found by measurement, not by reading.** After the import, the contended clients' total imbalance
|
||
was **$441,045**, roughly 100× worse than predicted. The cause:
|
||
|
||
1. 35,870 of 56,829 charges had **two parent orders**, one per twin.
|
||
2. The migration's `scope-of` resolved such a charge through whichever referencing order it found
|
||
first — sometimes the twin's — so it was re-keyed to, say, `square/charge/NGDU-DU-<id>`.
|
||
3. On import, NGDG computed `square/charge/NGDG-DB-<id>`, did not find it, fell back to the legacy
|
||
key — which the migration had already removed — and so **created a second charge**.
|
||
4. `:sales-order/charges` is cardinality **many**, so the new charge was *added* beside the stale
|
||
one. One order, two tenders for one payment. Tender roughly doubled.
|
||
|
||
Confirmed on a single order: `square/order/NGDG-DB-IKiIDdTK…` referencing both
|
||
`square/charge/NGDU-DU-dEhvoH50…` and `square/charge/NGDG-DB-dEhvoH50…`.
|
||
|
||
This is exactly what plan §4.3 warned about — re-keying gives each charge one new key, it does not
|
||
**split** a charge with two parents — and it was under-weighted when the migration was written.
|
||
|
||
**Before this migration ships it needs one of:**
|
||
- split shared charges (clone per client) as part of the migration rather than re-keying in place, or
|
||
- have the order upsert *reset* its charge refs rather than accumulate them, or
|
||
- run the re-key only after the duplicate clients' orders are gone.
|
||
|
||
On the restore this was repaired by retracting 15,961 stale charge refs — for each order, keeping
|
||
the charge scoped to its own client — after which no order had two charges for one payment id, and
|
||
the contended clients' error fell from $441,045 to $1,492.
|
||
|
||
### 2. `dirty-sales-summaries` scans the whole index per client
|
||
|
||
```clojure
|
||
(dc/index-pull db {:index :avet :start [:sales-summary/client+dirty [client-id true]]})
|
||
```
|
||
|
||
There is no `:end`, and the client filter that follows is a lazy `filter` which does not stop the
|
||
scan. So for every client the job walks every summary of every client sorting after it, pulling
|
||
their items. It is O(n²) in the number of summaries.
|
||
|
||
Observed: the recompute ran at ~180 client-days/minute early and collapsed to ~3/minute as
|
||
summaries accumulated — a full pass took over two hours for 6,720 days. Adding an `:end` bound at
|
||
the client boundary should fix it.
|
||
|
||
## Method note
|
||
|
||
The 96 non-contended clients were measured from the **persisted summaries** the job wrote, and
|
||
came to $56.37 across 69 days — identical to the in-memory prediction in Part 1, which validates
|
||
the job path end to end. The 20 contended clients were re-measured in memory after the charge
|
||
repair, because a second full pass through the job would have taken hours for the reason in defect
|
||
2. The two methods agree exactly where both were run.
|
||
|
||
## Ongoing deduplication, with both clients active again
|
||
|
||
The re-key is meant to make contention impossible even if a location is configured on two clients
|
||
again. To test that rather than assume it, all 10 twins were **re-activated** — every location
|
||
shared once more — and the import re-run.
|
||
|
||
**Repeated imports are idempotent.** Three consecutive refund imports for both halves of the
|
||
Concord pair, with both clients live on location `L43Z5GMW72VMG`:
|
||
|
||
| | run 0 | run 1 | run 2 | run 3 |
|
||
|---|---|---|---|---|
|
||
| NGCC | 127 | 127 | 127 | 127 |
|
||
| NGCD | 1,419 | 1,419 | 1,419 | 1,419 |
|
||
|
||
Not one entity created or moved. Before the re-key this is the exact configuration that made a
|
||
refund's owner flip every ~20 minutes.
|
||
|
||
The first import after the migration is the only one that grows anything, and it grows once: NGCD
|
||
gained 100 refunds materialising its own copies, while NGCC — whose refunds already carried its
|
||
own scoped key — gained only the 2 that were genuinely new. After that, steady state.
|
||
|
||
**State the restore was left in:** twins re-activated, i.e. Phase 0 deliberately undone for this
|
||
test. Re-applying it is the ten-datom retraction recorded above.
|
||
|
||
|
||
---
|
||
|
||
# Part 3 — all summaries updated, fixes compared to baseline
|
||
|
||
Every summary in the database was recomputed — **14,458 client-days**, 2024-04-01 through
|
||
2026-08-14 — through `sales-summaries-v2`, with zero failures and zero left dirty. The
|
||
comparison below is the **last 30 days, 2026-07-15 → 2026-08-13**.
|
||
|
||
Both arms are measured on the same, already-deduplicated data (Phase 0 applied, refunds and the
|
||
contended clients' charges re-keyed, duplicate charge refs repaired), so this isolates what the
|
||
**calculation fixes** are worth on top of the deduplication work.
|
||
|
||
## Making the full recompute possible
|
||
|
||
Two changes were needed before recomputing everything was practical:
|
||
|
||
1. **`dirty-sales-summaries` scanned to the end of the index** (fixed in
|
||
`perf(sales-summaries)`): 1,321 ms → **5.6 ms** per client, a 237× improvement, identical
|
||
results.
|
||
2. **The driver was one serial `doseq`.** `refresh-client!` was split out of `sales-summaries-v2`
|
||
so a client's work stands alone. Spread across threads, the remaining 5,463 client-days
|
||
finished in about 90 seconds — the serial run was tracking at roughly nine more hours.
|
||
|
||
## Method
|
||
|
||
Baseline is derived rather than recomputed: R1 and R2 only ever *add credits*, so
|
||
|
||
```
|
||
baseline imbalance = fixed imbalance + untendered tip + service charges
|
||
```
|
||
|
||
This identity was checked against a full from-scratch baseline recomputation on 20 randomly
|
||
sampled client-days and agreed on every one, to within a hundredth of a cent.
|
||
|
||
## Results — last 30 days, excluding the 10 deactivated twins
|
||
|
||
2,842 client-days across 96 clients.
|
||
|
||
| | Days off | Clean | Total \|Δ\| | Material (≥10¢) |
|
||
|---|---|---|---|---|
|
||
| **Baseline** (production calculation) | 398 | 86.00% | $22,527.40 | 336 |
|
||
| **Fixed** (R1 + R2) | **67** | **97.64%** | **$405.66** | **5** |
|
||
|
||
**331 client-days fixed, 0 regressed.** Not one day that balanced under the baseline stopped
|
||
balancing under the fixes, across the whole window.
|
||
|
||
Including the deactivated twins (3,040 client-days) the picture is the same shape: 432 → 70 days
|
||
off, $25,622.98 → $1,548.64.
|
||
|
||
### What is left
|
||
|
||
| Client | Date | Δ | |
|
||
|---|---|---|---|
|
||
| NGBK | 2026-08-06 | +$299.42 | refunds now held by both twins — see below |
|
||
| NGDA | 2026-08-01 | −$50.00 | auto-gratuity booked as a service charge |
|
||
| NGEB | 2026-08-10 | −$25.00 | ezCater fee semantics, predicted by the plan |
|
||
| NGEB | 2026-07-29 | −$20.00 | ezCater fee semantics, predicted by the plan |
|
||
| NGPS | 2026-08-12 | +$9.62 | predicted by the plan, still unexplained |
|
||
|
||
Everything else — 62 client-days — totals **$1.62**, with the largest single day at **9.00¢**.
|
||
The 10¢ materiality threshold cleanly separates register rounding from real variance, with
|
||
nothing sitting near the boundary.
|
||
|
||
NGBK is the one genuinely new residual and it is a consequence of the re-key rather than the
|
||
calculation: NGBK went from 5 refunds to 105 because it now holds its own copies of refunds that
|
||
had been sitting under its twin NGBR. Two stable copies is the intended behaviour, but it double
|
||
counts at the group level — which is the argument for Phase 0 being the real fix and the re-key
|
||
being the safety net.
|
||
|
||
### Representative days the fixes repair outright
|
||
|
||
| Client | Date | Baseline → Fixed |
|
||
|---|---|---|
|
||
| NTPT | 2026-08-06 | $427.10 → $0.00 |
|
||
| NGFO | 2026-07-16 | $275.23 → $0.00 |
|
||
| NGRN | 2026-08-06 | $236.96 → $0.00 |
|
||
| N-30008 | 2026-07-24 | $229.42 → $0.00 |
|
||
| N-30003 | 2026-07-18 | $225.00 → $0.00 |
|