3 Commits

Author SHA1 Message Date
f9438ba983 fix(ssr): only require account coding for manual transaction edits
Account coding lived in the always-applied base map of edit-form-schema, so
every action (including the link/apply-rule/unlink actions) required a valid
transaction-account/account. The edit modal always submits the Manual tab's
(usually blank) account row, so link submits failed validation before reaching
their save-handler and silently no-op'd. Move account validation into the
:manual branch of the action :multi so link actions validate without it.

Also surface whole-form validation errors in the wizard footer error bar:
default-step-footer only handled top-level/sequential error shapes, so nested
field-error maps (e.g. a hidden tab's account error) produced an empty bar and
a silent failure. Add flatten-form-errors to flatten the humanized error tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 21:30:29 -07:00
7d34b8a5f6 money 2026-06-18 20:26:07 -07:00
c09d85ede6 fix(ssr): fix Client Review (requires-feedback) status in bulk-code dialog
The bulk-code "Requires Feedback" option submitted "requires_feedback"
(underscore), which decoded to an enum keyword not present in the
schema (idents use a hyphen), so selecting it failed validation. Use
the hyphenated value and relabel the option, the reconciliation report
header to "Client Review" to unify with the sidebar terminology.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 10:38:00 -07:00
5 changed files with 93 additions and 59 deletions

View File

@@ -28,7 +28,7 @@
(com/data-grid-header {} "Synced count") (com/data-grid-header {} "Synced count")
(com/data-grid-header {} "Approved transactions") (com/data-grid-header {} "Approved transactions")
(com/data-grid-header {} "Unapproved transactions") (com/data-grid-header {} "Unapproved transactions")
(com/data-grid-header {} "Requires feedback transactions") (com/data-grid-header {} "Client Review transactions")
(com/data-grid-header {} "Missing transactions")]) (com/data-grid-header {} "Missing transactions")])
#_#_:thead-params {:class "sticky top-0 z-50"}} #_#_:thead-params {:class "sticky top-0 z-50"}}
(for [row report] (for [row report]
@@ -84,18 +84,18 @@
(com/validated-field {:label "Start" (com/validated-field {:label "Start"
:errors (fc/field-errors)} :errors (fc/field-errors)}
[:div {:class "w-64"} [:div {:class "w-64"}
(com/date-input {:name (fc/field-name) (com/date-input {:name (fc/field-name)
:class "w-64" :class "w-64"
:value (some-> (fc/field-value) :value (some-> (fc/field-value)
(atime/unparse-local atime/normal-date))})])) (atime/unparse-local atime/normal-date))})]))
(fc/with-field :end-date (fc/with-field :end-date
(com/validated-field {:label "End" (com/validated-field {:label "End"
:errors (fc/field-errors)} :errors (fc/field-errors)}
[:div {:class "w-64"} [:div {:class "w-64"}
(com/date-input {:name (fc/field-name) (com/date-input {:name (fc/field-name)
:class "w-64" :class "w-64"
:value (some-> (fc/field-value) :value (some-> (fc/field-value)
(atime/unparse-local atime/normal-date))})])) (atime/unparse-local atime/normal-date))})]))
(com/button {:color :primary :class "self-center w-24"} "Run")])] (com/button {:color :primary :class "self-center w-24"} "Run")])]
(if report (if report
(report* {:request request :report report}) (report* {:request request :report report})
@@ -104,15 +104,15 @@
(defn page [request] (defn page [request]
(base-page (base-page
request request
(com/page {:nav com/company-aside-nav (com/page {:nav com/company-aside-nav
:client-selection (:client-selection request) :client-selection (:client-selection request)
:client (:client request) :client (:client request)
:clients (:clients request) :clients (:clients request)
:identity (:identity request) :identity (:identity request)
:app-params {:hx-get (bidi/path-for ssr-routes/only-routes :company-reconciliation-report) :app-params {:hx-get (bidi/path-for ssr-routes/only-routes :company-reconciliation-report)
:hx-trigger "clientSelected from:body" :hx-trigger "clientSelected from:body"
:hx-select "#app-contents" :hx-select "#app-contents"
:hx-swap "outerHTML swap:300ms"}} :hx-swap "outerHTML swap:300ms"}}
(com/breadcrumbs {} (com/breadcrumbs {}
[:a {:href (bidi/path-for ssr-routes/only-routes :company)} [:a {:href (bidi/path-for ssr-routes/only-routes :company)}
"My Company"] "My Company"]
@@ -133,7 +133,7 @@
(defn get-report-data [start-date end-date client-ids] (defn get-report-data [start-date end-date client-ids]
(let [client-codes (map first (dc/q '[:find ?cc :in $ [?c ...] :where [?c :client/code ?cc]] (dc/db conn) client-ids))] (let [client-codes (map first (dc/q '[:find ?cc :in $ [?c ...] :where [?c :client/code ?cc]] (dc/db conn) client-ids))]
(for [[ib ba c] (seq (apply get-intuit-bank-accounts (dc/db conn) client-codes)) (for [[ib ba c] (seq (apply get-intuit-bank-accounts (dc/db conn) client-codes))
:let [raw-transactions (get-transactions (atime/unparse-local start-date atime/iso-date) :let [raw-transactions (get-transactions (atime/unparse-local start-date atime/iso-date)
(atime/unparse-local end-date atime/iso-date) (atime/unparse-local end-date atime/iso-date)
ib) ib)

View File

@@ -138,6 +138,33 @@
[:div.space-y-1 {} [:div.space-y-1 {}
children]) children])
(defn flatten-form-errors
"Walks a malli-humanized error structure and returns a flat sequence of
human-readable strings, prefixing each leaf message with the nearest
field name for context. Lets the footer's error bar surface every
validation error for the whole form, even ones whose field lives on a
hidden step/tab and so would otherwise be invisible."
([errors] (flatten-form-errors nil errors))
([field errors]
(let [label (cond (keyword? field) (name field)
(string? field) field
:else nil)
decorate (fn [msg] (if label (str label ": " msg) msg))]
(cond
(map? errors)
(mapcat (fn [[k v]] (flatten-form-errors k v)) errors)
(and (sequential? errors) (every? string? errors))
(map decorate errors)
(sequential? errors)
(mapcat #(flatten-form-errors field %) errors)
(string? errors)
[(decorate errors)]
:else nil))))
(defn default-step-footer [linear-wizard step & {:keys [validation-route (defn default-step-footer [linear-wizard step & {:keys [validation-route
discard-button discard-button
next-button next-button
@@ -146,7 +173,8 @@
[:div.flex.items-baseline.gap-x-4 [:div.flex.items-baseline.gap-x-4
(let [step-errors (:step-params fc/*form-errors*)] (let [step-errors (:step-params fc/*form-errors*)]
(com/form-errors {:errors (or (:errors step-errors) (com/form-errors {:errors (or (:errors step-errors)
(when (sequential? step-errors) step-errors))})) (when (sequential? step-errors) step-errors)
(seq (distinct (flatten-form-errors step-errors))))}))
(when (not= (first (steps linear-wizard)) (when (not= (first (steps linear-wizard))
(step-key step)) (step-key step))
(when validation-route (when validation-route

View File

@@ -185,28 +185,28 @@
:hx-target "#account-entries" :hx-target "#account-entries"
:hx-swap "innerHTML" :hx-swap "innerHTML"
:hx-include "closest form"} :hx-include "closest form"}
(fc/with-field :vendor (fc/with-field :vendor
(com/validated-field {:label "Vendor" (com/validated-field {:label "Vendor"
:errors (fc/field-errors)} :errors (fc/field-errors)}
(com/typeahead {:name (fc/field-name) (com/typeahead {:name (fc/field-name)
:placeholder "Search for vendor..." :placeholder "Search for vendor..."
:url (bidi/path-for ssr-routes/only-routes :vendor-search) :url (bidi/path-for ssr-routes/only-routes :vendor-search)
:value (fc/field-value) :value (fc/field-value)
:content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})))] :content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})))]
;; Status field ;; Status field
[:div [:div
(fc/with-field :approval-status (fc/with-field :approval-status
(com/validated-field {:label "Status" (com/validated-field {:label "Status"
:errors (fc/field-errors)} :errors (fc/field-errors)}
(com/select {:name (fc/field-name) (com/select {:name (fc/field-name)
:value (some-> (fc/field-value) :value (some-> (fc/field-value)
name) name)
:options [["" "No Change"] :options [["" "No Change"]
["approved" "Approved"] ["approved" "Approved"]
["unapproved" "Unapproved"] ["unapproved" "Unapproved"]
["suppressed" "Suppressed"] ["suppressed" "Suppressed"]
["requires_feedback" "Requires Feedback"]]})))] ["requires-feedback" "Client Review"]]})))]
;; Accounts section ;; Accounts section
[:div.col-span-2.pt-4 [:div.col-span-2.pt-4
@@ -219,10 +219,10 @@
(com/data-grid {:headers [(com/data-grid-header {} "Account") (com/data-grid {:headers [(com/data-grid-header {} "Account")
(com/data-grid-header {:class "w-32"} "Location") (com/data-grid-header {:class "w-32"} "Location")
(com/data-grid-header {:class "w-16"} "%") (com/data-grid-header {:class "w-16"} "%")
(com/data-grid-header {:class "w-16"})]} (com/data-grid-header {:class "w-16"})]}
(fc/cursor-map #(transaction-account-row* {:value %})) (fc/cursor-map #(transaction-account-row* {:value %}))
(com/data-grid-new-row {:colspan 4 (com/data-grid-new-row {:colspan 4
:hx-get (bidi/path-for ssr-routes/only-routes :hx-get (bidi/path-for ssr-routes/only-routes
::route/bulk-code-new-account) ::route/bulk-code-new-account)
:row-offset 0 :row-offset 0
@@ -357,10 +357,10 @@
{:errors (fc/field-errors)} {:errors (fc/field-errors)}
(com/data-grid {:headers [(com/data-grid-header {} "Account") (com/data-grid {:headers [(com/data-grid-header {} "Account")
(com/data-grid-header {:class "w-32"} "Location") (com/data-grid-header {:class "w-32"} "Location")
(com/data-grid-header {:class "w-16"} "%") (com/data-grid-header {:class "w-16"} "%")
(com/data-grid-header {:class "w-16"})]} (com/data-grid-header {:class "w-16"})]}
(fc/cursor-map #(transaction-account-row* {:value %})) (fc/cursor-map #(transaction-account-row* {:value %}))
(com/data-grid-new-row {:colspan 4 (com/data-grid-new-row {:colspan 4
:hx-get (bidi/path-for ssr-routes/only-routes :hx-get (bidi/path-for ssr-routes/only-routes
::route/bulk-code-new-account) ::route/bulk-code-new-account)
:row-offset 0 :row-offset 0

View File

@@ -72,6 +72,27 @@
(or (not= approval-status :transaction-approval-status/approved) (or (not= approval-status :transaction-approval-status/approved)
(seq accounts)))]]) (seq accounts)))]])
(def account-coding-schema
"Validation for manually-coded transaction account rows. Applied only for
the :manual action: link / apply-rule actions build their own accounts
server-side, so the (often blank) account row carried along by the manual
tab must not be required when one of those actions is submitted."
[:maybe
[:vector {:coerce? true}
[:and
[:map
[:db/id {:optional true} [:maybe [:or temp-id entity-id]]]
[:transaction-account/account [:and entity-id
[:fn {:error/message "Not an allowed account."}
#(check-allowance % :account/default-allowance)]]]
[:transaction-account/location :string]
[:transaction-account/amount :double]]
[:fn {:error/fn (fn [r x] (:type r))
:error/path [:transaction-account/location]}
(fn [iea]
(check-location-belongs (:transaction-account/location iea)
(:transaction-account/account iea)))]]]])
(def edit-form-schema (def edit-form-schema
(mc/schema (mc/schema
[:and [:and
@@ -81,23 +102,7 @@
[:transaction/memo {:optional true} [:maybe [:string {:decode/string strip}]]] [:transaction/memo {:optional true} [:maybe [:string {:decode/string strip}]]]
[:transaction/vendor {:optional true} [:maybe entity-id]] [:transaction/vendor {:optional true} [:maybe entity-id]]
[:transaction/approval-status {:optional true} [:maybe (ref->enum-schema "transaction-approval-status")]] [:transaction/approval-status {:optional true} [:maybe (ref->enum-schema "transaction-approval-status")]]
[:amount-mode {:optional true} [:maybe [:enum "$" "%"]]] [:amount-mode {:optional true} [:maybe [:enum "$" "%"]]]]
[:transaction/accounts {:optional true}
[:maybe
[:vector {:coerce? true}
[:and
[:map
[:db/id {:optional true} [:maybe [:or temp-id entity-id]]]
[:transaction-account/account [:and entity-id
[:fn {:error/message "Not an allowed account."}
#(check-allowance % :account/default-allowance)]]]
[:transaction-account/location :string]
[:transaction-account/amount :double]]
[:fn {:error/fn (fn [r x] (:type r))
:error/path [:transaction-account/location]}
(fn [iea]
(check-location-belongs (:transaction-account/location iea)
(:transaction-account/account iea)))]]]]]]
[:multi {:dispatch :action} [:multi {:dispatch :action}
[:apply-rule [:map [:apply-rule [:map
[:rule-id {:optional true} [:maybe entity-id]]]] [:rule-id {:optional true} [:maybe entity-id]]]]
@@ -110,7 +115,8 @@
[:autopay-invoice-ids {:decode/string (fn [x] (edn/read-string x))} [:vector {:coerce? true} entity-id]]]] [:autopay-invoice-ids {:decode/string (fn [x] (edn/read-string x))} [:vector {:coerce? true} entity-id]]]]
[:link-payment [:map [:link-payment [:map
[:payment-id entity-id]]] [:payment-id entity-id]]]
[:manual (require-approval [:map])]]])) [:manual (require-approval [:map
[:transaction/accounts {:optional true} account-coding-schema]])]]]))
(defn clientize-vendor [{:vendor/keys [terms-overrides automatically-paid-when-due default-account account-overrides] :as vendor} client-id] (defn clientize-vendor [{:vendor/keys [terms-overrides automatically-paid-when-due default-account account-overrides] :as vendor} client-id]
(if (nil? vendor) (if (nil? vendor)

View File

@@ -211,7 +211,7 @@
(com/data-grid-cell {} (fc/with-field :description-original (com/data-grid-cell {} (fc/with-field :description-original
(com/text-input {:value (fc/field-value) :name (fc/field-name)}))) (com/text-input {:value (fc/field-value) :name (fc/field-name)})))
(com/data-grid-cell {} (fc/with-field :amount (com/data-grid-cell {} (fc/with-field :amount
(com/money-input {:value (fc/field-value) :name (fc/field-name) :class "w-28"}))) (com/text-input {:value (fc/field-value) :name (fc/field-name) :class "w-28 text-right" :inputmode "decimal"})))
(com/data-grid-cell {} (fc/with-field :bank-account-code (com/data-grid-cell {} (fc/with-field :bank-account-code
(com/text-input {:value (fc/field-value) :name (fc/field-name) :class "w-28"}))) (com/text-input {:value (fc/field-value) :name (fc/field-name) :class "w-28"})))
(com/data-grid-cell {} (fc/with-field :client-code (com/data-grid-cell {} (fc/with-field :client-code