3 Commits

Author SHA1 Message Date
b6649a3d1d fixes 2026-05-31 08:37:44 -07:00
38ae6f460f Cleanup of simple/advanced mode 2026-05-31 08:30:11 -07:00
e156d8bfd8 fixes vendor selection bug 2026-05-30 09:21:39 -07:00
5 changed files with 654 additions and 165 deletions

View File

@@ -455,6 +455,93 @@ test.describe('Transaction Edit Vendor Pre-population', () => {
}); });
}); });
// Drives the *real* vendor typeahead the way a user does: open the dropdown,
// click a rendered result. The vendor search is backed by Solr (unavailable in
// tests), so the result option is injected into the typeahead's Alpine
// `elements` instead of being fetched. Everything else -- the dropdown's own
// search input firing a native `change` on blur, the `value = element` click
// handler, the Alpine reactivity, and the HTMX round-trip to
// `edit-vendor-changed` -- runs exactly as in production. This is the flow that
// regressed: a stale native `change` from the search input used to win the race
// and revert the vendor to its previous value.
async function selectVendorViaDropdown(page: any, vendorId: number, vendorName: string) {
const wrapper = page.locator('div[hx-post*="edit-vendor-changed"]').first();
const typeahead = wrapper.locator('div.relative[x-data]').first();
// Open the dropdown (tippy renders the popper into [data-tippy-root]).
await typeahead.locator('a[x-ref="input"]').click();
const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
await search.waitFor({ state: 'visible' });
// Type under the 3-char search threshold so no Solr request fires and clears
// our injected option, while still dirtying the input so it fires a native
// `change` on blur -- the event that used to clobber the selection.
await search.fill('te');
// Inject a clickable result into the typeahead's Alpine state.
await typeahead.evaluate(
(el: HTMLElement, opt: { id: number; label: string }) => {
(window as any).Alpine.$data(el).elements = [{ value: opt.id, label: opt.label }];
},
{ id: vendorId, label: vendorName }
);
// Click the rendered option: fires the search input's native change (stale
// value) AND the synthetic change carrying the new value, then HTMX swaps.
await page.locator('[data-tippy-root] a', { hasText: vendorName }).first().click();
await page.waitForResponse(
(response: any) =>
response.url().includes('/edit-vendor-changed') && response.status() === 200
);
await page.waitForTimeout(500);
}
// Opens the edit modal and activates the Manual tab, waiting on the vendor
// typeahead rather than the account grid (which only exists in advanced mode).
async function openManualVendorSection(page: any, transactionIndex: number) {
await page.goto('/transaction2');
await page.waitForSelector('table tbody tr');
const editButton = page
.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]')
.nth(transactionIndex);
await editButton.click();
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
await page.waitForSelector('#wizardmodal');
await page.click('button:has-text("Manual")');
await page.waitForSelector('div[hx-post*="edit-vendor-changed"]');
}
test.describe('Transaction Edit Vendor Selection', () => {
test('selecting a vendor from the dropdown updates the displayed vendor', async ({ page }) => {
await openManualVendorSection(page, 3);
const testInfo = await getTestInfo(page);
const vendorId: number = testInfo.accounts.vendor;
await selectVendorViaDropdown(page, vendorId, 'Test Vendor');
// The displayed vendor label must reflect the selection after the HTMX
// round-trip. Before the fix this reverted to blank because a stale
// `change` event submitted the previous vendor and its response won.
const label = page
.locator('div[hx-post*="edit-vendor-changed"] span[x-text="value.label"]')
.first();
await expect(label).toHaveText('Test Vendor');
// The server-rendered hidden input must carry the newly selected vendor id.
const hidden = page
.locator(
'div[hx-post*="edit-vendor-changed"] input[type="hidden"][name="step-params[transaction/vendor]"]'
)
.first();
await expect(hidden).toHaveValue(vendorId.toString());
});
});
test.describe('Transaction Link Date Display', () => { test.describe('Transaction Link Date Display', () => {
test('should show payment date when linking to payment', async ({ page }) => { test('should show payment date when linking to payment', async ({ page }) => {
await openEditModalForTransaction(page, 'Transaction for payment link'); await openEditModalForTransaction(page, 'Transaction for payment link');

View File

@@ -80,9 +80,7 @@
(defn- transaction-nav-url [request route & {:keys [default-params] :or {default-params {:date-range "month"}}}] (defn- transaction-nav-url [request route & {:keys [default-params] :or {default-params {:date-range "month"}}}]
(let [preserved (transaction-nav-params request)] (let [preserved (transaction-nav-params request)]
(hu/url (bidi/path-for ssr-routes/only-routes route) (hu/url (bidi/path-for ssr-routes/only-routes route)
#_(if (or (:start-date preserved) (:end-date preserved)) {:date-range "month"})))
preserved
(merge default-params preserved)))))
(defn left-aside- [{:keys [nav page-specific]} & _] (defn left-aside- [{:keys [nav page-specific]} & _]
[:aside {:id "left-nav", [:aside {:id "left-nav",

View File

@@ -83,7 +83,7 @@
"x-ref" "hidden" "x-ref" "hidden"
:type "hidden" :type "hidden"
":value" "value.value" ":value" "value.value"
:x-init (hiccup/raw (str "$watch('value', v => $dispatch('change')); "))))] :x-init (hiccup/raw (str "$watch('value', v => { $el.value = (v && v.value != null) ? v.value : ''; $nextTick(() => $dispatch('change')); }); "))))]
[:div.flex.w-full.justify-items-stretch [:div.flex.w-full.justify-items-stretch
[:span.flex-grow.text-left {"x-text" "value.label"}] [:span.flex-grow.text-left {"x-text" "value.label"}]
[:div {:class "w-3 h-3 m-1 inline ml-1 justify-self-end text-gray-500 self-center"} [:div {:class "w-3 h-3 m-1 inline ml-1 justify-self-end text-gray-500 self-center"}
@@ -104,9 +104,10 @@
(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full")) (hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))
"x-model" "search" "x-model" "search"
"placeholder" (:placeholder params) "placeholder" (:placeholder params)
"@change.stop" ""
"@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active" "@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active"
"@keydown.up.prevent" "active --; active = active < 0 ? 0 : active" "@keydown.up.prevent" "active --; active = active < 0 ? 0 : active"
"@keydown.enter.prevent.stop" "tippy.hide(); value = elements.length > 0 ? $data.elements[active] : {'value': '', label: ''}; $refs.input.focus()" "@keydown.enter.prevent.stop" "tippy.hide(); value = elements.length > 0 ? $data.elements[active >= 0 ? active : 0] : {'value': '', label: ''}; $refs.input.focus()"
"x-init" "$el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => {elements = data; active=-1; tippy.popperInstance.update()}) }})"}] "x-init" "$el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => {elements = data; active=-1; tippy.popperInstance.update()}) }})"}]
[:div.dropdown-options {:class "rounded-b-lg overflow-hidden"} [:div.dropdown-options {:class "rounded-b-lg overflow-hidden"}
[:template {:x-for "(element, index) in elements"} [:template {:x-for "(element, index) in elements"}
@@ -116,7 +117,7 @@
"@mouseover" "active = index" "@mouseover" "active = index"
"@mouseout" "active = -1" "@mouseout" "active = -1"
"@click.prevent" "value = element; tippy.hide(); $refs.input.focus()" "@click.prevent" "value = element; tippy.hide(); setTimeout(() => $refs.input.focus(), 10)"
"x-html" "element.label"}]]] "x-html" "element.label"}]]]
[:template {:x-if "elements.length == 0"} [:template {:x-if "elements.length == 0"}
[:li {:class "px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 text-gray-800 dark:text-gray-100 text-xs "} [:li {:class "px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 text-gray-800 dark:text-gray-100 text-xs "}

View File

@@ -514,6 +514,7 @@
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed) :hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed)
:hx-target "#manual-coding-section" :hx-target "#manual-coding-section"
:hx-swap "outerHTML" :hx-swap "outerHTML"
:hx-sync "this:replace"
:hx-include "closest form"} :hx-include "closest form"}
(fc/with-field :transaction/vendor (fc/with-field :transaction/vendor
(com/validated-field (com/validated-field
@@ -882,9 +883,13 @@
#_(require-approval (mut/select-keys (mm/form-schema linear-wizard) #{:transaction/client :transaction/vendor :transaction/memo :transaction/approval-status :db/id})) #_(require-approval (mut/select-keys (mm/form-schema linear-wizard) #{:transaction/client :transaction/vendor :transaction/memo :transaction/approval-status :db/id}))
(mm/form-schema linear-wizard)) (mm/form-schema linear-wizard))
(render-step [this {{:keys [snapshot] :as multi-form-state} :multi-form-state :as request}] (render-step [this {{:keys [snapshot step-params] :as multi-form-state} :multi-form-state :as request}]
(let [tx-id (mm/get-mfs-field multi-form-state :db/id) (let [tx-id (mm/get-mfs-field multi-form-state :db/id)
tx (d-transactions/get-by-id tx-id)] tx (d-transactions/get-by-id tx-id)
;; Preserve explicit mode choice from step-params; only fall back to
;; row-count heuristic on initial load when no mode has been chosen.
mode (keyword (or (:mode step-params)
(name (manual-mode-initial snapshot))))]
(mm/default-render-step (mm/default-render-step
linear-wizard this linear-wizard this
:head [:div.p-2 "Edit Transaction"] :head [:div.p-2 "Edit Transaction"]
@@ -950,7 +955,7 @@
(transaction-rules-view request)] (transaction-rules-view request)]
[:div {:x-show "activeForm === 'manual'", :x-transition:enter "transition ease-out duration-500", :x-transition:enter-start "opacity-0 transform scale-95", :x-transition:enter-end "opacity-100 transform scale-100"} [:div {:x-show "activeForm === 'manual'", :x-transition:enter "transition ease-out duration-500", :x-transition:enter-start "opacity-0 transform scale-95", :x-transition:enter-end "opacity-100 transform scale-100"}
[:div {} [:div {}
(manual-coding-section* (manual-mode-initial snapshot) request) (manual-coding-section* mode request)
(fc/with-field :transaction/approval-status (fc/with-field :transaction/approval-status
(com/validated-field (com/validated-field
{:label "Status" {:label "Status"
@@ -1429,10 +1434,13 @@
(let [multi-form-state (:multi-form-state request) (let [multi-form-state (:multi-form-state request)
snapshot (:snapshot multi-form-state) snapshot (:snapshot multi-form-state)
step-params (:step-params multi-form-state) step-params (:step-params multi-form-state)
mode (keyword (or (:mode step-params) "simple")) mode (keyword (or (:mode step-params)
(get (:form-params request) "mode")
"simple"))
client-id (or (:transaction/client snapshot) client-id (or (:transaction/client snapshot)
(-> request :entity :transaction/client :db/id)) (-> request :entity :transaction/client :db/id))
vendor-id (or (:transaction/vendor step-params) vendor-id (or (:transaction/vendor step-params)
(->db-id (get step-params "transaction/vendor"))
(:transaction/vendor snapshot)) (:transaction/vendor snapshot))
total (Math/abs (or (-> request :entity :transaction/amount) total (Math/abs (or (-> request :entity :transaction/amount)
(:transaction/amount snapshot) (:transaction/amount snapshot)
@@ -1440,10 +1448,21 @@
amount-mode (or (:amount-mode snapshot) "$") amount-mode (or (:amount-mode snapshot) "$")
existing-accounts (or (seq (:transaction/accounts step-params)) existing-accounts (or (seq (:transaction/accounts step-params))
(seq (:transaction/accounts snapshot))) (seq (:transaction/accounts snapshot)))
default-account (when (and (empty? existing-accounts) vendor-id client-id) ;; The form always submits an account row (even when empty with account=nil),
;; so we check if any row has a meaningful account ID.
has-meaningful-accounts? (some #(some? (:transaction-account/account %))
existing-accounts)
;; Simple mode: always populate vendor default (overwrite existing).
;; Advanced mode: populate only when 0 rows OR 1 empty row.
should-populate? (case mode
:simple true
:advanced (or (empty? existing-accounts)
(and (= 1 (count existing-accounts))
(not has-meaningful-accounts?))))
default-account (when (and should-populate? vendor-id client-id)
(vendor-default-account vendor-id client-id)) (vendor-default-account vendor-id client-id))
render-request render-request
(if (and (empty? existing-accounts) vendor-id client-id) (-> (if (and should-populate? vendor-id client-id)
(let [new-account (cond-> {:db/id (str (java.util.UUID/randomUUID)) (let [new-account (cond-> {:db/id (str (java.util.UUID/randomUUID))
:transaction-account/location (or (:account/location default-account) "Shared") :transaction-account/location (or (:account/location default-account) "Shared")
:transaction-account/amount (if (= amount-mode "%") 100.0 total)} :transaction-account/amount (if (= amount-mode "%") 100.0 total)}
@@ -1451,7 +1470,8 @@
(-> request (-> request
(assoc-in [:multi-form-state :snapshot :transaction/accounts] [new-account]) (assoc-in [:multi-form-state :snapshot :transaction/accounts] [new-account])
(assoc-in [:multi-form-state :step-params :transaction/accounts] [new-account]))) (assoc-in [:multi-form-state :step-params :transaction/accounts] [new-account])))
request)] request)
(assoc-in [:multi-form-state :step-params :transaction/vendor] vendor-id))]
(html-response (html-response
(fc/start-form (:multi-form-state render-request) nil (fc/start-form (:multi-form-state render-request) nil
(fc/with-field :step-params (fc/with-field :step-params

View File

@@ -5,7 +5,8 @@
[auto-ap.solr] [auto-ap.solr]
[auto-ap.ssr.components.multi-modal :as mm] [auto-ap.ssr.components.multi-modal :as mm]
[auto-ap.ssr.form-cursor :as fc] [auto-ap.ssr.form-cursor :as fc]
[auto-ap.ssr.transaction.edit :refer [clientize-vendor [auto-ap.ssr.transaction.edit
:refer [clientize-vendor
edit-vendor-changed-handler edit-vendor-changed-handler
edit-wizard-toggle-mode-handler edit-wizard-toggle-mode-handler
location-select* location-select*
@@ -105,7 +106,7 @@
(is (re-find #"Test Account" body) (is (re-find #"Test Account" body)
"Response should contain the vendor's default account name"))) "Response should contain the vendor's default account name")))
(testing "AC5: vendor selection in simple mode does NOT overwrite already-set account" (testing "AC5: vendor selection in simple mode DOES overwrite already-set account"
(let [result @(dc/transact conn [{:db/id "vendor-id" (let [result @(dc/transact conn [{:db/id "vendor-id"
:vendor/name "Test Vendor"} :vendor/name "Test Vendor"}
{:db/id "account-id" {:db/id "account-id"
@@ -126,9 +127,10 @@
:transaction/client "client-id"}]) :transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id") tx-id (tempid->id result "transaction-id")
vendor-id (tempid->id result "vendor-id") vendor-id (tempid->id result "vendor-id")
account-id (tempid->id result "account-id")
other-account-id (tempid->id result "other-account-id") other-account-id (tempid->id result "other-account-id")
client-id (tempid->id result "client-id") client-id (tempid->id result "client-id")
;; existing-accounts already set means vendor should NOT overwrite ;; existing-accounts already set — but simple mode should still overwrite
existing-accounts [{:db/id "row-id" existing-accounts [{:db/id "row-id"
:transaction-account/account other-account-id :transaction-account/account other-account-id
:transaction-account/location "DT" :transaction-account/location "DT"
@@ -149,12 +151,12 @@
;; The handler returns an html-response; verify the body is HTML ;; The handler returns an html-response; verify the body is HTML
(is (re-find #"manual-coding-section" body) (is (re-find #"manual-coding-section" body)
"Response body should contain the manual-coding-section element") "Response body should contain the manual-coding-section element")
;; The original account ID must still appear in the rendered HTML ;; The vendor's default account SHOULD appear (overwriting previous)
(is (re-find (re-pattern (str other-account-id)) body) (is (re-find (re-pattern (str account-id)) body)
"Response should contain the original (pre-existing) account ID") "Vendor change in simple mode should overwrite with vendor's default account")
;; The vendor's default account ID must NOT appear — it was not used ;; The previous account should NOT appear
(is (not (re-find (re-pattern (str (tempid->id result "account-id"))) body)) (is (not (re-find (re-pattern (str other-account-id)) body))
"Response should NOT contain the vendor's default account ID when existing account is set")))) "Previous account should be replaced by vendor default"))))
;;; --------------------------------------------------------------------------- ;;; ---------------------------------------------------------------------------
;;; AC6: save round-trip — manual mode saves vendor + account to DB ;;; AC6: save round-trip — manual mode saves vendor + account to DB
@@ -934,3 +936,384 @@
;; Should NOT show 'Switch to simple mode' ;; Should NOT show 'Switch to simple mode'
(is (not (re-find #"Switch to simple mode" html)) (is (not (re-find #"Switch to simple mode" html))
"AC20: Simple mode should NOT show 'Switch to simple mode' link")))) "AC20: Simple mode should NOT show 'Switch to simple mode' link"))))
;;; ---------------------------------------------------------------------------
;;; Bug: vendor selection gets erased on vendor-changed HTMX response
;;; ---------------------------------------------------------------------------
(deftest vendor-selection-preserved-in-htmx-response-test
(testing "BUG: vendor selection should be preserved when HTMX re-renders the edit form"
(let [result @(dc/transact conn [{:db/id "vendor-id"
:vendor/name "Test Vendor"}
{:db/id "account-id"
:account/name "Existing Account"
:account/type :account-type/expense}
{:db/id "client-id"
:client/code "VENDORCL"
:client/locations ["DT"]}
{:db/id "transaction-id"
:transaction/amount 100.0
:transaction/date #inst "2023-01-01"
:transaction/id (str (java.util.UUID/randomUUID))
:transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id")
vendor-id (tempid->id result "vendor-id")
account-id (tempid->id result "account-id")
client-id (tempid->id result "client-id")
;; Simulate the request after middleware decoding.
;; In production, form values arrive as strings. The middleware decodes
;; step-params with keyword keys but leaves values as strings.
existing-accounts [{:db/id "row-1"
:transaction-account/account account-id
:transaction-account/location "DT"
:transaction-account/amount 100.0}]
request {:multi-form-state (mm/->MultiStepFormState
{:db/id tx-id
:transaction/client client-id
:transaction/accounts existing-accounts}
[]
{:mode "simple"
;; This is how the vendor ID arrives from the form:
;; as a string, not a long.
:transaction/vendor (str vendor-id)
:transaction/accounts existing-accounts})
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}}
;; The handler should return a successful response with the vendor
;; preserved. Currently it crashes because the string vendor-id is
;; not converted to a long before being passed to Datomic.
response (try
(edit-vendor-changed-handler request)
(catch Exception e
{:error e}))]
(is (not (:error response))
(str "BUG: String vendor-id from form submission should be converted to long. "
"Server crashes with: " (some-> response :error ex-message)))
(when-not (:error response)
(is (= 200 (:status response))
"Response should be successful")
(is (re-find #"Test Vendor" (:body response))
"Vendor name should appear in the HTMX response")
(is (re-find (re-pattern (str vendor-id)) (:body response))
"Vendor ID should be preserved in the response HTML")))))
;;; ---------------------------------------------------------------------------
;;; Bug: vendor change does not populate account
;;; ---------------------------------------------------------------------------
(deftest vendor-change-simple-mode-overwrites-test
(testing "BUG: vendor change in simple mode should overwrite existing account"
;; When a vendor is changed in simple mode, it should always populate
;; the vendor's default account, even if an account was already set.
(let [result @(dc/transact conn [{:db/id "vendor-id"
:vendor/name "Vendor With Default"}
{:db/id "vendor-account-id"
:account/name "Vendor Default Account"
:account/type :account-type/expense}
{:db/id "vendor-id"
:vendor/default-account "vendor-account-id"}
{:db/id "existing-account-id"
:account/name "Previously Selected Account"
:account/type :account-type/expense}
{:db/id "client-id"
:client/code "VENDORCL"
:client/locations ["DT"]}
{:db/id "transaction-id"
:transaction/amount 100.0
:transaction/date #inst "2023-01-01"
:transaction/id (str (java.util.UUID/randomUUID))
:transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id")
vendor-id (tempid->id result "vendor-id")
vendor-account-id (tempid->id result "vendor-account-id")
existing-account-id (tempid->id result "existing-account-id")
client-id (tempid->id result "client-id")
;; Simulate form state with an already-selected account (as the form submits)
existing-accounts [{:db/id "row-1"
:transaction-account/account existing-account-id
:transaction-account/location "DT"
:transaction-account/amount 100.0}]
request {:multi-form-state (mm/->MultiStepFormState
{:db/id tx-id
:transaction/client client-id
:transaction/accounts existing-accounts}
[]
{:mode "simple"
:transaction/vendor vendor-id
:transaction/accounts existing-accounts})
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}}
response (edit-vendor-changed-handler request)
body (:body response)]
;; The vendor's default account SHOULD appear (overwriting the previous)
(is (re-find (re-pattern (str vendor-account-id)) body)
"BUG: Vendor change in simple mode should overwrite with vendor's default account")
;; The previously selected account should NOT appear
(is (not (re-find (re-pattern (str existing-account-id)) body))
"Previously selected account should be replaced by vendor default")
(is (re-find #"Vendor Default Account" body)
"Vendor default account name should appear"))))
(deftest vendor-change-advanced-mode-empty-row-test
(testing "BUG: vendor change in advanced mode should populate empty row"
;; In advanced mode with 1 empty row, changing vendor should populate it
(let [result @(dc/transact conn [{:db/id "vendor-id"
:vendor/name "Vendor With Default"}
{:db/id "vendor-account-id"
:account/name "Vendor Default Account"
:account/type :account-type/expense}
{:db/id "vendor-id"
:vendor/default-account "vendor-account-id"}
{:db/id "client-id"
:client/code "ADVEMPTYCL"
:client/locations ["DT"]}
{:db/id "transaction-id"
:transaction/amount 100.0
:transaction/date #inst "2023-01-01"
:transaction/id (str (java.util.UUID/randomUUID))
:transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id")
vendor-id (tempid->id result "vendor-id")
vendor-account-id (tempid->id result "vendor-account-id")
client-id (tempid->id result "client-id")
;; Simulate advanced mode with 1 empty row (account=nil, as form submits)
empty-row [{:db/id "row-1"
:transaction-account/account nil
:transaction-account/location "Shared"
:transaction-account/amount 100.0}]
request {:multi-form-state (mm/->MultiStepFormState
{:db/id tx-id
:transaction/client client-id
:transaction/accounts empty-row}
[]
{:mode "advanced"
:transaction/vendor vendor-id
:transaction/accounts empty-row})
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}}
response (edit-vendor-changed-handler request)
body (:body response)]
;; The vendor's default account SHOULD appear in the row
(is (re-find (re-pattern (str vendor-account-id)) body)
"BUG: Vendor change in advanced mode with empty row should populate it")
(is (re-find #"Vendor Default Account" body)
"Vendor default account name should appear in the row"))))
(deftest vendor-change-advanced-mode-filled-row-test
(testing "AC15b: vendor change in advanced mode with filled row should NOT overwrite"
;; In advanced mode with 1 row that already has an account selected,
;; changing vendor should NOT overwrite it
(let [result @(dc/transact conn [{:db/id "vendor-id"
:vendor/name "Vendor With Default"}
{:db/id "vendor-account-id"
:account/name "Vendor Default Account"
:account/type :account-type/expense}
{:db/id "vendor-id"
:vendor/default-account "vendor-account-id"}
{:db/id "existing-account-id"
:account/name "Manually Selected Account"
:account/type :account-type/expense}
{:db/id "client-id"
:client/code "ADVFILLEDCL"
:client/locations ["DT"]}
{:db/id "transaction-id"
:transaction/amount 100.0
:transaction/date #inst "2023-01-01"
:transaction/id (str (java.util.UUID/randomUUID))
:transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id")
vendor-id (tempid->id result "vendor-id")
vendor-account-id (tempid->id result "vendor-account-id")
existing-account-id (tempid->id result "existing-account-id")
client-id (tempid->id result "client-id")
;; Advanced mode with 1 row that already has an account
filled-row [{:db/id "row-1"
:transaction-account/account existing-account-id
:transaction-account/location "DT"
:transaction-account/amount 100.0}]
request {:multi-form-state (mm/->MultiStepFormState
{:db/id tx-id
:transaction/client client-id
:transaction/accounts filled-row}
[]
{:mode "advanced"
:transaction/vendor vendor-id
:transaction/accounts filled-row})
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}}
response (edit-vendor-changed-handler request)
body (:body response)]
;; The existing account should still be there
(is (re-find (re-pattern (str existing-account-id)) body)
"Existing account should remain when vendor changes in advanced mode with filled row")
;; The vendor's default account should NOT appear
(is (not (re-find (re-pattern (str vendor-account-id)) body))
"Vendor default should NOT overwrite filled row in advanced mode"))))
(deftest vendor-change-advanced-mode-two-rows-test
(testing "AC15c: vendor change in advanced mode with 2+ rows should NOT modify any"
;; In advanced mode with 2 or more rows, vendor change should not touch any row
(let [result @(dc/transact conn [{:db/id "vendor-id"
:vendor/name "Vendor With Default"}
{:db/id "vendor-account-id"
:account/name "Vendor Default Account"
:account/type :account-type/expense}
{:db/id "vendor-id"
:vendor/default-account "vendor-account-id"}
{:db/id "account-1"
:account/name "Account One"
:account/type :account-type/expense}
{:db/id "account-2"
:account/name "Account Two"
:account/type :account-type/expense}
{:db/id "client-id"
:client/code "ADVTWOROWCL"
:client/locations ["DT"]}
{:db/id "transaction-id"
:transaction/amount 100.0
:transaction/date #inst "2023-01-01"
:transaction/id (str (java.util.UUID/randomUUID))
:transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id")
vendor-id (tempid->id result "vendor-id")
vendor-account-id (tempid->id result "vendor-account-id")
account-1 (tempid->id result "account-1")
account-2 (tempid->id result "account-2")
client-id (tempid->id result "client-id")
;; Advanced mode with 2 rows
two-rows [{:db/id "row-1"
:transaction-account/account account-1
:transaction-account/location "DT"
:transaction-account/amount 50.0}
{:db/id "row-2"
:transaction-account/account account-2
:transaction-account/location "DT"
:transaction-account/amount 50.0}]
request {:multi-form-state (mm/->MultiStepFormState
{:db/id tx-id
:transaction/client client-id
:transaction/accounts two-rows}
[]
{:mode "advanced"
:transaction/vendor vendor-id
:transaction/accounts two-rows})
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}}
response (edit-vendor-changed-handler request)
body (:body response)]
;; Both existing accounts should remain
(is (re-find (re-pattern (str account-1)) body)
"First row account should remain")
(is (re-find (re-pattern (str account-2)) body)
"Second row account should remain")
;; Vendor default should NOT appear
(is (not (re-find (re-pattern (str vendor-account-id)) body))
"Vendor default should NOT modify rows when 2+ exist"))))
(deftest vendor-change-client-specific-override-test
(testing "BUG: vendor change should use client-specific account override if present"
;; When a vendor has a client-specific account override, changing vendor
;; should populate the client-specific account, not the global default.
(let [result @(dc/transact conn [{:db/id "global-account-id"
:account/name "Global Default"
:account/type :account-type/expense}
{:db/id "client-specific-account-id"
:account/name "Client Specific Account"
:account/type :account-type/expense}
{:db/id "client-id"
:client/code "CLIOVERRIDE"
:client/locations ["DT"]}
{:db/id "vendor-id"
:vendor/name "Clientized Vendor"
:vendor/default-account "global-account-id"
:vendor/account-overrides [{:vendor-account-override/client "client-id"
:vendor-account-override/account "client-specific-account-id"}]}])
vendor-id (tempid->id result "vendor-id")
client-id (tempid->id result "client-id")
global-account-id (tempid->id result "global-account-id")
client-specific-account-id (tempid->id result "client-specific-account-id")
;; Simple mode with empty account row
empty-row [{:db/id "row-1"
:transaction-account/account nil
:transaction-account/location "Shared"
:transaction-account/amount 100.0}]
request {:multi-form-state (mm/->MultiStepFormState
{:db/id 999999
:transaction/client client-id
:transaction/accounts empty-row}
[]
{:mode "simple"
:transaction/vendor vendor-id
:transaction/accounts empty-row})
:entity {:db/id 999999
:transaction/client {:db/id client-id}
:transaction/amount 100.0}}
response (edit-vendor-changed-handler request)
body (:body response)]
;; The client-specific account should appear, not the global default
(is (re-find (re-pattern (str client-specific-account-id)) body)
"BUG: Vendor change should populate client-specific account override")
(is (re-find #"Client Specific Account" body)
"Client-specific account name should appear")
;; The global default should NOT appear
(is (not (re-find (re-pattern (str global-account-id)) body))
"Global vendor default should NOT appear when client override exists"))))
;;; Update AC5: simple mode SHOULD overwrite existing accounts
(deftest vendor-change-simple-mode-overwrites-ac5-test
(testing "AC5 UPDATED: vendor selection in simple mode DOES overwrite already-set account"
(let [result @(dc/transact conn [{:db/id "vendor-id"
:vendor/name "Test Vendor"}
{:db/id "account-id"
:account/name "Test Account"
:account/type :account-type/expense}
{:db/id "vendor-id"
:vendor/default-account "account-id"}
{:db/id "other-account-id"
:account/name "Other Account"
:account/type :account-type/expense}
{:db/id "client-id"
:client/code "TESTCL2"
:client/locations ["DT"]}
{:db/id "transaction-id"
:transaction/amount 100.0
:transaction/date #inst "2023-01-01"
:transaction/id (str (java.util.UUID/randomUUID))
:transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id")
vendor-id (tempid->id result "vendor-id")
account-id (tempid->id result "account-id")
other-account-id (tempid->id result "other-account-id")
client-id (tempid->id result "client-id")
;; existing-accounts already set — but simple mode should still overwrite
existing-accounts [{:db/id "row-id"
:transaction-account/account other-account-id
:transaction-account/location "DT"
:transaction-account/amount 100.0}]
request {:multi-form-state (mm/->MultiStepFormState
{:db/id tx-id
:transaction/client client-id
:transaction/accounts existing-accounts}
[]
{:mode "simple"
:transaction/vendor vendor-id
:transaction/accounts existing-accounts})
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}}
response (edit-vendor-changed-handler request)
body (:body response)]
;; The handler returns an html-response; verify the body is HTML
(is (re-find #"manual-coding-section" body)
"Response body should contain the manual-coding-section element")
;; The vendor's default account SHOULD appear (overwriting previous)
(is (re-find (re-pattern (str account-id)) body)
"BUG: Vendor change in simple mode should overwrite with vendor's default account")
;; The previous account should NOT appear
(is (not (re-find (re-pattern (str other-account-id)) body))
"Previous account should be replaced by vendor default"))))