(ns auto-ap.ledger.reports #?@ (:clj [(:require [auto-ap.time :as atime] [auto-ap.utils :refer [dollars-0?]] [clojure.string :as str])] :cljs [(:require [auto-ap.utils :refer [dollars-0?]] [auto-ap.views.utils :as au] [clojure.string :as str])])) (defn date->str [d] #?(:clj (atime/unparse-local d atime/normal-date) :cljs (au/date->str d au/pretty))) (def ranges {:assets [11000 19999] :liabilities [20100 28999] :equities [30000 39999] :sales [40000 49999] :cogs [50000 59999] :payroll [60000 69999] :controllable [70000 79999] :fixed-overhead [80000 89999] :ownership-controllable [90000 99999]}) (def groupings {:assets [["1100 Cash and Bank Accounts" 11000 11999] ["1200 Accounts Receivable" 12000 12999] ["1300 Inventory" 13000 13999] ["1400 Prepaid Expenses" 14000 14999] ["1500 Property and Equipment" 15000 15999] ["1600 Intangible Assets" 16000 16999] ["1700 Other Assets" 17000 19999]] :liabilities [["2000 Accounts Payable" 20100 23999] ["2400 Accrued Expenses" 24000 24999] ["2500 Other Liabilities" 25000 25999] ["2600 Split Accounts" 26000 26999] ["2700 Current Portion of Long-Term Debt" 27000 27999] ["2800 Notes Payable" 28000 28999]] :equities [["3000 Owner's Equity" 30000 39999]] :sales [["40000-43999 Food Sales " 40000 43999] ["44000-46999 Alcohol Sales" 44000 46999] ["47000 Merchandise Sales" 47000 47999] ["48000 Other Operating Income" 48000 48999] ["49000 Non-Business Income" 49000 49999]] :cogs [ ["50000-54000 Food Costs" 50000 53999] ["54000-56000 Alcohol Costs" 54000 55999] ["56000 Merchandise Costs" 56000 56999] ["57000-60000 Other Costs of Sales" 57000 59999]] :payroll [["60000 Payroll - General" 60000 60999] ["61000 Payroll - Management" 61000 61999] ["62000 Payroll - BOH" 62000 62999] ["63000-66000 Payroll - FOH" 63000 65999] ["66000-70000 Payroll - Other" 66000 69999]] :controllable [["70000 72000 GM Controllable Costs - Ops Related" 70000 71999] ["72000 GM Controllable Costs - Customer Related" 72000 72999] ["73000 GM Controllable Costs - Employee Related" 73000 73999] ["74000 GM Controllable Costs - Building & Equipment Related" 74000 74999] ["75000 GM Controllable Costs - Office & Management Related" 75000 75999] ["76000-80000 GM Controllable Costs - Other" 76000 79999]] :fixed-overhead [["80000-82000 Operational Costs" 80000 81999] ["82000 Occupancy Costs" 82000 82999] ["83000 Utility Costs" 83000 83999] ["84000 Equipment Rental" 84000 84999] ["85000-87000 Taxes & Insurance" 85000 86999] ["87000-90000 Other Non-Controllable Costs" 87000 89999]] :ownership-controllable [["90000-93000 Research & Entertainment" 90000 92999] ["93000 Bank Charges & Interest" 93000 93999] ["94000-96000 Other Owner Controllable Costs" 94000 95999] ["96000 Depreciation" 96000 96999] ["97000 Taxes" 97000 97999] ["98000 Other Expenses" 98000 98999]]}) (defn in-range? [code] (if code (reduce (fn [acc [start end]] (if (<= start code end) (reduced true) acc)) false (vals ranges)) false)) (defn client-locations [pnl-data] (->> pnl-data :data (filter (comp in-range? :numeric-code)) (filter #(not= "A" (:location %))) (group-by (juxt :client-id :location)) (filter (fn [[_ as]] (not (dollars-0? (reduce + 0 (map :amount as)))))) (mapcat second) (map (fn [a] (if (or (not (:client-id a)) (empty? (:location a))) nil [(:client-id a) (:location a)]))) (filter identity) (set) (sort-by (fn [x] [(:client-id x) (if (= (:location x) "HQ" ) "ZZZZZZ" (:location x))])))) (defn locations [pnl-data] (->> (client-locations pnl-data) (map second) set (sort-by (fn [x] (if (= x "HQ" ) "ZZZZZZ" x))))) (defn aggregate-accounts [pnl-data] (reduce (fnil + 0.0) 0.0 (map :amount (:data pnl-data)))) (defn best-category [a] (->> ranges (filter (fn [[_ [start end]]] (and (:numeric-code a) (<= start (:numeric-code a) end)))) first first)) (defn filter-client [pnl-data client] (-> pnl-data (update :data (fn [data] ((group-by :client-id data) client))) (update :filters (fn [f] (assoc f :client-id client))))) (defn filter-location [pnl-data location] (-> pnl-data (update :data (fn [data] ((group-by :location data) location))) (update :filters (fn [f] (assoc f :location location))))) (defn filter-numeric-code [pnl-data from to] (-> pnl-data (update :data (fn [data] (filter #(<= from (or (:numeric-code %) 0) to) data))) (update :filters (fn [f] (assoc f :from-numeric-code from :to-numeric-code to))))) (defn filter-categories [pnl-data categories] (if (= 1 (count categories)) (let [[from to] (ranges (first categories))] (-> pnl-data (filter-numeric-code from to))) (-> pnl-data (update :data (fn [data] (mapcat identity ((apply juxt categories) (group-by best-category data)))))))) (defn filter-period [pnl-data period] (-> pnl-data (update :data (fn [data] ((group-by :period data) period))) (update :filters (fn [f] (assoc f :date-range period))))) (defn zebra [pnl-data i] (if (odd? i) (assoc-in pnl-data [:cell-args :bg-color] [240 240 240]) pnl-data)) (defn negate [pnl-data types] (update pnl-data :data (fn [accounts] (map (fn [account] (if (types (best-category account)) (update account :amount -) account)) accounts)))) (defn used-accounts [pnl-datas] (->> pnl-datas (mapcat :data) (map #(select-keys % [:numeric-code :name])) (set) (sort-by :numeric-code))) (defn subtotal-by-column-row [pnl-datas title & [cell-args]] (into [{:value title :bold true}] (map (fn [p] (merge {:format :dollar :value (aggregate-accounts p) :filters (when (:from-numeric-code (:filters p)) ;; don't allow filtering when you don't at least filter numeric codes (:filters p))} (:cell-args p) cell-args)) pnl-datas))) (defn calc-percent-of-sales [table pnl-datas] (let [sales (map (fn [p] (aggregate-accounts (filter-categories p [:sales]))) pnl-datas)] (->> table (map (fn [[_ & values]] (map (fn [v s] {:border (:border v) :bg-color (:bg-color v) :format (if (string? (:value v)) :text :percent) :color [128 128 128] :value (cond (string? (:value v)) "" (dollars-0? s) 0.0 :else (/ (:value v) s))}) values sales)))))) (defn calc-deltas [table] (->> table (map (fn [[_ & values]] (->> values (partition 2 1) (map (fn [[a b]] (if (or (string? (:value b)) (string? (:value a))) {:value "" :format :text :bg-color (:bg-color b)} {:border (:border b) :format :dollar :value (- (:value b) (:value a)) :bg-color (:bg-color b)})))))))) (defn combine-tables ([[pnl-data] table percent-of-sales deltas] (map (fn [[title & row] percent-of-sales deltas ] (let [deltas (cons {:value 0.0 :format :dollar :border (:border (first row))} deltas)] (into [title] (mapcat (fn [v p d] (if (:include-deltas (:args pnl-data)) [v p d] [v p])) row percent-of-sales deltas)))) table percent-of-sales deltas))) (defn headers [[pnl-data :as pnl-datas] header-title] (let [big-header (into [{:value header-title :bold true}] (map-indexed (fn [i p] (cond-> {:value (str (date->str (:start p)) " - " (date->str (:end p))) :colspan (cond (-> pnl-data :args :include-deltas) 3 (-> pnl-data :args :column-per-location) (* 2 (/ (count pnl-datas) (count (-> pnl-data :args :periods)))) :else 2) :align :center :bold true} (odd? i) (assoc :bg-color [240 240 240]))) (:periods (:args pnl-data)))) sub-header (into [{:value ""}] (if (-> pnl-data :args :column-per-location) (mapcat (fn [p] (cond-> [(merge {:value (or (when (-> p :filters :location) (str ((-> p :client-codes) (-> p :filters :client-id)) "-" (-> p :filters :location))) "Total") :align :right} (:cell-args p)) (merge {:value "%" :align :right} (:cell-args p))] (-> pnl-data :args :include-deltas) (conj (merge {:value "+/-" :align :right} (:cell-args p))))) pnl-datas) (mapcat (fn [p] (cond-> [(merge {:value "Amt" :align :right} (:cell-args p)) (merge {:value "%" :align :right} (:cell-args p))] (-> pnl-data :args :include-deltas) (conj (merge {:value "+/-" :align :right} (:cell-args p))))) pnl-datas)))] [big-header sub-header])) (defn location-summary-table [pnl-datas title] (let [table [(subtotal-by-column-row (map #(filter-categories % [:sales]) pnl-datas) "Sales") (subtotal-by-column-row (map #(filter-categories % [:cogs ]) pnl-datas) "Cogs") (subtotal-by-column-row (map #(filter-categories % [:payroll ]) pnl-datas) "Payroll") (subtotal-by-column-row (map #(-> % (filter-categories [:sales :payroll :cogs]) (negate #{:payroll :cogs})) pnl-datas) "Gross Profits") (subtotal-by-column-row (map #(filter-categories % [:controllable :fixed-overhead :ownership-controllable]) pnl-datas) "Overhead") (subtotal-by-column-row (map #(-> % (filter-categories [:sales :cogs :payroll :controllable :fixed-overhead :ownership-controllable]) (negate #{:cogs :payroll :controllable :fixed-overhead :ownership-controllable})) pnl-datas) "Net Income")] percent-of-sales (calc-percent-of-sales table pnl-datas) deltas (calc-deltas table)] {:header (headers pnl-datas title) :rows (combine-tables pnl-datas table percent-of-sales deltas)})) (defn detail-rows [pnl-datas grouping title] (let [pnl-datas (map #(filter-categories % [grouping]) pnl-datas) individual-accounts (for [[grouping-name from to] (groupings grouping) :let [pnl-datas (map #(filter-numeric-code % from to) pnl-datas) account-codes (used-accounts pnl-datas)] :when (seq account-codes) row (-> [(into [{:value (str "---" grouping-name "---")}] (map (fn [p] (assoc (:cell-args p) :value "" :format "")) pnl-datas) )] (into (for [{:keys [numeric-code name]} account-codes] (into [{:value name}] (map (fn [p] (let [pnl-data (-> p (filter-numeric-code numeric-code numeric-code))] (merge {:format :dollar :filters (:filters pnl-data) :value (aggregate-accounts pnl-data)} (:cell-args p)))) pnl-datas)))) (conj (subtotal-by-column-row pnl-datas "" {:border [:top]})))] row)] (-> [(into [{:value title :bold true}] (map (fn [p] (assoc (:cell-args p) :value "" :format "")) pnl-datas))] (into individual-accounts) (conj (subtotal-by-column-row pnl-datas title))))) (defn location-detail-table [pnl-datas client-datas title prefix] (let [table (-> [] (into (detail-rows pnl-datas :sales (str prefix " Sales"))) (into (detail-rows pnl-datas :cogs (str prefix " COGS"))) (into (detail-rows pnl-datas :payroll (str prefix " Payroll"))) (conj (subtotal-by-column-row (map #(filter-categories % [:payroll :cogs]) pnl-datas) (str prefix " Prime Costs"))) (conj (subtotal-by-column-row (map #(-> % (filter-categories [:sales :payroll :cogs]) (negate #{:payroll :cogs})) pnl-datas) (str prefix " Gross Profits"))) (into (detail-rows pnl-datas :controllable (str prefix " Controllable Expenses"))) (into (detail-rows pnl-datas :fixed-overhead (str prefix " Fixed Overhead"))) (into (detail-rows pnl-datas :ownership-controllable (str prefix " Ownership Controllable"))) (conj (subtotal-by-column-row (map #(filter-categories % [:controllable :fixed-overhead :ownership-controllable]) pnl-datas) (str prefix " Overhead"))) (conj (subtotal-by-column-row (map #(-> % (filter-categories [:sales :cogs :payroll :controllable :fixed-overhead :ownership-controllable]) (negate #{:cogs :payroll :controllable :fixed-overhead :ownership-controllable})) pnl-datas) (str prefix " Net Income")))) table (if (seq client-datas) (conj table (subtotal-by-column-row (map #(-> % (filter-categories [:sales :cogs :payroll :controllable :fixed-overhead :ownership-controllable]) (negate #{:cogs :payroll :controllable :fixed-overhead :ownership-controllable})) client-datas) "All Location Net Income")) table) percent-of-sales (calc-percent-of-sales table pnl-datas) deltas (into [] (calc-deltas table))] {:header (headers pnl-datas title) :rows (combine-tables pnl-datas table percent-of-sales deltas)})) (defn warning-message [pnl-data] (let [errors (->> pnl-data :data (filter (fn [{:keys [numeric-code]}] (nil? numeric-code)))) error-count (count errors)] (when (> error-count 0) (str "This report does not include " (str/join ", " (map #(str (:count %) " unresolved ledger entries for " (if (str/blank? (:location %)) " all locations" (:location %))) errors)))))) (defn summarize-pnl [pnl-data] {:warning (warning-message pnl-data) :summaries (if (-> pnl-data :args :column-per-location) [(location-summary-table (mapcat identity (for [[period i] (map vector (-> pnl-data :args :periods ) (range))] (concat (for [[client-id location] (client-locations pnl-data)] (-> pnl-data (filter-client client-id) (filter-location location) (filter-period period) (zebra i))) [(zebra (filter-period pnl-data period) i)]))) "All location Summary")] (for [[client-id location] (client-locations pnl-data)] (location-summary-table (for [[period i] (map vector (-> pnl-data :args :periods ) (range))] (-> pnl-data (filter-client client-id) (filter-location location) (filter-period period) (zebra i))) (str (-> pnl-data :clients-by-id (get client-id)) " (" location ") Summary")))) :details (doall (if (-> pnl-data :args :column-per-location) [(location-detail-table (mapcat identity (for [[period i] (map vector (-> pnl-data :args :periods ) (range))] (concat (for [[client-id location] (client-locations pnl-data)] (-> pnl-data (filter-client client-id) (filter-location location) (filter-period period) (zebra i))) [(-> pnl-data (filter-period period) (zebra i))]))) nil "All location Detail" "")] (for [[client-id location] (client-locations pnl-data)] (location-detail-table (for [[period i] (map vector (-> pnl-data :args :periods ) (range))] (-> pnl-data (filter-client client-id) (filter-location location) (filter-period period) (zebra i))) (for [[period i] (map vector (-> pnl-data :args :periods ) (range))] (-> pnl-data (filter-client client-id) (filter-period period) (zebra i))) (str (-> pnl-data :clients-by-id (get client-id)) " (" location ") Detail") location))))}) (defn balance-sheet-headers [pnl-data] [(cond-> [{:value "Period Ending"} {:value (date->str (:date (:args pnl-data)))}] (:include-comparison (:args pnl-data)) (into [{:value (date->str (:comparison-date (:args pnl-data)))} {:value "+/-"}]))]) (defn append-deltas [table] (->> table (map (fn [[title a b]] [title a b (and (:value a) (:value b) {:border (:border b) :format :dollar :value (- (or (:value a) 0.0) (or (:value b) 0.0))})])))) (defn summarize-balance-sheet [pnl-data] (let [pnl-datas (map (fn [p] (filter-period pnl-data p)) (:periods (:args pnl-data))) table (-> [] (into (detail-rows pnl-datas :assets "Assets")) (into (detail-rows pnl-datas :liabilities "Liabilities")) (into (detail-rows pnl-datas :equities "Owner's Equity")) (conj (subtotal-by-column-row (map #(-> % (filter-categories [:sales :cogs :payroll :controllable :fixed-overhead :ownership-controllable]) (negate #{:cogs :payroll :controllable :fixed-overhead :ownership-controllable})) pnl-datas) "Retained Earnings"))) table (if (:include-comparison (:args pnl-data)) (append-deltas table) table)] {:header (balance-sheet-headers pnl-data) :rows table})) (defrecord PNLData [args data client-codes])