Files
integreat/src/clj/auto_ap/ssr/ledger/balance_sheet.clj
Bryce 0df9a29022 feat(ssr): add report email hand-off to ledger export modal
The SSR ledger reports could generate and download a PDF but had lost the
SPA's ability to hand the report off to the client's email contacts. This
restores it for Profit and Loss, Balance Sheet and Cash Flows.

The server still sends nothing: the modal offers a mailto: link, pre-filled
with the client's email contacts, subject and body, that opens in the user's
own mail client so they can review before sending.

Extracts the modal the three reports duplicated into a shared
auto-ap.ssr.ledger.export-modal namespace, and tightens the SPA's rules
along the way:

- admin-only, uniformly (the SPA's Cash Flows page skipped this check)
- single client only, uniformly (the SPA's Balance Sheet page did not check,
  so a multi-client report could be mailed to one client's contacts)
- recipients joined with "," per RFC 6068 rather than Outlook's ";"
- subject percent-encoded, like the body already was
- body links built from :base-url and bidi routes, fixing the dead
  /reports/ link (the page now lives at /company/reports) and the
  hardcoded prod domain in the requires-feedback link

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 22:55:53 -07:00

262 lines
12 KiB
Clojure

(ns auto-ap.ssr.ledger.balance-sheet
(: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]]
[auto-ap.permissions :refer [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.export-modal :refer [export-modal]]
[auto-ap.ssr.ledger.report-table :as rtable]
[auto-ap.ssr.ui :refer [base-page]]
[auto-ap.ssr.utils
:refer [apply-middleware-to-all-handlers clj-date-schema
html-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]}]]]]
[:include-deltas {:default false}
[:boolean {:decode/string {:enter #(if (= % "on") true
(boolean %))}}]]
[:date {:unspecified/fn (fn [] [(atime/local-now)])}
[:vector {:coerce? true
:decode/string (fn [s] (if (string? s) (str/split s #", ")
s))}
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
(defn get-report [{{:keys [date client] :as qp} :query-params :as request}]
(when (and date client)
(let [client (if (= :all client) (take 5 (:clients request)) client)
date (reverse (sort date))
client-ids (map :db/id client)
_ (doseq [client-id client-ids]
(assert-can-see-client (:identity request) client-id))
_ (upsert-running-balance (into #{} client-ids))
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
d date
[client-id account-id location debits credits balance count] (iol-ion.query/detailed-account-snapshot (dc/db conn) client-id (coerce/to-date d))
: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 balance
:account-type (:account_type account)
:numeric-code (:numeric_code account)
:name (:name account)
:period (coerce/to-date d)}))
args (assoc (:query-params request)
:periods (map coerce/to-date (filter identity date)))
clients (pull-many (dc/db conn) [:client/code :client/name :db/id] client-ids)
pnl-data (l-reports/->PNLData args data (by :db/id :client/code clients))
report (l-reports/summarize-balance-sheet pnl-data)]
(alog/info ::balance-sheet :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 balance-sheet* [{{:keys [date client]} :query-params :as request}]
[:div#report
(when (and date client)
(let [{:keys [client warning]} (maybe-trim-clients request client)
{:keys [data report]} (get-report (assoc-in request [:query-params :client] client))
client-count (count (set (map :client-id (:data data))))]
(list
[:div.text-2xl.font-bold.text-gray-600 (str "Balance Sheet - " (str/join ", " (map :client/name client)))]
(rtable/table {:widths (cond-> (into [30] (repeat 13 client-count))
(> (count date) 1) (into (repeat 13 (* 2 client-count (dec (count date)))))
(and (> client-count 1) (= (count date) 1)) (conj 13))
:investigate-url (bidi.bidi/path-for ssr-routes/only-routes ::route/investigate)
:table report
:warning (not-empty (str/join "\n " (filter not-empty [warning (:warning report)])))}))))])
(defn form* [request & children]
(let [params (or (:query-params request) {})]
(fc/start-form
params
(:form-errors request)
[:div#balance-sheet-form.flex.flex-col.gap-4.mt-4
[:div.flex.gap-8
[:form {:hx-get (bidi.bidi/path-for ssr-routes/only-routes ::route/run-balance-sheet)
:hx-target "#balance-sheet-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 :date
(com/validated-inline-field {:label "Date"
:errors (fc/field-errors)}
(com/dates-dropdown {:value (fc/field-value)
:name (fc/field-name)})))
(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-balance-sheet)} "Export PDF")]]]]
children])))
(defn form [request]
(html-response (form* request
(balance-sheet* request))
:headers {"hx-retarget" "#balance-sheet-form"
"hx-push-url" (str "?" (:query-string request))}))
(defn balance-sheet [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))
"Balance Sheet"))
(defn make-balance-sheet-pdf [request report]
(let [output-stream (ByteArrayOutputStream.)
client-count (count (or (seq (:client (:query-params request)))
(seq (:client (:form-params request)))))
date (:date (:query-params request))]
(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 "Balance Sheet - " (str/join ", " (map :client/name (or (seq (:client (:query-params request)))
(seq (:client (:form-params request)))))))]]
(conj [:paragraph {:color [128 0 0] :size 9} (:warning report)])
(conj
(table->pdf report
(cond-> (into [30] (repeat client-count 13))
(> (count date) 1) (into (repeat (* 2 client-count (dec (count date))) 13))
(and (> client-count 1) (= (count date) 1)) (conj 13)))))
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 balance-sheet-args->name [request]
(let [date (atime/unparse-local
(:date (:query-params request))
atime/iso-date)
name (->> request :query-params :client (map :db/id) join-names)]
(format "Balance-sheet-%s-for-%s" date name)))
(defn print-balance-sheet [request]
(let [uuid (str (UUID/randomUUID))
{:keys [client warning]} (maybe-trim-clients request (:client (:query-params request)))
request (assoc-in request [:query-params :client] client)
pdf-data (make-balance-sheet-pdf request (:report (get-report request)))
name (balance-sheet-args->name request)
key (str "reports/balance-sheet/" 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
:report/clients client}))
;; TODO PRINT WARNING
(defn export [request]
(export-modal request (print-balance-sheet request)))
(def key->handler
(apply-middleware-to-all-handlers
(->
{::route/balance-sheet (-> balance-sheet
(wrap-schema-enforce :query-schema query-schema)
(wrap-form-4xx-2 balance-sheet))
::route/run-balance-sheet (-> form
(wrap-schema-enforce :query-schema query-schema)
(wrap-form-4xx-2 form))
::route/export-balance-sheet (-> export
(wrap-schema-enforce :query-schema query-schema)
(wrap-form-4xx-2 form))})
(fn [h]
(-> h
#_(wrap-merge-prior-hx)
(wrap-must {:activity :read :subject :balance-sheet})
(wrap-client-redirect-unauthenticated)))))