(ns auto-ap.jobs.sales-summaries (:require [auto-ap.datomic :refer [conn]] [auto-ap.datomic.sales-summaries :as d-ss] [auto-ap.jobs.core :refer [execute]] [auto-ap.logging :as alog] [auto-ap.time :as atime] [clj-time.coerce :as c] [clj-time.core :as time] [clj-time.periodic :as per] [clojure.string :as str] [com.brunobonacci.mulog :as mu] [config.core :refer [env]] [datomic.api :as dc])) (defn mark-dirty [client start end] (let [client (dc/entid (dc/db conn) client)] @(dc/transact conn (for [s (per/periodic-seq start end (time/days 1))] {:sales-summary/client client :sales-summary/date (c/to-date s) :sales-summary/dirty true :sales-summary/client+date [client (c/to-date s)]})))) (defn last-n-days [n] [(.toDateMidnight (atime/localize (time/plus (time/now) (time/days (- n))))) (.toDateMidnight (atime/localize (time/now)))]) (defn mark-all-dirty [days] (doseq [[c] (dc/q '[:find ?c :in $ :where [_ :sales-order/client ?c]] (dc/db conn))] (apply mark-dirty c (last-n-days days)))) (defn lookup-account [number] (ffirst (dc/q '[:find ?a :in $ ?number :where [?a :account/numeric-code ?number]] (dc/db conn) number))) (defn delete-all [] @(dc/transact-async conn (->> (dc/q '[:find ?ss :where [?ss :sales-summary/date]] (dc/db conn)) (map (fn [[ss]] [:db/retractEntity ss]))))) (def item-read "Enough of a summary item to both evaluate `d-ss/accepted?` and transact the item back unchanged. `:db/id` matters: `:sales-summary/items` is a component attribute upserted via `[:reset-rels ...]`, so an item re-transacted without its id is deleted and recreated." '[:db/id :sales-summary-item/category :sales-summary-item/sort-order :sales-summary-item/manual? :ledger-mapped/amount {:ledger-mapped/ledger-side [:db/ident]} {:ledger-mapped/account [:db/id]}]) (defn dirty-sales-summaries "The client's dirty summaries, with enough of each item to evaluate and re-transact it. `index-pull` returns a lazy seq running from `:start` to the END of the index, so this must stop at the client boundary rather than filter: `:sales-summary/client+dirty` sorts by client first, so every later client's summaries sit beyond this client's and filtering would walk all of them — for every client — pulling their items on the way. That is quadratic in the number of summaries, and it showed up as a full refresh degrading from ~180 client-days a minute to ~3 as the summary count grew." [c] (let [client-id (dc/entid (dc/db conn) c)] (->> (dc/index-pull (dc/db conn) {:index :avet :selector (conj '[:sales-summary/date :sales-summary/client :db/id] {:sales-summary/items item-read}) :start [:sales-summary/client+dirty [client-id true]]}) (take-while (fn [sales-summary] (= client-id (:db/id (:sales-summary/client sales-summary)))))))) (def default-days "How far back the scheduled refresh looks for summaries that still need recomputing." 7) (defn trailing-window "`[start end)` covering the last `days` business days, ending with today. `end` is exclusive, matching both `periodic-seq`'s 3-arity and the grid's date filters, so `(trailing-window 7)` is day -6 through today inclusive." [days] [(.toDateMidnight (atime/localize (time/minus (time/now) (time/days (dec days))))) (.toDateMidnight (atime/localize (time/plus (time/now) (time/days 1))))]) (defn accepted-client+dates "Set of `[client-id date]` pairs in `[start end)` whose summary is already accepted, and so should be left alone rather than re-marked. Accepted means balanced with every line mapped to an account — the condition the grid renders as \"Balanced\"." [db start end] (->> (dc/q '[:find (pull ?ss selector) :in $ ?start ?end selector :where [?ss :sales-summary/date ?d] [(>= ?d ?start)] [(< ?d ?end)]] db (c/to-date start) (c/to-date end) (conj '[:sales-summary/date {:sales-summary/client [:db/id]}] {:sales-summary/items item-read})) (map first) (filter #(d-ss/accepted? (map d-ss/<-pulled-item (:sales-summary/items %)))) (map (juxt (comp :db/id :sales-summary/client) :sales-summary/date)) set)) (defn mark-stale-dirty "Marks every client/day in the trailing `days` window dirty so `sales-summaries-v2` will recompute it, skipping days whose summary is already accepted. Because accepted is derived rather than stored, a summary that later falls out of balance is picked up again on the next run. Returns the number of client/days marked." [days] (let [db (dc/db conn) [start end] (trailing-window days) accepted (accepted-client+dates db start end) clients (map first (dc/q '[:find ?c :in $ :where [_ :sales-order/client ?c]] db)) dates (map c/to-date (per/periodic-seq start end (time/days 1))) tx-data (for [client clients date dates :when (not (accepted [client date]))] {:sales-summary/client client :sales-summary/date date :sales-summary/dirty true :sales-summary/client+date [client date]})] (alog/info ::marking-dirty :days days :client-count (count clients) :accepted-count (count accepted) :marked (count tx-data)) (doseq [batch (partition-all 500 tx-data)] @(dc/transact conn batch)) (count tx-data))) (defn- get-fee [c date] (- (or (ffirst (dc/q '[:find ?f :in $ ?client ?d :where [?e :expected-deposit/client ?client] [?e :expected-deposit/sales-date ?d] [?e :expected-deposit/fee ?f]] (dc/db conn) c 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 "cash refunds" 41400 "food app payments" 72350 "unknown" 40000 "discounts" 41000 "fees" 75400 "alcohol" 46900 "beverages" 42000 "bowls" 40118 "catering" 43000 "ezcater catering" 43010 "desserts" 40116 "fries" 40117 "plates" 40113 "sides" 40115 "soup & salads" 40114 "uncategorized" 40000 "tax" 25700 "tip" 25500 "card refunds" 41400 "food app refunds" 41400}) (defn get-payment-items [c date] (->> (dc/q '[:find ?processor ?type-name (sum ?total) :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/type-name ?type-name] (or-join [?c ?processor] (and [?c :charge/processor ?p] [?p :db/ident ?processor]) (and (not [?c :charge/processor]) [(ground :ccp-processor/na) ?processor])) [?c :charge/total ?total]] (dc/db conn) [[c] date date]) (reduce (fn [acc [processor type-name total]] (update acc (cond (= type-name "CARD") "Card Payments" (= type-name "CASH") "Cash Payments" (#{"SQUARE_GIFT_CARD" "WALLET" "GIFT_CARD"} type-name) "Gift Card Payments" (#{:ccp-processor/toast #_:ccp-processor/ezcater #_:ccp-processor/koala :ccp-processor/doordash :ccp-processor/grubhub :ccp-processor/uber-eats} processor) "Food App Payments" :else "Unknown") (fnil + 0.0) total)) {}) (map (fn [[k v]] {:db/id (str (java.util.UUID/randomUUID)) :sales-summary-item/sort-order 0 :sales-summary-item/category k :ledger-mapped/amount (if (= "Card Payments" k) (- v (get-fee c date)) v) :ledger-mapped/ledger-side :ledger-side/debit})))) (defn get-discounts [c date] (when-let [discount (ffirst (dc/q '[:find (sum ?discount) :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/discount ?discount]] (dc/db conn) [[c] date date]))] {:db/id (str (java.util.UUID/randomUUID)) :sales-summary-item/sort-order 1 :sales-summary-item/category "Discounts" :ledger-mapped/amount discount :ledger-mapped/ledger-side :ledger-side/debit})) (defn get-refund-items [c date] (->> (dc/q '[:find ?type-name (sum ?t) :with ?e :in $ [?clients ?start-date ?end-date] :where :where [(iol-ion.query/scan-sales-refunds $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]] [?e :sales-refund/type ?type-name] [?e :sales-refund/total ?t]] (dc/db conn) [[c] date date]) (reduce (fn [acc [type-name total]] (update acc (cond (= type-name "CARD") "Card Refunds" (= type-name "CASH") "Cash Refunds" :else "Food App Refunds") (fnil + 0.0) total)) {}) (map (fn [[k v]] {:db/id (str (java.util.UUID/randomUUID)) :sales-summary-item/sort-order 3 :sales-summary-item/category k :ledger-mapped/amount v :ledger-mapped/ledger-side :ledger-side/credit})))) (defn get-fees [c date] (when-let [fee (get-fee c date)] {:db/id (str (java.util.UUID/randomUUID)) :sales-summary-item/sort-order 2 :sales-summary-item/category "Fees" :ledger-mapped/amount fee :ledger-mapped/ledger-side :ledger-side/debit})) (defn- get-tax [c date] {:db/id (str (java.util.UUID/randomUUID)) :sales-summary-item/category "Tax" :sales-summary-item/sort-order 1 :ledger-mapped/ledger-side :ledger-side/credit :ledger-mapped/amount (or (ffirst (dc/q '[:find (sum ?tax) :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/tax ?tax] #_[?e :sales-order/charges ?c] #_[?c :charge/tax ?tax]] (dc/db conn) [[c] date date])) 0.0)}) (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 (+ (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) :with ?e ?li :in $ [?clients ?start-date ?end-date] :where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]] [?e :sales-order/line-items ?li] [(get-else $ ?li :order-line-item/category "Unknown") ?category] [?li :order-line-item/total ?total] [?li :order-line-item/tax ?tax] [?li :order-line-item/discount ?discount]] (dc/db conn) [[c] date date]))] (for [[category total tax discount] sales] {:db/id (str (java.util.UUID/randomUUID)) :sales-summary-item/category category :sales-summary-item/sort-order 0 :sales-summary-item/total total :sales-summary-item/net (- (+ total discount) tax) :sales-summary-item/tax tax :sales-summary-item/discount discount :ledger-mapped/ledger-side :ledger-side/credit :ledger-mapped/amount (- (+ total discount) tax) #_#_:ledger-mapped/account nil}))) ;; A day carrying refunds and no sales at all is left out of balance on purpose. It is tempting ;; to close it by booking a return against the day's refunds — the arithmetic works, and no ;; trading day could be affected. Do not. Those days are overwhelmingly not "a refund settled ;; while the restaurant was shut": they are days whose *orders were never imported*, on client ;; records that took ownership of another record's refunds through the unscoped keys this branch ;; fixes. Balancing them would convert the only signal that a client's sales are missing into ;; silence. See `docs/2026-08-15-sales-summary-rollout-plan.md`. (defn- get-returns [c date] (when-let [amount (ffirst (dc/q '[:find (sum ?r) :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/returns ?r] #_[?e :sales-order/charges ?c] #_[?c :charge/tax ?tax]] (dc/db conn) [[c] date date]))] {:db/id (str (java.util.UUID/randomUUID)) :sales-summary-item/category "Returns" :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 refresh-client! "Recomputes every dirty summary for one client. Split out of the driver loop so a client's work stands on its own: it can be run for a single client, and a backfill over the whole history can spread clients across threads instead of grinding through the largest ones one day at a time." [c client-code] (doseq [{:sales-summary/keys [date] :db/keys [id] :as existing-summary} (dirty-sales-summaries c)] (mu/with-context {:client-code client-code :date date} (alog/info ::updating) (let [manual-items (->> existing-summary :sales-summary/items (filter :sales-summary-item/manual?) (map d-ss/<-pulled-item)) calculated-items (->> (get-sales c date) (concat (get-payment-items c date)) (concat (get-refund-items c date)) (cons (get-discounts c date)) (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] (assoc z :ledger-mapped/account (some-> z :sales-summary-item/category str/lower-case name->number lookup-account) :sales-summary-item/manual? false)))) all-items (concat calculated-items manual-items) result {:db/id id :sales-summary/client c :sales-summary/date date :sales-summary/dirty false :sales-summary/client+date [c date] :sales-summary/items all-items}] (if (seq (:sales-summary/items result)) (do (alog/info ::upserting-summaries :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}])))))) (defn sales-summaries-v2 "Recomputes every dirty summary, client by client." [] (doseq [[c client-code] (dc/q '[:find ?c ?client-code :in $ :where [?c :client/code ?client-code]] (dc/db conn))] (refresh-client! c client-code))) (defn reset-summaries [] @(dc/transact conn (->> (dc/q '[:find ?sos :in $ :where [?sos :sales-summary/client]] (dc/db conn)) (map (fn [[sos]] [:db/retractEntity sos]))))) (comment (auto-ap.datomic/transact-schema conn) (apply mark-dirty [:client/code "NGCL"] (last-n-days 30)) (apply mark-dirty [:client/code "NGDG"] (last-n-days 30)) (dirty-sales-summaries [:client/code "NGWH"]) (apply mark-dirty [:client/code "NGWH"] (last-n-days 5)) (iol-ion.tx.upsert-sales-summary-ledger/summary->journal-entry (dc/db conn) 17592314245819) (iol-ion.tx.upsert-sales-summary-ledger/upsert-sales-summary (dc/db conn) {:db/id 17592314241429}) (mark-all-dirty 5) (delete-all) (sales-summaries-v2) 1 (dc/q '[:find (pull ?sos [* {:sales-summary/sales-items [*]}]) :in $ :where [?sos :sales-summary/client [:client/code "NGHW"]] [?sos :sales-summary/date ?d] [(= ?d #inst "2024-04-10T00:00:00-07:00")]] (dc/db conn)) (dc/q '[:find ?n ?p2 (sum ?total) :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/type-name ?n] [?c :charge/processor ?p] [?p :db/ident ?p2] [?c :charge/total ?total]] (dc/db conn) [[(auto-ap.datomic/pull-attr (dc/db conn) :db/id [:client/code "NGHW"])] #inst "2024-04-11T00:00:00-07:00" #inst "2024-04-11T00:00:00-07:00"]) (dc/q '[:find ?n :in $ [?clients ?start-date ?end-date] :where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]] [?e :sales-order/line-items ?li] [?li :order-line-item/item-name ?n]] (dc/db conn) [[(auto-ap.datomic/pull-attr (dc/db conn) :db/id [:client/code "NGCL"])] #inst "2024-04-11T00:00:00-07:00" #inst "2024-04-24T00:00:00-07:00"]) @(dc/transact conn [{:db/id :sales-summary/total-tax :db/ident :sales-summary/total-tax-legacy} {:db/id :sales-summary/total-tip :db/ident :sales-summary/total-tip-legacy}]) (auto-ap.datomic/transact-schema conn)) (defn days-arg "Trailing-window size from the job's `args`, e.g. `{:days 30}` set as a container override from the admin Background Jobs page for an ad-hoc wider backfill. Values arrive as EDN but may still be strings, so coerce defensively the way load-historical-sales does." [args] (let [days (:days args)] (cond-> (or days default-days) (string? days) (#(Long/parseLong %))))) (defn refresh-sales-summaries "Marks the trailing `days` window dirty, skipping accepted summaries, then recomputes everything left dirty." ([] (refresh-sales-summaries default-days)) ([days] (mark-stale-dirty days) (sales-summaries-v2))) (defn -main [& _] (execute "sales-summaries" #(refresh-sales-summaries (days-arg (:args env)))))