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

Open
notid wants to merge 9 commits from sales-summary-balancing-v2 into master
Owner

Three faults were leaving restaurant days out of balance — one in the data, two in the arithmetic. A fourth was found late and turns out not to be a balancing fault at all; it is deliberately left visible, and it is the most important thing in this PR.

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.

Days out of balance Clean Variance
Today's calculation, ninety days re-run 1,258 93.34% $69,560.10
+ refunded tips 971 94.86% $66,414.39
+ service charges 279 98.52% $7,790.54

979 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 279 days left, 171 are not arithmetic faults — see The fourth problem below.

📊 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 order


Nothing changes on deploy

The one behaviour change that alters a client's books sits behind a per-client feature flag that is off by default: summary-service-charges.

Merging and deploying changes no client's books until a client is opted in. Removing the 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/charges is :db/isComponent, so removing a voided order cascaded into payments the other client still needed. See docs/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-tip summed 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-charge at all. Now credited for Square orders only, both signs.

The fourth problem — refunds with no sales behind them

Read this one. After the three fixes, 156 of the 279 remaining days are a record carrying refunds on a day it recorded no sales at all.

The obvious reading is that the refund settled on a closed day. It is wrong. Checking each day against the date its client first recorded any order: 132 of 156 fall before that client had a single order in the system, and for seven of the nine records affected, every one does. These are periods whose sales were never imported.

The refunds are not theirs either. Ownership history settles it — a $35.35 refund dated 26 February belonged to NGDG that same day and was taken over by NGDU on 12 August, with others flipping between the two several times a day across 12–15 August. NGDU's first order is 2 August; it holds 94 refunds dated before it existed as a trading record. Unscoped refund keys meant whichever import ran last took ownership — the data fault above, seen from the other end.

Across nine records, 659 refunds worth $15,225.24 sit on a record dated before its own first order. NG4S and NGPS are not among the ten shared locations, so this is wider than the duplicate records.

A fix for this was written, measured, and removed. Booking a return equal to the day's refunds whenever the client recorded no sales closes 156 days and $4,820.19, is incapable of touching a trading day, and breaks nothing. It came out anyway: an unbalanced day is the only visible signal that a restaurant's sales are not being imported, and closing it would remove the alarm and leave the fire. What remains is a comment explaining why and a test asserting the day stays out of balance, so the next person to notice the arithmetic finds the reasoning first.

Step 9 of the rollout plan is the real fix and needs a business decision: either import the missing sales, or move the refunds to the record that has them.

Supporting changes

Change Why
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 an out-of-balance day was only visible by opening the screen
Bound the dirty-summary scan to one client 1,321 ms → 5.6 ms per client
compare-sales-summaries moved to test/clj as auto-ap.tools.* a verification harness, not part of the running application — nothing in production should be able to depend on it

One correction worth reading

d/as-of cannot be used to compare summary amounts. :ledger-mapped/amount, ledger-side and account are all :db/noHistory, so superseded values are discarded — a recomputed summary reads back through as-of with 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

  • Which client record survives at each shared location (business). The newer record usually has no history from before the split, so keeping it loses years of that location's books.
  • Which account service charges post to (accounting). Currently 49000 Service Income, chosen so the work could be measured; it affects reporting, never whether a day balances.
  • 659 refunds on records with no sales for them (business, then engineering) — the top open item, $15,225.24. Step 9.

What this does not fix

279 client-days over ninety days, $7,790.54 — but only 108 of them are arithmetic:

  • 156 days / $4,988.53 — sales never imported (above). Left visible on purpose.
  • 15 days / $974.99 — a processing fee landing on a day with no trading: the same shape from the payout side, so it needs the payout modelled rather than a rule in the summary.
  • 108 days / $1,995.36 — real trading days with genuine discrepancies: the NGBR/NGBK tender gap ($6,358.99 of tender against $6,059.57 of order totals on one day), the ezCater fee question, and unexplained clusters on NGMV and NGEB.

Testing

lein test auto-ap.jobs.sales-summaries-test auto-ap.square.core3-test auto-ap.jobs.rekey-square-external-ids-test26 tests, 62 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

Three faults were leaving restaurant days out of balance — one in the data, two in the arithmetic. A fourth was found late and turns out **not** to be a balancing fault at all; it is deliberately left visible, and it is the most important thing in this PR. 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. | | Days out of balance | Clean | Variance | |---|---:|---:|---:| | Today's calculation, ninety days re-run | 1,258 | 93.34% | $69,560.10 | | + refunded tips | 971 | 94.86% | $66,414.39 | | **+ service charges** | **279** | **98.52%** | **$7,790.54** | **979 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 279 days left, **171 are not arithmetic faults** — see *The fourth problem* below. 📊 **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 order --- ## Nothing changes on deploy The one behaviour change that alters a client's books sits behind a per-client feature flag that is **off by default**: `summary-service-charges`. Merging and deploying changes no client's books until a client is opted in. Removing the 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/charges` is `:db/isComponent`, so removing a voided order cascaded into payments the other client still needed. See `docs/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-tip` summed 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-charge` at all. Now credited for Square orders only, both signs. ## The fourth problem — refunds with no sales behind them **Read this one.** After the three fixes, 156 of the 279 remaining days are a record carrying refunds on a day it recorded **no sales at all**. The obvious reading is that the refund settled on a closed day. It is wrong. Checking each day against the date its client first recorded *any* order: **132 of 156 fall before that client had a single order in the system**, and for seven of the nine records affected, every one does. These are periods whose sales were never imported. The refunds are not theirs either. Ownership history settles it — a $35.35 refund dated 26 February belonged to `NGDG` that same day and was taken over by `NGDU` on 12 August, with others flipping between the two several times a day across 12–15 August. `NGDU`'s first order is 2 August; it holds 94 refunds dated before it existed as a trading record. Unscoped refund keys meant whichever import ran last took ownership — the data fault above, seen from the other end. Across nine records, **659 refunds worth $15,225.24 sit on a record dated before its own first order.** `NG4S` and `NGPS` are not among the ten shared locations, so this is wider than the duplicate records. **A fix for this was written, measured, and removed.** Booking a return equal to the day's refunds whenever the client recorded no sales closes 156 days and $4,820.19, is incapable of touching a trading day, and breaks nothing. It came out anyway: an unbalanced day is the only visible signal that a restaurant's sales are not being imported, and closing it would remove the alarm and leave the fire. What remains is a comment explaining why and **a test asserting the day stays out of balance**, so the next person to notice the arithmetic finds the reasoning first. Step 9 of the rollout plan is the real fix and needs a business decision: either import the missing sales, or move the refunds to the record that has them. ## Supporting changes | Change | Why | |---|---| | 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 | an out-of-balance day was only visible by opening the screen | | Bound the dirty-summary scan to one client | 1,321 ms → 5.6 ms per client | | `compare-sales-summaries` moved to `test/clj` as `auto-ap.tools.*` | a verification harness, not part of the running application — nothing in production should be able to depend on it | ## One correction worth reading `d/as-of` **cannot** be used to compare summary amounts. `:ledger-mapped/amount`, `ledger-side` and `account` are all `:db/noHistory`, so superseded values are discarded — a recomputed summary reads back through `as-of` with 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 - **Which client record survives at each shared location** (business). The newer record usually has no history from before the split, so keeping it loses years of that location's books. - **Which account service charges post to** (accounting). Currently 49000 Service Income, chosen so the work could be measured; it affects reporting, never whether a day balances. - **659 refunds on records with no sales for them** (business, then engineering) — the top open item, $15,225.24. Step 9. ## What this does not fix 279 client-days over ninety days, $7,790.54 — but only 108 of them are arithmetic: - **156 days / $4,988.53** — sales never imported (above). Left visible on purpose. - **15 days / $974.99** — a processing fee landing on a day with no trading: the same shape from the payout side, so it needs the payout modelled rather than a rule in the summary. - **108 days / $1,995.36** — real trading days with genuine discrepancies: the NGBR/NGBK tender gap ($6,358.99 of tender against $6,059.57 of order totals on one day), the ezCater fee question, and unexplained clusters on NGMV and NGEB. ## Testing `lein test auto-ap.jobs.sales-summaries-test auto-ap.square.core3-test auto-ap.jobs.rekey-square-external-ids-test` — **26 tests, 62 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](https://claude.com/claude-code)
notid added 4 commits 2026-08-15 20:18:56 -07:00
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>
Three faults were leaving restaurant days out of balance — one in the
data, two in the arithmetic — plus a fourth that turned out to be a
missing-data problem and is deliberately left visible. 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 279 days and $7,790.54 — of which 171 are
not arithmetic faults at all, but days whose sales were never imported.

979 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.

The flag is 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.

WHAT IS DELIBERATELY NOT FIXED

156 of the 279 remaining days carry refunds on a record that recorded no
sales at all that day, and 132 of those fall before that client's first
ever order. The refunds are not theirs: ownership history shows a $35.35
refund dated 26 February belonging to NGDG that day and taken over by
NGDU on 12 August, flipping between the two several times a day. Across
nine records, 659 refunds worth $15,225.24 sit on a record dated before
its own first order — unscoped keys let whichever import ran last take
ownership.

A rule closing those days was written and measured (156 days, $4,820.19,
nothing broken) and then removed. An unbalanced day is the only visible
signal that a restaurant's sales are not being imported; balancing it
would remove the alarm and leave the fire. A comment and a test hold that
decision in place. Step 9 of the rollout plan is the real fix, and it
needs a business decision.

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.

26 tests, 62 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Owner

Code review — 8 reviewer lenses + empirical verification

Verdict: Not ready. One confirmed P0 that doubles money under the rollout order this PR documents. Everything else is fixable in place; the migration logic itself held up well under attack.

I verified the load-bearing claims by executing them against Datomic (in-memory databases and the restored production copy) rather than by reading. Three things that looked like bugs turned out not to be, and are not reported below: {:db/id nil} is accepted by Datomic (treated as absent → tempid), the take-while boundary in dirty-sales-summaries is sound (false sorts before true in the tuple, verified), and upsert-cash-shifts' new 2-arity is behaviour-preserving.


P0 — Deploy window doubles tender for the second client on a shared location

src/clj/auto_ap/square/core3.clj:595-596 (payout path) and :305-306 (tender path)

Confirmed end to end against a real database using the actual functions. Sequence:

  1. Charge X exists under the legacy key square/charge/P, referenced by client B's order.
  2. Client A's payout import runs. existing-id resolves X via its legacy-key fallback and renames it to square/charge/AAA-LA-P — A renames a charge it does not own, and writes no :charge/client/:charge/location.
  3. Client B's order re-imports. existing-id now misses both schemes (scoped-B never existed; the legacy key was consumed in step 2), returns nil, and a second charge square/charge/BBB-LB-P is minted.
  4. :sales-order/charges is :db.cardinality/many and orders are transacted as plain maps (core3.clj:652), so nothing retracts the stale ref.

Result: order B holds two charge entities for one Square payment — $200 of tender for a $100 payment. get-payment-items and tendered-tip both sum them.

It then compounds. Running the migration on that state produces:

square/charge/BBB-LB-AAA-LA-P     <- double-scoped
square/charge/BBB-LB-P
tender total: 200.0 for a 100.0 payment

That double-scoped shape is exactly the failure the report describes as having already "doubled the tender on five clients by $3,000–$7,000 each". raw-square-id's owner-prefix guard cannot strip A's scope because step 2 left the charge with no :charge/client.

Why the 90-day validation missed it: that measurement ran after Phase 0 deactivated the duplicate locations — i.e. after the two-writer condition was removed. The rollout plan puts deploy (Step 1) before deactivation (Step 3), so it re-creates the very condition the validation had already eliminated. Ten client pairs are live in that window.

Suggested resolutions (either closes it):

  • Reorder the rollout: deactivate the duplicate client at each shared location before deploying the importer.
  • And/or stop the payout path renaming a charge whose :charge/client is a different client, and have the order import retract stale :sales-order/charges refs.

Found by: adversarial (P0) + correctness (P1), then reproduced empirically.


P1 — The go/no-go gate samples the oldest 2% of orders

docs/2026-08-15-sales-summary-rollout-plan.md:113

(rk/charges-with-multiple-parents (d/db conn) (take 400000 (rk/all-order-ids (d/db conn))))

all-order-ids streams :aevt, which is ascending entity id — so take 400000 is the oldest 400k of 19,040,785 orders. Measured on the restored copy, that sample spans 2019-12-31 → 2021-06-03. The shared-location contention and the entire analysis window are in 2026. This gate is systematically blind to the risk it exists to detect, and Step 7 re-enables remove-voided-orders — a :db/retractEntity cascade — on the strength of it.

This also corrects something I reported earlier in this branch's history: "0 shared across 400,000 orders checked" was measured on that wrong slice. I re-ran it on the newest 400,000 orders (2026-01-14 → 2026-08-18, covering the whole contention period): still 0. The conclusion holds; the evidence originally given for it did not.

Fix: drop the take, or label it explicitly as a progress sample and make the unsampled call the actual gate.

Found by: data-migration + adversarial (both P1), confirmed empirically.


P1 — The prescribed pre-flight collision check reads a key plan never returns

docs/2026-08-15-sales-summary-rollout-plan.md:91-92

The plan tells the operator to "check :collisions is empty for every attribute" and hands them (dissoc (rk/plan ...) :new-keys). But plan's accumulator is {:total :to-migrate :already-scoped :unscopable :new-keys} (rekey_square_external_ids.clj:84) — there is no :collisions key, and the command dissocs the only input collisions accepts. The check silently passes on every database.

Fix: (rk/collisions (:new-keys (rk/plan db attr prefix))).

Found by: adversarial (confidence 100).


P2 — Findings with multi-reviewer agreement

# Finding File Reviewers
1 Charge catch-all rekey skips the collisions guard the other three attributes get — on the largest, most collision-prone attribute (17M rows) rekey_square_external_ids.clj:303-304 vs :298-300 data-migration, maintainability, adversarial, correctness
2 No rollback path for the clone/repoint. 200,027 entities created and refs repointed; the only rollback documented is the feature flag, which covers the unrelated arithmetic change rekey_square_external_ids.clj:220-226; plan:172 data-migration
3 unscoped-report's docstring contradicts itself. It says "once every count reads zero" the legacy fallback can be removed, but :no-owner can never reach zero for charges (~283k payout stubs lack :charge/client by design). migrate-all!'s docstring correctly says the signal is :legacy = 0. The two disagree about the same "completeness gate" rekey_square_external_ids.clj:261-262 vs :292-293 data-migration
4 No in-loop progress logging in split-and-rekey-charges! — its sibling migrate! logs every 20 batches, yet the function that walks all 19M orders logs only on completion. A mid-run death leaves no trail of how far it got rekey_square_external_ids.clj:198 vs :106-109 reliability
5 New existing-id DB call sits outside the try/catch that protects the sibling payouts path. A transient failure aborts the whole client's import chain (and, since refunds run first, silently skips that client's cash-drawer shifts for the cycle) core3.clj:620, :739 vs :533-601 reliability
6 Concurrent nightly import can re-attach a charge the migration just detached — blind retract+repoint against a per-batch snapshot with no CAS, and the rollout never says to pause the importer during Step 4 rekey_square_external_ids.clj:225-226 adversarial
7 The report claims a cross-batch behaviour the code cannot produce (below) docs/…reconciliation.html:574-577 maintainability, correctness, verified

P2 — A documentation claim that is false, and provably harmful

docs/2026-08-15-sales-summary-reconciliation.html:574-577 states that for two orders of the same client sharing a payment, "run across nineteen million orders in batches of two thousand, such pairs almost always fall in different batches and the second order now takes a copy," and flags it as unmeasured risk to check before production.

I ran the real migration at batch-size 1 (forcing separate batches):

{:rekeyed 1, :cloned 0}   ->   one charge, both orders still on it

The charge is not cloned. Once batch 1's :keep writes :charge/client/:charge/location alongside the rename, batch 2's raw-square-id takes its owner branch, new-key reconstructs old, and the guard (not= old new-key) at rekey_square_external_ids.clj:174 drops the row before :action is ever read. Batch size is irrelevant for same client + location.

This is worth fixing beyond tidiness: the false claim manufactured a P1 in an independent reviewer, which cited the document as its evidence. It also creates a pre-production action item that would cost real time to chase. Two reviewers independently traced the code and reached the same conclusion I measured.


P3 / smaller

  • :cloned counts intent, not entities. The counter increments while building the tx (:206, :219), and two same-client clones in one batch collapse onto one string tempid — so the report's "the record count rose by exactly 200,027, which is the check that it created what it meant to" is a weaker check than stated. (correctness)
  • A flagged client with no account numbered 49000 gets :ledger-mapped/account nil on its Service Charges line, so accepted? never passes and mark-stale-dirty re-marks that day nightly, forever. Worth a pre-flight check in Step 6. (correctness)
  • split-and-rekey-charges! never updates :expected-deposit/charges, and deposit imports only add refs, never retract — a deposit may keep pointing at a charge reassigned to another client. (data-migration, unconfirmed)

Withdrawn after verification

Reported by a reviewer, then disproved — recording these so they don't get re-raised:

  • "The deliberate-non-fix test bypasses the pipeline it guards" (testing P1, adversarial P2). Both assumed the removed rule was a separate item cons'd into refresh-client!. It was not — git show f8ef7918 shows refund-only-returns wired inside get-returns via the or at line 449, which the test calls directly. The test does catch the documented reintroduction shape. Downgraded to a hardening suggestion: an end-to-end refresh-client! test would be strictly stronger.
  • "Same-client cross-batch cloning is an unverified behaviour change" (testing P1). Premise came from the false doc claim above. Real residue: no test covers the cross-batch same-client case — worth adding, since the behaviour is now pinned only by a trace and my one-off run.

Coverage

  • Reviewers run: correctness, adversarial (in-process), data-migration, reliability, testing, performance, maintainability, project-standards.
  • Adversarial lens: ran in-process, not cross-model — no sanctioned peer CLI is available on this host (only aider, which is not a review route). Its findings therefore carry no independent-model corroboration; the P0 above earns its confidence from reproduction, not from agreement.
  • Project standards: AGENTS.md covers only Gitea/PR workflow, so it has no bearing on this diff. The new tests follow the project's testing conventions (:refer imports, wrap-setup, DB-state assertions) — no violations.
  • Performance: no findings. The two extra per-client-day queries (untendered-tip, service-charge-total) follow the file's existing per-item pattern and are narrow index-range scans.
  • Scope: local-aligned against 943bc188. tmp/restore_checks.clj is untracked and excluded.
  • Not covered: no cross-model peer; the migration was not exercised under concurrent import load; migrate-all! has no end-to-end test.

Biggest test gaps

  1. Two clients on one location, order re-imported after the other re-keyed the shared charge — assert the order ends with exactly one :sales-order/charges ref. (This would have caught the P0.)
  2. migrate-all! end to end: pass ordering, the collision gate, idempotence of a second full run.
  3. Cross-batch same-client sharing at batch-size 1.
  4. A genuine key collision, asserting migrate-all! throws as documented.

🤖 review generated with Claude Code

## Code review — 8 reviewer lenses + empirical verification **Verdict: Not ready.** One confirmed P0 that doubles money under the rollout order this PR documents. Everything else is fixable in place; the migration logic itself held up well under attack. I verified the load-bearing claims by executing them against Datomic (in-memory databases and the restored production copy) rather than by reading. Three things that *looked* like bugs turned out not to be, and are **not** reported below: `{:db/id nil}` is accepted by Datomic (treated as absent → tempid), the `take-while` boundary in `dirty-sales-summaries` is sound (`false` sorts before `true` in the tuple, verified), and `upsert-cash-shifts`' new 2-arity is behaviour-preserving. --- ### P0 — Deploy window doubles tender for the second client on a shared location `src/clj/auto_ap/square/core3.clj:595-596` (payout path) and `:305-306` (tender path) **Confirmed end to end against a real database using the actual functions.** Sequence: 1. Charge `X` exists under the legacy key `square/charge/P`, referenced by **client B's** order. 2. **Client A's** payout import runs. `existing-id` resolves `X` via its legacy-key fallback and renames it to `square/charge/AAA-LA-P` — A renames a charge it does not own, and writes no `:charge/client`/`:charge/location`. 3. **Client B's** order re-imports. `existing-id` now misses both schemes (scoped-B never existed; the legacy key was consumed in step 2), returns nil, and a second charge `square/charge/BBB-LB-P` is minted. 4. `:sales-order/charges` is `:db.cardinality/many` and orders are transacted as plain maps (`core3.clj:652`), so nothing retracts the stale ref. Result: **order B holds two charge entities for one Square payment — $200 of tender for a $100 payment.** `get-payment-items` and `tendered-tip` both sum them. It then compounds. Running the migration on that state produces: ``` square/charge/BBB-LB-AAA-LA-P <- double-scoped square/charge/BBB-LB-P tender total: 200.0 for a 100.0 payment ``` That double-scoped shape is exactly the failure the report describes as having already "doubled the tender on five clients by $3,000–$7,000 each". `raw-square-id`'s owner-prefix guard cannot strip A's scope because step 2 left the charge with no `:charge/client`. **Why the 90-day validation missed it:** that measurement ran *after* Phase 0 deactivated the duplicate locations — i.e. after the two-writer condition was removed. The rollout plan puts **deploy (Step 1) before deactivation (Step 3)**, so it re-creates the very condition the validation had already eliminated. Ten client pairs are live in that window. **Suggested resolutions** (either closes it): - Reorder the rollout: deactivate the duplicate client at each shared location *before* deploying the importer. - And/or stop the payout path renaming a charge whose `:charge/client` is a different client, and have the order import retract stale `:sales-order/charges` refs. *Found by: adversarial (P0) + correctness (P1), then reproduced empirically.* --- ### P1 — The go/no-go gate samples the oldest 2% of orders `docs/2026-08-15-sales-summary-rollout-plan.md:113` ```clojure (rk/charges-with-multiple-parents (d/db conn) (take 400000 (rk/all-order-ids (d/db conn)))) ``` `all-order-ids` streams `:aevt`, which is ascending entity id — so `take 400000` is the **oldest** 400k of 19,040,785 orders. Measured on the restored copy, that sample spans **2019-12-31 → 2021-06-03**. The shared-location contention and the entire analysis window are in 2026. This gate is systematically blind to the risk it exists to detect, and Step 7 re-enables `remove-voided-orders` — a `:db/retractEntity` cascade — on the strength of it. This also corrects something I reported earlier in this branch's history: "0 shared across 400,000 orders checked" was measured on that wrong slice. I re-ran it on the **newest** 400,000 orders (2026-01-14 → 2026-08-18, covering the whole contention period): still **0**. The conclusion holds; the evidence originally given for it did not. Fix: drop the `take`, or label it explicitly as a progress sample and make the unsampled call the actual gate. *Found by: data-migration + adversarial (both P1), confirmed empirically.* --- ### P1 — The prescribed pre-flight collision check reads a key `plan` never returns `docs/2026-08-15-sales-summary-rollout-plan.md:91-92` The plan tells the operator to "check `:collisions` is empty for every attribute" and hands them `(dissoc (rk/plan ...) :new-keys)`. But `plan`'s accumulator is `{:total :to-migrate :already-scoped :unscopable :new-keys}` (`rekey_square_external_ids.clj:84`) — there is no `:collisions` key, and the command `dissoc`s the only input `collisions` accepts. **The check silently passes on every database.** Fix: `(rk/collisions (:new-keys (rk/plan db attr prefix)))`. *Found by: adversarial (confidence 100).* --- ### P2 — Findings with multi-reviewer agreement | # | Finding | File | Reviewers | |---|---------|------|-----------| | 1 | **Charge catch-all rekey skips the `collisions` guard** the other three attributes get — on the largest, most collision-prone attribute (17M rows) | `rekey_square_external_ids.clj:303-304` vs `:298-300` | data-migration, maintainability, adversarial, correctness | | 2 | **No rollback path for the clone/repoint.** 200,027 entities created and refs repointed; the only rollback documented is the feature flag, which covers the unrelated arithmetic change | `rekey_square_external_ids.clj:220-226`; plan:172 | data-migration | | 3 | **`unscoped-report`'s docstring contradicts itself.** It says "once every count reads zero" the legacy fallback can be removed, but `:no-owner` can never reach zero for charges (~283k payout stubs lack `:charge/client` by design). `migrate-all!`'s docstring correctly says the signal is `:legacy = 0`. The two disagree about the same "completeness gate" | `rekey_square_external_ids.clj:261-262` vs `:292-293` | data-migration | | 4 | **No in-loop progress logging** in `split-and-rekey-charges!` — its sibling `migrate!` logs every 20 batches, yet the function that walks all 19M orders logs only on completion. A mid-run death leaves no trail of how far it got | `rekey_square_external_ids.clj:198` vs `:106-109` | reliability | | 5 | **New `existing-id` DB call sits outside the try/catch** that protects the sibling payouts path. A transient failure aborts the whole client's import chain (and, since refunds run first, silently skips that client's cash-drawer shifts for the cycle) | `core3.clj:620`, `:739` vs `:533-601` | reliability | | 6 | **Concurrent nightly import can re-attach a charge the migration just detached** — blind retract+repoint against a per-batch snapshot with no CAS, and the rollout never says to pause the importer during Step 4 | `rekey_square_external_ids.clj:225-226` | adversarial | | 7 | **The report claims a cross-batch behaviour the code cannot produce** (below) | `docs/…reconciliation.html:574-577` | maintainability, correctness, verified | --- ### P2 — A documentation claim that is false, and provably harmful `docs/2026-08-15-sales-summary-reconciliation.html:574-577` states that for two orders of the *same* client sharing a payment, "run across nineteen million orders in batches of two thousand, such pairs almost always fall in different batches and the second order now takes a copy," and flags it as unmeasured risk to check before production. I ran the real migration at batch-size 1 (forcing separate batches): ``` {:rekeyed 1, :cloned 0} -> one charge, both orders still on it ``` The charge is **not** cloned. Once batch 1's `:keep` writes `:charge/client`/`:charge/location` alongside the rename, batch 2's `raw-square-id` takes its owner branch, `new-key` reconstructs `old`, and the guard `(not= old new-key)` at `rekey_square_external_ids.clj:174` drops the row before `:action` is ever read. Batch size is irrelevant for same client + location. This is worth fixing beyond tidiness: **the false claim manufactured a P1 in an independent reviewer**, which cited the document as its evidence. It also creates a pre-production action item that would cost real time to chase. Two reviewers independently traced the code and reached the same conclusion I measured. --- ### P3 / smaller - **`:cloned` counts intent, not entities.** The counter increments while building the tx (`:206`, `:219`), and two same-client clones in one batch collapse onto one string tempid — so the report's "the record count rose by exactly 200,027, which is the check that it created what it meant to" is a weaker check than stated. *(correctness)* - **A flagged client with no account numbered 49000** gets `:ledger-mapped/account nil` on its Service Charges line, so `accepted?` never passes and `mark-stale-dirty` re-marks that day nightly, forever. Worth a pre-flight check in Step 6. *(correctness)* - `split-and-rekey-charges!` never updates `:expected-deposit/charges`, and deposit imports only add refs, never retract — a deposit may keep pointing at a charge reassigned to another client. *(data-migration, unconfirmed)* --- ### Withdrawn after verification Reported by a reviewer, then disproved — recording these so they don't get re-raised: - **"The deliberate-non-fix test bypasses the pipeline it guards"** (testing P1, adversarial P2). Both assumed the removed rule was a separate item `cons`'d into `refresh-client!`. It was not — `git show f8ef7918` shows `refund-only-returns` wired *inside* `get-returns` via the `or` at line 449, which the test calls directly. The test does catch the documented reintroduction shape. Downgraded to a hardening suggestion: an end-to-end `refresh-client!` test would be strictly stronger. - **"Same-client cross-batch cloning is an unverified behaviour change"** (testing P1). Premise came from the false doc claim above. Real residue: no test covers the cross-batch same-client case — worth adding, since the behaviour is now pinned only by a trace and my one-off run. --- ### Coverage - **Reviewers run:** correctness, adversarial (in-process), data-migration, reliability, testing, performance, maintainability, project-standards. - **Adversarial lens:** ran **in-process, not cross-model** — no sanctioned peer CLI is available on this host (only `aider`, which is not a review route). Its findings therefore carry no independent-model corroboration; the P0 above earns its confidence from reproduction, not from agreement. - **Project standards:** `AGENTS.md` covers only Gitea/PR workflow, so it has no bearing on this diff. The new tests follow the project's testing conventions (`:refer` imports, `wrap-setup`, DB-state assertions) — no violations. - **Performance:** no findings. The two extra per-client-day queries (`untendered-tip`, `service-charge-total`) follow the file's existing per-item pattern and are narrow `index-range` scans. - **Scope:** local-aligned against `943bc188`. `tmp/restore_checks.clj` is untracked and excluded. - **Not covered:** no cross-model peer; the migration was not exercised under concurrent import load; `migrate-all!` has no end-to-end test. ### Biggest test gaps 1. Two clients on one location, order re-imported after the other re-keyed the shared charge — assert the order ends with **exactly one** `:sales-order/charges` ref. (This would have caught the P0.) 2. `migrate-all!` end to end: pass ordering, the collision gate, idempotence of a second full run. 3. Cross-batch same-client sharing at batch-size 1. 4. A genuine key collision, asserting `migrate-all!` throws as documented. 🤖 review generated with [Claude Code](https://claude.com/claude-code)
notid added 1 commit 2026-08-16 08:10:01 -07:00
Two clients on one Square location double a day's tender in the window
between deploying and finishing the migration. Reproduced end to end:

  1. charge X carries the legacy key square/charge/P and belongs to
     client B's order
  2. client A's payout import resolves X through existing-id's legacy
     fallback and renames it into A's scope
  3. client B's next order import matches neither scheme, so it mints a
     second charge
  4. :sales-order/charges is cardinality-many and orders transact as
     plain maps, so nothing retracts the first

B's order ends up holding two charges for one payment — $200 of tender
for a $100 payment — and running the migration on that state produces
square/charge/BBB-LB-AAA-LA-P, the same double-scoped shape that already
doubled tender on five clients once during this work.

existing-id's legacy branch now declines any record already owned by a
different client, reading the owner attribute and, for charges that
predate :charge/client, the client of the referencing order. Declining is
also correct on its merits: the write then lands on this client's own
copy, which is what the scoped keys exist to create. The payout path also
writes :charge/client/:charge/location alongside the key, so a charge's
scope and its owner can no longer disagree.

The guard is transitional and gets deleted with the legacy branch it
protects, at rollout step 9.

Rollout resequenced for the decision to leave duplicate client records
active: no deactivation, no "which record survives" call, and the risk
window closed by pausing the importer across deploy + migrate rather than
by removing one of the two writers. Both records converge to independent
stable histories once every key carries its owner.

Also from review:

- migrate-all! now collision-checks the charge pass like the other three
  attributes instead of discovering a clash mid-run over 17M rows
- split-and-rekey-charges! logs progress every 200 batches; an
  interrupted 19M-order run left no trail
- unscoped-report's docstring no longer promises a zero its :no-owner
  column cannot reach; plan is named as the authoritative signal
- the rollout's pre-flight asked for a :collisions key plan never
  returns, so it silently passed on every database
- the multi-parent gate sampled (take 400000 (all-order-ids db)), which
  streams :aevt — ascending entity id — and so read the OLDEST 2% of
  orders: 2019-12-31 to 2021-06-03, before any of the contention it
  looks for. Now every order of the last year via the client+date index,
  5,159,787 on the restored copy, reading 0
- the report claimed same-client pairs get copied once batches split
  them. They do not, at any batch size; verified at batch-size 1 and now
  pinned by a test

30 tests, 72 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Owner

Review feedback applied — 10d0d01b

Design decision that supersedes part of the review above: duplicate client records stay active. Client config is left as-is and the re-key is what resolves the pairs — each record ends up with an independent, stable history under its own keys. So "retire the duplicate client records" is gone from the plan entirely, along with the "which record survives" business decision.

That changes what the P0 fix has to be. Deactivation is no longer available as the thing that ends the two-writer condition, so the exposure is narrowed to what it actually is: the interval between deploying and finishing the migration, while legacy keys still exist for a client to resolve.

P0 — fixed and proven

existing-id's legacy branch now declines any record already owned by a different client, reading the owner attribute and — for charges predating :charge/client — the client of the referencing order. The payout path also writes :charge/client/:charge/location alongside the key, so a charge's scope and its owner can no longer disagree.

Re-ran the exact reproduction:

before after
charges on B's order 2 1
tender for a $100 payment $200.00 $100.00
after migration BBB-LB-AAA-LA-P (double-scoped) BBB-LB-P, migration a no-op

Pinned by deploy-window-does-not-double-a-second-clients-tender.

Declining is also right on the merits — the write then lands on this client's own copy, which is what the scoped keys exist to create. The guard is transitional and gets deleted together with the legacy branch at step 9, which is now a scheduled step rather than "no hurry": with both records permanently live, that branch is the only code path that can ever cross a client boundary.

Rollout resequenced

Imports are paused across deploy + migrate, which closes the window rather than narrowing it — the migration is ~13 minutes, so the maintenance window is short.

1 Guard remove-voided-orders
2 Pause the Square importer
3 Deploy
4 Run the migration + verify
5 Resume the importer (with the charge count added to the doubling check)
6–7 Recompute → flags on
8 Re-enable remove-voided-orders
9 Remove the legacy lookup — now scheduled
10 The refunds with no sales behind them

Also fixed

  • The :collisions pre-flight asked for a key plan never returns, so it passed on every database. Now (rk/collisions (:new-keys (rk/plan ...))), and migrate-all! collision-checks the charge pass too instead of discovering a clash mid-run over 17M rows.
  • The multi-parent gate. Confirmed your reviewer's read empirically: (take 400000 (all-order-ids db)) streams :aevt, so it read the oldest 2% — 2019-12-31 → 2021-06-03, entirely before the contention. Replaced with every order of the last year via the client+date index (5,159,787 on the restored copy, reads 0), scoped that way deliberately because remove-voided-orders only deletes recent orders. Completeness across all 17M is plan's :to-migrate 0, and the doc now says which check does which job.
  • unscoped-report's docstring no longer promises a zero its :no-owner column cannot reach.
  • Progress logging every 200 batches in split-and-rekey-charges!.
  • The false cross-batch claim in the report, corrected with the mechanism and a note that it manufactured a defect report. Pinned by a batch-size-1 test.

Deliberately not done

  • Rollback tooling for the clone/repoint. Documented expectation instead: restore from the pre-migration backup. Building a bespoke reversal is more risk than it retires.
  • try/catch around the per-record import fns. Real asymmetry, but wrapping it silently skips records — missing money reads worse than a failed cycle that retries.
  • The two withdrawn findings stay withdrawn; git show f8ef7918 confirms the removed rule was inside get-returns.

Unchanged: the 659 refunds sitting on a record with no sales behind them (step 10). Leaving config as-is fixes the future, not the past.

30 tests, 72 assertions, 0 failures.

🤖 Claude Code

## Review feedback applied — `10d0d01b` Design decision that supersedes part of the review above: **duplicate client records stay active.** Client config is left as-is and the re-key is what resolves the pairs — each record ends up with an independent, stable history under its own keys. So "retire the duplicate client records" is gone from the plan entirely, along with the "which record survives" business decision. That changes what the P0 fix has to be. Deactivation is no longer available as the thing that ends the two-writer condition, so the exposure is narrowed to what it actually is: **the interval between deploying and finishing the migration, while legacy keys still exist for a client to resolve.** ### P0 — fixed and proven `existing-id`'s legacy branch now declines any record already owned by a different client, reading the owner attribute and — for charges predating `:charge/client` — the client of the referencing order. The payout path also writes `:charge/client`/`:charge/location` alongside the key, so a charge's scope and its owner can no longer disagree. Re-ran the exact reproduction: | | before | after | |---|---|---| | charges on B's order | 2 | **1** | | tender for a $100 payment | $200.00 | **$100.00** | | after migration | `BBB-LB-AAA-LA-P` (double-scoped) | `BBB-LB-P`, migration a no-op | Pinned by `deploy-window-does-not-double-a-second-clients-tender`. Declining is also right on the merits — the write then lands on this client's own copy, which is what the scoped keys exist to create. The guard is transitional and gets deleted together with the legacy branch at step 9, which is now a **scheduled** step rather than "no hurry": with both records permanently live, that branch is the only code path that can ever cross a client boundary. ### Rollout resequenced Imports are paused across deploy + migrate, which closes the window rather than narrowing it — the migration is ~13 minutes, so the maintenance window is short. | | | |---|---| | 1 | Guard `remove-voided-orders` | | 2 | **Pause the Square importer** | | 3 | Deploy | | 4 | Run the migration + verify | | 5 | **Resume the importer** (with the charge count added to the doubling check) | | 6–7 | Recompute → flags on | | 8 | Re-enable `remove-voided-orders` | | 9 | **Remove the legacy lookup** — now scheduled | | 10 | The refunds with no sales behind them | ### Also fixed - **The `:collisions` pre-flight** asked for a key `plan` never returns, so it passed on every database. Now `(rk/collisions (:new-keys (rk/plan ...)))`, and `migrate-all!` collision-checks the charge pass too instead of discovering a clash mid-run over 17M rows. - **The multi-parent gate.** Confirmed your reviewer's read empirically: `(take 400000 (all-order-ids db))` streams `:aevt`, so it read the **oldest** 2% — 2019-12-31 → 2021-06-03, entirely before the contention. Replaced with every order of the last year via the `client+date` index (5,159,787 on the restored copy, reads 0), scoped that way deliberately because `remove-voided-orders` only deletes recent orders. Completeness across all 17M is `plan`'s `:to-migrate 0`, and the doc now says which check does which job. - **`unscoped-report`'s docstring** no longer promises a zero its `:no-owner` column cannot reach. - **Progress logging** every 200 batches in `split-and-rekey-charges!`. - **The false cross-batch claim** in the report, corrected with the mechanism and a note that it manufactured a defect report. Pinned by a batch-size-1 test. ### Deliberately not done - **Rollback tooling for the clone/repoint.** Documented expectation instead: restore from the pre-migration backup. Building a bespoke reversal is more risk than it retires. - **`try/catch` around the per-record import fns.** Real asymmetry, but wrapping it silently skips records — missing money reads worse than a failed cycle that retries. - **The two withdrawn findings** stay withdrawn; `git show f8ef7918` confirms the removed rule was inside `get-returns`. Unchanged: the 659 refunds sitting on a record with no sales behind them (step 10). Leaving config as-is fixes the future, not the past. **30 tests, 72 assertions, 0 failures.** 🤖 [Claude Code](https://claude.com/claude-code)
notid added 1 commit 2026-08-16 08:35:10 -07:00
migrate-all! drove the charge split from all-order-ids, which streams
:aevt — ascending entity id, so oldest first. On the production copy that
means the first several hours are spent on 2019 and 2020 data no import
will ever read, leaving the recent end (the part the importer actually
touches) for last. Interrupt it there and the data is unmigrated exactly
where it matters.

Now:

- refunds, payouts and cash-drawer shifts run first. Together they are
  ~266k records and take seconds, so an interruption cannot leave them
  half done.
- the long order walk then runs a month at a time from the current month
  backwards, logging ::month-complete with per-month counts.

Stop it after any month and everything from that month forward is fully
scoped, so imports can resume against a partially migrated database and
the older tail can be finished later — the re-run skips what is done.
While a tail remains unmigrated, existing-id's ownership guard is what
keeps it safe.

order-months-newest-first tiles [start end] windows with no gaps (each
month ends the day before the next begins) and is bounded below by a
constant comfortably older than the oldest order. Windows are walked via
the :sales-order/client+date index. Verified against the restored copy:
141 windows from 2026-08 back, August returning 241,126 orders and July
476,235, each dated inside its window.

all-order-ids keeps its old behaviour but its docstring now warns that a
prefix of it is the oldest orders, not a sample — the trap that made an
earlier verification gate read 2019-2021 data.

31 tests, 76 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
notid added 1 commit 2026-08-16 09:59:41 -07:00
The report's figures were taken with one client record of each shared
pair deactivated and a live Square import run afterwards. That is no
longer how this deploys — both records stay live and the re-key is what
separates them — so the numbers described a configuration that will not
exist.

Re-run from scratch: fresh restore of backup point 209608347 (verified
back to 16,545,495 charges and legacy keys before starting), the new
month-wise migration, then two full ninety-day recomputes.

  before  1,451 days out of balance / $81,023.96
  after     542 days out of balance / $20,239.00
            915 into balance, 6 out, 17,916 summaries untouched

Two corrections to claims that no longer hold:

- "zero days knocked out of balance" is now six. All six are the same
  shape — the Tip line falls by a round amount and the day breaks by
  exactly that — and all six are on shared-location records. Each is a
  tip reversed on one record whose refund went to its twin: the fix makes
  a hidden mis-attribution visible rather than causing one.
- the migration takes ~38 minutes, not ~13. The month-wise walk adds
  per-month index overhead, and the earlier figure predated it.

What did NOT move is the part that should not. For the 190 clients that
do not share a Square location the residue is 119 days and $1,730.61 in
both runs, with the same five restaurants accounting for it (NG4S, NGMV,
NGEB, NGPS, N-30012). The arithmetic fixes behave identically whatever is
done to the duplicates, which is a stronger check than either run alone.

$18,508.39 of the $20,239.00 — 91% — sits on the twenty shared-location
records. The report now states plainly what retiring the duplicates would
be worth (~$12,000 of variance across ~260 client-days per ninety days),
while noting the comparison is not perfectly isolated because the earlier
run also included a live import.

Migration re-measured: 16,236,839 re-keyed, 500,438 cloned, plan reports
{:total 17045933 :to-migrate 0 :already-scoped 17045933 :unscopable 0},
and the multi-parent gate reads 0 across all 5,158,470 orders of the last
year. Step 10's refunds re-measured too: 660 worth $15,237.02 dated
before their own record's first order, 140 of the 171 no-sales days
falling before it.

31 tests, 76 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
notid added 1 commit 2026-08-16 17:59:07 -07:00
Re-keying stops two client records on one Square location fighting over a
record, but it does not make their books equal. Sales orders have always
been keyed by client, so each record built its own order history from the
start. Refunds, payouts and cash-drawer shifts were not, so only ONE
record holds each of them — whichever imported it last. The migration
freezes that ownership rather than evening it out.

The record left without them shows returns from its own orders and no
refunds against them. NGBK held 158,535 orders and five refunds. On
2026-05-11 both NGBK and NGBR held the same 221 orders; NGBK had no
refunds, NGBR had two worth $2,232.29, and NGBK was out by $2,232.29 to
the cent.

Rather than manufacture copies, ask Square again. Client-scoped keys mean
each record now creates its own copy of whatever it reads, so replaying a
window converges the two histories with no code inventing a duplicate.
`backfill-history` does that for a date range across orders, payouts,
refunds and shifts. After it, all ten pairs held matching order and
refund counts.

Fixes a capped read found by doing this: the refunds import asked Square
for a location's refunds and read only the first page — no cursor, no
date range. Square pages at a hundred, so a location with more refunds
silently returned a hundred and the response looked complete. That is why
each twin held almost exactly 100 refunds and why an earlier import added
exactly 1,000 across ten locations. `refunds`/`upsert-refunds` now follow
the cursor and accept a window.

Re-measured from the same fresh restore, duplicates left active:

  before  1,191 days out of balance / $70,276.50
  after     122 days out of balance / $2,379.45   (32 material)
            1,069 into balance, 0 out, 0 already-balanced days altered

The shared records went from 423 days / $18,508.39 to 3 days / $648.84 —
NGBK and NGBR at $299.42 each (the known tender-versus-order gap) and
NGDA at $50.00 (auto-gratuity as a service charge). The zero-regression
guarantee is restored too: the six days that broke without the backfill
were tips reversed on one record whose refund sat on the twin, and all
six closed once both sides had their own copy.

This beats retiring the duplicate records, which left 279 days and
$7,790.54, and it needs no decision about whose history to abandon.

Unchanged across every run: for the 190 clients that do not share a
location, 119 days and $1,730.61, same five restaurants.

Cost: 5.9 hours for ninety days across twenty records. Every Square call
shares one 25 req/s throttle, refunds and shifts cost one API call each,
and backfill-history imports three clients at a time. Reads are not the
limit — existing-id measures 32 microseconds.

31 tests, 76 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
notid added 1 commit 2026-08-16 19:04:54 -07:00
The headline and the new backfill step were current, but step 11 and
"what this will not fix" still carried pre-backfill figures. The backfill
did not just change the totals — it changed step 11's population.

Before it, nine records held refunds dated before their own first order:
660 refunds worth $15,237.02. Seven of those were shared-location twins
whose refunds only looked orphaned because their orders had never been
imported; replaying the window gave them their orders and the refunds
stopped predating them.

Two are left, and they are a different case — neither shares a Square
location, so there is no twin holding the other half:

  NG4S  first order 2026-05-29  79 refunds  $2,180.08  10 days
  NGPS  first order 2026-05-26  81 refunds  $2,167.60   7 days

Step 11 now recommends trying backfill-history on them first, with a
window reaching back before their first order, since that is exactly what
resolved the other seven.

"What this will not fix" re-measured: 122 days / $2,379.45, of which 106
are real trading days ($1,151.80) and 16 are refunds with no sales
imported ($1,227.65). Only 3 of the trading days are on shared-location
records, all already diagnosed. The other 103 days and $502.96 have been
identical in every run of this analysis — deactivated, live, and
backfilled — and are the floor this work reaches.

Also adds the backfill's ~5.9 hour runtime to the up-front table, and
notes that "two entities per Square object" holds automatically for new
imports but needs step 5 for existing history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin sales-summary-balancing-v2:sales-summary-balancing-v2
git checkout sales-summary-balancing-v2
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: notid/integreat#18