fix(square): do not resolve a record that belongs to another client
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>
This commit is contained in:
@@ -230,6 +230,17 @@
|
|||||||
shifts</strong> — the same figures as at the restore point. Refunds went from 50,986 to 51,986,
|
shifts</strong> — the same figures as at the restore point. Refunds went from 50,986 to 51,986,
|
||||||
and all 1,000 of those came from the live Square import run afterwards, not from the renaming.
|
and all 1,000 of those came from the live Square import run afterwards, not from the renaming.
|
||||||
Had the fallback lookup been missing, each of these would have doubled instead.</p>
|
Had the fallback lookup been missing, each of these would have doubled instead.</p>
|
||||||
|
<p><strong>The fallback also has to refuse.</strong> Reading the old name is what stops
|
||||||
|
duplicates; reading <em>anyone's</em> old name is what creates them. Two clients share a Square
|
||||||
|
location, so client A's payout import can resolve a payment that belongs to client B's order,
|
||||||
|
rename it into A's scope, and leave B's next import matching neither name — at which point B
|
||||||
|
mints a second payment and, because an order's payments are a set that is added to rather than
|
||||||
|
replaced, B's order ends up holding both. That is a doubled day's tender, and it was
|
||||||
|
reproduced end to end before being fixed. The lookup now declines any record already owned by
|
||||||
|
a different client, which is also the right answer on its merits: the write then lands on this
|
||||||
|
client's own copy, which is what the scoped names exist to create.</p>
|
||||||
|
<p>This is transitional. Once no legacy names remain, the fallback and the refusal are deleted
|
||||||
|
together and the guarantee stops depending on either.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>3 · Give every order its own payment record</h3>
|
<h3>3 · Give every order its own payment record</h3>
|
||||||
@@ -250,7 +261,7 @@
|
|||||||
<div class="measure">
|
<div class="measure">
|
||||||
<p>Run over the whole database that was <strong>9,100,314 renamed and 200,027 copied</strong>,
|
<p>Run over the whole database that was <strong>9,100,314 renamed and 200,027 copied</strong>,
|
||||||
and payments owned by two orders went from 11,469 in a 20,000-order sample to zero across
|
and payments owned by two orders went from 11,469 in a 20,000-order sample to zero across
|
||||||
400,000 orders checked. The record count rose by exactly 200,027 — the number of copies it
|
every order of the last year. The record count rose by about 200,027 — the number of copies it
|
||||||
reported making, which is the check that it created what it meant to and nothing else.</p>
|
reported making, which is the check that it created what it meant to and nothing else.</p>
|
||||||
<p>One subtlety worth recording, because it bit us: the Square id has to be recovered from the
|
<p>One subtlety worth recording, because it bit us: the Square id has to be recovered from the
|
||||||
record's current owner rather than by trimming a fixed prefix. Client codes contain dashes —
|
record's current owner rather than by trimming a fixed prefix. Client codes contain dashes —
|
||||||
@@ -454,10 +465,9 @@
|
|||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Step</th><th>Result</th></tr></thead>
|
<thead><tr><th>Step</th><th>Result</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td>Deactivate the duplicate client at each shared location</td><td class="n">10 locations · shared locations remaining: <span class="good">0</span></td></tr>
|
|
||||||
<tr><td>Walk every order in the database</td><td class="n">19,040,785 orders</td></tr>
|
<tr><td>Walk every order in the database</td><td class="n">19,040,785 orders</td></tr>
|
||||||
<tr><td>Give every order its own payment record</td><td class="n">9,100,314 re-keyed · 200,027 copied</td></tr>
|
<tr><td>Give every order its own payment record</td><td class="n">9,100,314 re-keyed · 200,027 copied</td></tr>
|
||||||
<tr><td><strong>Payments owned by two orders</strong></td><td class="n good">0 <span class="dim">across 400,000 orders checked</span></td></tr>
|
<tr><td><strong>Payments owned by two orders</strong></td><td class="n good">0 <span class="dim">across every order of the last year — 5,159,787</span></td></tr>
|
||||||
<tr><td>Client-scope refunds, payouts and cash-drawer shifts</td><td class="n good">counts unchanged · 0 collisions</td></tr>
|
<tr><td>Client-scope refunds, payouts and cash-drawer shifts</td><td class="n good">counts unchanged · 0 collisions</td></tr>
|
||||||
<tr><td>Live Square import afterwards</td><td class="n good">0 orders with duplicated payment · 0 shared payments</td></tr>
|
<tr><td>Live Square import afterwards</td><td class="n good">0 orders with duplicated payment · 0 shared payments</td></tr>
|
||||||
<tr><td>Ownership changes after the change</td><td class="n good">0 refunds · 0 payouts · 0 shifts</td></tr>
|
<tr><td>Ownership changes after the change</td><td class="n good">0 refunds · 0 payouts · 0 shifts</td></tr>
|
||||||
@@ -465,7 +475,8 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="measure">
|
<div class="measure">
|
||||||
<p>The count checks are the ones that matter. If re-keying had gone wrong it would have created a second copy of every record rather than updating the existing one, and the totals would have doubled. They did not move. The payment-copy step is the exception and is meant to add records — it added exactly 200,027, matching the number of copies it reported making.</p>
|
<p>The count checks are the ones that matter. If re-keying had gone wrong it would have created a second copy of every record rather than updating the existing one, and the totals would have doubled. They did not move. The payment-copy step is the exception and is meant to add records — it added exactly 200,027, close to the number of copies it reported making. (Close, not exact: the counter increments while the transaction is being assembled, so two copies that resolve onto one entity are counted twice. It is a good check, not a proof.)</p>
|
||||||
|
<p><strong>The measurement above was taken with the duplicate client records deactivated, and that is not how this will be deployed.</strong> Leaving both records live is the intended configuration — the re-key is what separates them — but it means the deactivation that made this measurement clean will not be there. The gap that opens is narrow and specific: while any record still carries a legacy key, a second client can resolve onto it. That is why the deployment runs the migration with imports paused, and why <code>existing-id</code> now refuses to resolve a record belonging to another client.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="callout">
|
<div class="callout">
|
||||||
@@ -568,15 +579,20 @@
|
|||||||
<p>The problem this work exists to solve is gone: no payment answers to two orders, so the
|
<p>The problem this work exists to solve is gone: no payment answers to two orders, so the
|
||||||
component relationship means what it says and deleting an order can no longer take another
|
component relationship means what it says and deleting an order can no longer take another
|
||||||
order's money with it.</p>
|
order's money with it.</p>
|
||||||
<p><strong>One behaviour changed when the pass was run over everything, and it is worth
|
<p><strong>Where Square splits one tender across two of a single client's own orders, the
|
||||||
recording.</strong> Where Square splits one tender across two of a single client's own orders,
|
payment stays shared — at any batch size.</strong> Both orders compute the same name, so there
|
||||||
the earlier design left the payment shared on purpose — both orders compute the same name, so
|
is no second name for a copy to take, and a copy would double that client's takings for the
|
||||||
there is no second name for a copy to take. That rule only holds for two orders processed in
|
day. The mechanism is worth stating precisely, because it is not obvious from reading: once
|
||||||
the same batch. Run across nineteen million orders in batches of two thousand, such pairs
|
the first order re-keys the payment it also writes the owner attributes in the same
|
||||||
almost always fall in different batches and the second order now takes a copy. Inside the
|
transaction, so a later order recovers the bare Square id from those, computes the name the
|
||||||
ninety-day window this changed nothing measurable: the recompute after the database-wide pass
|
payment already carries, and the guard <code class="mono">(not= old new-key)</code> drops the
|
||||||
matched the one before it to the cent. Outside the window it has not been measured, and it
|
row before any copy decision is reached. Verified by running the migration at a batch size of
|
||||||
should be before this runs against production.</p>
|
one, which forces the two orders into separate batches: no copy is made.</p>
|
||||||
|
<p class="dim">An earlier draft of this report claimed the opposite — that such pairs would be
|
||||||
|
copied once the batches split them — and flagged it as unmeasured risk to check before
|
||||||
|
production. That was wrong, and it is recorded here rather than quietly deleted because it did
|
||||||
|
real damage: an independent reviewer cited this paragraph as evidence and raised a defect that
|
||||||
|
does not exist. A test now pins the behaviour at batch size one.</p>
|
||||||
<p>The guard on <code>remove-voided-orders</code> is still worth having regardless. It is
|
<p>The guard on <code>remove-voided-orders</code> is still worth having regardless. It is
|
||||||
cheap, and it makes the safety a property of the deletion rather than of the migration having
|
cheap, and it makes the safety a property of the deletion rather than of the migration having
|
||||||
been run first.</p>
|
been run first.</p>
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ Measured on a restored copy of production (backup point `209608347`), 210 client
|
|||||||
with zero days knocked out of balance and zero already-balanced days altered.
|
with zero days knocked out of balance and zero already-balanced days altered.
|
||||||
|
|
||||||
Of the 279 days left, **171 are not balancing faults** — they are days where a client's sales were
|
Of the 279 days left, **171 are not balancing faults** — they are days where a client's sales were
|
||||||
never imported while its refunds were. Step 9 is about those, and it is the most important thing in
|
never imported while its refunds were. Step 10 is about those, and it is the most important
|
||||||
this document.
|
thing in this document.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -23,16 +23,54 @@ this document.
|
|||||||
| Expected migration runtime | ~13 minutes for 19M orders on a warm cache |
|
| Expected migration runtime | ~13 minutes for 19M orders on a warm cache |
|
||||||
| Nothing here touches | invoices, payments, the ledger, or any client without the flag set |
|
| Nothing here touches | invoices, payments, the ledger, or any client without the flag set |
|
||||||
|
|
||||||
**One prerequisite that is not code.** Ten Square locations are configured against two client records
|
**Client configuration is left exactly as it is.** Ten Square locations are configured against two
|
||||||
each. Someone in the business has to decide which record survives at each. The newer record usually
|
client records each, and both stay active. The re-key is what resolves them: once every record
|
||||||
has no history from before the split, so keeping it loses years of that location's books. Do this
|
carries its owner in its key, each client's import resolves only its own records and the two
|
||||||
before step 3.
|
records keep independent, stable histories. No "which record survives" decision is needed, and
|
||||||
|
nothing is deactivated.
|
||||||
|
|
||||||
|
The consequence to be aware of: each Square payment, refund, payout and shift at a shared location
|
||||||
|
becomes **two entities, one per client record** — by design. That is the stable end state, not a
|
||||||
|
duplicate to clean up. If any report or export aggregates across client records, one restaurant's
|
||||||
|
takings would be counted twice at that layer. Nothing in this work changes that either way.
|
||||||
|
|
||||||
|
**The only window of risk is between deploying and finishing the migration**, while legacy keys
|
||||||
|
still exist for a client to resolve. Steps 2–5 exist to make that window effectively zero.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 1 — Deploy the code
|
## Step 1 — Guard `remove-voided-orders`
|
||||||
|
|
||||||
Deploy the branch as normal. The flag is absent from every client, so:
|
Do this before the migration, not after. `:sales-order/charges` is `:db/isComponent true`, so
|
||||||
|
retracting an order cascades into its payments. Until step 4 finishes there are still payments with
|
||||||
|
two parent orders, and deleting one client's voided order can take the other client's payment with
|
||||||
|
it.
|
||||||
|
|
||||||
|
Either leave `remove-voided-orders` switched off until step 4 verifies clean, or change it to detach
|
||||||
|
a payment that has more than one parent rather than delete it. Detaching is worth doing regardless —
|
||||||
|
it makes the safety a property of the deletion rather than of the migration having been run first.
|
||||||
|
|
||||||
|
See `docs/2026-08-15-remove-voided-orders-risk.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2 — Pause the Square importer
|
||||||
|
|
||||||
|
**This is what makes the deploy safe, and it is easy to skip.** Steps 2 through 5 should be one
|
||||||
|
maintenance action, not separate days' work.
|
||||||
|
|
||||||
|
While legacy keys exist, `square.core3/existing-id` falls back to them — and at a shared location
|
||||||
|
that is the one code path that can reach across client records. Running the migration with imports
|
||||||
|
paused means no client is resolving keys while the keys are being rewritten, so the window closes
|
||||||
|
entirely rather than merely narrowing.
|
||||||
|
|
||||||
|
The migration itself takes about **13 minutes** for all 19M orders, so the pause is short.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3 — Deploy the code
|
||||||
|
|
||||||
|
Deploy the branch. The flag is absent from every client, so:
|
||||||
|
|
||||||
- tips are calculated exactly as they are today,
|
- tips are calculated exactly as they are today,
|
||||||
- no `Service Charges` line is written.
|
- no `Service Charges` line is written.
|
||||||
@@ -41,60 +79,33 @@ The only changes that take effect immediately are the safe ones: imbalance loggi
|
|||||||
dirty-summary scan bounded to one client (1,321 ms → 5.6 ms per client), the schema-ordering fix,
|
dirty-summary scan bounded to one client (1,321 ms → 5.6 ms per client), the schema-ordering fix,
|
||||||
and the importer's new client-scoped keys.
|
and the importer's new client-scoped keys.
|
||||||
|
|
||||||
**The importer starts writing client-scoped keys straight away, and reads both schemes.** That is
|
**The importer reads both key schemes**, so the deploy does not depend on the migration having
|
||||||
deliberate and is what makes the deploy independent of the migration. Do not remove the legacy
|
finished. Two protections cover the interval before it does: imports are paused (step 2), and
|
||||||
lookup in `square.core3/existing-id` yet — see step 8.
|
`existing-id` refuses to resolve a record that already belongs to a different client. Do not remove
|
||||||
|
the legacy lookup yet — see step 9.
|
||||||
**Verify before moving on.** After one nightly import cycle:
|
|
||||||
|
|
||||||
```clojure
|
|
||||||
;; refunds, payouts and shifts must not have doubled
|
|
||||||
(count (d/datoms (d/db conn) :aevt :sales-refund/external-id))
|
|
||||||
(count (d/datoms (d/db conn) :aevt :expected-deposit/external-id))
|
|
||||||
(count (d/datoms (d/db conn) :aevt :cash-drawer-shift/external-id))
|
|
||||||
```
|
|
||||||
|
|
||||||
Compare against the same counts taken immediately before deploy. Growth should be ordinary daily
|
|
||||||
volume. A near-doubling means the legacy fallback is not working — **stop and roll back the deploy**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 2 — Guard `remove-voided-orders`
|
|
||||||
|
|
||||||
Do this before the migration, not after. `:sales-order/charges` is `:db/isComponent true`, so
|
|
||||||
retracting an order cascades into its payments. Until step 3 finishes there are still payments with
|
|
||||||
two parent orders, and deleting one client's voided order can take the other client's payment with
|
|
||||||
it.
|
|
||||||
|
|
||||||
Either leave `remove-voided-orders` switched off until step 3 completes, or change it to detach a
|
|
||||||
payment that has more than one parent rather than delete it. Detaching is worth doing regardless —
|
|
||||||
it makes the safety a property of the deletion rather than of the migration having been run first.
|
|
||||||
|
|
||||||
See `docs/2026-08-15-remove-voided-orders-risk.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 3 — Retire the duplicate client records
|
|
||||||
|
|
||||||
Business decision from the top of this document. Deactivate the losing record's Square location so
|
|
||||||
the importer stops fetching for it. The record itself stays; its history is untouched.
|
|
||||||
|
|
||||||
**Verify:** no Square location is configured against two active client records.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 4 — Run the migration
|
## Step 4 — Run the migration
|
||||||
|
|
||||||
|
Run it immediately after the deploy, while imports are still paused.
|
||||||
|
|
||||||
```clojure
|
```clojure
|
||||||
(require '[auto-ap.jobs.rekey-square-external-ids :as rk])
|
(require '[auto-ap.jobs.rekey-square-external-ids :as rk])
|
||||||
|
|
||||||
;; read-only first — check :collisions is empty for every attribute
|
;; read-only first — no two entities may want the same key. `plan` does NOT return a
|
||||||
(dissoc (rk/plan (d/db conn) :charge/external-id rk/charge-prefix) :new-keys)
|
;; :collisions key; you have to hand its :new-keys to `collisions` yourself.
|
||||||
|
(rk/collisions (:new-keys (rk/plan (d/db conn) :charge/external-id rk/charge-prefix)))
|
||||||
|
;; => [] (anything else: stop, do not migrate)
|
||||||
|
|
||||||
;; then the whole thing
|
;; then the whole thing
|
||||||
(rk/migrate-all! 2000)
|
(rk/migrate-all! 2000)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`migrate-all!` runs this same check itself, on every attribute including charges, and throws
|
||||||
|
rather than transacting if it finds one. Running it by hand first just means finding out before
|
||||||
|
the 13-minute walk rather than partway through it.
|
||||||
|
|
||||||
Runs in about thirteen minutes over 19M orders. It is **idempotent and resumable** — a record that
|
Runs in about thirteen minutes over 19M orders. It is **idempotent and resumable** — a record that
|
||||||
already carries the right name is skipped, so it can be stopped and re-run without consequence.
|
already carries the right name is skipped, so it can be stopped and re-run without consequence.
|
||||||
|
|
||||||
@@ -102,25 +113,69 @@ If it appears to crawl, the cause is almost certainly garbage collection in the
|
|||||||
not the transactor. That misdiagnosis cost two days of projected runtime during this work. Free
|
not the transactor. That misdiagnosis cost two days of projected runtime during this work. Free
|
||||||
retained memory in the REPL and re-measure before changing anything about the database.
|
retained memory in the REPL and re-measure before changing anything about the database.
|
||||||
|
|
||||||
**Verify — all four must read zero to migrate and zero unscopable:**
|
**Verify.** Two checks, doing two different jobs — run both.
|
||||||
|
|
||||||
|
**(a) Completeness, across everything.** `plan` must report nothing left to do, for all four
|
||||||
|
attributes:
|
||||||
|
|
||||||
```clojure
|
```clojure
|
||||||
(rk/unscoped-report (d/db conn))
|
|
||||||
(dissoc (rk/plan (d/db conn) :charge/external-id rk/charge-prefix) :new-keys)
|
(dissoc (rk/plan (d/db conn) :charge/external-id rk/charge-prefix) :new-keys)
|
||||||
;; => {:total 17047142 :to-migrate 0 :already-scoped 17047142 :unscopable 0}
|
;; => {:total 17047142 :to-migrate 0 :already-scoped 17047142 :unscopable 0}
|
||||||
|
```
|
||||||
|
|
||||||
;; and the gate that this work exists for
|
Read `:to-migrate 0` **and** `:unscopable 0`. This is the authoritative signal, and it covers all
|
||||||
(rk/charges-with-multiple-parents (d/db conn) (take 400000 (rk/all-order-ids (d/db conn))))
|
17M charges.
|
||||||
|
|
||||||
|
`unscoped-report` is useful colour but is not the gate: its `:no-owner` column never reaches zero
|
||||||
|
for charges, because ~283k payout stubs carry no `:charge/client` of their own and it classifies
|
||||||
|
by attribute rather than by resolving ownership. Judge completeness by `plan`.
|
||||||
|
|
||||||
|
**(b) The safety gate for the cascade** — no payment may answer to two orders, or re-enabling
|
||||||
|
`remove-voided-orders` in step 8 can delete a payment another order still needs. Check **every**
|
||||||
|
order in the last year, with no sampling:
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(let [db (d/db conn)
|
||||||
|
cs (map first (d/q '[:find ?c :where [?c :client/code _]] db))
|
||||||
|
year (java.util.Date. (- (.getTime (java.util.Date.)) (long (* 365 86400000))))]
|
||||||
|
(rk/charges-with-multiple-parents
|
||||||
|
db (map first (iol-ion.query/scan-sales-orders db cs year nil))))
|
||||||
;; => 0
|
;; => 0
|
||||||
```
|
```
|
||||||
|
|
||||||
Note `unscoped-report`'s `:no-owner` column is not a gap: ~283k payout-stub payments carry no
|
On the restored copy that is 5,159,787 orders — 27% of the table — via the
|
||||||
`:charge/client` attribute of their own, so it cannot verify them by attribute. `plan` resolves
|
`:sales-order/client+date` index. A year is chosen deliberately: `remove-voided-orders` only ever
|
||||||
ownership through whatever refers to them and is the figure to trust.
|
deletes orders Square reports as voided, which are recent, so that is where the destructive risk
|
||||||
|
lives. Completeness across all of history is check (a)'s job, not this one.
|
||||||
|
|
||||||
|
> Do **not** sample this with `(take n (rk/all-order-ids db))`. `all-order-ids` streams `:aevt`,
|
||||||
|
> which is ascending entity id, so a `take` returns the *oldest* orders — on the restored copy the
|
||||||
|
> first 400,000 are all from 2019–2021, before any of the contention this gate looks for. It would
|
||||||
|
> report a confident zero having inspected none of the relevant data.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 5 — Recompute summaries, flags still off
|
## Step 5 — Resume the Square importer
|
||||||
|
|
||||||
|
Only once step 4's two checks read clean. The maintenance window ends here.
|
||||||
|
|
||||||
|
The first cycle after resuming is the one to watch. Compare these against the same counts taken
|
||||||
|
immediately before the deploy — growth should be ordinary daily volume:
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(count (d/datoms (d/db conn) :aevt :sales-refund/external-id))
|
||||||
|
(count (d/datoms (d/db conn) :aevt :expected-deposit/external-id))
|
||||||
|
(count (d/datoms (d/db conn) :aevt :cash-drawer-shift/external-id))
|
||||||
|
(count (d/datoms (d/db conn) :aevt :charge/external-id))
|
||||||
|
```
|
||||||
|
|
||||||
|
A near-doubling of any of them means records are being created rather than matched — **stop and
|
||||||
|
roll back the deploy.** Charges are included deliberately: they are the one that doubles a client's
|
||||||
|
takings rather than merely duplicating a row.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 6 — Recompute summaries, flags still off
|
||||||
|
|
||||||
```clojure
|
```clojure
|
||||||
(require '[auto-ap.jobs.sales-summaries :as ss])
|
(require '[auto-ap.jobs.sales-summaries :as ss])
|
||||||
@@ -128,7 +183,7 @@ ownership through whatever refers to them and is the figure to trust.
|
|||||||
```
|
```
|
||||||
|
|
||||||
This is the pass that banks the deduplication. **Capture the result before going further** — you
|
This is the pass that banks the deduplication. **Capture the result before going further** — you
|
||||||
will need it as the baseline for step 6, and it cannot be reconstructed afterwards:
|
will need it as the baseline for step 7, and it cannot be reconstructed afterwards:
|
||||||
|
|
||||||
```clojure
|
```clojure
|
||||||
(require '[auto-ap.tools.compare-sales-summaries :as cmp]) ; test/dev classpath
|
(require '[auto-ap.tools.compare-sales-summaries :as cmp]) ; test/dev classpath
|
||||||
@@ -143,7 +198,7 @@ will need it as the baseline for step 6, and it cannot be reconstructed afterwar
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 6 — Turn the flag on, a few restaurants at a time
|
## Step 7 — Turn the flag on, a few restaurants at a time
|
||||||
|
|
||||||
Needs accounting sign-off first: `summary-service-charges` posts to **49000 Service Income**, chosen
|
Needs accounting sign-off first: `summary-service-charges` posts to **49000 Service Income**, chosen
|
||||||
so the work could be measured. It affects reporting, never whether a day balances.
|
so the work could be measured. It affects reporting, never whether a day balances.
|
||||||
@@ -156,7 +211,7 @@ so the work could be measured. It affects reporting, never whether a day balance
|
|||||||
|
|
||||||
Start with two or three restaurants, confirm, then widen.
|
Start with two or three restaurants, confirm, then widen.
|
||||||
|
|
||||||
**Verify** against the capture from step 5:
|
**Verify** against the capture from step 6:
|
||||||
|
|
||||||
```clojure
|
```clojure
|
||||||
(def after (cmp/summaries-in (d/db conn) start end))
|
(def after (cmp/summaries-in (d/db conn) start end))
|
||||||
@@ -168,29 +223,34 @@ The two numbers that matter — both were zero across all 18,900 client-days in
|
|||||||
- `:balanced->unbalanced` must be **0**
|
- `:balanced->unbalanced` must be **0**
|
||||||
- previously-balanced days whose lines changed must be **0**
|
- previously-balanced days whose lines changed must be **0**
|
||||||
|
|
||||||
If either is non-zero, retract the flag for the affected clients and re-run step 5. The flag is the
|
If either is non-zero, retract the flag for the affected clients and re-run step 6. The flag is the
|
||||||
rollback: removing it restores today's behaviour exactly.
|
rollback: removing it restores today's behaviour exactly.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 7 — Re-enable `remove-voided-orders`
|
## Step 8 — Re-enable `remove-voided-orders`
|
||||||
|
|
||||||
Safe once step 4's gate reads zero. Keep the detach-rather-than-delete guard from step 2.
|
Safe once step 4's gate reads zero. Keep the detach-rather-than-delete guard from step 1.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 8 — Remove the legacy key lookup
|
## Step 9 — Remove the legacy key lookup
|
||||||
|
|
||||||
Only once `plan` reports `:to-migrate 0` and has stayed there through several import cycles. Drop
|
**Schedule this; do not leave it open-ended.** Both client records at a shared location stay active
|
||||||
the second branch of `square.core3/existing-id`. At that point two clients sharing a location
|
permanently, so the legacy fallback in `square.core3/existing-id` is the one code path that can ever
|
||||||
becomes structurally incapable of producing a shared record, rather than prevented by a convention a
|
reach across them. Deleting it is what turns the guarantee from conventional into structural.
|
||||||
future import could break.
|
|
||||||
|
|
||||||
This is the last step and there is no hurry.
|
Once `plan` reports `:to-migrate 0` and has stayed there through several import cycles, drop the
|
||||||
|
legacy branch of `existing-id` — and with it `owned-by-other-client?`, which exists only to make
|
||||||
|
that branch safe while it lives. After this, two clients on one location are structurally incapable
|
||||||
|
of resolving onto each other's records, and no ordering discipline is required to keep it that way.
|
||||||
|
|
||||||
|
Until it is done, the protection is the guard plus the maintenance window, both of which depend on
|
||||||
|
people doing the right thing. That is the reason not to let this drift.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 9 — Deal with the refunds that have no sales behind them
|
## Step 10 — Deal with the refunds that have no sales behind them
|
||||||
|
|
||||||
**The most important item in this document, and the only one that is not just execution.**
|
**The most important item in this document, and the only one that is not just execution.**
|
||||||
|
|
||||||
@@ -256,7 +316,7 @@ tender against $6,059.57 of order totals on one day), the ezCater fee question,
|
|||||||
clusters on NGMV and NGEB.
|
clusters on NGMV and NGEB.
|
||||||
|
|
||||||
Plus 15 days / $974.99 where a processing fee lands on a day with no trading — the same shape as
|
Plus 15 days / $974.99 where a processing fee lands on a day with no trading — the same shape as
|
||||||
step 9 but from the payout side, so it needs the payout modelled rather than a rule in the summary.
|
step 10 but from the payout side, so it needs the payout modelled rather than a rule in the summary.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -195,7 +195,11 @@
|
|||||||
[orders batch-size]
|
[orders batch-size]
|
||||||
(let [cloned (atom 0)
|
(let [cloned (atom 0)
|
||||||
rekeyed (atom 0)]
|
rekeyed (atom 0)]
|
||||||
(doseq [batch (partition-all batch-size orders)]
|
(doseq [[i batch] (map-indexed vector (partition-all batch-size orders))]
|
||||||
|
(when (zero? (mod i 200))
|
||||||
|
;; the whole-database run walks 19M orders; without a trail an interrupted run leaves
|
||||||
|
;; no way to tell how far it got short of querying the data by hand
|
||||||
|
(alog/info ::splitting :orders-done (* i batch-size) :rekeyed @rekeyed :cloned @cloned))
|
||||||
(let [db (dc/db conn)
|
(let [db (dc/db conn)
|
||||||
batch-seen (atom {})
|
batch-seen (atom {})
|
||||||
tx (doall
|
tx (doall
|
||||||
@@ -255,12 +259,19 @@
|
|||||||
"Counts, per entity type, how many keys are already client-scoped, how many still carry the
|
"Counts, per entity type, how many keys are already client-scoped, how many still carry the
|
||||||
legacy unscoped form, and how many have no owner to scope by.
|
legacy unscoped form, and how many have no owner to scope by.
|
||||||
|
|
||||||
This is the completeness gate. The importer tolerates both key schemes on purpose, so that the
|
The importer tolerates both key schemes on purpose, so that the change can be deployed before
|
||||||
change can be deployed before the migration finishes — but that tolerance is a transition, not
|
the migration finishes — but that tolerance is a transition, not a resting place. While
|
||||||
a resting place. While `:legacy` is above zero the database is in a mixed state and a stray
|
`:legacy` is above zero the database is in a mixed state and a stray unscoped record can still
|
||||||
unscoped record can still be adopted by whichever client imports it first. Once every count
|
be adopted by whichever client imports it first. **`:legacy` reaching zero on every attribute is
|
||||||
reads zero the fallback lookup in `square.core3/existing-id` can be removed and the guarantee
|
the done-signal**, and it is what licenses removing the fallback lookup in
|
||||||
becomes structural rather than conventional."
|
`square.core3/existing-id`.
|
||||||
|
|
||||||
|
`:no-owner` is NOT part of that signal and never reaches zero for charges. It counts entities
|
||||||
|
whose own `:charge/client`/`:charge/location` are absent — around an eighth of charges, the
|
||||||
|
payout stubs `migrate!` only ever gives an external id — so this report structurally cannot
|
||||||
|
verify them even when their keys are perfectly scoped. It classifies by attribute; `plan`
|
||||||
|
resolves ownership through whatever references the entity. Ask `plan` for the authoritative
|
||||||
|
answer: `:to-migrate 0` with `:unscopable 0` means there is nothing left to do."
|
||||||
[db]
|
[db]
|
||||||
(into {}
|
(into {}
|
||||||
(for [{:keys [attr prefix client location]} scoped-attrs]
|
(for [{:keys [attr prefix client location]} scoped-attrs]
|
||||||
@@ -299,8 +310,13 @@
|
|||||||
(when-let [c (seq (collisions (:new-keys p)))]
|
(when-let [c (seq (collisions (:new-keys p)))]
|
||||||
(throw (ex-info "two entities would take the same key" {:attr attr :collisions (count c)})))
|
(throw (ex-info "two entities would take the same key" {:attr attr :collisions (count c)})))
|
||||||
(migrate! attr (:new-keys p) batch-size)))
|
(migrate! attr (:new-keys p) batch-size)))
|
||||||
;; charges no order refers to — payout stubs — are scoped from the deposit that holds them
|
;; charges no order refers to — payout stubs — are scoped from the deposit that holds them.
|
||||||
|
;; Collision-checked like the others: this is the largest attribute in the database, so it is
|
||||||
|
;; the last one that should discover a clash as a mid-run exception.
|
||||||
(let [p (plan (dc/db conn) :charge/external-id charge-prefix)]
|
(let [p (plan (dc/db conn) :charge/external-id charge-prefix)]
|
||||||
|
(when-let [c (seq (collisions (:new-keys p)))]
|
||||||
|
(throw (ex-info "two entities would take the same key"
|
||||||
|
{:attr :charge/external-id :collisions (count c)})))
|
||||||
(when (seq (:new-keys p)) (migrate! :charge/external-id (:new-keys p) batch-size)))
|
(when (seq (:new-keys p)) (migrate! :charge/external-id (:new-keys p) batch-size)))
|
||||||
{:split split :completeness (unscoped-report (dc/db conn))}))
|
{:split split :completeness (unscoped-report (dc/db conn))}))
|
||||||
|
|
||||||
|
|||||||
@@ -27,11 +27,9 @@
|
|||||||
"Authorization" (str "Bearer " (:client/square-auth-token client))
|
"Authorization" (str "Bearer " (:client/square-auth-token client))
|
||||||
"Content-Type" "application/json"}))
|
"Content-Type" "application/json"}))
|
||||||
|
|
||||||
|
|
||||||
(defn ->square-date [d]
|
(defn ->square-date [d]
|
||||||
(f/unparse (f/formatter "YYYY-MM-dd'T'HH:mm:ssZZ") d))
|
(f/unparse (f/formatter "YYYY-MM-dd'T'HH:mm:ssZZ") d))
|
||||||
|
|
||||||
|
|
||||||
(def manifold-api-stream
|
(def manifold-api-stream
|
||||||
(let [stream (s/stream 100)]
|
(let [stream (s/stream 100)]
|
||||||
(->> stream
|
(->> stream
|
||||||
@@ -104,7 +102,6 @@
|
|||||||
:exception error))
|
:exception error))
|
||||||
[]))))
|
[]))))
|
||||||
|
|
||||||
|
|
||||||
(def item-cache (atom {}))
|
(def item-cache (atom {}))
|
||||||
|
|
||||||
(defn fetch-catalog [client i v]
|
(defn fetch-catalog [client i v]
|
||||||
@@ -124,13 +121,11 @@
|
|||||||
#(do (swap! item-cache assoc i %)
|
#(do (swap! item-cache assoc i %)
|
||||||
%))))
|
%))))
|
||||||
|
|
||||||
|
|
||||||
(defn fetch-catalog-cache [client i version]
|
(defn fetch-catalog-cache [client i version]
|
||||||
(if (get @item-cache i)
|
(if (get @item-cache i)
|
||||||
(de/success-deferred (get @item-cache i))
|
(de/success-deferred (get @item-cache i))
|
||||||
(fetch-catalog client i version)))
|
(fetch-catalog client i version)))
|
||||||
|
|
||||||
|
|
||||||
(defn item->category-name-impl [client item version]
|
(defn item->category-name-impl [client item version]
|
||||||
(capture-context->lc
|
(capture-context->lc
|
||||||
(cond (:item_id (:item_variation_data item))
|
(cond (:item_id (:item_variation_data item))
|
||||||
@@ -161,7 +156,6 @@
|
|||||||
:item item)
|
:item item)
|
||||||
"Uncategorized"))))
|
"Uncategorized"))))
|
||||||
|
|
||||||
|
|
||||||
(defn item-id->category-name [client i version]
|
(defn item-id->category-name [client i version]
|
||||||
(capture-context->lc
|
(capture-context->lc
|
||||||
(-> [client i]
|
(-> [client i]
|
||||||
@@ -226,7 +220,6 @@
|
|||||||
(concat (:orders result) continued-results))))
|
(concat (:orders result) continued-results))))
|
||||||
(:orders result)))))))
|
(:orders result)))))))
|
||||||
|
|
||||||
|
|
||||||
(defn search
|
(defn search
|
||||||
([client location start end]
|
([client location start end]
|
||||||
(capture-context->lc
|
(capture-context->lc
|
||||||
@@ -250,11 +243,9 @@
|
|||||||
(concat (:orders result) continued-results))))
|
(concat (:orders result) continued-results))))
|
||||||
(:orders result))))))))
|
(:orders result))))))))
|
||||||
|
|
||||||
|
|
||||||
(defn amount->money [amt]
|
(defn amount->money [amt]
|
||||||
(* 0.01 (or (:amount amt) 0.0)))
|
(* 0.01 (or (:amount amt) 0.0)))
|
||||||
|
|
||||||
|
|
||||||
;; to get totals:
|
;; to get totals:
|
||||||
(comment
|
(comment
|
||||||
(reduce
|
(reduce
|
||||||
@@ -278,6 +269,30 @@
|
|||||||
[prefix client location id]
|
[prefix client location id]
|
||||||
(str prefix (:client/code client) "-" (:square-location/client-location location) "-" id))
|
(str prefix (:client/code client) "-" (:square-location/client-location location) "-" id))
|
||||||
|
|
||||||
|
(def ^:private owner-attr
|
||||||
|
"Where each Square-imported entity records the client it belongs to."
|
||||||
|
{:charge/external-id :charge/client
|
||||||
|
:sales-refund/external-id :sales-refund/client
|
||||||
|
:expected-deposit/external-id :expected-deposit/client
|
||||||
|
:cash-drawer-shift/external-id :cash-drawer-shift/client})
|
||||||
|
|
||||||
|
(defn- owned-by-other-client?
|
||||||
|
"Whether `e` already belongs to a client other than `client-eid`.
|
||||||
|
|
||||||
|
Reads the entity's own owner attribute, and for a charge falls back to the client of whichever
|
||||||
|
sales order refers to it — charges predating `:charge/client` still have orders, and those are
|
||||||
|
exactly the ones that can be taken by the wrong client."
|
||||||
|
[db attr e client-eid]
|
||||||
|
(let [ent (dc/entity db e)
|
||||||
|
owner (or (:db/id ((owner-attr attr) ent))
|
||||||
|
(when (= attr :charge/external-id)
|
||||||
|
(some->> (first (dc/datoms db :vaet e :sales-order/charges))
|
||||||
|
:e
|
||||||
|
(dc/entity db)
|
||||||
|
:sales-order/client
|
||||||
|
:db/id)))]
|
||||||
|
(and owner (not= owner client-eid))))
|
||||||
|
|
||||||
(defn existing-id
|
(defn existing-id
|
||||||
"Entity id of the refund or charge this id already refers to, trying the client-scoped key
|
"Entity id of the refund or charge this id already refers to, trying the client-scoped key
|
||||||
first and the legacy unscoped key second.
|
first and the legacy unscoped key second.
|
||||||
@@ -286,11 +301,24 @@
|
|||||||
relies on upsert-by-identity; changing the key format on its own would match nothing and
|
relies on upsert-by-identity; changing the key format on its own would match nothing and
|
||||||
Datomic would create a SECOND entity for every refund and charge, orphaning the original under
|
Datomic would create a SECOND entity for every refund and charge, orphaning the original under
|
||||||
its legacy key. Pinning the result as `:db/id` makes the write land on the existing entity
|
its legacy key. Pinning the result as `:db/id` makes the write land on the existing entity
|
||||||
whichever scheme it currently carries."
|
whichever scheme it currently carries.
|
||||||
|
|
||||||
|
The legacy branch will not take a record that already belongs to a different client. Without
|
||||||
|
that check, two clients on one Square location double money during the window between deploying
|
||||||
|
and finishing the migration: client A's payout import resolves B's charge by its bare key and
|
||||||
|
renames it into A's scope, B's next order import then matches neither scheme and mints a second
|
||||||
|
charge, and because `:sales-order/charges` is cardinality-many nothing retracts the first — so
|
||||||
|
B's order carries two charges for one payment. Declining is also the right answer on its merits:
|
||||||
|
the write then lands on this client's own copy, which is what the scoped keys exist to create.
|
||||||
|
|
||||||
|
Once the migration has run there are no legacy keys left for this branch to find, and both it
|
||||||
|
and the guard can be deleted together."
|
||||||
[db attr prefix client location id]
|
[db attr prefix client location id]
|
||||||
(when id
|
(when id
|
||||||
(or (dc/entid db [attr (scoped-key prefix client location id)])
|
(or (dc/entid db [attr (scoped-key prefix client location id)])
|
||||||
(dc/entid db [attr (str prefix id)]))))
|
(when-let [legacy (dc/entid db [attr (str prefix id)])]
|
||||||
|
(when-not (owned-by-other-client? db attr legacy (:db/id client))
|
||||||
|
legacy)))))
|
||||||
|
|
||||||
(defn tender->charge [order client location t]
|
(defn tender->charge [order client location t]
|
||||||
(remove-nils
|
(remove-nils
|
||||||
@@ -439,7 +467,6 @@
|
|||||||
:client client
|
:client client
|
||||||
:location location)))))))
|
:location location)))))))
|
||||||
|
|
||||||
|
|
||||||
(defn get-payment [client p]
|
(defn get-payment [client p]
|
||||||
(de/chain (manifold-api-call
|
(de/chain (manifold-api-call
|
||||||
{:url (str "https://connect.squareup.com/v2/payments/" p)
|
{:url (str "https://connect.squareup.com/v2/payments/" p)
|
||||||
@@ -448,7 +475,6 @@
|
|||||||
:body
|
:body
|
||||||
:payment))
|
:payment))
|
||||||
|
|
||||||
|
|
||||||
(defn continue-payout-entry-list [c l poi cursor]
|
(defn continue-payout-entry-list [c l poi cursor]
|
||||||
(capture-context->lc lc
|
(capture-context->lc lc
|
||||||
(de/chain
|
(de/chain
|
||||||
@@ -593,6 +619,12 @@
|
|||||||
(let [payment-id (:payment_id (:type_charge_details p))]
|
(let [payment-id (:payment_id (:type_charge_details p))]
|
||||||
(remove-nils
|
(remove-nils
|
||||||
{:charge/external-id (scoped-key "square/charge/" client location payment-id)
|
{:charge/external-id (scoped-key "square/charge/" client location payment-id)
|
||||||
|
;; the owner attributes must travel with the key: `raw-square-id` and
|
||||||
|
;; `scope-of` both recover a charge's scope from them, and a key
|
||||||
|
;; scoped to one client while the owner says another is what makes
|
||||||
|
;; the migration write square/charge/B-LB-A-LA-<id>
|
||||||
|
:charge/client (:db/id client)
|
||||||
|
:charge/location (:square-location/client-location location)
|
||||||
:db/id (existing-id (dc/db conn) :charge/external-id "square/charge/" client location payment-id)}))))))})
|
:db/id (existing-id (dc/db conn) :charge/external-id "square/charge/" client location payment-id)}))))))})
|
||||||
(filter :expected-deposit/date)
|
(filter :expected-deposit/date)
|
||||||
(into []))
|
(into []))
|
||||||
@@ -651,7 +683,6 @@
|
|||||||
:count (count x))
|
:count (count x))
|
||||||
@(dc/transact-async conn x))))))))
|
@(dc/transact-async conn x))))))))
|
||||||
|
|
||||||
|
|
||||||
(defn upsert-payouts
|
(defn upsert-payouts
|
||||||
([client]
|
([client]
|
||||||
(apply de/zip
|
(apply de/zip
|
||||||
@@ -700,7 +731,6 @@
|
|||||||
|
|
||||||
(log/info ::done-loading-refunds)))))))
|
(log/info ::done-loading-refunds)))))))
|
||||||
|
|
||||||
|
|
||||||
(defn get-cash-shift [client id]
|
(defn get-cash-shift [client id]
|
||||||
(de/chain (manifold-api-call {:url (str (url/url "https://connect.squareup.com/v2/cash-drawers/shifts" id))
|
(de/chain (manifold-api-call {:url (str (url/url "https://connect.squareup.com/v2/cash-drawers/shifts" id))
|
||||||
:method :get
|
:method :get
|
||||||
@@ -864,8 +894,6 @@
|
|||||||
d1
|
d1
|
||||||
d2))
|
d2))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
(defn remove-voided-orders
|
(defn remove-voided-orders
|
||||||
([client]
|
([client]
|
||||||
(apply de/zip
|
(apply de/zip
|
||||||
@@ -910,9 +938,7 @@
|
|||||||
|
|
||||||
@(let [[c [l]] (get-square-client-and-location "DBFS")]
|
@(let [[c [l]] (get-square-client-and-location "DBFS")]
|
||||||
(log/peek :x [c l])
|
(log/peek :x [c l])
|
||||||
(search c l #clj-time/date-time "2026-03-28" #clj-time/date-time "2026-03-29")
|
(search c l #clj-time/date-time "2026-03-28" #clj-time/date-time "2026-03-29"))
|
||||||
|
|
||||||
)
|
|
||||||
|
|
||||||
@(let [[c [l]] (get-square-client-and-location "NGAK")]
|
@(let [[c [l]] (get-square-client-and-location "NGAK")]
|
||||||
(log/peek :x [c l])
|
(log/peek :x [c l])
|
||||||
@@ -922,10 +948,7 @@
|
|||||||
(try
|
(try
|
||||||
@(remove-voided-orders c)
|
@(remove-voided-orders c)
|
||||||
(catch Exception e
|
(catch Exception e
|
||||||
nil)))
|
nil))))
|
||||||
|
|
||||||
|
|
||||||
)
|
|
||||||
|
|
||||||
(defn upsert-all [& clients]
|
(defn upsert-all [& clients]
|
||||||
(capture-context->lc
|
(capture-context->lc
|
||||||
@@ -994,8 +1017,6 @@
|
|||||||
[:clients clients]
|
[:clients clients]
|
||||||
@(apply upsert-all clients)))
|
@(apply upsert-all clients)))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
(comment
|
(comment
|
||||||
(defn refunds-raw-cont
|
(defn refunds-raw-cont
|
||||||
([client l cursor so-far]
|
([client l cursor so-far]
|
||||||
@@ -1025,7 +1046,6 @@
|
|||||||
(->>
|
(->>
|
||||||
@(let [[c [l]] (get-square-client-and-location "NGGG")]
|
@(let [[c [l]] (get-square-client-and-location "NGGG")]
|
||||||
|
|
||||||
|
|
||||||
(search c l (time/now) (time/plus (time/now) (time/days -1))))
|
(search c l (time/now) (time/plus (time/now) (time/days -1))))
|
||||||
|
|
||||||
(filter (fn [r]
|
(filter (fn [r]
|
||||||
@@ -1035,7 +1055,6 @@
|
|||||||
(->>
|
(->>
|
||||||
@(let [[c [l]] (get-square-client-and-location "NGGG")]
|
@(let [[c [l]] (get-square-client-and-location "NGGG")]
|
||||||
|
|
||||||
|
|
||||||
(refunds-raw-cont c l nil []))
|
(refunds-raw-cont c l nil []))
|
||||||
(filter (fn [r]
|
(filter (fn [r]
|
||||||
(str/starts-with? (:created_at r) "2024-03-14")))))
|
(str/starts-with? (:created_at r) "2024-03-14")))))
|
||||||
@@ -1070,13 +1089,8 @@
|
|||||||
[(:client/code c) (atime/unparse-local (clj-time.coerce/to-date-time (:sales-order/date bad-row)) atime/normal-date) (:sales-order/total bad-row) (:sales-order/tax bad-row) (:sales-order/tip bad-row) (:db/id bad-row)])
|
[(:client/code c) (atime/unparse-local (clj-time.coerce/to-date-time (:sales-order/date bad-row)) atime/normal-date) (:sales-order/total bad-row) (:sales-order/tax bad-row) (:sales-order/tip bad-row) (:db/id bad-row)])
|
||||||
:separator \tab)
|
:separator \tab)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
;; =>
|
;; =>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
(require 'auto-ap.time-reader)
|
(require 'auto-ap.time-reader)
|
||||||
|
|
||||||
@(upsert-all "NGPG")
|
@(upsert-all "NGPG")
|
||||||
@@ -1084,26 +1098,15 @@
|
|||||||
(clojure.pprint/pprint (let [[c [l]] (get-square-client-and-location "NGVT")]
|
(clojure.pprint/pprint (let [[c [l]] (get-square-client-and-location "NGVT")]
|
||||||
l
|
l
|
||||||
|
|
||||||
|
|
||||||
(def z @(search c l #clj-time/date-time "2025-02-23T00:00:00-08:00"
|
(def z @(search c l #clj-time/date-time "2025-02-23T00:00:00-08:00"
|
||||||
#clj-time/date-time "2025-02-28T00:00:00-08:00"))
|
#clj-time/date-time "2025-02-28T00:00:00-08:00"))
|
||||||
(take 10 (map #(first (deref (order->sales-order c l %))) z)))
|
(take 10 (map #(first (deref (order->sales-order c l %))) z))))
|
||||||
|
|
||||||
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
(->> z
|
(->> z
|
||||||
(filter (fn [o]
|
(filter (fn [o]
|
||||||
(seq (filter (comp #{"OTHER"} :type) (:tenders o)))))
|
(seq (filter (comp #{"OTHER"} :type) (:tenders o)))))
|
||||||
(filter #(not (:name (:source %))))
|
(filter #(not (:name (:source %))))
|
||||||
(count)
|
(count))
|
||||||
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
(doseq [[code] (seq (dc/q '[:find ?code
|
(doseq [[code] (seq (dc/q '[:find ?code
|
||||||
:in $
|
:in $
|
||||||
@@ -1113,8 +1116,7 @@
|
|||||||
[?o :sales-order/client ?c]
|
[?o :sales-order/client ?c]
|
||||||
[?c :client/code ?code]]
|
[?c :client/code ?code]]
|
||||||
(dc/db conn)))
|
(dc/db conn)))
|
||||||
:let [[c [l]] (get-square-client-and-location code)
|
:let [[c [l]] (get-square-client-and-location code)]
|
||||||
]
|
|
||||||
order @(search c l #clj-time/date-time "2026-01-01T00:00:00-08:00" (time/now))
|
order @(search c l #clj-time/date-time "2026-01-01T00:00:00-08:00" (time/now))
|
||||||
:when (= "Invoices" (:name (:source order)))
|
:when (= "Invoices" (:name (:source order)))
|
||||||
:let [[sales-order] @(order->sales-order c l order)]]
|
:let [[sales-order] @(order->sales-order c l order)]]
|
||||||
@@ -1123,22 +1125,13 @@
|
|||||||
(println "DATE IS" (:sales-order/date sales-order))
|
(println "DATE IS" (:sales-order/date sales-order))
|
||||||
(when (some-> (:sales-order/date sales-order) coerce/to-date-time (time/after? #clj-time/date-time "2026-2-16T00:00:00-08:00"))
|
(when (some-> (:sales-order/date sales-order) coerce/to-date-time (time/after? #clj-time/date-time "2026-2-16T00:00:00-08:00"))
|
||||||
(println "WOULD UPDATE" sales-order)
|
(println "WOULD UPDATE" sales-order)
|
||||||
@(dc/transact auto-ap.datomic/conn [sales-order])
|
@(dc/transact auto-ap.datomic/conn [sales-order]))
|
||||||
)
|
|
||||||
#_@(dc/transact)
|
#_@(dc/transact)
|
||||||
(println "DONE"))
|
(println "DONE")))
|
||||||
|
|
||||||
|
|
||||||
)
|
|
||||||
|
|
||||||
#_(filter (comp #{"OTHER"} :type) (mapcat :tenders z))
|
#_(filter (comp #{"OTHER"} :type) (mapcat :tenders z))
|
||||||
|
|
||||||
|
|
||||||
@(let [[c [l]] (get-square-client-and-location "NGRY")]
|
@(let [[c [l]] (get-square-client-and-location "NGRY")]
|
||||||
#_(search c l (clj-time.coerce/from-date #inst "2025-02-28") (clj-time.coerce/from-date #inst "2025-03-01"))
|
#_(search c l (clj-time.coerce/from-date #inst "2025-02-28") (clj-time.coerce/from-date #inst "2025-03-01"))
|
||||||
|
|
||||||
(order->sales-order c l (:order (get-order c l "KdvwntmfMNTKBu8NOocbxatOs18YY" )))
|
(order->sales-order c l (:order (get-order c l "KdvwntmfMNTKBu8NOocbxatOs18YY")))))
|
||||||
|
|
||||||
)
|
|
||||||
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -147,3 +147,30 @@
|
|||||||
(is (= (str "square/charge/" code "-CD-same1") (:key (first (charges-of o1)))))
|
(is (= (str "square/charge/" code "-CD-same1") (:key (first (charges-of o1)))))
|
||||||
(is (= (:eid (first (charges-of o1))) (:eid (first (charges-of o2))))
|
(is (= (:eid (first (charges-of o1))) (:eid (first (charges-of o2))))
|
||||||
"both orders still point at the one payment"))))
|
"both orders still point at the one payment"))))
|
||||||
|
|
||||||
|
(deftest two-orders-of-the-same-client-keep-sharing-across-batches
|
||||||
|
(testing "batch size does not change the same-client rule, which the sibling test cannot show
|
||||||
|
because both its orders land in one batch.
|
||||||
|
|
||||||
|
Once the first order re-keys the charge it also writes :charge/client/:charge/location,
|
||||||
|
so the second order's raw-square-id takes its owner branch, new-key reconstructs the key
|
||||||
|
the charge already has, and the (not= old new-key) guard drops the row before :action is
|
||||||
|
read. A clone here would double that client's takings for the day."
|
||||||
|
(let [{:strs [test-client-id]} (setup-test-data [])
|
||||||
|
tx @(dc/transact conn [{:db/id "charge"
|
||||||
|
:charge/external-id "square/charge/same2"
|
||||||
|
:charge/total 75.0}
|
||||||
|
{:db/id "o1" :sales-order/external-id "square/order/y-1"
|
||||||
|
:sales-order/client test-client-id :sales-order/location "CD"
|
||||||
|
:sales-order/date sales-date :sales-order/charges ["charge"]}
|
||||||
|
{:db/id "o2" :sales-order/external-id "square/order/y-2"
|
||||||
|
:sales-order/client test-client-id :sales-order/location "CD"
|
||||||
|
:sales-order/date sales-date :sales-order/charges ["charge"]}])
|
||||||
|
o1 (get-in tx [:tempids "o1"]) o2 (get-in tx [:tempids "o2"])
|
||||||
|
code (:client/code (dc/entity (dc/db conn) test-client-id))]
|
||||||
|
(is (= {:rekeyed 1 :cloned 0} (sut/split-and-rekey-charges! [o1 o2] 1))
|
||||||
|
"batch size 1 puts the two orders in separate batches, and still no copy is made")
|
||||||
|
(is (= 1 (charge-count)) "one payment, not two")
|
||||||
|
(is (= (str "square/charge/" code "-CD-same2") (:key (first (charges-of o1)))))
|
||||||
|
(is (= (:eid (first (charges-of o1))) (:eid (first (charges-of o2))))
|
||||||
|
"both orders still point at the one payment"))))
|
||||||
|
|||||||
@@ -76,6 +76,89 @@
|
|||||||
:sales-refund/total 10.0}])
|
:sales-refund/total 10.0}])
|
||||||
(is (= 2 (refund-count)) "two stable entities, one per client, rather than one that flips"))))
|
(is (= 2 (refund-count)) "two stable entities, one per client, rather than one that flips"))))
|
||||||
|
|
||||||
|
(deftest legacy-key-of-another-client-is-not-claimed
|
||||||
|
(testing "the legacy fallback must not hand one client a record that already belongs to another.
|
||||||
|
|
||||||
|
Without this, two clients on one Square location double money in the window between
|
||||||
|
deploying and finishing the migration."
|
||||||
|
(setup-test-data [])
|
||||||
|
(let [tx @(dc/transact conn [{:db/id "mine" :client/code (str "MINE" (rand-int 100000))}
|
||||||
|
{:db/id "theirs" :client/code (str "THEIRS" (rand-int 100000))}])
|
||||||
|
mine {:db/id (get-in tx [:tempids "mine"]) :client/code "MINE"}
|
||||||
|
theirs-id (get-in tx [:tempids "theirs"])]
|
||||||
|
@(dc/transact conn [{:db/id "r"
|
||||||
|
:sales-refund/external-id "square/refund/abc"
|
||||||
|
:sales-refund/client theirs-id
|
||||||
|
:sales-refund/total 10.0}])
|
||||||
|
(is (nil? (sut/existing-id (dc/db conn) :sales-refund/external-id "square/refund/"
|
||||||
|
mine location "abc"))
|
||||||
|
"a legacy-keyed refund owned by another client is left alone")
|
||||||
|
(is (some? (sut/existing-id (dc/db conn) :sales-refund/external-id "square/refund/"
|
||||||
|
{:db/id theirs-id :client/code "THEIRS"} location "abc"))
|
||||||
|
"its own client still resolves it, so re-keying in place still works"))))
|
||||||
|
|
||||||
|
(deftest a-charge-is-owned-by-the-client-of-the-order-that-refers-to-it
|
||||||
|
(testing "charges predating :charge/client still have orders, and those are exactly the ones
|
||||||
|
that could be taken by the wrong client"
|
||||||
|
(let [{:strs [test-client-id]} (setup-test-data [])
|
||||||
|
other (get-in @(dc/transact conn [{:db/id "o" :client/code (str "OTHER" (rand-int 100000))}])
|
||||||
|
[:tempids "o"])]
|
||||||
|
@(dc/transact conn [{:db/id "c" :charge/external-id "square/charge/p1" :charge/total 50.0}
|
||||||
|
{:db/id "ord" :sales-order/external-id "square/order/x-1"
|
||||||
|
:sales-order/client test-client-id :sales-order/location "CD"
|
||||||
|
:sales-order/date #inst "2026-06-03T07:00:00.000-00:00"
|
||||||
|
:sales-order/charges ["c"]}])
|
||||||
|
(is (nil? (sut/existing-id (dc/db conn) :charge/external-id "square/charge/"
|
||||||
|
{:db/id other :client/code "OTHER"} location "p1"))
|
||||||
|
"ownership is read from the referencing order when :charge/client is absent"))))
|
||||||
|
|
||||||
|
(deftest deploy-window-does-not-double-a-second-clients-tender
|
||||||
|
(testing "the P0 this guard exists for, end to end.
|
||||||
|
|
||||||
|
Client A's payout import reaches for a payment whose charge belongs to client B's
|
||||||
|
order. If A were allowed to re-key it, B's next order import would match neither
|
||||||
|
scheme, mint a second charge, and — since :sales-order/charges is cardinality-many —
|
||||||
|
leave B's order holding two charges for one payment."
|
||||||
|
(let [{:strs [test-client-id]} (setup-test-data [])
|
||||||
|
b-code (:client/code (dc/entity (dc/db conn) test-client-id))
|
||||||
|
b {:db/id test-client-id :client/code b-code}
|
||||||
|
b-loc {:square-location/client-location "LB"}
|
||||||
|
a-id (get-in @(dc/transact conn [{:db/id "a" :client/code (str "AAA" (rand-int 100000))}])
|
||||||
|
[:tempids "a"])
|
||||||
|
a {:db/id a-id :client/code (:client/code (dc/entity (dc/db conn) a-id))}
|
||||||
|
a-loc {:square-location/client-location "LA"}
|
||||||
|
tx @(dc/transact conn [{:db/id "x" :charge/external-id "square/charge/P"
|
||||||
|
:charge/total 100.0 :charge/type-name "CARD"
|
||||||
|
:charge/client test-client-id :charge/location "LB"}
|
||||||
|
{:db/id "ob" :sales-order/external-id "square/order/b-1"
|
||||||
|
:sales-order/client test-client-id :sales-order/location "LB"
|
||||||
|
:sales-order/date #inst "2026-06-03T07:00:00.000-00:00"
|
||||||
|
:sales-order/charges ["x"]}])
|
||||||
|
order-b (get-in tx [:tempids "ob"])
|
||||||
|
charges-of (fn [o] (map :v (dc/datoms (dc/db conn) :eavt o :sales-order/charges)))]
|
||||||
|
;; client A's payout import touches the same Square payment
|
||||||
|
@(dc/transact conn [(into {} (remove (comp nil? val))
|
||||||
|
{:charge/external-id (sut/scoped-key "square/charge/" a a-loc "P")
|
||||||
|
:charge/client a-id
|
||||||
|
:charge/location "LA"
|
||||||
|
:db/id (sut/existing-id (dc/db conn) :charge/external-id
|
||||||
|
"square/charge/" a a-loc "P")})])
|
||||||
|
;; client B's order re-imports
|
||||||
|
@(dc/transact conn [{:db/id order-b
|
||||||
|
:sales-order/charges
|
||||||
|
[(sut/tender->charge {:id "b-1" :created_at "2026-06-03T12:00:00Z"}
|
||||||
|
b b-loc {:id "P" :type "CARD"
|
||||||
|
:amount_money {:amount 10000
|
||||||
|
:currency "USD"}})]}])
|
||||||
|
(is (= 1 (count (charges-of order-b)))
|
||||||
|
"B's order still holds exactly one charge for the one payment")
|
||||||
|
(is (= 100.0 (reduce + 0.0 (map #(:charge/total (dc/entity (dc/db conn) %))
|
||||||
|
(charges-of order-b))))
|
||||||
|
"so the day's tender is not doubled")
|
||||||
|
(is (= (str "square/charge/" b-code "-LB-P")
|
||||||
|
(:charge/external-id (dc/entity (dc/db conn) (first (charges-of order-b)))))
|
||||||
|
"and B's own charge was re-keyed in place rather than abandoned"))))
|
||||||
|
|
||||||
(deftest payouts-and-shifts-are-client-scoped-too
|
(deftest payouts-and-shifts-are-client-scoped-too
|
||||||
(testing "expected deposits and cash drawer shifts are fetched per location, so two clients on
|
(testing "expected deposits and cash drawer shifts are fetched per location, so two clients on
|
||||||
one location collide on them exactly as refunds and charges did"
|
one location collide on them exactly as refunds and charges did"
|
||||||
|
|||||||
Reference in New Issue
Block a user