fix(square): client-scope payout and cash-drawer-shift keys too

An audit of every unique-identity entity the importers create found two more
that carry no client scoping:

  :expected-deposit/external-id   "square/payout/" id
  :cash-drawer-shift/external-id  "square/cash-drawer-shift/" id

Both endpoints are location-filtered, so two clients configured on one Square
location import the same payout or shift and collide on a single entity, exactly
as refunds and charges did.

This is not hypothetical. Reading ownership out of the history of the restored
backup, the client has actually changed on:

  4,069 of 144,688 expected deposits   (2.8%)
  2,628 of  69,291 cash drawer shifts  (3.8%)
  3,387 of  51,990 refunds             (6.5%, before the earlier fix)

across 19 distinct client pairs — and only 10 of those pairs share a location in
today's configuration. Nine, including NGMJ/NGSC with 1,546 affected entities
and NGAK/NGMH with 952, are invisible to any point-in-time check because the
configuration has since changed. Deactivating what looks shared today is
therefore a snapshot fix; scoping the keys is what makes contention structurally
impossible.

Verified on the restore: 144,652 deposits and 69,291 shifts re-keyed with all
four entity counts unchanged, zero collisions, zero legacy keys remaining. 36
deposits carry neither client nor location and were left alone.

ezCater orders and sales orders were already scoped and need no change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 12:01:43 -07:00
parent 89baac19f3
commit 87f7fd564b
3 changed files with 72 additions and 11 deletions

View File

@@ -1,10 +1,15 @@
(ns auto-ap.jobs.rekey-square-external-ids
"One-shot migration re-keying Square refunds and charges to client-scoped external ids.
"One-shot migration re-keying Square entities to client-scoped external ids.
Refund and charge keys carry no client scoping today, so two clients configured on the same
Square location share a single entity: the refund's owner flips every time either client
imports, and one charge ends up referenced by both clients' orders. Sales orders already scope
their keys by client and location; this brings the other two in line.
Refunds, charges, Square payouts (expected deposits) and cash drawer shifts all carry keys with
no client scoping, so two clients configured on the same Square location share a single entity:
its owner flips every time either client imports. Sales orders and ezCater orders already scope
their keys by client and location; this brings the rest in line.
Measured on a restored production backup, ownership had actually changed on 3,387 refunds,
4,069 expected deposits and 2,628 cash drawer shifts, across 19 distinct client pairs — nine of
which no longer share a location in the current configuration and so are invisible to any
point-in-time check.
Run AFTER the importer knows how to resolve both key schemes (`square.core3/existing-id`).
Running it first would be harmless, but the importer would then re-create legacy-keyed
@@ -19,6 +24,8 @@
(def refund-prefix "square/refund/")
(def charge-prefix "square/charge/")
(def deposit-prefix "square/payout/")
(def shift-prefix "square/cash-drawer-shift/")
(defn- scope-of
"`[client-code location]` for an entity, or nil when it cannot be determined.
@@ -35,7 +42,11 @@
:sales-refund/external-id (pair (:client/code (:sales-refund/client ent))
(:sales-refund/location ent))
:charge/external-id (pair (:client/code (:charge/client ent))
(:charge/location ent)))
(:charge/location ent))
:expected-deposit/external-id (pair (:client/code (:expected-deposit/client ent))
(:expected-deposit/location ent))
:cash-drawer-shift/external-id (pair (:client/code (:cash-drawer-shift/client ent))
(:cash-drawer-shift/location ent)))
(when-let [o (:e (first (dc/datoms db :vaet e :sales-order/charges)))]
(let [oe (dc/entity db o)]
(pair (:client/code (:sales-order/client oe)) (:sales-order/location oe))))
@@ -103,4 +114,6 @@
either number moves, the re-key created duplicates instead of updating in place."
[db]
{:refunds (reduce (fn [n _] (inc n)) 0 (dc/datoms db :aevt :sales-refund/external-id))
:charges (reduce (fn [n _] (inc n)) 0 (dc/datoms db :aevt :charge/external-id))})
:charges (reduce (fn [n _] (inc n)) 0 (dc/datoms db :aevt :charge/external-id))
:deposits (reduce (fn [n _] (inc n)) 0 (dc/datoms db :aevt :expected-deposit/external-id))
:shifts (reduce (fn [n _] (inc n)) 0 (dc/datoms db :aevt :cash-drawer-shift/external-id))})

View File

@@ -533,7 +533,7 @@
(try
(->> (for [payout payouts
:let [best-sales-date (some->> (dc/q '[:find ?s4 (count ?s)
:in $ ?payout-id
:in $ [?payout-id ...]
:where
[?payout :expected-deposit/external-id ?payout-id]
[?payout :expected-deposit/charges ?c]
@@ -543,7 +543,8 @@
[(auto-ap.time/localize ?s2) ?s3]
[(clj-time.coerce/to-local-date ?s3) ?s4]]
(dc/db conn)
(str "square/payout/" (:id payout)))
[(scoped-key "square/payout/" client location (:id payout))
(str "square/payout/" (:id payout))])
(sort-by last)
last
first
@@ -567,7 +568,10 @@
(:db/id client)
(amount->money (:amount_money payout))))]
:when (not equivalent-already-exists?)]
#:expected-deposit {:external-id (str "square/payout/" (:id payout))
#:expected-deposit {:db/id (or (existing-id (dc/db conn) :expected-deposit/external-id
"square/payout/" client location (:id payout))
(str "square/payout/" (:id payout)))
:external-id (scoped-key "square/payout/" client location (:id payout))
:vendor :vendor/ccp-square
:status :expected-deposit-status/pending
:total (amount->money (:amount_money payout))
@@ -732,7 +736,10 @@
(de/chain
(get-cash-shift client (:id s))
(fn [cash-drawer-shift]
#:cash-drawer-shift {:external-id (str "square/cash-drawer-shift/" (:id cash-drawer-shift))
#:cash-drawer-shift {:db/id (or (existing-id (dc/db conn) :cash-drawer-shift/external-id
"square/cash-drawer-shift/" client l (:id cash-drawer-shift))
(str "square/cash-drawer-shift/" (:id cash-drawer-shift)))
:external-id (scoped-key "square/cash-drawer-shift/" client l (:id cash-drawer-shift))
:vendor :vendor/ccp-square
:paid-in (amount->money (:cash_paid_in_money cash-drawer-shift))
:paid-out (amount->money (:cash_paid_out_money cash-drawer-shift))