diff --git a/src/clj/auto_ap/ssr/ledger/balance_sheet.clj b/src/clj/auto_ap/ssr/ledger/balance_sheet.clj index 45f6b56d..4236cd40 100644 --- a/src/clj/auto_ap/ssr/ledger/balance_sheet.clj +++ b/src/clj/auto_ap/ssr/ledger/balance_sheet.clj @@ -16,12 +16,12 @@ [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.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 + html-response wrap-form-4xx-2 wrap-schema-enforce]] [auto-ap.time :as atime] [bidi.bidi :as bidi] @@ -235,30 +235,12 @@ :report/creator (:user (:identity request)) :report/created (java.util.Date.)}]) {:report/name name - :report/url url})) + :report/url url + :report/clients client})) ;; TODO PRINT WARNING (defn export [request] - (modal-response - (com/modal {} - (com/modal-card - {} - "Ready!" - (com/modal-body {} - (let [bs (print-balance-sheet 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")))) + (export-modal request (print-balance-sheet request))) (def key->handler (apply-middleware-to-all-handlers diff --git a/src/clj/auto_ap/ssr/ledger/cash_flows.clj b/src/clj/auto_ap/ssr/ledger/cash_flows.clj index c5ad8ed3..ec5d9306 100644 --- a/src/clj/auto_ap/ssr/ledger/cash_flows.clj +++ b/src/clj/auto_ap/ssr/ledger/cash_flows.clj @@ -16,13 +16,13 @@ [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 :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]] + wrap-form-4xx-2 wrap-schema-enforce]] [auto-ap.time :as atime] [bidi.bidi :as bidi] [clj-pdf.core :as pdf] @@ -226,31 +226,12 @@ :report/creator (:user (:identity request)) :report/created (java.util.Date.)}]) {:report/name name - :report/url url})) + :report/url url + :report/clients client})) ;; 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")))) + (export-modal request (print-cash-flows request))) (def key->handler (apply-middleware-to-all-handlers diff --git a/src/clj/auto_ap/ssr/ledger/export_modal.clj b/src/clj/auto_ap/ssr/ledger/export_modal.clj new file mode 100644 index 00000000..fc25891b --- /dev/null +++ b/src/clj/auto_ap/ssr/ledger/export_modal.clj @@ -0,0 +1,105 @@ +(ns auto-ap.ssr.ledger.export-modal + "Shared \"your report is ready\" modal for the SSR ledger reports. + + Offers the generated PDF for download and, for admins running a report for a + single client, a `mailto:` hand-off pre-filled with that client's email + contacts. Nothing is sent by the server - the link opens the user's own mail + client so they can review before sending." + (:require + [auto-ap.datomic :refer [conn pull-many]] + [auto-ap.graphql.utils :refer [is-admin?]] + [auto-ap.routes.transactions :as transaction-routes] + [auto-ap.ssr-routes :as ssr-routes] + [auto-ap.ssr.components :as com] + [auto-ap.ssr.svg :as svg] + [auto-ap.ssr.utils :refer [modal-response]] + [bidi.bidi :as bidi] + [clojure.string :as str] + [config.core :refer [env]] + [datomic.api :as dc]) + (:import + [java.net URLEncoder])) + +(def recipient-separator + "RFC 6068 separator for multiple mailto recipients. Outlook, Gmail and Apple + Mail all accept a comma; the legacy SPA used a semicolon, which only Outlook + understood." + ",") + +(defn url-encode + "Percent-encode a mailto header value. URLEncoder does form encoding, so + spaces come back as `+`; mail clients want %20." + [s] + (-> (URLEncoder/encode (str s) "UTF-8") + (str/replace "+" "%20"))) + +(defn app-url [route] + (str (:base-url env) (bidi/path-for ssr-routes/only-routes route))) + +(defn email-body [report-url] + (str + "Hello, +Click here (" report-url ") to download your financial reports. We have not finished reviewing and reconciling these numbers with you. Please review and let us know if anything seems missing or in need of correction. +Click here (" (:base-url env) ") to login to the Financials app to review the details here. +Click here (https://share.vidyard.com/watch/MHTo5PyXPxXUpVH93RWFM9?) for a video on how to run a P&L on your own. +To see a history of past financial reports, click here: " (app-url :company-reports) " + +NOTE: Please review the transactions we may have question for you here: " (app-url ::transaction-routes/requires-feedback-page) ". You can either edit the transaction to what expense account it should be or email back what it should be.")) + +(defn email-contacts + "Email contacts configured on `clients` (a seq of entity maps with :db/id)." + [clients] + (->> (pull-many (dc/db conn) + [{:client/emails [:email-contact/email :email-contact/description]}] + (map :db/id clients)) + (mapcat :client/emails) + (filter :email-contact/email))) + +(defn recipients + "The contacts to offer a mailto hand-off to, or nil when we should not offer + one: only admins may email a report out, and only for a single client - a + multi-client report would expose one client's numbers to another's contacts." + [request clients] + (when (and (is-admin? (:identity request)) + (= 1 (count clients))) + (seq (email-contacts clients)))) + +(defn mailto-href [contacts {:report/keys [name url]}] + (str "mailto:" (str/join recipient-separator (map :email-contact/email contacts)) + "?subject=" (url-encode (str name " is ready")) + "&body=" (url-encode (email-body url)))) + +(defn describe-contacts [contacts] + (str/join ", " (map (fn [{:email-contact/keys [email description]}] + (if (str/blank? description) + email + (str email " (" description ")"))) + contacts))) + +(defn export-modal + "Modal response for a freshly printed `report` - the map returned by the + report namespaces' print-* functions." + [request {:report/keys [url clients] :as report}] + (let [contacts (recipients request clients)] + (modal-response + (com/modal {} + (com/modal-card + {} + "Ready!" + (com/modal-body {} + [:div.flex.flex-col.mt-4.space-y-4.items-center + [:a {:href url} + [: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 url} "here") + " to download"] + (when contacts + [:span.text-gray-800.text-center + "Once you've confirmed you're happy with it, click " + (com/link {:href (mailto-href contacts report)} "here") + " to open your email client and to send it to " + (describe-contacts contacts) + "."])]) + nil))))) diff --git a/src/clj/auto_ap/ssr/ledger/profit_and_loss.clj b/src/clj/auto_ap/ssr/ledger/profit_and_loss.clj index e869355c..1093418f 100644 --- a/src/clj/auto_ap/ssr/ledger/profit_and_loss.clj +++ b/src/clj/auto_ap/ssr/ledger/profit_and_loss.clj @@ -16,13 +16,13 @@ [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 :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]] + wrap-form-4xx-2 wrap-schema-enforce]] [auto-ap.time :as atime] [bidi.bidi :as bidi] [clj-pdf.core :as pdf] @@ -282,31 +282,12 @@ :report/creator (:user (:identity request)) :report/created (java.util.Date.)}]) {:report/name name - :report/url url})) + :report/url url + :report/clients client})) ;; 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")))) + (export-modal request (print-profit-and-loss request))) (def key->handler (apply-middleware-to-all-handlers diff --git a/test/clj/auto_ap/ssr/ledger/export_modal_test.clj b/test/clj/auto_ap/ssr/ledger/export_modal_test.clj new file mode 100644 index 00000000..301972ae --- /dev/null +++ b/test/clj/auto_ap/ssr/ledger/export_modal_test.clj @@ -0,0 +1,86 @@ +(ns auto-ap.ssr.ledger.export-modal-test + (:require + [auto-ap.datomic :refer [conn]] + [auto-ap.integration.util :refer [admin-token test-client user-token + wrap-setup]] + [auto-ap.ssr.ledger.export-modal :as sut] + [clojure.string :as str] + [clojure.test :refer [deftest is testing use-fixtures]] + [datomic.api :as dc])) + +(use-fixtures :each wrap-setup) + +(def report {:report/name "Profit-and-loss-2026-07-31-for-Acme" + :report/url "https://data.example.com/reports/pnl/abc/report.pdf"}) + +(defn client-with-emails + "Transacts a client with `emails` contacts. :client/emails is a plain ref, not + a component, so each nested contact needs its own tempid." + [emails] + (let [suffix (rand-int 1000000) + client-tid (str "client-" suffix) + contacts (map-indexed (fn [i e] (assoc e :db/id (str "email-contact-" suffix "-" i))) + emails) + tempids (:tempids @(dc/transact conn + (into [(test-client :db/id client-tid + :client/emails (map :db/id contacts))] + contacts)))] + {:db/id (get tempids client-tid)})) + +(defn modal-body [request report] + (:body (sut/export-modal request report))) + +(deftest export-modal-download-link + (testing "Should always offer the report for download" + (let [client (client-with-emails []) + body (modal-body {:identity (admin-token)} + (assoc report :report/clients [client]))] + (is (str/includes? body (:report/url report)) + "the S3 url should be linked") + (is (str/includes? body "to download"))))) + +(deftest export-modal-email-handoff + (testing "Should offer a mailto hand-off addressed to the client's contacts" + (let [client (client-with-emails [{:email-contact/email "owner@acme.com" + :email-contact/description "Owner"} + {:email-contact/email "cpa@acme.com"}]) + body (modal-body {:identity (admin-token)} + (assoc report :report/clients [client]))] + (is (str/includes? body "mailto:")) + (is (str/includes? body "owner@acme.com")) + (is (str/includes? body "cpa@acme.com")) + (is (str/includes? body "Owner") + "contacts should be described so the sender can see who it goes to"))) + + (testing "Should not offer a hand-off when the client has no email contacts" + (let [client (client-with-emails []) + body (modal-body {:identity (admin-token)} + (assoc report :report/clients [client]))] + (is (not (str/includes? body "mailto:"))))) + + (testing "Should not offer a hand-off to non-admins" + (let [client (client-with-emails [{:email-contact/email "owner@acme.com"}]) + body (modal-body {:identity (user-token (:db/id client))} + (assoc report :report/clients [client]))] + (is (not (str/includes? body "mailto:"))))) + + (testing "Should not offer a hand-off for a multi-client report" + (let [a (client-with-emails [{:email-contact/email "owner@acme.com"}]) + b (client-with-emails [{:email-contact/email "owner@beta.com"}]) + body (modal-body {:identity (admin-token)} + (assoc report :report/clients [a b]))] + (is (not (str/includes? body "mailto:")) + "one client's numbers must not be mailed to another client's contacts")))) + +(deftest mailto-href-encoding + (let [contacts [{:email-contact/email "owner@acme.com"} + {:email-contact/email "cpa@acme.com"}] + href (sut/mailto-href contacts report)] + (testing "Should address every contact" + (is (str/starts-with? href "mailto:owner@acme.com,cpa@acme.com?"))) + (testing "Should percent-encode the subject and body" + (is (str/includes? href "subject=Profit-and-loss-2026-07-31-for-Acme%20is%20ready")) + (is (not (re-find #"[\s\n]" href)) + "raw whitespace would truncate the href")) + (testing "Should carry the report url in the body" + (is (str/includes? (sut/email-body (:report/url report)) (:report/url report))))))