(ns auto-ap.ssr.ledger.cash-flows (: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]] [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.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]}]]]] [: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 (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) _ (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] (iol-ion.query/detailed-account-snapshot (dc/db conn) client-id (coerce/to-date (time/plus (:end p) (time/days 1)))) :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 {: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-ids) pnl-data (l-reports/->PNLData args data (by :db/id :client/code clients)) report (l-reports/summarize-cash-flows pnl-data)] (alog/info ::cash-flows :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 cash-flows* [{ {: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 (:details report))] (list [:div.text-2xl.font-bold.text-gray-600 (str "Cash flows - " (str/join ", " (map :client/name client)))] (table {:widths (into [20] (take (dec (cell-count table-contents)) (mapcat identity (repeat [13 6])))) :investigate-url (bidi.bidi/path-for ssr-routes/only-routes ::route/investigate) :table table-contents :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#cash-flows-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-cash-flows) :hx-target "#cash-flows-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)}))) (com/button {:color :primary :class "w-32"} "Run") (com/button {:formaction (bidi.bidi/path-for ssr-routes/only-routes ::route/export-cash-flows) } "Export PDF")]]]] children]))) (defn form [request] (html-response (form* (assoc request :query-params (:form-params request)) (cash-flows* request)) :headers {"hx-retarget" "#cash-flows-form" "hx-push-url" (str "?" (:query-string request))})) (defn cash-flows [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)) "Cash Flows")) (defn make-cash-flows-pdf [request report] (let [ output-stream (ByteArrayOutputStream.) date (:periods (:form-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 (seq (:client (:form-params request))))))]] (conj [:paragraph {:color [128 0 0] :size 9} (:warning report)]) (conj (table->pdf (concat-tables (:details report)) (into [20 ] (mapcat identity (repeat (count date) [ 13 13 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 cash-flows-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-cash-flows [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 (make-cash-flows-pdf request (:report (get-report request))) name (cash-flows-args->name request) key (str "reports/cash-flows/" 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-cash-flows 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/cash-flows (-> cash-flows (wrap-schema-enforce :query-schema query-schema) (wrap-form-4xx-2 cash-flows)) ::route/run-cash-flows (-> form (wrap-schema-enforce :form-schema query-schema) (wrap-form-4xx-2 form)) ::route/export-cash-flows (-> 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 :cash-flows}) (wrap-nested-form-params) (wrap-client-redirect-unauthenticated)))))