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.

Documents Module Split — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Split documents.clj and document_management.clj into a com.getorcha.erp.http.documents.* namespace tree with grouped handlers, unified URLs, and deduplicated shared code.

Architecture: Pure refactoring with one behavior change (add list SSE to management). Create ~12 new namespace files, wire routes through parent assembler, update all external references, redistribute tests, delete old files.

Tech Stack: Clojure, Reitit routing, HTMX/Hiccup, HoneySQL, core.async (SSE)

Design doc: docs/plans/2026-02-25-documents-module-split-design.md


Reference: Route Name Mapping

Old route names → new route names. Every external reference must be updated.

Old Route Name New Route Name
:com.getorcha.erp.http.documents/list :com.getorcha.erp.http.documents.accounts-payable/list
:com.getorcha.erp.http.documents/list-events :com.getorcha.erp.http.documents.accounts-payable/list-events
:com.getorcha.erp.http.documents/search :com.getorcha.erp.http.documents.accounts-payable/search
:com.getorcha.erp.http.documents/upload :com.getorcha.erp.http.documents.upload/upload
:com.getorcha.erp.http.documents/detail :com.getorcha.erp.http.documents.view/detail
:com.getorcha.erp.http.documents/detail-events :com.getorcha.erp.http.documents.view/detail-events
:com.getorcha.erp.http.documents/by-hash :com.getorcha.erp.http.documents/by-hash (stays in parent)
:com.getorcha.erp.http.documents/bulk-toggle :com.getorcha.erp.http.documents.accounts-payable/bulk-toggle
:com.getorcha.erp.http.documents/bulk-toggle-all :com.getorcha.erp.http.documents.accounts-payable/bulk-toggle-all
:com.getorcha.erp.http.documents/bulk-deselect-all :com.getorcha.erp.http.documents.accounts-payable/bulk-deselect-all
:com.getorcha.erp.http.documents/bulk-export-datev :com.getorcha.erp.http.documents.accounts-payable/bulk-export-datev
:com.getorcha.erp.http.documents/reingest :com.getorcha.erp.http.documents.view/reingest
:com.getorcha.erp.http.documents/export-datev :com.getorcha.erp.http.documents.view.invoice/export-datev
:com.getorcha.erp.http.documents/datev-export-status :com.getorcha.erp.http.documents.view.invoice/datev-export-status
:com.getorcha.erp.http.documents/add-to-qa-dataset :com.getorcha.erp.http.documents.view.invoice/add-to-qa-dataset
:com.getorcha.erp.http.document-management/list :com.getorcha.erp.http.documents.management/list
:com.getorcha.erp.http.document-management/detail :com.getorcha.erp.http.documents.view/detail (unified)
:com.getorcha.erp.http.document-management/detail-events :com.getorcha.erp.http.documents.view/detail-events (unified)
:com.getorcha.erp.http.document-management/upload :com.getorcha.erp.http.documents.upload/upload (unified)
:com.getorcha.erp.http.document-management/search :com.getorcha.erp.http.documents.management/search
:com.getorcha.erp.http.document-management/reingest :com.getorcha.erp.http.documents.view/reingest (unified)

Reference: External Files That Reference Old Route Names

These files (outside the two being split) reference old route names and need updating:

File Lines Reference Update To
src/com/getorcha/erp/http.clj 49-50 Both routes calls Single erp.http.documents/routes
src/com/getorcha/erp/ui/layout.clj 39-40 Sidebar nav links to documents list + doc-mgmt list New AP + management route names
src/com/getorcha/erp/http/profile.clj 33 Redirect to documents list New AP list route name
src/com/getorcha/erp/http/login.clj 242, 313, 394 Post-login redirect to documents list New AP list route name

Reference: File Path Layout

src/com/getorcha/erp/http/
├── documents.clj                          (REWRITE: route assembly + by-hash + 404)
├── documents/
│   ├── shared.clj                         (NEW)
│   ├── upload.clj                         (NEW)
│   ├── accounts_payable.clj               (NEW)
│   ├── management.clj                     (NEW)
│   └── view.clj                           (NEW: route assembly + get-document dispatch)
│   └── view/
│       ├── shared.clj                     (NEW)
│       ├── invoice.clj                    (NEW)
│       ├── notice.clj                     (NEW)
│       ├── contract.clj                   (NEW)
│       ├── purchase_order.clj             (NEW)
│       └── goods_received_note.clj        (NEW)
├── document_management.clj                (DELETE)

test/com/getorcha/erp/http/
├── documents_test.clj                     (REWRITE: route assembly tests only)
├── documents/
│   ├── upload_test.clj                    (NEW)
│   ├── accounts_payable_test.clj          (NEW)
│   ├── management_test.clj               (NEW)
│   └── view_test.clj                     (NEW)
│   └── view/
│       ├── invoice_test.clj               (NEW)
│       └── contract_test.clj              (NEW)
├── document_management_test.clj           (DELETE)

Phase 1: Create New Namespaces

During this phase, old files remain untouched and functional. New namespaces are created but not route-wired. Verify each compiles in the REPL.

Task 1: Create documents.shared

Files:

What moves here (from documents.clj unless noted):

Function Source Lines Notes
document-builder-fn docs:51-52, dm:61-62 Identical in both files
legal-entity-ids docs:55-58, dm:65-67 Identical in both files
primary-legal-entity-id docs:61-64 docs only
excel-content-type? docs:103-108, dm:1228-1232 Identical
excel-file? docs:111-117 docs only
display-file-path docs:120-132 docs only, used in detail views
wants-json? docs:96-100 Used in list + detail handlers
lateral-joins docs:1309-1326 SQL fragments for ingestion/export status
sortable-th docs:534-558, dm:132-154 Near-duplicate — generalize, take filter-params builder as arg
pagination-url docs:561-569, dm:201-210 Near-duplicate — generalize with filter-params arg
pagination-controls dm:213-226 docs builds inline; extract from DM

New functions to create:

make-list-events-handler — parameterized list SSE factory:

(defn make-list-events-handler
  "Creates a list-events SSE handler. Takes:
   - `type-filter` — set of document type keywords to include (nil = all)
   - `row-renderer` — fn of [router opts document] → hiccup row"
  [type-filter row-renderer]
  (fn [{:keys [db-pool identity document-events ::reitit/router] :as request}
       respond _raise]
    ;; Same pub/sub pattern as current list-events in documents.clj (lines 2389-2474)
    ;; but with type-filter check: skip events where doc type not in filter set
    ;; and delegate row rendering to row-renderer
    ...))

Step 1: Create the file with all shared functions. Make all functions public (they're cross-namespace now).

Step 2: Verify it compiles:

(require 'com.getorcha.erp.http.documents.shared :reload)

Step 3: Commit:

git add src/com/getorcha/erp/http/documents/shared.clj
git commit -m "refactor(documents): create shared namespace with deduplicated helpers"

Task 2: Create documents.upload

Files:

What moves here:

The upload handler from documents.clj:1723-1778. This is the single unified upload handler.

Key change: the HX-Redirect on single file upload should point to the new unified detail route ::view/detail instead of ::detail. Since the upload namespace can't reference the view namespace's route name at compile time (circular), use path-for with the fully qualified keyword :com.getorcha.erp.http.documents.view/detail.

(defn routes [_config]
  ["/upload"
   {:name ::upload
    :post {:summary    "Upload document(s)"
           :parameters {:multipart [:map-of :keyword :any]}
           :handler    #'upload}}])

Step 1: Create the file. Require documents.shared for legal-entity-ids, excel-content-type?.

Step 2: Verify it compiles.

Step 3: Commit:

git add src/com/getorcha/erp/http/documents/upload.clj
git commit -m "refactor(documents): create upload namespace with unified handler"

Task 3: Create view type-specific modules

Files:

view.invoice — from documents.clj:

Function Source Lines Notes
invoice-detail-view 922-964 UI component
datev-export-status->class 200-206 Helper for DATEV badge CSS
datev-export-section 209-286 DATEV export UI
export-datev 1836-1889 Handler
datev-export-status 2157-2230 Handler (poll/update)
add-to-qa-dataset 2233-2303 Handler
in-qa-dataset? 135-142 Helper

This module also defines its own sub-routes (mounted under the view routes):

(defn routes [_config]
  [""
   ["/export/datev"
    {:name ::export-datev
     :post {:handler #'export-datev}}]
   ["/export/datev/status"
    {:name ::datev-export-status
     :get  {:handler #'datev-export-status}}]
   ["/add-to-qa-dataset"
    {:name ::add-to-qa-dataset
     :post {:handler #'add-to-qa-dataset}}]])

Public functions needed by other modules:

view.notice — from documents.clj:

Function Source Lines
notice-type-labels 883-886
notice-detail-view 889-919

Public: notice-detail-view

No routes — purely a rendering module.

view.contract — from document_management.clj:

Function Source Lines
contract-type-labels dm:41-53
type-icons dm:55-59
contract-status dm:87-103
date-delta-label dm:106-122
contract-detail-view dm:568-713

Public: contract-detail-view

No routes.

view.purchase-order — from document_management.clj:

Function Source Lines
po-detail-view dm:716-779

Public: po-detail-view

No routes.

view.goods-received-note — from document_management.clj:

Function Source Lines
grn-detail-view dm:782-865

Public: grn-detail-view

No routes.

Step 1: Create all five files.

Step 2: Verify each compiles.

Step 3: Commit:

git add src/com/getorcha/erp/http/documents/view/
git commit -m "refactor(documents): create type-specific detail view modules"

Task 4: Create documents.view.shared

Files:

What moves here:

Function Source Notes
excel-preview-banner docs:967-978 UI
detail-page-content docs:981-1215, dm:868-1038 Merge both; dispatch to type-specific view based on doc type
detail-area-content docs:1218-1241, dm:868-1038 Merge; unified SSE connector
detail-page docs:1244-1302, dm:1041-1118 Merge; unified layout
get-adjacent-document-ids docs:1329-1374, dm:1121-1152 Merge (DM version is simpler)
reingest docs:1781-1833, dm:1280-1310 Merge; use the docs version (returns rendered HTML, better UX)
detail-events docs:2306-2386, dm:1313-1379 Merge; unified SSE, no cross-type redirects

Key design for the merged detail-page-content: dispatch rendering by doc type:

(case (some-> (:document/type document) name)
  "invoice"            (view.invoice/invoice-detail-view ...)
  "financial-notice"   (view.notice/notice-detail-view ...)
  "contract"           (view.contract/contract-detail-view ...)
  "purchase-order"     (view.purchase-order/po-detail-view ...)
  "goods-received-note" (view.goods-received-note/grn-detail-view ...)
  ;; default/unknown: show raw structured data or placeholder
  nil)

Key design for merged detail-events: remove the cross-type redirect. On terminal ingestion, just re-render detail-area-content with the document. On :export events, delegate to view.invoice/datev-export-section.

Key design for merged reingest: use the documents.clj version which returns rendered detail-area-content HTML (stays on page with SSE progress). The DM version just does HX-Redirect which is inferior UX.

Step 1: Create the file.

Step 2: Verify it compiles.

Step 3: Commit:

git add src/com/getorcha/erp/http/documents/view/shared.clj
git commit -m "refactor(documents): create shared detail view with unified SSE and dispatch"

Task 5: Create documents.view

Files:

What moves here:

The get-document handler — merged from documents.clj:1596-1703 and document_management.clj:1155-1224. This is the dispatcher that loads the document and delegates to the type-specific view via view.shared/detail-page.

Also: get-document-by-hash from documents.clj:1706-1720 — stays here since it's a document-level lookup, not type-specific. Actually, per the design this goes to the parent. Move it there in Task 7.

Route assembly:

(defn routes [_config]
  ["/view/:document-id"
   [""
    {:name ::detail
     :get  {:summary    "Document detail view"
            :parameters {:path {:document-id :uuid}}
            :handler    #'get-document}}]
   ["/events"
    {:name ::detail-events
     :get  {:summary    "SSE events for document detail"
            :parameters {:path {:document-id :uuid}}
            :handler    #'view.shared/detail-events}}]
   ["/reingest"
    {:name ::reingest
     :post {:summary    "Re-ingest document"
            :parameters {:path {:document-id :uuid}}
            :handler    #'view.shared/reingest}}]
   (view.invoice/routes _config)])

Step 1: Create the file.

Step 2: Verify it compiles.

Step 3: Commit:

git add src/com/getorcha/erp/http/documents/view.clj
git commit -m "refactor(documents): create view namespace with route assembly and type dispatch"

Task 6: Create documents.accounts-payable

Files:

What moves here (all from documents.clj):

Function Source Lines Notes
selection-session-key 35-37 Bulk selection
sort-session-key 39-41 List sort persistence
get-selection 44-48 Bulk selection helper
default-from-date 67-70 Date range default
default-to-date 73-76 Date range default
date-range-params 79-83 URL builder
filter-params 86-93 URL builder
has-any-warnings 293-308 SQL fragment
needs-review-where 311-329 SQL fragment
has-errors-where 332-342 SQL fragment
status-priority-order 345-377 SQL ORDER BY
order-by-for-sort 380-398 SQL ORDER BY
document-row 405-499 UI
document-status-filter 502-521 UI
column-default-dir 524-531 UI helper
bulk-actions-bar 148-171 UI
select-all-cell 174-193 UI
list-page 572-880 UI
list-documents 1377-1537 Handler
search-documents 1540-1593 Handler
fetch-exportable-ids 1892-1914 Bulk helper
bulk-toggle 1917-1966 Handler
bulk-toggle-all 1969-2023 Handler
bulk-deselect-all 2026-2071 Handler
bulk-export-datev 2074-2154 Handler
list-events 2389-2474 Handler (or use make-list-events-handler from shared)

List events: either move list-events directly (it has AP-specific logic like bulk selection rendering and DATEV status) or call shared/make-list-events-handler with AP config. Given the AP list-events has bulk selection and DATEV-specific logic interleaved, it's cleaner to keep it as a direct handler here rather than trying to parameterize all that.

Route definition:

(defn routes [_config]
  ["/accounts-payable"
   [""
    {:name ::list
     :get  {:summary    "List AP documents"
            :parameters {:query [...]}
            :handler    #'list-documents}}]
   ["/events"
    {:name ::list-events
     :get  {:summary "SSE events for AP list"
            :handler #'list-events}}]
   ["/search"
    {:name ::search
     :get  {:summary    "Search AP documents"
            :parameters {:query [:map [:q {:optional true} :string]]}
            :handler    #'search-documents}}]
   ["/bulk"
    ["/toggle/:document-id"
     {:name ::bulk-toggle
      :post {:summary    "Toggle document selection"
             :parameters {:path {:document-id :uuid}}
             :handler    #'bulk-toggle}}]
    ["/toggle-all"
     {:name ::bulk-toggle-all
      :post {:handler #'bulk-toggle-all}}]
    ["/deselect-all"
     {:name ::bulk-deselect-all
      :post {:handler #'bulk-deselect-all}}]
    ["/export-datev"
     {:name ::bulk-export-datev
      :post {:handler #'bulk-export-datev}}]]])

Important: Update all internal path-for calls to use new route names. For example, document-row generates links to ::detail which must become :com.getorcha.erp.http.documents.view/detail.

Step 1: Create the file.

Step 2: Verify it compiles.

Step 3: Commit:

git add src/com/getorcha/erp/http/documents/accounts_payable.clj
git commit -m "refactor(documents): create accounts-payable namespace with list, bulk, and SSE"

Task 7: Create documents.management

Files:

What moves here (all from document_management.clj):

Function Source Lines Notes
per-page dm:29
managed-types dm:31-33
type-labels dm:35-39
type-icon dm:125-129 UI
display-fields dm:70-84
document-row dm:157-198 UI (different from AP row)
type-filter-dropdown dm:229-250 UI
list-page dm:253-372 UI
lateral-join-ingestion dm:379-388 SQL
build-where dm:391-414 SQL
build-order-by dm:417-426 SQL
list-documents dm:429-517 Handler
search-documents dm:520-561 Handler

New: Add list-events SSE handler. Use shared/make-list-events-handler with management's type filter and row renderer:

(def ^:private list-events
  (shared/make-list-events-handler
   managed-types   ;; #{:contract :purchase-order :goods-received-note :other}
   document-row))  ;; management's row renderer

If the parameterized approach is too awkward due to management-specific query/rendering needs, write a dedicated handler modeled on the AP one but filtering to management types.

Route definition:

(defn routes [_config]
  ["/management"
   [""
    {:name ::list
     :get  {:summary    "List managed documents"
            :parameters {:query [...]}
            :handler    #'list-documents}}]
   ["/events"
    {:name ::list-events
     :get  {:summary "SSE events for management list"
            :handler #'list-events}}]
   ["/search"
    {:name ::search
     :get  {:summary    "Search managed documents"
            :parameters {:query [:map [:q {:optional true} :string]]}
            :handler    #'search-documents}}]])

Important: Same as AP — update all path-for calls to use new route names. Detail links in document-row:com.getorcha.erp.http.documents.view/detail.

Step 1: Create the file.

Step 2: Verify it compiles.

Step 3: Commit:

git add src/com/getorcha/erp/http/documents/management.clj
git commit -m "refactor(documents): create management namespace with list, search, and SSE"

Phase 2: Wire Routes and Switch Over

Task 8: Rewrite documents.clj as route assembler

Files:

Rewrite the file entirely. It becomes a thin route assembler (~30 lines):

(ns com.getorcha.erp.http.documents
  "Document routes — assembles all document sub-module routes."
  (:require [com.getorcha.erp.http.documents.accounts-payable :as accounts-payable]
            [com.getorcha.erp.http.documents.management :as management]
            [com.getorcha.erp.http.documents.shared :as shared]
            [com.getorcha.erp.http.documents.upload :as upload]
            [com.getorcha.erp.http.documents.view :as view]
            [com.getorcha.db.sql :as db.sql]
            [ring.util.http-response :as ring.resp]))


(defn ^:private get-document-by-hash
  "Get document by content hash."
  [{:keys [db-pool parameters] :as request} respond _raise]
  (let [hash     (get-in parameters [:path :document-hash])
        document (db.sql/execute-one!
                  db-pool
                  {:select [:*]
                   :from   [:document]
                   :where  [:and
                            [:= :content-hash hash]
                            [:in :legal-entity-id (shared/legal-entity-ids request)]]}
                  {:builder-fn shared/document-builder-fn})]
    (if document
      (respond (ring.resp/ok document))
      (respond (ring.resp/not-found {:error "Document not found"})))))


(defn routes [_config]
  ["/documents"
   ["" {:get {:handler (fn [_ respond _] (respond (ring.resp/not-found "")))}}]
   ["/by-hash/:document-hash"
    {:name ::by-hash
     :get  {:summary    "Get document by content hash"
            :parameters {:path {:document-hash :string}}
            :handler    #'get-document-by-hash}}]
   (upload/routes _config)
   (accounts-payable/routes _config)
   (management/routes _config)
   (view/routes _config)])

Step 1: Rewrite the file.

Step 2: Verify it compiles.

Step 3: Do NOT commit yet — tests will fail until external references are updated.


Task 9: Update external references

Files:

erp/http.clj changes:

erp/ui/layout.clj changes (lines 39-40):

;; Old:
(erp.http.routes/path-for router :com.getorcha.erp.http.documents/list)
(erp.http.routes/path-for router :com.getorcha.erp.http.document-management/list)
;; New:
(erp.http.routes/path-for router :com.getorcha.erp.http.documents.accounts-payable/list)
(erp.http.routes/path-for router :com.getorcha.erp.http.documents.management/list)

erp/http/profile.clj change (line 33):

;; Old:
(erp.http.routes/path-for router :com.getorcha.erp.http.documents/list)
;; New:
(erp.http.routes/path-for router :com.getorcha.erp.http.documents.accounts-payable/list)

erp/http/login.clj changes (lines 242, 313, 394):

;; Old (all 3 occurrences):
(erp.http.routes/path-for router :com.getorcha.erp.http.documents/list)
;; New:
(erp.http.routes/path-for router :com.getorcha.erp.http.documents.accounts-payable/list)

Also update the require alias in login.clj if it uses ::erp.http.documents/list shorthand — it does on line 242. Either switch to fully qualified keyword or update the alias.

Step 1: Make all changes.

Step 2: Delete document_management.clj.

Step 3: Verify all source files compile:

(require 'com.getorcha.erp.http :reload-all)

Step 4: Commit:

git add src/com/getorcha/erp/http.clj \
        src/com/getorcha/erp/http/documents.clj \
        src/com/getorcha/erp/ui/layout.clj \
        src/com/getorcha/erp/http/profile.clj \
        src/com/getorcha/erp/http/login.clj
git rm src/com/getorcha/erp/http/document_management.clj
git commit -m "refactor(documents): wire new route structure, remove old document-management"

Phase 3: Redistribute Tests

Task 10: Create test files for new namespaces

Files:

Redistribute from existing tests. Route name references must be updated throughout.

upload_test.clj — from documents_test.clj:

accounts_payable_test.clj — from documents_test.clj:

view_test.clj — from both test files:

management_test.clj — from document_management_test.clj:

view/invoice_test.clj — if any DATEV export tests exist in documents_test, move them here. Currently none exist as standalone tests, but display-file-path-test touches DATEV. Keep it in view_test.clj for now.

Shared test helpers (temp-file, purge-queue!, read-sse-events, update-ingestion-status!, mark-ingestion-completed!) — put in a shared test helper namespace or duplicate in each test file that needs them. Prefer a shared test helper:

Step 1: Create all test files with redistributed tests and updated route references.

Step 2: Run tests:

clj -X:test:silent :nses '[com.getorcha.erp.http.documents.upload-test
                            com.getorcha.erp.http.documents.accounts-payable-test
                            com.getorcha.erp.http.documents.management-test
                            com.getorcha.erp.http.documents.view-test]'

Step 3: Fix any failures.

Step 4: Delete old test files:

git rm test/com/getorcha/erp/http/documents_test.clj
git rm test/com/getorcha/erp/http/document_management_test.clj

Step 5: Commit:

git add test/com/getorcha/erp/http/documents/
git rm test/com/getorcha/erp/http/documents_test.clj
git rm test/com/getorcha/erp/http/document_management_test.clj
git commit -m "refactor(documents): redistribute tests to match new namespace structure"

Phase 4: Verify and Clean Up

Task 11: Run full test suite

Step 1: Run all tests:

clj -X:test:silent 2>&1 | grep -A 5 -E "(FAIL in|ERROR in|Execution error|failed because|Ran .* tests)"

Step 2: Fix any failures. Common issues:

Step 3: Grep for any remaining old references:

# Should return zero results
grep -r "erp.http.document-management" src/ test/
grep -r "erp.http.documents/" src/ test/ | grep -v "erp.http.documents\." | grep -v documents.clj

Step 4: Commit any fixes:

git commit -m "fix(documents): resolve remaining references after module split"

Task 12: Verify UI manually

Step 1: Start the system: (reset) in REPL.

Step 2: Verify in browser:

Step 3: Fix any issues found.

Step 4: Final commit if needed.