Files
integreat/src/clj/auto_ap/ssr/ledger/profit_and_loss.clj
Bryce ec4f88b7fc fix(ssr): hide P&L warning box when there is no warning
The profit-and-loss report always passed :warning as a [:div ...] hiccup
vector, which is truthy even when empty. The shared report table renders
its red warning box with (when warning ...), so a clean report with no
warning and no unresolved entries still showed an empty red error box.

Only build the warning div when there is actual warning text or sample
links, matching how the balance-sheet and cash-flows reports pass nil.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:56:43 -07:00

330 lines
16 KiB
Clojure

(ns auto-ap.ssr.ledger.profit-and-loss
(:require
[amazonica.aws.s3 :as s3]
[auto-ap.datomic
:refer [conn pull-many]]
[auto-ap.graphql.utils :refer [assert-can-see-client]]
[auto-ap.ledger :refer [build-account-lookup upsert-running-balance]]
[auto-ap.ledger.reports :as l-reports]
[auto-ap.logging :as alog]
[auto-ap.pdf.ledger :refer [table->pdf *report-pedantic*]]
[auto-ap.permissions :refer [can? wrap-must]]
[auto-ap.routes.ledger :as route]
[auto-ap.routes.utils
:refer [wrap-client-redirect-unauthenticated]]
[auto-ap.ssr-routes :as ssr-routes]
[auto-ap.ssr.components :as com]
[auto-ap.ssr.form-cursor :as fc]
[auto-ap.ssr.hx :as hx]
[auto-ap.ssr.ledger.report-table :refer [cell-count concat-tables table]]
[auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]]
[auto-ap.ssr.svg :as svg]
[auto-ap.ssr.ui :refer [base-page]]
[auto-ap.ssr.utils
:refer [apply-middleware-to-all-handlers clj-date-schema html-response
modal-response wrap-form-4xx-2 wrap-schema-enforce]]
[auto-ap.time :as atime]
[bidi.bidi :as bidi]
[clj-pdf.core :as pdf]
[clj-time.coerce :as coerce]
[clj-time.core :as time]
[clojure.java.io :as io]
[clojure.string :as str]
[config.core :refer [env] :as env]
[datomic.api :as dc]
[iol-ion.utils :refer [by]]
[malli.core :as mc])
(:import
[java.util UUID]
[org.apache.commons.io.output ByteArrayOutputStream]))
(def query-schema (mc/schema
[:maybe [:map
[:client {:unspecified/value :all}
[:or
[:enum :all]
[:vector {:coerce? true :min 1}
[:entity-map {:pull [:db/id :client/name :client/feature-flags]}]]]]
[:column-per-location {:default false}
[:boolean {:decode/string {:enter #(if (= % "on") true
(boolean %))}}]]
[:include-deltas {:default false}
[:boolean {:decode/string {:enter #(if (= % "on") true
(boolean %))}}]]
[:periods {:unspecified/fn (fn [] (let [now (atime/local-now)]
[{:start (atime/as-local-time (time/date-time (time/year now)
1
1))
:end (atime/local-now)}]))}
[:vector {:coerce? true}
[:map
[:start clj-date-schema]
[:end clj-date-schema]]]]]]))
;; TODO
;; 1. Rerender form when running
;; 2. Don't throw crazy errors when missing a field
;; 3. General cleanup of the patterns in run-balance-sheet
;; 4. Review ledger dialog
;; 5. pagination and filtering within dialog. looks weird with the full screen refresh
(defn get-report [{{:keys [periods client] :as qp} :form-params :as request}]
(when (and (seq periods) client)
(let [client (if (= :all client) (take 5 (:clients request)) client)
client-ids (map :db/id client)
_ (upsert-running-balance (into #{} client-ids))
_ (doseq [client-id client-ids]
(assert-can-see-client (:identity request) client-id))
lookup-account (->> client-ids
(map (fn build-lookup [client-id]
[client-id (build-account-lookup client-id)]))
(into {}))
data (into []
(for [client-id client-ids
p periods
[client-id account-id location debits credits balance count sample] (iol-ion.query/detailed-account-snapshot (dc/db conn) client-id (coerce/to-date (:end p)) (coerce/to-date (:start p)))
:let [account ((or (lookup-account client-id) {}) account-id)]]
{:client-id client-id
:account-id account-id
:location location
:debits debits
:credits credits
:count count
:amount (if (#{:account-type/asset
:account-type/dividend
:account-type/expense} (:account_type account))
(- (or debits 0.0) (or credits 0.0))
(- (or credits 0.0) (or debits 0.0)))
:account-type (:account_type account)
:numeric-code (:numeric_code account)
:name (:name account)
:sample sample
:period {:start (coerce/to-date (:start p)) :end (coerce/to-date (:end p))}}))
args (assoc (:form-params request)
:periods (map (fn [d]
{:start (coerce/to-date (:start d)) :end (coerce/to-date (:end d))}) periods))
clients (pull-many (dc/db conn) [:client/code :client/name :db/id :client/feature-flags] client-ids)
pnl-data (l-reports/->PNLData args data (by :db/id :client/code clients))
#_#__ (clojure.pprint/pprint pnl-data)
report (l-reports/summarize-pnl pnl-data)]
(alog/info ::profit-and-loss :params args)
{:data report
:report report})))
(defn maybe-trim-clients [request client]
(if (= :all client)
(cond-> {:client (take 20 (:clients request))}
(> (count (:clients request)) 20)
(assoc :warning "You requested a report with more than 20 clients. This report will only contain the first 20."))
{:client client}))
(defn profit-and-loss* [{{:keys [periods client]} :form-params :as request}]
[:div#report
(when (and periods client)
(let [{:keys [client warning]} (maybe-trim-clients request client)
{:keys [data report]} (get-report (assoc-in request [:form-params :client] client))
client-count (count (set (map :client-id (:data data))))
table-contents (concat-tables (concat (:summaries report) (:details report)))
warning-text (not-empty (str/join "\n " (filter not-empty [warning (:warning report)])))
sample-links (when (can? (:identity request)
{:subject :history
:activity :view})
(seq (for [n (:invalid-ids report)]
[:div
(com/link {:href (str (bidi/path-for ssr-routes/only-routes
:admin-history)
"/" n)}
"Sample")])))]
(list
[:div.text-2xl.font-bold.text-gray-600 (str "Profit and loss - " (str/join ", " (map :client/name client)))]
(table {:widths (into [20] (take (dec (cell-count table-contents))
(mapcat identity
(repeat
(if (-> data :args :include-deltas)
[13 6 13]
[13 6])))))
:investigate-url (bidi.bidi/path-for ssr-routes/only-routes ::route/investigate)
:table table-contents
:warning (when (or warning-text sample-links)
[:div warning-text sample-links])}))))])
(defn form* [request & children]
(let [params (or (:query-params request) {})]
(fc/start-form
params
(:form-errors request)
[:div#profit-and-loss-form.flex.flex-col.gap-4.mt-4
[:div.flex.gap-8
[:form {:hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/run-profit-and-loss)
:hx-target "#profit-and-loss-form"
:hx-swap "outerHTML"
:hx-disabled-elt "find fieldset"}
[:fieldset
[:div.flex.gap-8 {:x-data (hx/json {})}
(fc/with-field :client
(com/validated-inline-field
{:label "Customers" :errors (fc/field-errors)}
(com/multi-typeahead {:name (fc/field-name)
:placeholder "Search for companies..."
:class "w-64"
:id "client"
:url (bidi/path-for ssr-routes/only-routes :company-search)
:value (fc/field-value)
:value-fn :db/id
:content-fn :client/name})))
(fc/with-field :periods
(com/validated-inline-field {:label "Periods"
:errors (fc/field-errors)}
(com/periods-dropdown {:value (fc/field-value)})))
(fc/with-field :column-per-location
(com/toggle {:name (fc/field-name)
:checked (fc/field-value)}
"Column per location"))
(fc/with-field :include-deltas
(com/toggle {:name (fc/field-name)
:checked (fc/field-value)}
"Include deltas"))
(com/button {:color :primary :class "w-32"}
"Run")
(com/button {:formaction (bidi.bidi/path-for ssr-routes/only-routes ::route/export-profit-and-loss)} "Export PDF")]]]]
children])))
(defn form [request]
(html-response (form* (assoc request :query-params (:form-params request))
(profit-and-loss* request))
:headers {"hx-retarget" "#profit-and-loss-form"
"hx-push-url" (str "?" (:query-string request))}))
(defn profit-and-loss [request]
(base-page
request
(com/page {:nav com/main-aside-nav
:client-selection (:client-selection request)
:clients (:clients request)
:client (:client request)
:identity (:identity request)
:request request}
(apply com/breadcrumbs {} [[:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)}
"Ledger"]])
(form* request))
"Profit and loss"))
(defn make-profit-and-loss-pdf [request report]
(let [output-stream (ByteArrayOutputStream.)
date (:periods (:form-params request))
table (concat-tables (:details report))]
(pdf/pdf
(-> [{:left-margin 10 :right-margin 10 :top-margin 15 :bottom-margin 15
:size :letter
:font {:size 6
:ttf-name "fonts/calibri-light.ttf"}}
[:heading (str "Profit and Loss - " (str/join ", " (map :client/name (seq (:client (:form-params request))))))]]
(conj [:paragraph {:color [128 0 0] :size 9} (:warning report)])
(into
(for [table (concat (:summaries report)
(:details report))]
(table->pdf table
(into [20] (take (dec (cell-count table))
(mapcat identity
(repeat
(if (-> (:form-params request) :include-deltas)
[13 6 13]
[13 6]))))))))
#_(conj
(table->pdf table
(into [20] (take (dec (cell-count table))
(mapcat identity
(repeat
(if (-> (:form-params request) :include-deltas)
[13 6 13]
[13 6]))))))))
output-stream)
(.toByteArray output-stream)))
(defn join-names [client-ids]
(str/replace (->> client-ids (pull-many (dc/db conn) [:client/name]) (map :client/name) (str/join "-")) #"[^\w]" "_"))
(defn profit-and-loss-args->name [request]
(let [{:keys [client periods]} (:form-params request)
client (if (= :all client) (:clients request) client)
date (some-> periods last :end (atime/unparse-local atime/iso-date))
name (->> client (map :db/id) join-names)]
(format "Profit-and-loss-%s-for-%s" date name)))
(defn print-profit-and-loss [request]
(let [uuid (str (UUID/randomUUID))
{:keys [client warning]} (maybe-trim-clients request (:client (:form-params request)))
request (assoc-in request [:form-params :client] client)
pdf-data (binding [*report-pedantic* (boolean ((set (:client/feature-flags (first client)))
"report-pedantic"))] (make-profit-and-loss-pdf request (:report (get-report request))))
name (profit-and-loss-args->name request)
key (str "reports/profit-and-loss/" uuid "/" name ".pdf")
url (str "https://" (:data-bucket env) "/" key)]
(s3/put-object :bucket-name (:data-bucket env/env)
:key key
:input-stream (io/make-input-stream pdf-data {})
:metadata {:content-length (count pdf-data)
:content-type "application/pdf"})
@(dc/transact conn
[{:report/name name
:report/client (map :db/id client)
:report/key key
:report/url url
:report/creator (:user (:identity request))
:report/created (java.util.Date.)}])
{:report/name name
:report/url url}))
;; TODO PRINT WARNING
(defn export [request]
(modal-response
(com/modal {}
(com/modal-card
{}
"Ready!"
(com/modal-body {}
(let [bs (print-profit-and-loss request)]
[:div.flex.flex-col.mt-4.space-y-4.items-center
[:a {:href (:report/url bs)}
[:div.w-24.h-24.bg-green-50.rounded-full.p-4.text-green-300 {:class " hover:scale-110 transition duration-100"}
svg/download]]
[:span.text-gray-800
"Click "
(com/link {:href (:report/url bs)} "here")
" to download"]]))
nil))
:headers (-> {}
(assoc "hx-retarget" ".modal-stack")
(assoc "hx-reswap" "beforeend"))))
(def key->handler
(apply-middleware-to-all-handlers
(->
{::route/profit-and-loss (-> profit-and-loss
(wrap-schema-enforce :query-schema query-schema)
(wrap-form-4xx-2 profit-and-loss))
::route/run-profit-and-loss (-> form
(wrap-schema-enforce :form-schema query-schema)
(wrap-form-4xx-2 form))
::route/export-profit-and-loss (-> export
(wrap-schema-enforce :form-schema query-schema)
(wrap-form-4xx-2 form))})
(fn [h]
(-> h
#_(wrap-merge-prior-hx)
(wrap-must {:activity :read :subject :profit-and-loss})
(wrap-nested-form-params)
(wrap-client-redirect-unauthenticated)))))