feat(square): client-scope refund and charge external ids
Sales order keys already carry client and location; refund and charge keys do not. That is why two clients configured on the same Square location share one entity: the refund's owner flips every time either client imports, and a single charge ends up referenced by both clients' orders. Scoping the keys the same way makes contention structurally impossible — each client gets its own entity. The hazard is the cutover. These ids are :db.unique/identity and the import relies on upsert-by-identity, so changing the key format alone would match nothing and Datomic would create a SECOND entity for every refund and charge, orphaning the originals under their legacy keys. square.core3/existing-id resolves the entity explicitly, scoped key first and legacy key second, and pins the result as :db/id so the write lands on the existing entity whichever scheme it currently carries. All three construction sites are covered: order tenders, refunds, and the payout path, which mints bare charge stubs from an external id alone. The migration job re-keys whatever the importer has not yet touched. It recovers scope from the referencing sales order or expected deposit for the ~12.8% of charges that carry neither :charge/client nor :charge/location, detects already-scoped entities by comparing against the key they should have rather than pattern-matching ids that may themselves contain dashes, and is therefore idempotent and re-runnable over a partially migrated database. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
106
src/clj/auto_ap/jobs/rekey_square_external_ids.clj
Normal file
106
src/clj/auto_ap/jobs/rekey_square_external_ids.clj
Normal file
@@ -0,0 +1,106 @@
|
||||
(ns auto-ap.jobs.rekey-square-external-ids
|
||||
"One-shot migration re-keying Square refunds and charges 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.
|
||||
|
||||
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
|
||||
entities on its next pass.
|
||||
|
||||
The migration is idempotent: an entity already carrying its scoped key is skipped, so it can
|
||||
be re-run over a partially migrated database."
|
||||
(:require
|
||||
[auto-ap.datomic :refer [conn]]
|
||||
[auto-ap.logging :as alog]
|
||||
[datomic.api :as dc]))
|
||||
|
||||
(def refund-prefix "square/refund/")
|
||||
(def charge-prefix "square/charge/")
|
||||
|
||||
(defn- scope-of
|
||||
"`[client-code location]` for an entity, or nil when it cannot be determined.
|
||||
|
||||
Charges are the awkward case: about an eighth of them carry neither `:charge/client` nor
|
||||
`:charge/location`. Those are stubs minted by the payout path, which asserts an external id
|
||||
alone and lets unique-identity upsert bring a bare entity into being, plus older tender
|
||||
records that predate the client attribute. None are orphaned, so the scope is recovered from
|
||||
whatever references them — the sales order first, then the expected deposit."
|
||||
[db attr e]
|
||||
(let [ent (dc/entity db e)
|
||||
pair (fn [code loc] (when (and code loc) [code loc]))]
|
||||
(or (case attr
|
||||
: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)))
|
||||
(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))))
|
||||
(when-let [d (:e (first (dc/datoms db :vaet e :expected-deposit/charges)))]
|
||||
(let [de (dc/entity db d)]
|
||||
(pair (:client/code (:expected-deposit/client de)) (:expected-deposit/location de)))))))
|
||||
|
||||
(defn planned-key
|
||||
"`[eid new-key]` for an entity that still needs re-keying, or nil when it is already scoped or
|
||||
cannot be scoped at all.
|
||||
|
||||
Detection compares against the key this entity *should* have rather than pattern-matching the
|
||||
id, because Square ids may themselves contain dashes and no pattern separates the two schemes
|
||||
reliably. That also makes the migration idempotent."
|
||||
[db attr prefix datom]
|
||||
(let [old (:v datom)]
|
||||
(when-let [[code loc] (scope-of db attr (:e datom))]
|
||||
(let [scoped-prefix (str prefix code "-" loc "-")]
|
||||
(when-not (.startsWith ^String old scoped-prefix)
|
||||
[(:e datom) (str scoped-prefix (subs old (count prefix)))])))))
|
||||
|
||||
(defn plan
|
||||
"Everything the migration would change, plus what it cannot touch. Read-only — run this and
|
||||
check `:collisions` is empty before transacting anything."
|
||||
[db attr prefix]
|
||||
(let [acc (reduce (fn [acc d]
|
||||
(let [acc (update acc :total inc)]
|
||||
(if-let [[e new-key] (planned-key db attr prefix d)]
|
||||
(-> acc
|
||||
(update :to-migrate inc)
|
||||
(update :new-keys conj! [e new-key]))
|
||||
(if (scope-of db attr (:e d))
|
||||
(update acc :already-scoped inc)
|
||||
(update acc :unscopable inc)))))
|
||||
{:total 0 :to-migrate 0 :already-scoped 0 :unscopable 0 :new-keys (transient [])}
|
||||
(dc/datoms db :aevt attr))]
|
||||
(update acc :new-keys persistent!)))
|
||||
|
||||
(defn collisions
|
||||
"Any two entities that would land on the same new key. Must be empty: a collision would merge
|
||||
two entities into one and lose whichever lost."
|
||||
[new-keys]
|
||||
(->> new-keys
|
||||
(group-by second)
|
||||
(keep (fn [[k es]] (when (> (count es) 1) [k (mapv first es)])))
|
||||
vec))
|
||||
|
||||
(defn migrate!
|
||||
"Asserts the new external id on each planned entity. The attribute is cardinality one, so the
|
||||
legacy value is retracted by the same assertion and the entity keeps its identity — nothing is
|
||||
created and nothing is deleted.
|
||||
|
||||
Returns the number of entities re-keyed."
|
||||
[attr new-keys batch-size]
|
||||
(let [total (count new-keys)]
|
||||
(alog/info ::migrating :attr attr :count total)
|
||||
(doseq [[i batch] (map-indexed vector (partition-all batch-size new-keys))]
|
||||
@(dc/transact conn (for [[e new-key] batch] {:db/id e attr new-key}))
|
||||
(when (zero? (mod i 20))
|
||||
(alog/info ::migrated :attr attr :done (* i batch-size) :of total)))
|
||||
total))
|
||||
|
||||
(defn counts
|
||||
"Entity totals, for the before/after assertion that is this migration's real safety net: if
|
||||
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))})
|
||||
@@ -269,6 +269,29 @@
|
||||
0.0
|
||||
[]))
|
||||
|
||||
(defn scoped-key
|
||||
"Client-scoped external id, in the shape sales order keys already use.
|
||||
|
||||
Without the client and location in the key, two clients configured on the same Square location
|
||||
collide on a single entity: a refund changes owner every time either client imports, and one
|
||||
charge ends up shared between both clients' orders."
|
||||
[prefix client location id]
|
||||
(str prefix (:client/code client) "-" (:square-location/client-location location) "-" id))
|
||||
|
||||
(defn existing-id
|
||||
"Entity id of the refund or charge this id already refers to, trying the client-scoped key
|
||||
first and the legacy unscoped key second.
|
||||
|
||||
This is what makes re-keying safe. These external ids are `:db.unique/identity`, so the import
|
||||
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
|
||||
its legacy key. Pinning the result as `:db/id` makes the write land on the existing entity
|
||||
whichever scheme it currently carries."
|
||||
[db attr prefix client location id]
|
||||
(when id
|
||||
(or (dc/entid db [attr (scoped-key prefix client location id)])
|
||||
(dc/entid db [attr (str prefix id)]))))
|
||||
|
||||
(defn tender->charge [order client location t]
|
||||
(remove-nils
|
||||
#:charge
|
||||
@@ -278,8 +301,9 @@
|
||||
:note (:note t)
|
||||
:location (:square-location/client-location location)
|
||||
:reference-link (str (url/url "https://squareup.com/receipt/preview" (:id t)))
|
||||
:db/id (existing-id (dc/db conn) :charge/external-id "square/charge/" client location (:id t))
|
||||
:external-id (when (:id t)
|
||||
(str "square/charge/" (:id t)))
|
||||
(scoped-key "square/charge/" client location (:id t)))
|
||||
:processor (cond
|
||||
(#{"OTHER" "THIRD_PARTY_CARD"} (:type t))
|
||||
(condp = (some-> (:note t) str/lower-case)
|
||||
@@ -561,7 +585,11 @@
|
||||
(coerce/to-date)))
|
||||
:charges (reverse (->> (:payout_entries payout)
|
||||
(filter (comp :payment_id :type_charge_details))
|
||||
(map (fn [p] {:charge/external-id (str "square/charge/" (:payment_id (:type_charge_details p)))}))))})
|
||||
(map (fn [p]
|
||||
(let [payment-id (:payment_id (:type_charge_details p))]
|
||||
(remove-nils
|
||||
{:charge/external-id (scoped-key "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)
|
||||
(into []))
|
||||
(catch Throwable e
|
||||
@@ -585,7 +613,8 @@
|
||||
(de/chain
|
||||
(get-payment client (:payment_id r))
|
||||
(fn [payment]
|
||||
#:sales-refund {:external-id (str "square/refund/" (:id r))
|
||||
#:sales-refund {:db/id (existing-id (dc/db conn) :sales-refund/external-id "square/refund/" client l (:id r))
|
||||
:external-id (scoped-key "square/refund/" client l (:id r))
|
||||
:vendor :vendor/ccp-square
|
||||
:total (amount->money (:amount_money r))
|
||||
:fee (transduce
|
||||
|
||||
Reference in New Issue
Block a user