Note (2026-04-24): After this document was written, legal_entity was renamed to tenant and the old tenant was renamed to organization. Read references to these terms with the pre-rename meaning.

Account Master Data Picker Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace the current split debit-account, credit-account, and cost-center inline edits with a single HTMX-loaded picker backed by legal-entity master data and client-side filtering.

Architecture: Keep the document detail page server-rendered. Clicking a supported line-item field swaps in an HTMX fragment that embeds only the relevant category dataset (gl-accounts or cost-centers) for that document’s legal entity. The picker runs local filtering in a dedicated JS runtime and commits a single structured patch that updates all subfields atomically.

Tech Stack: Clojure, Ring/Reitit, HTMX, Hiccup, next.jdbc/HoneySQL, vanilla JavaScript, clojure.test


File Structure

Task 1: Render Single Combined Rest-State Fields

Files:

Add a rendering-focused test that proves the line-item rest state is a single clickable field per supported assignment and that the old separate number/name wrappers are gone.

(deftest line-item-account-fields-render-as-single-picker-target
  (let [html (str
              (hiccup/html
               (ui/line-item-card
                {:id             "li-1"
                 :description    "Consulting"
                 :amount         100.0
                 :debit-account  {:number "4000" :name "Travel Expense"}
                 :credit-account {:number "120000" :name "Kreditoren"}
                 :cost-center    {:number "CC-100" :name "Operations" :employee "Max Mustermann"}}
                "EUR"
                {})))]
    (is (string/includes? html "4000 Travel Expense"))
    (is (string/includes? html "120000 Kreditoren"))
    (is (string/includes? html "CC-100 Operations - Max Mustermann"))
    (is (not (string/includes? html ">4000</span> <span class=\"account-name\">Travel Expense"))
        "Old split number/name rendering should be removed")
    (is (string/includes? html "data-picker-kind=\"gl-accounts\""))
    (is (string/includes? html "data-picker-field=\"debit-account\""))
    (is (string/includes? html "data-picker-field=\"credit-account\""))
    (is (string/includes? html "data-picker-kind=\"cost-centers\""))))

Run: clj -X:test:silent :nses '[com.getorcha.app.http.documents.edits-test]' :vars '[line-item-account-fields-render-as-single-picker-target]'

Expected: FAIL because the current markup still renders separate editable wrappers for :number and :name, and no picker metadata exists.

Update line-item-card so debit account, credit account, and cost center use a single wrapper each. Add a helper that formats the combined label and emits HTMX/picker metadata on the wrapper.

(defn ^:private master-data-display-label
  [{:keys [number name employee]}]
  (string/trim
   (str (or number "")
        (when (and number name) " ")
        (or name "")
        (when employee (str " - " employee)))))


(defn ^:private master-data-picker-value
  [path field picker-kind value provenance]
  [:span.editable-value.master-data-picker
   (cond-> {:data-field-path   (json-patch.path/clj-path->pointer path)
            :data-field-type   "master-data-picker"
            :data-picker-field field
            :data-picker-kind  picker-kind}
     provenance (assoc :title (str "Edited at " (:edited-at provenance))))
   (master-data-display-label value)])

Then replace the existing split account/cost-center wrap calls with:

[:span.meta-value
 (master-data-picker-value
  [:structured-data :line-items {:id id} :debit-account]
  "debit-account"
  "gl-accounts"
  debit-account
  (get provenance-map
       (json-patch.path/clj-path->pointer
        [:structured-data :line-items {:id id} :debit-account])))]

Apply the same pattern to credit-account and cost-center, using "cost-centers" for the picker kind.

Run: clj -X:test:silent :nses '[com.getorcha.app.http.documents.edits-test]' :vars '[line-item-account-fields-render-as-single-picker-target]'

Expected: PASS

git add src/com/getorcha/app/ui/components.clj test/com/getorcha/app/http/documents/edits_test.clj
git commit -m "feat(edits): render account assignments as picker targets"

Task 2: Add HTMX Picker Fragment Endpoint

Files:

Add a route test that requests the picker fragment for a debit-account field and verifies that:

(deftest master-data-picker-fragment-renders-gl-accounts
  (let [le-id   (dev-legal-entity-id)
        doc-id  (seed-invoice-document!)
        item-id (str (random-uuid))]
    (db.sql/execute-one!
     fixtures/*db*
     {:update :document
      :set    {:structured-data
               [:lift {:document-type  "invoice"
                       :invoice-number "INV-1"
                       :line-items     [{:id            item-id
                                         :order         0
                                         :description   "Consulting"
                                         :debit-account {:number "4999" :name "Legacy Account"}
                                         :page-location [1 1]}]}]}
      :where  [:= :id doc-id]})
    (db.sql/execute-one!
     fixtures/*db*
     {:insert-into :gl-accounts-dataset
      :values      [{:legal-entity-id le-id
                     :is-active       true
                     :data            [:lift [{:number "4000" :name "Travel Expense"}
                                              {:number "4100" :name "Office Supplies"}]]}]})
    (let [response (fixtures/request
                    {:route [::edits/master-data-picker
                             {:document-id doc-id}]
                     :method :get
                     :query-params {"path"  (str "/line-items[id=" item-id "]/debit-account")
                                    "field" "debit-account"
                                    "kind"  "gl-accounts"}
                     :as :string})
          body (:body response)]
      (is (= 200 (:status response)))
      (is (string/includes? body "Travel Expense"))
      (is (string/includes? body "Office Supplies"))
      (is (string/includes? body "Legacy Account"))
      (is (not (string/includes? body "Max Mustermann"))))))

Add a script-inclusion test:

(deftest detail-view-loads-master-data-picker-runtime
  (let [le-id  (helpers/create-legal-entity!)
        doc-id (seed-invoice! le-id {:document-type "invoice"
                                     :issuer {:name "Acme"}
                                     :line-items [{:id "li-1"
                                                   :order 0
                                                   :description "Consulting"
                                                   :debit-account {:number "4000" :name "Travel Expense"}
                                                   :page-location [1 1]}]})
        doc    (db.sql/execute-one! fixtures/*db*
                                    {:select [:*] :from [:document] :where [:= :id doc-id]}
                                    {:builder-fn documents.shared/document-builder-fn})
        html   (str (hiccup/html
                     (view.shared/detail-page-content
                      test-router
                      doc
                      {:identity {:legal-entity-ids #{le-id}}}))) ]
    (is (string/includes? html "/public/js/master-data-picker.js"))))

Run: clj -X:test:silent :nses '[com.getorcha.app.http.documents.edits-test com.getorcha.app.http.documents.view.shared-test]' :vars '[master-data-picker-fragment-renders-gl-accounts detail-view-loads-master-data-picker-runtime]'

Expected: FAIL because no picker route exists and the detail page does not load the picker runtime.

In edits.clj, add helpers to:

Add a route:

["/edit/:document-id/master-data-picker"
 {:name ::master-data-picker
  :get  master-data-picker-fragment}]

Use category-specific loaders:

(defn ^:private load-gl-accounts [db-pool legal-entity-id]
  (or (:gl-accounts-dataset/data
       (db.sql/execute-one! db-pool
                            {:select [:data]
                             :from   [:gl-accounts-dataset]
                             :where  [:and
                                      [:= :legal-entity-id legal-entity-id]
                                      [:= :is-active true]]}))
      []))


(defn ^:private load-cost-centers [db-pool legal-entity-id]
  (mapv :cost-center-dataset/data
        (db.sql/execute! db-pool
                         {:select   [:data]
                          :from     [:cost-center-dataset]
                          :where    [:= :legal-entity-id legal-entity-id]
                          :order-by [[:position :asc]]})))

Render a fragment shell like:

[:div.master-data-picker
 {:data-picker-kind "gl-accounts"
  :data-picker-path pointer}
 [:input.master-data-picker-input
  {:type "text"
   :value ""
   :placeholder "Search by number or name"}]
 [:script {:type "application/json"
           :class "master-data-picker-options"}
  (json/generate-string {:current current-value
                         :options options})]
 [:div.master-data-picker-results]]

In view/shared.clj, load the runtime next to editable-fields.js:

[:script {:src   (str "/public/js/master-data-picker.js?v=" app.ui.layout/assets-version)
          :defer true}]

Run: clj -X:test:silent :nses '[com.getorcha.app.http.documents.edits-test com.getorcha.app.http.documents.view.shared-test]' :vars '[master-data-picker-fragment-renders-gl-accounts detail-view-loads-master-data-picker-runtime]'

Expected: PASS

git add src/com/getorcha/app/http/documents/edits.clj src/com/getorcha/app/http/documents/view/shared.clj test/com/getorcha/app/http/documents/edits_test.clj test/com/getorcha/app/http/documents/view/shared_test.clj
git commit -m "feat(edits): add HTMX master-data picker fragment"

Task 3: Add Atomic Selection Commit Endpoint

Files:

Add one test for GL-account selection and one for cost-center selection. Each should verify that the whole structured value is replaced atomically and that the success fragment returns the new combined rest-state label.

(deftest master-data-selection-updates-account-atomically
  (let [doc-id  (seed-invoice-document!)
        item-id (str (random-uuid))]
    (db.sql/execute-one!
     fixtures/*db*
     {:update :document
      :set    {:structured-data
               [:lift {:document-type  "invoice"
                       :invoice-number "INV-1"
                       :line-items     [{:id            item-id
                                         :order         0
                                         :description   "Consulting"
                                         :debit-account {:number "4999" :name "Legacy Account"}
                                         :page-location [1 1]}]}]}
      :where  [:= :id doc-id]})
    (let [response (fixtures/request
                    {:route [::edits/master-data-selection {:document-id doc-id}]
                     :method :patch
                     :form-params {"path"             (str "/line-items[id=" item-id "]/debit-account")
                                   "field"            "debit-account"
                                   "kind"             "gl-accounts"
                                   "number"           "4000"
                                   "name"             "Travel Expense"
                                   "expected-version" "1"}
                     :as :string})
          body (:body response)
          {:document/keys [structured-data version]} (document-version-and-sd doc-id)]
      (is (= 200 (:status response)))
      (is (string/includes? body "4000 Travel Expense"))
      (is (= 2 version))
      (is (= {:number "4000" :name "Travel Expense"}
             (-> structured-data :line-items first :debit-account))))))
(deftest master-data-selection-updates-cost-center-atomically
  (let [doc-id  (seed-invoice-document!)
        item-id (str (random-uuid))]
    (db.sql/execute-one!
     fixtures/*db*
     {:update :document
      :set    {:structured-data
               [:lift {:document-type  "invoice"
                       :invoice-number "INV-1"
                       :line-items     [{:id            item-id
                                         :order         0
                                         :description   "Consulting"
                                         :cost-center   {:number "OLD" :name "Legacy" :employee "Jane Doe"}
                                         :page-location [1 1]}]}]}
      :where  [:= :id doc-id]})
    (let [response (fixtures/request
                    {:route [::edits/master-data-selection {:document-id doc-id}]
                     :method :patch
                     :form-params {"path"             (str "/line-items[id=" item-id "]/cost-center")
                                   "field"            "cost-center"
                                   "kind"             "cost-centers"
                                   "number"           "CC-100"
                                   "name"             "Operations"
                                   "employee"         "Max Mustermann"
                                   "expected-version" "1"}
                     :as :string})
          {:document/keys [structured-data]} (document-version-and-sd doc-id)]
      (is (= 200 (:status response)))
      (is (= {:number "CC-100" :name "Operations" :employee "Max Mustermann"}
             (-> structured-data :line-items first :cost-center))))))

Run: clj -X:test:silent :nses '[com.getorcha.app.http.documents.edits-test]' :vars '[master-data-selection-updates-account-atomically master-data-selection-updates-cost-center-atomically]'

Expected: FAIL because no grouped commit endpoint exists and current scalar editing can only patch one subfield at a time.

Add a dedicated selection endpoint and patch builder that replaces the whole object at the requested path.

(defn ^:private master-data-value
  [{:strs [kind number name employee]}]
  (case kind
    "gl-accounts"  {:number number :name name}
    "cost-centers" (cond-> {:number number :name name}
                     (seq employee) (assoc :employee employee))))

Route:

["/edit/:document-id/master-data-selection"
 {:name ::master-data-selection
  :patch master-data-selection-edit}]

Use apply-edit-tx with a prepare-patch that emits:

(fn [_structured-data]
  {:patch [{"op"    "replace"
            "path"  pointer
            "value" (master-data-value form-params)}]})

Render success as the authoritative rest-state wrapper:

(let [clj-path   (json-patch.path/pointer->clj-path pointer)
      new-value  (get-at-path new-sd clj-path)]
  (ui/master-data-picker-value
   (into [:structured-data] clj-path)
   field
   kind
   new-value
   nil))

Also update editable-fields.js so it does not activate the scalar editor for picker wrappers:

if (el.dataset.fieldType === 'master-data-picker') return;

Run: clj -X:test:silent :nses '[com.getorcha.app.http.documents.edits-test]' :vars '[master-data-selection-updates-account-atomically master-data-selection-updates-cost-center-atomically]'

Expected: PASS

git add src/com/getorcha/app/http/documents/edits.clj src/com/getorcha/app/ui/components.clj resources/app/public/js/editable-fields.js test/com/getorcha/app/http/documents/edits_test.clj
git commit -m "feat(edits): persist master-data picker selections atomically"

Task 4: Implement Client-Side Filtering And Picker UX

Files:

Add a fragment test that ensures the picker contains all data needed by the runtime and category-specific placeholder text.

(deftest master-data-picker-fragment-includes-json-payload-and-placeholder
  (let [le-id (dev-legal-entity-id)
        doc-id (seed-invoice-document!)
        item-id (str (random-uuid))]
    (db.sql/execute-one!
     fixtures/*db*
     {:update :document
      :set {:structured-data
            [:lift {:document-type "invoice"
                    :invoice-number "INV-1"
                    :line-items [{:id item-id
                                  :order 0
                                  :description "Consulting"
                                  :cost-center {:number "CC-100" :name "Operations" :employee "Max Mustermann"}
                                  :page-location [1 1]}]}]}
      :where [:= :id doc-id]})
    (db.sql/execute-one!
     fixtures/*db*
     {:insert-into :cost-center-dataset
      :values [{:legal-entity-id le-id
                :position 0
                :data [:lift {:number "CC-100" :name "Operations" :employee "Max Mustermann"}]}]})
    (let [response (fixtures/request
                    {:route [::edits/master-data-picker {:document-id doc-id}]
                     :method :get
                     :query-params {"path"  (str "/line-items[id=" item-id "]/cost-center")
                                    "field" "cost-center"
                                    "kind"  "cost-centers"}
                     :as :string})
          body (:body response)]
      (is (= 200 (:status response)))
      (is (string/includes? body "Search by number, name, or employee"))
      (is (string/includes? body "master-data-picker-options"))
      (is (string/includes? body "\"employee\":\"Max Mustermann\""))))

Run: clj -X:test:silent :nses '[com.getorcha.app.http.documents.edits-test]' :vars '[master-data-picker-fragment-includes-json-payload-and-placeholder]'

Expected: FAIL because the fragment does not yet include the final payload shape or placeholder text expected by the JS runtime.

Create resources/app/public/js/master-data-picker.js with delegated handlers that:

(function () {
  'use strict';

  document.body.addEventListener('htmx:afterSwap', (event) => {
    const root = event.target.closest?.('.master-data-picker') || event.target.querySelector?.('.master-data-picker');
    if (!root) return;
    initPicker(root);
  });

  function initPicker(root) {
    const payload = JSON.parse(root.querySelector('.master-data-picker-options').textContent);
    const input = root.querySelector('.master-data-picker-input');
    const results = root.querySelector('.master-data-picker-results');
    let filtered = payload.options.slice();
    let activeIndex = 0;

    function matches(option, query) {
      const q = query.trim().toLowerCase();
      if (!q) return true;
      return [option.number, option.name, option.employee]
        .filter(Boolean)
        .some((value) => String(value).toLowerCase().includes(q));
    }

    function render() {
      results.innerHTML = '';
      if (!filtered.length) {
        results.innerHTML = '<div class="master-data-picker-empty">No matches</div>';
        return;
      }
      filtered.forEach((option, index) => {
        const button = document.createElement('button');
        button.type = 'button';
        button.className = 'master-data-picker-option' + (index === activeIndex ? ' is-active' : '');
        button.textContent = option.employee
          ? `${option.number} ${option.name} - ${option.employee}`
          : `${option.number} ${option.name}`;
        button.addEventListener('click', () => commit(root, option));
        results.appendChild(button);
      });
    }

    input.addEventListener('input', () => {
      filtered = payload.options.filter((option) => matches(option, input.value));
      activeIndex = 0;
      render();
    });

    input.addEventListener('keydown', (event) => {
      if (event.key === 'ArrowDown') {
        event.preventDefault();
        activeIndex = Math.min(activeIndex + 1, filtered.length - 1);
        render();
      } else if (event.key === 'ArrowUp') {
        event.preventDefault();
        activeIndex = Math.max(activeIndex - 1, 0);
        render();
      } else if (event.key === 'Enter' && filtered[activeIndex]) {
        event.preventDefault();
        commit(root, filtered[activeIndex]);
      } else if (event.key === 'Escape') {
        event.preventDefault();
        htmx.ajax('GET', root.dataset.cancelUrl, { target: root, swap: 'outerHTML' });
      }
    });

    render();
    input.focus();
  }
})();

Update the fragment markup in edits.clj so the runtime has:

Run: clj -X:test:silent :nses '[com.getorcha.app.http.documents.edits-test]' :vars '[master-data-picker-fragment-includes-json-payload-and-placeholder]'

Expected: PASS

Then run the narrow combined suite:

Run: clj -X:test:silent :nses '[com.getorcha.app.http.documents.edits-test com.getorcha.app.http.documents.view.shared-test]'

Expected: PASS

git add resources/app/public/js/master-data-picker.js src/com/getorcha/app/http/documents/edits.clj test/com/getorcha/app/http/documents/edits_test.clj
git commit -m "feat(edits): add client-side master-data picker filtering"

Self-Review