Sales summaries · restored production backup

Ninety-Day Reconciliation

A true baseline: the job re-run three times over ninety days — production's calculation, then each fix added — so every number below is measured output, not arithmetic.

Window 2026-05-10 → 2026-08-07 Client-days 8,733 with activity Clients 106 Most recent week omitted
Baseline $58,531.75 1,024 days out of balance · 87.74% clean
After both fixes $852.38 105 days out of balance · 98.74% clean
919client-days brought into balance
0days knocked out of balance
0balanced days whose numbers moved
98.5%of the dollar variance removed

Figures exclude the ten deactivated duplicate clients, which the plan says to exclude from reporting. Across the full 8,733 client-days including them the shape is the same: 1,087 → 108 days out of balance, $63,764.92 → $1,995.36.

Why this baseline is different

The earlier thirty-day report derived its baseline arithmetically. This one does not. The job was run three separate times over the same ninety days, against the same data, writing real summaries each time:

  • Run A — baseline. The service-charge flag cleared on all 210 clients and get-tip restored to its tendered-only form. This is exactly what production calculates today.
  • Run B — plus R1. Only get-tip changed.
  • Run C — plus R2. The service-charge flag switched on as well.

The underlying data — Phase 0, the re-key, the repaired charge references — is identical across all three, so what separates them is the calculation and nothing else. 18,900 summaries were written per run, 56,700 in total.

The most recent week (08-08 → 08-14) is excluded deliberately. The backup was cut mid-evening on the 14th, and the days either side of that are partial, which distorted the earlier window.

Impact by underlying fix

Applied in sequence, each measured against the run before it, over 8,350 reportable client-days.

R1

Tips on untendered orders
OutcomeClient-daysShare
Unchanged8,05496.5%
Out of balance → balanced2723.3%
Balanced → out of balance0
Balanced → balanced, numbers changed0
Still out of balance, but closer240.3%
Days touched · dollars moved296$3,623.44

R2

Square service charges, both signs
OutcomeClient-daysShare
Unchanged7,70192.2%
Out of balance → balanced6477.7%
Balanced → out of balance0
Balanced → balanced, numbers changed0
Still out of balance, but closer2<0.1%
Days touched · dollars moved649$54,775.79

Both together, full population

OutcomeClient-days
Unchanged7,749
Out of balance → balanced979
Balanced → out of balance0
Balanced → balanced, numbers changed0
Still out of balance, but closer5
Days touched · dollars moved984 · $62,069.64
Neither fix touches a healthy day — now confirmed over three times the window

Across 8,733 client-days and 90 days of trading, not one day that balanced under production's calculation was altered by either fix: zero knocked out of balance, and zero whose amounts moved while staying balanced. Every day they touched was already out of balance. The thirty-day run found the same thing; this replicates it on a much larger sample.

What actually changed on the page

Both fixes add a single credit line. Nothing else in a summary moves — no sales figure, no tender, no tax. That is why they can only ever help a day that was already short, and it is visible in the line items.

R1 — NGLK, 2026-08-04

LineBeforeAfterChange
Tip (credit)482.94422.94−60.00
Card Refunds (credit)60.0060.00
every other lineunchanged
Total debits10,094.8110,094.81
Total credits10,154.8110,094.81−60.00
Imbalance−60.000.00balanced

Read the two credit lines together and the story is complete: the day already carried a $60.00 card refund — the guest was given their money back, tip included — but the tip was still credited in full at $482.94, because the reversal lives on an order with no tender and get-tip only reached tips through tenders. The books claimed $60 of tip income that had been handed back. The corrected figure, $422.94, matches the refund exactly.

R2 — NTPT, 2026-08-06

LineBeforeAfterChange
Service Charges (credit)absent427.10+427.10
Card Payments (debit)4,975.894,975.89
every other lineunchanged
Total debits7,777.207,777.20
Total credits7,350.107,777.20+427.10
Imbalance+427.100.00balanced

The customer paid $427.10 of service charge as part of a $4,975.89 card tender, so the money arrived on the debit side. No line credited it, so the day showed $427.10 more collected than earned. Adding the credit closes it exactly, and the single order responsible is square/order/NTPT-PT-KrMZzcon1cpQEJUyetErkBIpcdEZY, carrying a :sales-order/service-charge of 427.10 on a 3,198.78 order.

The largest tip repairs

ClientDateTip beforeTip afterImbalance beforeAfter
NGFL2026-05-19238.4670.42−168.040.00
NGMI2026-07-09230.0180.01−150.000.00
NGVA2026-07-03152.6640.12−112.540.00
NGLK2026-08-04482.94422.94−60.000.00
NGPA2026-06-15383.70331.19−52.510.00
NGLK2026-06-09518.88467.69−51.190.00

Every one overstated tip income, and in every case the correction equals the imbalance to the cent. NGFL was crediting $238.46 of tips on a day where $168.04 had been given back.

The largest service-charge repairs

ClientDateService charges beforeAfterImbalance beforeAfter
NGPA2026-06-04absent1,344.86+1,344.860.00
NTPT2026-08-06absent427.10+427.100.00
N-300032026-05-27absent405.83+405.830.00
NGPA2026-07-02absent348.45+348.450.00
NGA12026-06-04absent296.35+296.350.00

NGPA 2026-06-04 is the single largest repair in the ninety days: $1,344.86 of service charges collected from customers and credited to no revenue account at all.

Where both fixes land on one day

NGNP, 2026-06-25BeforeAfterChange
Tip (credit)93.9092.10−1.80
Service Charges (credit)absent301.40+301.40
Imbalance+299.600.00301.40 − 1.80

The two corrections pull in opposite directions and still land on zero, which is a useful check that they are independent and neither is compensating for the other.

What is left

Fifteen client-days above the ten-cent materiality threshold, out of 8,350. Everything else — 90 client-days — totals $2.26.

ClientDateVarianceNote
NGBK2026-08-06+299.42tender exceeds order totals by exactly this much
NGMV2026-05-24+100.87a five-day cluster in late May, new to this window and not yet diagnosed
NGMV2026-05-20+72.05
NGMV2026-05-21+57.64
NGMV2026-05-22+14.41
NGMV2026-05-26+14.41
NGEB2026-05-19−79.01ezCater fee semantics — the plan's open question §15.4
NGEB2026-06-23−50.28
NGEB2026-05-13−49.80
NGEB2026-07-29−20.00
NGDA2026-08-01−50.00auto-gratuity booked as a service charge
N-300122026-05-20+17.32two consecutive days, undiagnosed
N-300122026-05-21+12.99
NG4S2026-05-29−11.78undiagnosed
PNSP2026-07-12−0.14register rounding, just over threshold

Widening from thirty days to ninety surfaced two clusters the shorter window could not see: NGMV across five days in late May, and NGEB across four days spanning May to July. NGEB is the known ezCater fee-semantics question. NGMV and N-30012 are new and worth a look before this ships — they are the kind of thing only a longer baseline exposes.

The ten deactivated twins, excluded above, contribute a further $1,142.98 across three days. They should not be reported on at all once one client per location is settled.

Two performance defects found on the way

Running the job 56,700 times surfaced two problems that a normal nightly pass would hide, both now fixed or documented.

ProblemEffectFix
dirty-sales-summaries scanned the whole index1,321 ms → 5.6 ms per clientbound the scan at the client boundary
Transactor sized for a toy database~35/min → 5,300/minobject cache 2 GB → 8 GB, heap 4 GB → 16 GB

The second is a deployment setting rather than a code change, but it is dramatic: with a 2 GB object cache against a 27 GB database, the final 7,958 client-days of a pass were crawling at about 35 a minute. After resizing they completed in 90 seconds. Worth checking what production's transactor is sized at.

Reproduce it

;; connect to the restored backup
(def conn (d/connect "datomic:dev://localhost:4337/integreat-prod-restore"))

;; baseline mode: clear the flag, restore tendered-only tips
(doseq [[c _] clients]
  @(d/transact conn [[:db/retract c :client/feature-flags "summary-service-charges"]]))
(alter-var-root #'ss/get-tip (constantly baseline-get-tip))

;; mark the window and run the real job, then capture
(doseq [[c _] clients] (ss/mark-dirty c w90-start w90-end))
(pmap (fn [[c code]] (ss/refresh-client! c code)) pending)

;; the single orders behind the worked examples
(d/pull (d/db conn) '[*]
        [:sales-order/external-id
         "square/order/NGLK-SM-OxSX9gpXJV394qqT8mnBGypUwKNZY"])
(d/pull (d/db conn) '[*]
        [:sales-order/external-id
         "square/order/NTPT-PT-KrMZzcon1cpQEJUyetErkBIpcdEZY"])

The unit tests covering both fixes and the re-key:

lein test auto-ap.jobs.sales-summaries-test auto-ap.square.core3-test
;; => 15 tests, 26 assertions, 0 failures
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.

Isolation: which entities could still swap owners

Balancing was only half the problem. The other half is whether an entity can change hands between two clients at all. Rather than reason about it, every entity type the importers create with a unique external id was audited, and ownership was read out of the database's own history.

Entities whose client has actually changed

EntitySwappedOf totalKey before
Expected deposits (Square payouts)4,069144,688square/payout/<id>
Cash drawer shifts2,62869,291square/cash-drawer-shift/<id>
Refunds3,38751,990fixed earlier
Sales orders, ezCater orders0already scoped

Both payout and cash-shift endpoints are filtered by location, so two clients configured on one Square location import the same records and collide on a single entity — the identical defect refunds and charges had, in two types nobody had looked at.

Nineteen client pairs have contended, but only ten are visible today

Nine pairs no longer share a location in the current configuration, so no point-in-time check would find them — yet their data is still mixed. The largest are NGMJ/NGSC with 1,546 affected entities, NGAK/NGMH with 952, NGBW/NGWD with 780 and NGNP/NGVZ with 645. This is the argument for scoping the keys rather than only deactivating what currently looks shared: deactivation is a snapshot fix that rots the next time someone configures a location twice.

Worth knowing alongside it: 105 of the clients share a single Square auth token, so these importers all reach into one Square account. The blast radius of a future misconfiguration is the estate, not a pair.

What was done, and what it cost the books

StepResult
Re-key expected deposits144,652 re-keyed · 0 collisions · 36 unscopable
Re-key cash drawer shifts69,291 re-keyed · 0 collisions · 0 unscopable
Entity counts before vs afterunchanged on all four types
Legacy-scheme keys remaining0
Live re-import, 66 of 102 clients, 90-day window0 entities created · 0 values rewritten
Ownership changes since the re-key0 deposits · 0 shifts · 0 refunds
Summaries recomputed and compared18,900 · 0 differences

The re-import is the important negative result. Every payout and shift the importer fetched resolved to the client's own entity and wrote nothing — the re-key is transparent to the importer, which is exactly what the legacy-key fallback is for. And recomputing all 18,900 client-days afterwards reproduced the previous figures exactly — largest difference 0.000000, so re-keying 213,943 entities moved no money at all.

One caveat worth stating plainly: re-keying stops future contention, but it does not retrospectively re-attribute records that were claimed by the wrong client while the configuration was shared. Those stay where they were last written. Correcting them is a separate exercise, and one to do only once the business decides which client owns each location.

Why remove-voided-orders is dangerous

The schema says a charge belongs to its order:

{:db/ident :sales-order/charges, :db/isComponent true}

:db/isComponent means Datomic treats charges as parts of the order rather than independent records. So [:db/retractEntity <order>] deletes the order and its charges. That is the documented behaviour, and normally it is what you want.

remove-voided-orders asks Square for the last ten days of orders, keeps the ones Square reports as voided, and retracts them. Correct on its own terms.

The problem is that where two clients shared a location, both clients' orders resolved to the same charge entity, because charge keys carried no client:

NGCD order ──┐
             ├──> charge 17592490524   ← one entity, two parents
NGCC order ──┘

Retract either order and Datomic deletes that shared charge. The other client's order survives with its sales intact but its payment gone: the day silently goes out of balance and the tender disappears from the current database value. Measured on the restore, 35,870 of 56,829 charges (63%) in the contended clients' recent window have two parent orders.

The re-key stops new sharing but does not un-share those. Two remedies, either sufficient: split them by re-importing the affected window now that keys are client-scoped, or guard the retraction so it detaches a shared charge instead of deleting it. The guard is small and makes the operation safe whatever shape the data is in.