fix(sales-summaries): count reversed tips, credit Square service charges
Two calculation defects that leave a day out of balance, plus the first tests to cover sales summaries. Tips: get-tip joins through :sales-order/charges, so it only sees tips that settled on a tender. A return-only order has no tender — it carries the reversal on :sales-order/tip, which nothing read — so the day credited a tip that had been handed back. Now additive: tendered tips plus the tips on orders that have no tender at all. Deliberately not a swap, because where an order does have a tender the tender is the correct source; real orders exist whose tender carries a tip their :sales-order/tip does not (auto-gratuity booked as a service charge, wallet tips missing from the net amounts), and reading the order would drop them. Service charges: nothing reads :sales-order/service-charge. The charge is collected inside the card tender but no line credits it, so every order carrying one leaves the day short by exactly that amount. Both signs count — a returned catering fee arrives as a negative service charge and is subtracted back out of :sales-order/returns, so dropping negatives would lose the reversal. The vendor gate is load-bearing: ezCater service charges are commission deducted from the restaurant rather than collected from the diner, and crediting those makes the day worse. It matches on :sales-order/vendor where that is set and falls back to the external id prefix where it is not — whole eras of Square orders carry no vendor attribute at all, and a gate on vendor alone would have silently credited nothing. Service charges stay behind the per-client "summary-service-charges" flag, and the account they map to (49000 Service Income) needs accounting sign-off before that flag is enabled anywhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -147,8 +147,18 @@
|
||||
date))
|
||||
0.0)))
|
||||
|
||||
(def service-charges-account
|
||||
"Where a credited Square service charge lands. 49000 is the existing \"Service Income\"
|
||||
revenue account, which is the closest fit for auto-gratuity and catering fees.
|
||||
|
||||
NEEDS ACCOUNTING SIGN-OFF before `service-charges-flag` is enabled for any client: the wrong
|
||||
account misstates revenue, and a category with no account at all keeps a day from ever
|
||||
reaching accepted, since `accepted?` requires every line to be mapped."
|
||||
49000)
|
||||
|
||||
(def name->number
|
||||
{"gyros and pitas" 40111
|
||||
"service charges" service-charges-account
|
||||
"returns" 41300
|
||||
"card payments" 75460
|
||||
"cash payments" 75452
|
||||
@@ -292,21 +302,48 @@
|
||||
[[c] date date]))
|
||||
0.0)})
|
||||
|
||||
(defn- get-tip [c date]
|
||||
(defn- tendered-tip
|
||||
"Tips read off the tenders, which is where a tip actually settles."
|
||||
[c date]
|
||||
(or (ffirst (dc/q '[:find (sum ?tip)
|
||||
:with ?c
|
||||
:in $ [?clients ?start-date ?end-date]
|
||||
:where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
|
||||
[?e :sales-order/charges ?c]
|
||||
[?c :charge/tip ?tip]]
|
||||
(dc/db conn)
|
||||
[[c] date date]))
|
||||
0.0))
|
||||
|
||||
(defn- untendered-tip
|
||||
"Tips on orders that carry no tender at all. A return-only order reverses its tip on
|
||||
`:sales-order/tip` but has no charge to join through, so the reversal is invisible to
|
||||
`tendered-tip` and the day ends up crediting a tip that was handed back."
|
||||
[c date]
|
||||
(or (ffirst (dc/q '[:find (sum ?tip)
|
||||
:with ?e
|
||||
:in $ [?clients ?start-date ?end-date]
|
||||
:where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
|
||||
[?e :sales-order/tip ?tip]
|
||||
(not [?e :sales-order/charges])]
|
||||
(dc/db conn)
|
||||
[[c] date date]))
|
||||
0.0))
|
||||
|
||||
(defn- get-tip
|
||||
"Tendered tips plus the tips on untendered orders. Additive rather than substitutive on
|
||||
purpose: where an order does have a tender, the tender is the correct source, and real
|
||||
orders exist whose tender carries a tip their `:sales-order/tip` does not — auto-gratuity
|
||||
booked as a service charge, and wallet tips absent from the net amounts. Reading the order
|
||||
instead of the tender would drop those."
|
||||
[c date]
|
||||
{:ledger-mapped/ledger-side :ledger-side/credit
|
||||
:sales-summary-item/sort-order 2
|
||||
:db/id (str (java.util.UUID/randomUUID))
|
||||
|
||||
:sales-summary-item/category "Tip"
|
||||
:ledger-mapped/amount (or (ffirst (dc/q '[:find (sum ?tip)
|
||||
:with ?c
|
||||
:in $ [?clients ?start-date ?end-date]
|
||||
:where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
|
||||
[?e :sales-order/charges ?c]
|
||||
[?c :charge/tip ?tip]]
|
||||
(dc/db conn)
|
||||
[[c] date date]))
|
||||
0.0)})
|
||||
:ledger-mapped/amount (+ (tendered-tip c date)
|
||||
(untendered-tip c date))})
|
||||
|
||||
(defn- get-sales [c date]
|
||||
(let [sales (->> (dc/q '[:find ?category (sum ?total) (sum ?tax) (sum ?discount)
|
||||
@@ -349,6 +386,74 @@
|
||||
:ledger-mapped/amount amount
|
||||
:ledger-mapped/ledger-side :ledger-side/debit}))
|
||||
|
||||
(def service-charges-flag
|
||||
"Per-client rollout lever for crediting Square service charges, in the same style as
|
||||
`new-square` and `import-custom-amount`. Absent, the summary behaves exactly as it does
|
||||
today."
|
||||
"summary-service-charges")
|
||||
|
||||
(defn- service-charges-enabled? [c]
|
||||
(contains? (set (:client/feature-flags (dc/pull (dc/db conn) '[:client/feature-flags] c)))
|
||||
service-charges-flag))
|
||||
|
||||
(defn service-charge-total
|
||||
"Square service charges for the day, both signs.
|
||||
|
||||
A service charge is collected inside the card tender but nothing credits it, so every order
|
||||
carrying one leaves the day short by exactly that amount. Both signs matter: a returned
|
||||
catering fee arrives as a negative service charge and is subtracted back out of
|
||||
`:sales-order/returns`, so dropping negatives would lose the reversal.
|
||||
|
||||
The vendor gate is load-bearing — ezCater service charges are commission deducted from the
|
||||
restaurant rather than collected from the diner, and crediting those would make things worse.
|
||||
It matches on `:sales-order/vendor` where that is set and falls back to the external id
|
||||
prefix where it is not, because whole eras of Square orders carry no vendor attribute at all
|
||||
and a gate on vendor alone silently credits nothing.
|
||||
|
||||
Kept separate from the rollout flag so the arithmetic can be measured on its own."
|
||||
[c date]
|
||||
(ffirst (dc/q '[:find (sum ?service-charge)
|
||||
:with ?e
|
||||
:in $ [?clients ?start-date ?end-date]
|
||||
:where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
|
||||
[?e :sales-order/service-charge ?service-charge]
|
||||
(or-join [?e]
|
||||
[?e :sales-order/vendor :vendor/ccp-square]
|
||||
(and (not [?e :sales-order/vendor])
|
||||
[?e :sales-order/external-id ?external-id]
|
||||
[(clojure.string/starts-with? ?external-id "square/order/")]))]
|
||||
(dc/db conn)
|
||||
[[c] date date])))
|
||||
|
||||
(defn- get-service-charges
|
||||
"The day's service charges as a summary item, for clients opted in to the rollout."
|
||||
[c date]
|
||||
(when (service-charges-enabled? c)
|
||||
(when-let [amount (service-charge-total c date)]
|
||||
(when-not (zero? amount)
|
||||
{:db/id (str (java.util.UUID/randomUUID))
|
||||
:sales-summary-item/category "Service Charges"
|
||||
:sales-summary-item/sort-order 2
|
||||
:ledger-mapped/amount amount
|
||||
:ledger-mapped/ledger-side :ledger-side/credit}))))
|
||||
|
||||
(def ^:private suspect-categories
|
||||
"The terms a balancing investigation keeps returning to. Logged beside the imbalance so a
|
||||
day's shape can be read out of the logs without re-running the job."
|
||||
["Tip" "Service Charges" "Returns" "Card Refunds" "Cash Refunds" "Food App Refunds"])
|
||||
|
||||
(defn- suspect-totals
|
||||
"Amounts for `suspect-categories` present on this day, omitting the ones that are zero."
|
||||
[items]
|
||||
(into {}
|
||||
(for [category suspect-categories
|
||||
:let [amount (->> items
|
||||
(filter #(= category (:sales-summary-item/category %)))
|
||||
(map #(:ledger-mapped/amount % 0.0))
|
||||
(reduce + 0.0))]
|
||||
:when (not (zero? amount))]
|
||||
[category amount])))
|
||||
|
||||
(defn sales-summaries-v2 []
|
||||
(doseq [[c client-code] (dc/q '[:find ?c ?client-code
|
||||
:in $
|
||||
@@ -370,6 +475,7 @@
|
||||
(cons (get-fees c date))
|
||||
(cons (get-tax c date))
|
||||
(cons (get-tip c date))
|
||||
(cons (get-service-charges c date))
|
||||
(cons (get-returns c date))
|
||||
(filter identity)
|
||||
(map (fn [z]
|
||||
@@ -385,7 +491,10 @@
|
||||
(if (seq (:sales-summary/items result))
|
||||
(do
|
||||
(alog/info ::upserting-summaries
|
||||
:category-count (count (:sales-summary/items result)))
|
||||
:category-count (count (:sales-summary/items result))
|
||||
:imbalance (d-ss/imbalance all-items)
|
||||
:balanced? (d-ss/balanced? all-items)
|
||||
:suspect-totals (suspect-totals all-items))
|
||||
@(dc/transact conn [[:upsert-sales-summary result]]))
|
||||
@(dc/transact conn [{:db/id id :sales-summary/dirty false}]))))))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user