(ns auto-ap.ssr.invoice.new-invoice-wizard "New / Edit Invoice wizard, migrated onto the session-backed engine (wizard2). A dual-purpose wizard (create + edit) with a CONDITIONAL middle step: basic-details --(customize-accounts = :default)--> :done (default vendor account) \\-(customize-accounts = :customize)-> accounts --> :done Per-step data lives in the Ring session (wizard-state); the engine's get-all merges them for the done-fn (`create-invoice!`). Only an opaque wizard-id + current-step ride in the form -- no EDN snapshot. The pre-migration `mm` flow routed basic-details \"Save\" through a PUT /navigate whose `:to` query-schema 500s on empty query-params (the {}->nil main-transformer quirk); the engine's submit is a POST with no query-schema, so that latent bug is gone. Dates in step-data are kept as java.util.Date (#inst) -- EDN-safe for the cookie session store, unlike clj-time DateTimes (which have no reader); they are coerced to clj-time only for display." (:require [auto-ap.datomic :refer [audit-transact conn pull-attr]] [auto-ap.datomic.accounts :as d-accounts] [auto-ap.datomic.invoices :as d-invoices] [auto-ap.graphql.utils :refer [assert-can-see-client assert-not-locked exception->4xx exception->notification]] [auto-ap.logging :as alog] [auto-ap.routes.invoice :as route] [auto-ap.routes.utils :refer [wrap-client-redirect-unauthenticated]] [auto-ap.solr :as solr] [auto-ap.ssr-routes :as ssr-routes] [auto-ap.ssr.components :as com] [auto-ap.ssr.components.wizard-state :as ws] [auto-ap.ssr.components.wizard2 :as wizard2] [auto-ap.ssr.hx :as hx] [auto-ap.ssr.invoice.common :refer [default-read]] [auto-ap.ssr.nested-form-params :as nfp] [auto-ap.ssr.svg :as svg] [auto-ap.ssr.utils :refer [->db-id apply-middleware-to-all-handlers assert-schema check-allowance check-location-belongs clj-date-schema entity-id form-validation-error html-response main-transformer modal-response money path->name2 strip wrap-schema-enforce]] [auto-ap.time :as atime] [bidi.bidi :as bidi] [clj-time.coerce :as coerce] [clj-time.core :as time] [clojure.string :as str] [datomic.api :as dc] [hiccup.util :as hu] [iol-ion.query :refer [dollars=]] [malli.core :as mc] [malli.util :as mut] [slingshot.slingshot :refer [try+]])) ;; --------------------------------------------------------------------------- ;; Domain helpers (unchanged from the pre-migration wizard). ;; --------------------------------------------------------------------------- (defn get-vendor [vendor-id] (dc/pull (dc/db conn) [:vendor/terms :vendor/automatically-paid-when-due {:vendor/default-account d-accounts/default-read :vendor/account-overrides [:vendor-account-override/client {:vendor-account-override/account d-accounts/default-read}]} {:vendor/terms-overrides [:vendor-terms-override/client :vendor-terms-override/terms]}] vendor-id)) (defn check-vendor-default-account [vendor-id] (some? (:vendor/default-account (get-vendor vendor-id)))) (def new-form-schema [:map [:db/id {:optional true} [:maybe entity-id]] [:customize-due-and-scheduled? {:optional true :default false :decode/arbitrary (fn [x] (if (= "" x) false x))} [:maybe :boolean]] [:customize-accounts {:optional true :default :default} [:enum :default :customize]] [:invoice/client {:optional true} [:maybe entity-id]] [:invoice/date clj-date-schema] [:invoice/due {:optional true} [:maybe clj-date-schema]] [:invoice/scheduled-payment {:optional true} [:maybe clj-date-schema]] [:invoice/vendor {:optional true} [:and entity-id [:fn {:error/message "Vendor is missing default expense account"} check-vendor-default-account]]] [:invoice/invoice-number {:optional true} [:string {:min 1 :decode/string strip}]] [:invoice/total money] [:invoice/expense-accounts [:vector {:coerce? true} [:and [:map [:invoice-expense-account/account [:and entity-id [:fn {:error/message "Not an allowed account."} #(check-allowance % :account/invoice-allowance)]]] [:invoice-expense-account/location :string] [:invoice-expense-account/amount :double]] [:fn {:error/fn (fn [r x] (:type r)) :error/path [:invoice-expense-account/location]} (fn [iea] (check-location-belongs (:invoice-expense-account/location iea) (:invoice-expense-account/account iea)))]]]]]) (defn clientize-vendor [{:vendor/keys [terms-overrides automatically-paid-when-due default-account account-overrides] :as vendor} client-id] (if (nil? vendor) nil (let [terms-override (->> terms-overrides (filter (fn [to] (= (->db-id (:vendor-terms-override/client to)) client-id))) (map :vendor-terms-override/terms) first) account (or (->> account-overrides (filter (fn [to] (= (->db-id (:vendor-account-override/client to)) client-id))) (map :vendor-account-override/account) first) default-account) account (d-accounts/clientize account client-id) automatically-paid-when-due (->> automatically-paid-when-due (filter (fn [to] (= (->db-id to) client-id))) seq boolean) vendor (cond-> vendor terms-override (assoc :vendor/terms terms-override) true (assoc :vendor/automatically-paid-when-due automatically-paid-when-due :vendor/default-account account) true (dissoc :vendor/account-overrides :vendor/terms-overrides))] vendor))) (defn location-select* [{:keys [name account-location client-locations value]}] (let [options (into (cond account-location [[account-location account-location]] (seq client-locations) (into [["Shared" "Shared"]] (for [cl client-locations] [cl cl])) :else [["Shared" "Shared"]]))] (com/select {:options options :name name :value (or value (ffirst options)) :class "w-full"}))) (defn account-typeahead* [{:keys [name value client-id x-model]}] [:div.flex.flex-col (com/typeahead {:name name :placeholder "Search..." :url (hu/url (bidi/path-for ssr-routes/only-routes :account-search) {:client-id client-id :purpose "invoice"}) :id name :x-model x-model :value value :content-fn (fn [value] (:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value) client-id)))})]) (defn assert-no-conflicting [{:invoice/keys [invoice-number client vendor] :db/keys [id]}] (when (seq (d-invoices/find-conflicting {:invoice/invoice-number invoice-number :invoice/vendor (->db-id vendor) :invoice/client (->db-id client) :db/id id})) (form-validation-error (str "Invoice '" invoice-number "' already exists.")))) (defn assert-invoice-amounts-add-up [{:keys [:invoice/expense-accounts :invoice/total]}] (let [expense-account-total (reduce + 0 (map (fn [x] (:invoice-expense-account/amount x)) expense-accounts))] (when-not (dollars= total expense-account-total) (form-validation-error (str "Expense account total (" expense-account-total ") does not equal invoice total (" total ")"))))) (defn- calculate-spread "Helper function to calculate the amount to be assigned to each location" [shared-amount total-locations] (let [base-amount (int (/ shared-amount total-locations)) remainder (- shared-amount (* base-amount total-locations))] {:base-amount base-amount :remainder remainder})) (defn- spread-expense-account "Spreads the expense account amount across the given locations" [locations expense-account] (if (= "Shared" (:invoice-expense-account/location expense-account)) (let [{:keys [base-amount remainder]} (calculate-spread (:invoice-expense-account/amount expense-account) (count locations))] (map-indexed (fn [idx _] (assoc expense-account :invoice-expense-account/amount (+ base-amount (if (< idx remainder) 1 0)) :invoice-expense-account/location (nth locations idx))) locations)) [expense-account])) (defn $->cents [x] (int (let [result (* 100M (bigdec x))] (.setScale result 0 java.math.BigDecimal/ROUND_HALF_UP)))) (defn cents->$ [x] (double (let [result (* 0.01M (bigdec x))] (.setScale result 2 java.math.BigDecimal/ROUND_HALF_UP)))) (defn- apply-total-delta-to-account [invoice-total eas] (when (seq eas) (let [leftover (- invoice-total (reduce + 0 (map :invoice-expense-account/amount eas))) leftover-beyond-a-single-cent? (or (< leftover -1) (> leftover 1)) leftover (if leftover-beyond-a-single-cent? 0 leftover) [first-eas & rest] eas] (cons (update first-eas :invoice-expense-account/amount #(+ % leftover)) rest)))) (defn maybe-spread-locations "Converts any expense account for a \"Shared\" location into a separate expense account for all valid locations for that client" ([invoice] (maybe-spread-locations invoice (pull-attr (dc/db conn) :client/locations (:invoice/client invoice)))) ([invoice locations] (update-in invoice [:invoice/expense-accounts] (fn [expense-accounts] (->> expense-accounts (map (fn [ea] (update ea :invoice-expense-account/amount $->cents))) (mapcat (partial spread-expense-account locations)) (apply-total-delta-to-account ($->cents (:invoice/total invoice))) (map (fn [ea] (update ea :invoice-expense-account/amount cents->$)))))))) ;; --------------------------------------------------------------------------- ;; De-cursored field names + per-render errors. ;; --------------------------------------------------------------------------- (def ^:dynamic *errors* "Humanized form errors for the current step render, keyed by schema paths. Bound by each render fn from the engine ctx :errors." {}) (defn- ferr [& path] (get-in *errors* (vec path))) (defn- err? [& path] (boolean (seq (apply ferr path)))) (defn- ea-name [index field] (path->name2 :invoice/expense-accounts index field)) (defn- ea-errors [index field] (ferr :invoice/expense-accounts index field)) (defn- fmt-date "java.util.Date (#inst, EDN-safe) -> the MM/DD/YYYY display string." [d] (some-> d coerce/from-date (atime/unparse-local atime/normal-date))) ;; --------------------------------------------------------------------------- ;; Schemas + decode (per step). ;; --------------------------------------------------------------------------- (def ^:private basic-details-schema (mc/schema (mut/select-keys new-form-schema #{:invoice/client :invoice/vendor :invoice/date :invoice/due :invoice/scheduled-payment :invoice/total :invoice/invoice-number :db/id :customize-due-and-scheduled? :customize-accounts}))) (def ^:private accounts-schema (mc/schema (mut/select-keys new-form-schema #{:invoice/expense-accounts}))) (defn- ->edn-safe-dates "clj-time DateTimes -> java.util.Date so step-data round-trips through the cookie store." [m] (reduce (fn [acc k] (cond-> acc (get acc k) (update k coerce/to-date))) m [:invoice/date :invoice/due :invoice/scheduled-payment])) (defn- decode-basic-details "Step 1 posts flat invoice/* fields (the engine already stripped its nav fields)." [request] (let [nested (:form-params (nfp/nested-params-request request {}))] (-> (mc/decode basic-details-schema nested main-transformer) ->edn-safe-dates))) (defn- decode-accounts "Step 2: nested invoice/expense-accounts[i][...] rows." [request] (let [nested (:form-params (nfp/nested-params-request request {}))] (mc/decode accounts-schema nested main-transformer))) (defn- validate-basic-details "Mirror the old wrap-schema :fn: a new invoice needs client + vendor + invoice-number; date + total are always required; a chosen vendor must have a default expense account." [data _request] (let [new? (not (:db/id data)) errs (cond-> {} (nil? (:invoice/date data)) (assoc :invoice/date ["required"]) (nil? (:invoice/total data)) (assoc :invoice/total ["required"]) (and new? (nil? (:invoice/client data))) (assoc :invoice/client ["required"]) (and new? (nil? (:invoice/vendor data))) (assoc :invoice/vendor ["required"]) (and new? (str/blank? (:invoice/invoice-number data))) (assoc :invoice/invoice-number ["required"]) (and (:invoice/vendor data) (not (check-vendor-default-account (->db-id (:invoice/vendor data))))) (assoc :invoice/vendor ["Vendor is missing default expense account"]))] (when (seq errs) errs))) ;; --------------------------------------------------------------------------- ;; Renders (de-cursored: explicit data + path->name2 + *errors*). ;; --------------------------------------------------------------------------- (defn account-prediction-radio "The customize-accounts radio (default vendor account vs customize), computed from the posted/known vendor + client. Lives in an async fragment so it appears once a vendor is chosen." [{:keys [vendor-id client-id value]}] (let [vendor (clientize-vendor (get-vendor vendor-id) (->db-id client-id)) account-name (:account/name (:vendor/default-account vendor))] (when vendor (com/radio-list {:name "customize-accounts" :value (name (or value :default)) :options (filter identity [(when account-name {:value (name :default) :content (com/pill {:color :primary} account-name)}) {:value (name :customize) :content [:div "Customize accounts"]}])})))) (defn render-basic-details [{:keys [step-data request errors]}] (binding [*errors* (or errors {})] (let [data (or step-data {}) extant? (:db/id data) client-from-req (:db/id (:client request)) client-val (or (:invoice/client data) client-from-req)] (com/modal-card-advanced {"@keydown.enter.prevent.stop" "if ($refs.next) {$refs.next.click()}" :class " md:w-[750px] md:h-[600px] w-full h-full fade-in transition-opacity duration-300" "x-data" ""} (com/modal-header {} [:div.p-2 (if extant? "Edit invoice" "New invoice")]) (com/modal-body {} [:div {:x-data (hx/json {:clientId client-val :vendorId (:invoice/vendor data) :date (fmt-date (:invoice/date data)) :due (fmt-date (:invoice/due data)) :scheduledPayment (fmt-date (:invoice/scheduled-payment data)) :customizeDueAndScheduled (boolean (:customize-due-and-scheduled? data))})} (when extant? (com/hidden {:name "db/id" :value extant?})) (com/hidden {:name "customize-due-and-scheduled?" :value (boolean (:customize-due-and-scheduled? data)) :x-model "customizeDueAndScheduled"}) (if (or (:client request) extant?) (com/hidden {:name "invoice/client" :value client-val}) (com/validated-field {:label "Client" :errors (ferr :invoice/client)} [:div.w-96 (com/typeahead {:name "invoice/client" :error? (err? :invoice/client) :class "w-96" :placeholder "Search..." :url (bidi/path-for ssr-routes/only-routes :company-search) :value (:invoice/client data) :content-fn (fn [c] (pull-attr (dc/db conn) :client/name c)) :x-model "clientId"})])) (com/validated-field {:label "Vendor" :errors (ferr :invoice/vendor)} [:div.w-96 (com/typeahead {:name "invoice/vendor" :error? (err? :invoice/vendor) :disabled (boolean extant?) :class "w-96" :placeholder "Search..." :url (bidi/path-for ssr-routes/only-routes :vendor-search) :value (:invoice/vendor data) :content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c)) :x-model "vendorId"})]) [:div.flex.items-center.gap-2 (com/validated-field {:label "Date" :errors (ferr :invoice/date)} [:div {:class "w-24"} (com/date-input {:value (fmt-date (:invoice/date data)) :name "invoice/date" :error? (err? :invoice/date) :x-model "date" :placeholder "1/1/2024"})]) [:div {:x-show "!customizeDueAndScheduled"} (com/link {"@click" "customizeDueAndScheduled=true" :x-show "!due && !scheduledPayment"} "Add due / scheduled payment date") (com/link {"@click" "customizeDueAndScheduled=true" :x-show "due || scheduledPayment"} "Change due / scheduled payment date")]] (com/validated-field (hx/alpine-appear {:label "Due (optional)" :errors (ferr :invoice/due) :x-show "customizeDueAndScheduled"}) [:div {:class "w-24" :hx-put (bidi/path-for ssr-routes/only-routes ::route/due-date) :x-dispatch:changed "[clientId, vendorId, date]" :hx-trigger "changed" :hx-target "this" :hx-swap "innerHTML"} (com/date-input {:value (fmt-date (:invoice/due data)) :name "invoice/due" :x-model "due" :error? (err? :invoice/due) :placeholder "1/1/2024"})]) (com/validated-field (hx/alpine-appear {:label "Scheduled payment (optional)" :errors (ferr :invoice/scheduled-payment) :x-show "customizeDueAndScheduled"}) [:div {:class "w-24" :hx-put (bidi/path-for ssr-routes/only-routes ::route/scheduled-payment-date) :x-dispatch:changed "[clientId, vendorId, due]" :hx-trigger "changed" :hx-target "this" :hx-swap "innerHTML"} (com/date-input {:value (fmt-date (:invoice/scheduled-payment data)) :name "invoice/scheduled-payment" :error? (err? :invoice/scheduled-payment) :placeholder "1/1/2024"})]) (com/validated-field {:label "Invoice Number" :errors (ferr :invoice/invoice-number)} [:div {:class "w-24"} (com/text-input {:value (:invoice/invoice-number data) :name "invoice/invoice-number" :error? (err? :invoice/invoice-number) :placeholder "HA-123"})]) (com/validated-field {:label "Total" :errors (ferr :invoice/total)} [:div {:class "w-16"} (com/money-input {:value (:invoice/total data) :name "invoice/total" :class "w-24" :error? (err? :invoice/total) :placeholder "212.44"})]) [:div#expense-account-prediction (hx/alpine-appear {:x-dispatch:bryce "[vendorId]" :hx-trigger "bryce" :hx-put (bidi/path-for ssr-routes/only-routes ::route/account-prediction) :hx-target "this" :hx-swap "innerHTML"}) (account-prediction-radio {:vendor-id (->db-id (:invoice/vendor data)) :client-id client-val :value (:customize-accounts data)})]]) (com/modal-footer {} (wizard2/nav-footer {:save? true :save-label "Save"})))))) (defn- invoice-expense-account-row* "One expense-account row, de-cursored: a plain account map + its index." [{:keys [account index client-id client-locations]}] (let [acct (:invoice-expense-account/account account) acct-id (if (map? acct) (:db/id acct) acct) aname (ea-name index :invoice-expense-account/account) lname (ea-name index :invoice-expense-account/location)] (com/data-grid-row (-> {:x-data (hx/json {:show (boolean (not (:new? account))) :accountId acct-id}) :data-key "show" :x-ref "p"} hx/alpine-mount-then-appear) (com/hidden {:name (ea-name index :db/id) :value (:db/id account)}) (com/data-grid-cell {} (com/validated-field {:errors (ea-errors index :invoice-expense-account/account)} (account-typeahead* {:value acct-id :client-id client-id :name aname :x-model "accountId"}))) (com/data-grid-cell {} (com/validated-field {:errors (ea-errors index :invoice-expense-account/location) :x-hx-val:account-id "accountId" :hx-vals (hx/json {:name lname :client-id client-id}) :x-dispatch:changed "accountId" :hx-trigger "changed" :hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select) :hx-target "find *" :hx-swap "outerHTML"} (location-select* {:name lname :account-location (:account/location (when (nat-int? acct-id) (dc/pull (dc/db conn) '[:account/location] acct-id))) :client-locations client-locations :value (:invoice-expense-account/location account)}))) (com/data-grid-cell {} (com/validated-field {:errors (ea-errors index :invoice-expense-account/amount)} (com/money-input {:name (ea-name index :invoice-expense-account/amount) :class "w-16 amount-field" :value (:invoice-expense-account/amount account)}))) (com/data-grid-cell {:class "align-top"} (com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x))))) (defn- expense-accounts-or-default "The accounts grid's rows: the posted rows, or -- on first landing -- a single row prefilled with the vendor's default account + the invoice total (the old Initializable)." [{:keys [step-data all-data]}] (let [rows (:invoice/expense-accounts step-data)] (if (seq rows) (vec rows) [{:db/id "123" :invoice-expense-account/location "Shared" :invoice-expense-account/account (:db/id (:vendor/default-account (clientize-vendor (get-vendor (->db-id (:invoice/vendor all-data))) (->db-id (:invoice/client all-data))))) :invoice-expense-account/amount (:invoice/total all-data)}]))) (defn- expense-accounts-total* [rows] (->> rows (map (fnil :invoice-expense-account/amount 0.0)) (filter number?) (reduce + 0.0))) (defn render-accounts [{:keys [all-data errors] :as ctx}] (binding [*errors* (or errors {})] (let [client-id (->db-id (:invoice/client all-data)) client-locations (pull-attr (dc/db conn) :client/locations client-id) rows (expense-accounts-or-default ctx) invoice-total (:invoice/total all-data) total (expense-accounts-total* rows)] (com/modal-card-advanced {"@keydown.enter.prevent.stop" "if ($refs.next) {$refs.next.click()}" :class " md:w-[750px] md:h-[600px] w-full h-full fade-in transition-opacity duration-300" "x-data" ""} (com/modal-header {} [:div.p-2 "Invoice accounts "]) (com/modal-body {} [:div {} (pull-attr (dc/db conn) :client/name client-id) (com/validated-field {:errors (ferr :invoice/expense-accounts)} (com/data-grid {:headers [(com/data-grid-header {} "Account") (com/data-grid-header {:class "w-32"} "Location") (com/data-grid-header {:class "w-16"} "$") (com/data-grid-header {:class "w-16"})]} (map-indexed (fn [i a] (invoice-expense-account-row* {:account a :index i :client-id client-id :client-locations client-locations})) rows) (com/data-grid-new-row {:colspan 4 :hx-get (bidi/path-for ssr-routes/only-routes ::route/new-wizard-new-account) :index (count rows) :tr-params {:hx-vals (hx/json {:client-id client-id})}} "New account") (com/data-grid-row {} (com/data-grid-cell {}) (com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TOTAL"]) (com/data-grid-cell {:id "total" :class "text-right" :hx-trigger "change from:closest form target:.amount-field" :hx-put (bidi/path-for ssr-routes/only-routes ::route/expense-account-total) :hx-target "this" :hx-swap "innerHTML"} (format "$%,.2f" total)) (com/data-grid-cell {})) (com/data-grid-row {} (com/data-grid-cell {}) (com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "BALANCE"]) (com/data-grid-cell {:id "balance" :class "text-right" :hx-trigger "change from:closest form target:.amount-field" :hx-put (bidi/path-for ssr-routes/only-routes ::route/expense-account-balance) :hx-target "this" :hx-swap "innerHTML"} [:span {:class (when-not (dollars= 0.0 (- invoice-total total)) "text-red-300")} (format "$%,.2f" (- invoice-total total))]) (com/data-grid-cell {})) (com/data-grid-row {} (com/data-grid-cell {}) (com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "INVOICE TOTAL"]) (com/data-grid-cell {:class "text-right"} (format "$%,.2f" invoice-total)) (com/data-grid-cell {}))))]) (com/modal-footer {} (wizard2/nav-footer {:back? true :save? true :save-label "Save"})))))) ;; --------------------------------------------------------------------------- ;; Async step fragments (read from posted form / session -- no multi-form-state). ;; --------------------------------------------------------------------------- (defn- form-vendor-client "Decode just the vendor + client + dates a fragment needs from the posted basic-details form (flat invoice/* fields)." [request] (let [nested (:form-params (nfp/nested-params-request request {}))] (mc/decode basic-details-schema nested main-transformer))) (defn account-prediction [request] (let [{:invoice/keys [vendor client] :keys [customize-accounts]} (form-vendor-client request)] (html-response (account-prediction-radio {:vendor-id (->db-id vendor) :client-id client :value customize-accounts})))) (defn due-date [request] (let [{:invoice/keys [vendor client date due]} (form-vendor-client request) vendor (clientize-vendor (get-vendor (->db-id vendor)) (->db-id client)) good-date (or (when (and date (:vendor/terms vendor)) (time/plus date (time/days (:vendor/terms vendor)))) due)] (html-response (com/date-input {:value (some-> good-date (atime/unparse-local atime/normal-date)) :name "invoice/due" :x-init (format "due='%s'" (or (some-> good-date (atime/unparse-local atime/normal-date)) "")) :x-model "due" :error? false :placeholder "1/1/2024"})))) (defn scheduled-payment-date [request] (let [{:invoice/keys [vendor client due]} (form-vendor-client request) vendor (clientize-vendor (get-vendor (->db-id vendor)) (->db-id client)) good-date (when (and due (:vendor/automatically-paid-when-due vendor)) due)] (html-response (com/date-input {:value (some-> good-date (atime/unparse-local atime/normal-date)) :name "invoice/scheduled-payment" :error? false :placeholder "1/1/2024"})))) (defn location-select [{{:keys [name account-id client-id value]} :query-params}] (html-response (location-select* {:name name :value value :account-location (some->> account-id (pull-attr (dc/db conn) :account/location)) :client-locations (some->> client-id (pull-attr (dc/db conn) :client/locations))}))) (defn- posted-amount-total "Sum the amounts in the posted accounts form." [request] (let [nested (:form-params (nfp/nested-params-request request {})) decoded (mc/decode accounts-schema nested main-transformer)] (expense-accounts-total* (:invoice/expense-accounts decoded)))) (defn- session-invoice-total "The invoice total stored in basic-details step-data (the accounts form doesn't carry it)." [request] (let [wid (get-in request [:form-params "wizard-id"])] (or (:invoice/total (ws/get-all (:session request) wid)) 0.0))) (defn expense-account-total [request] (html-response (format "$%,.2f" (posted-amount-total request)))) (defn expense-account-balance [request] (let [balance (- (session-invoice-total request) (posted-amount-total request))] (html-response [:span {:class (when-not (dollars= 0.0 balance) "text-red-300")} (format "$%,.2f" balance)]))) (defn new-account "Add-row: render one fresh expense-account row at the posted index." [request] (let [idx (-> request :query-params :index) idx (if (string? idx) (Integer/parseInt idx) idx) client-id (-> request :query-params :client-id) client-locations (some->> client-id (pull-attr (dc/db conn) :client/locations))] (html-response (invoice-expense-account-row* {:account (wizard2/blank-row :invoice-expense-account/location "Shared") :index idx :client-id client-id :client-locations client-locations})))) ;; --------------------------------------------------------------------------- ;; done-fn: create / update the invoice, then show next-steps (or the updated row). ;; --------------------------------------------------------------------------- (defn- next-steps-modal "After a NEW invoice is created: offer to pay it now, add another, or close." [invoice-id] (modal-response (com/modal {} (com/modal-card-advanced {:class "transition duration-300 ease-in-out scale-100 translate-x-0 opacity-100"} (com/modal-header {} [:div.p-2 "Invoice accounts "]) (com/modal-body {} [:p.text-lg "Would you like to pay this invoice now?"] (com/navigation-button-list {} (com/navigation-button (-> {:class "w-48" :hx-get (hu/url (bidi/path-for ssr-routes/only-routes ::route/pay-wizard) {:selected invoice-id :replace-modal true})} hx/trigger-click-or-enter) "Pay now") (com/navigation-button (-> {:class "w-48" :hx-get (hu/url (bidi/path-for ssr-routes/only-routes ::route/new-wizard) {:replace-modal true})} hx/trigger-click-or-enter) "Add another") (com/navigation-button {:class "w-48" :next-arrow? false "@click" "$dispatch('modalclose') " "@keyup.enter.stop" "$dispatch('modalclose')"} "Close"))))) :headers {"hx-trigger" "invalidated"})) (defn- updated-row-response "After an EDIT: swap the table row in place (resolve invoices/row* to avoid a cycle)." [identity invoice-id request] (html-response (@(resolve 'auto-ap.ssr.invoices/row*) identity (dc/pull (dc/db conn) default-read invoice-id) {:flash? true :request request}) :headers {"hx-trigger" "modalclose" "hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" invoice-id) "hx-reswap" "outerHTML"})) (defn create-invoice! "Engine done-fn: merge the steps into the invoice, validate, upsert, and respond. Default accounts use the vendor's default account; :customize uses the posted grid." [all-data {:keys [identity] :as request}] (let [invoice all-data extant? (:db/id invoice) client-id (->db-id (:invoice/client invoice)) vendor-id (->db-id (:invoice/vendor invoice)) paid-amount (if-let [outstanding-balance (and extant? (- (pull-attr (dc/db conn) :invoice/total (:db/id invoice)) (pull-attr (dc/db conn) :invoice/outstanding-balance (:db/id invoice))))] outstanding-balance 0.0) outstanding-balance (- (:invoice/total invoice) paid-amount) entity (-> invoice (assoc :db/id (or (:db/id invoice) "invoice")) (dissoc :customize-due-and-scheduled? :customize-accounts) (assoc :invoice/expense-accounts (if (= :customize (:customize-accounts invoice)) (:invoice/expense-accounts invoice) [{:db/id "123" :invoice-expense-account/location "Shared" :invoice-expense-account/account (:db/id (:vendor/default-account (clientize-vendor (get-vendor vendor-id) client-id))) :invoice-expense-account/amount (:invoice/total invoice)}])) (assoc :invoice/outstanding-balance outstanding-balance :invoice/import-status :import-status/imported :invoice/status (if (dollars= 0.0 outstanding-balance) :invoice-status/paid :invoice-status/unpaid)) (maybe-spread-locations) (update :invoice/date coerce/to-date) (update :invoice/due coerce/to-date) (update :invoice/scheduled-payment coerce/to-date))] (assert-invoice-amounts-add-up entity) (assert-no-conflicting invoice) (exception->4xx #(assert-can-see-client identity client-id)) (exception->4xx #(assert-not-locked client-id (:invoice/date invoice))) (let [transaction-result (audit-transact [[:upsert-invoice entity]] identity)] (try (solr/touch-with-ledger (or (:db/id invoice) (get-in transaction-result [:tempids "invoice"]))) (catch Exception e (alog/error ::cant-save-solr :error e))) (if extant? (updated-row-response identity (:db/id invoice) request) (next-steps-modal (get-in transaction-result [:tempids "invoice"])))))) ;; --------------------------------------------------------------------------- ;; Engine config + open / submit handlers. ;; --------------------------------------------------------------------------- (defn new-init-fn "Engine :init-fn -- new vs edit branch on a route db/id. Dates ride as java.util.Date (EDN-safe). For new, the date defaults to today; for edit, the persisted invoice is read and customize-accounts is forced to :customize so its accounts are editable." [request] (if-let [id (->db-id (get-in request [:route-params :db/id]))] (let [entity (-> (dc/pull (dc/db conn) default-read id) (select-keys (mut/keys new-form-schema)) ->edn-safe-dates (assoc :customize-accounts :customize))] {:init-data {:basic-details (dissoc entity :invoice/expense-accounts) :accounts (select-keys entity [:invoice/expense-accounts])}}) {:init-data {:basic-details {:invoice/date (coerce/to-date (time/now)) :customize-accounts :default}}})) (def new-invoice-config {:name :new-invoice :form-id "wizard-form" :submit-route (bidi/path-for ssr-routes/only-routes ::route/new-invoice-submit) :form-attrs {:hx-ext "response-targets" :hx-target-400 "#form-errors"} :open-response (fn [form] (modal-response [:div#transitioner.flex-1 form])) :init-fn new-init-fn :steps [{:key :basic-details :decode decode-basic-details :validate validate-basic-details :render render-basic-details :next (fn [data] (if (= :customize (:customize-accounts data)) :accounts :done))} {:key :accounts :decode decode-accounts :render render-accounts :next (fn [_] :done)}] :done-fn create-invoice!}) (defn open-wizard "GET open (new or edit): build the wizard in its modal shell." [request] (exception->notification #(wizard2/open-wizard new-invoice-config request))) (defn new-invoice-step "POST handler for every transition: basic-details Save, accounts Save/Back. Surface the create-time validation (conflict, amounts, locks) as a 4xx into #form-errors." [request] (try+ (wizard2/handle-step-submit new-invoice-config request) (catch #(#{:form-validation :schema-validation :field-validation :notification} (:type %)) e (html-response [:span.error-content.text-red-500 (or (some->> (:form-validation-errors e) (str/join " ")) (:message e) "Could not save the invoice.")] :status 400)))) (def key->handler (apply-middleware-to-all-handlers {::route/new-wizard (partial open-wizard) ::route/edit-wizard (-> (partial open-wizard) (wrap-schema-enforce :route-schema [:map [:db/id entity-id]])) ::route/new-invoice-submit new-invoice-step ::route/account-prediction account-prediction ::route/due-date due-date ::route/scheduled-payment-date scheduled-payment-date ::route/expense-account-total expense-account-total ::route/expense-account-balance expense-account-balance ::route/location-select (-> location-select (wrap-schema-enforce :query-schema [:map [:name :string] [:client-id {:optional true} [:maybe entity-id]] [:account-id {:optional true} [:maybe entity-id]]])) ::route/new-wizard-new-account (-> new-account (wrap-schema-enforce :query-schema [:map [:index {:optional true} [:maybe nat-int?]] [:client-id {:optional true} [:maybe entity-id]]]))} (fn [h] (-> h (wrap-client-redirect-unauthenticated)))))