Note (2026-04-24): After this document was written,
legal_entitywas renamed totenantand the oldtenantwas renamed toorganization. Read references to these terms with the pre-rename meaning.
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Add an Accounts Receivable module with an invoice creation/edit UI, overview list, and supporting data model.
Architecture: Split into 7 sequential tasks. First, extract shared PDF renderer and invoice schemas. Then, migrate the database and rename invoice → incoming-invoice across the codebase. Finally, build the AR UI (create/edit form with live PDF preview, overview list).
Tech Stack: Clojure, HTMX, Hiccup, HoneySQL, Malli, OpenHTMLToPDF, PostgreSQL.
Extract the duplicated OpenHTMLToPDF usage from workers.ap.acquisition.html and integrations.ap.cover-page into a shared protocol.
Files:
src/com/getorcha/pdf/renderer.cljsrc/com/getorcha/workers/ap/acquisition/html.cljsrc/com/getorcha/integrations/ap/cover_page.cljtest/com/getorcha/pdf/renderer_test.cljStep 1: Write the failing test
Create test/com/getorcha/pdf/renderer_test.clj:
(ns com.getorcha.pdf.renderer-test
(:require [clojure.test :refer [deftest is testing]]
[com.getorcha.pdf.renderer :as pdf.renderer]))
(deftest render-to-pdf-test
(testing "renders minimal HTML to PDF bytes"
(let [renderer (pdf.renderer/open-html-to-pdf-renderer)
html (str "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
"<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\" "
"\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd\">"
"<html xmlns=\"http://www.w3.org/1999/xhtml\">"
"<head><meta charset=\"UTF-8\"/></head>"
"<body><p>Hello</p></body></html>")
result (pdf.renderer/render-to-pdf renderer html {})]
(is (bytes? result))
(is (pos? (alength result)))
;; PDF magic bytes: %PDF-
(is (= 0x25 (bit-and (aget result 0) 0xFF)))
(is (= 0x50 (bit-and (aget result 1) 0xFF)))))
(testing "renders with SVG support when enabled"
(let [renderer (pdf.renderer/open-html-to-pdf-renderer {:svg? true})
html (str "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
"<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\" "
"\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd\">"
"<html xmlns=\"http://www.w3.org/1999/xhtml\">"
"<head><meta charset=\"UTF-8\"/></head>"
"<body>"
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"10\" height=\"10\">"
"<circle cx=\"5\" cy=\"5\" r=\"4\"/></svg>"
"</body></html>")
result (pdf.renderer/render-to-pdf renderer html {})]
(is (bytes? result))
(is (pos? (alength result))))))
Step 2: Run test to verify it fails
clj -X:test:silent :nses '[com.getorcha.pdf.renderer-test]'
Expected: FAIL — namespace com.getorcha.pdf.renderer doesn't exist.
Step 3: Write the implementation
Create src/com/getorcha/pdf/renderer.clj:
(ns com.getorcha.pdf.renderer
"Shared PDF rendering protocol and implementations.
Abstracts the HTML-to-PDF conversion behind a protocol so the rendering
engine can be swapped (e.g., from OpenHTMLToPDF to Gotenberg) without
changing callers."
(:import (com.openhtmltopdf.pdfboxout PdfRendererBuilder)
(com.openhtmltopdf.svgsupport BatikSVGDrawer)
(com.openhtmltopdf.util XRLog)
(java.io ByteArrayOutputStream)))
;; Disable verbose OpenHTMLToPDF logging (uses JUL, not SLF4J)
(XRLog/setLoggingEnabled false)
(defprotocol PdfRenderer
(render-to-pdf [this html-string options]
"Converts an XHTML string to PDF bytes.
Arguments:
html-string - Well-formed XHTML document string
options - Map of rendering options (currently unused, reserved for
future page-size/margin overrides)
Returns PDF as byte array."))
(defrecord OpenHtmlToPdfRenderer [svg?]
PdfRenderer
(render-to-pdf [_this html-string _options]
(with-open [os (ByteArrayOutputStream.)]
(let [builder (PdfRendererBuilder.)]
(.useFastMode builder)
(when svg?
(.useSVGDrawer builder (BatikSVGDrawer.)))
(.withHtmlContent builder html-string nil)
(.toStream builder os)
(.run builder))
(.toByteArray os))))
(defn open-html-to-pdf-renderer
"Creates an OpenHTMLToPDF-based renderer.
Options:
:svg? - Enable SVG rendering via BatikSVGDrawer (default false)"
([] (->OpenHtmlToPdfRenderer false))
([{:keys [svg?] :or {svg? false}}]
(->OpenHtmlToPdfRenderer svg?)))
Step 4: Run test to verify it passes
clj -X:test:silent :nses '[com.getorcha.pdf.renderer-test]'
Expected: PASS
Step 5: Refactor workers.ap.acquisition.html to use shared renderer
In src/com/getorcha/workers/ap/acquisition/html.clj:
PdfRendererBuilder and XRLog imports[com.getorcha.pdf.renderer :as pdf.renderer](def ^:private renderer (pdf.renderer/open-html-to-pdf-renderer))PdfRendererBuilder pattern in html-to-pdf and email-to-pdf with calls to (pdf.renderer/render-to-pdf renderer wrapped {})The clean-html, wrap-html, wrap-email-html functions stay — they're content-specific, not rendering-specific.
Step 6: Refactor integrations.ap.cover-page to use shared renderer
In src/com/getorcha/integrations/ap/cover_page.clj:
PdfRendererBuilder, BatikSVGDrawer, and XRLog imports (keep PDFBox imports for prepend-cover-page)[com.getorcha.pdf.renderer :as pdf.renderer](def ^:private renderer (pdf.renderer/open-html-to-pdf-renderer {:svg? true}))render-cover-pdf body with (pdf.renderer/render-to-pdf renderer html {})Step 7: Run existing tests to verify no regressions
clj -X:test:silent 2>&1 | grep -A 5 -E "(FAIL in|ERROR in|Execution error|failed because|Ran .* tests)"
Expected: All tests pass.
Step 8: Lint
clj-kondo --lint src test dev
Fix any issues.
Step 9: Commit
git add src/com/getorcha/pdf/renderer.clj test/com/getorcha/pdf/renderer_test.clj src/com/getorcha/workers/ap/acquisition/html.clj src/com/getorcha/integrations/ap/cover_page.clj
git commit -m "refactor: extract shared PdfRenderer protocol from AP callsites"
Extract shared invoice fields into schema.invoice.common, keep pipeline-specific fields in a renamed schema.invoice.incoming, create schema.invoice.outgoing.
Files:
src/com/getorcha/schema/invoice/common.cljsrc/com/getorcha/schema/invoice/outgoing.cljsrc/com/getorcha/schema/invoice/structured_data.clj → content becomes incoming.clj (see note below)src/com/getorcha/schema/structured_data.cljtest/com/getorcha/schema/invoice/common_test.cljtest/com/getorcha/schema/invoice/outgoing_test.cljImportant note on file rename: Don't literally rename the file yet — first create common.clj and outgoing.clj, then create incoming.clj that imports from common, then update structured_data.clj references. Finally, move the remaining incoming-specific types from the old file into incoming.clj and delete the old file. This avoids breaking everything at once.
Step 1: Write tests for common schema
Create test/com/getorcha/schema/invoice/common_test.clj:
(ns com.getorcha.schema.invoice.common-test
(:require [clojure.test :refer [deftest is testing]]
[com.getorcha.schema.invoice.common :as invoice.common]
[malli.core :as m]))
(deftest common-line-item-test
(testing "validates a minimal line item"
(is (m/validate invoice.common/LineItem
{:description "Consulting"
:quantity 10
:unit-price 100.0
:tax-rate 19.0
:amount 1190.0})))
(testing "rejects line item without description"
(is (not (m/validate invoice.common/LineItem
{:quantity 1 :amount 100})))))
(deftest common-invoice-data-test
(testing "validates minimal outgoing-compatible invoice"
(is (m/validate invoice.common/CommonInvoiceData
{:invoice-number "INV-001"
:invoice-date "2026-03-14"
:total 1190.0
:issuer {:name "Orcha GmbH"}
:line-items [{:description "Service" :amount 1000.0}]})))
(testing "validates full invoice with all optional fields"
(is (m/validate invoice.common/CommonInvoiceData
{:invoice-number "INV-002"
:invoice-date "2026-03-14"
:total 2380.0
:issuer {:name "Orcha GmbH"
:country "DE"
:address "Berlin"
:iban "DE89370400440532013000"}
:recipient {:name "Client Corp"
:country "US"}
:currency "EUR"
:subtotal 2000.0
:discount 0
:tax-rate 19.0
:tax-amount 380.0
:amount-due 2380.0
:due-date "2026-04-14"
:payment-terms "Net 30"
:notes "Thank you"
:line-items [{:description "Consulting" :quantity 20 :unit-price 100.0 :amount 2000.0}]}))))
Step 2: Run test to verify it fails
clj -X:test:silent :nses '[com.getorcha.schema.invoice.common-test]'
Step 3: Create schema.invoice.common
Create src/com/getorcha/schema/invoice/common.clj. Extract from the current structured_data.clj:
Prepayment, Installment — shared financial typesTaxRateBreakdown — tax breakdownLineItem — but without the post-processing fields (debit-account, credit-account, accrual, cost-center, vat-validation, bu-code, page-location). These stay in incoming.CommonInvoiceData — a new schema with all the shared fields (everything except confidence, document-type, invoice-subtype, invoice-description, service-category, tax-issues, fraud-flags, validation-results, missing-fields, financial-page-location)The common LineItem has: description, article-code, quantity, unit, unit-price, price-per, quantity-multiplier, discount, discount-type, tax-rate, amount, category, surcharges. No post-processing fields.
The common CommonInvoiceData has: invoice-number, invoice-date, total, issuer, service-period, po-references, gr-references, recipient, shipping-address, shipping-country, currency, subtotal, discount, shipping, packaging, surcharges, tax-rate, tax-amount, tax-rate-breakdowns, prepayments, installments, amount-due, due-date, payment-reference, payment-terms, skonto-percentage, skonto-deadline, paid-date, incoterm-code, incoterm-place, freight-included, packaging-included, customs-included, insurance-included, delivery-terms-raw, line-items (using common LineItem), compliance-statements, notes (new field for outgoing). Use :maybe for optional fields, matching the existing pattern.
Step 4: Run tests to verify they pass
clj -X:test:silent :nses '[com.getorcha.schema.invoice.common-test]'
Step 5: Create schema.invoice.outgoing
Create src/com/getorcha/schema/invoice/outgoing.clj:
(ns com.getorcha.schema.invoice.outgoing
"Schema for outgoing (AR) invoice structured data.
Wraps common invoice fields with AR-specific additions."
(:require [com.getorcha.schema.invoice.common :as invoice.common]
[malli.core :as m]
[malli.util :as mu]))
(def OutgoingInvoiceData
"Schema for outgoing invoice structured data.
Extends CommonInvoiceData with AR-specific fields."
(m/schema
(mu/merge
invoice.common/CommonInvoiceData
[:map
[:internal-notes [:maybe :string]]])))
Step 6: Write test for outgoing schema
Create test/com/getorcha/schema/invoice/outgoing_test.clj:
(ns com.getorcha.schema.invoice.outgoing-test
(:require [clojure.test :refer [deftest is testing]]
[com.getorcha.schema.invoice.outgoing :as invoice.outgoing]
[malli.core :as m]))
(deftest outgoing-invoice-data-test
(testing "validates outgoing invoice with internal notes"
(is (m/validate invoice.outgoing/OutgoingInvoiceData
{:invoice-number "INV-001"
:invoice-date "2026-03-14"
:total 1000.0
:issuer {:name "Orcha GmbH"}
:line-items [{:description "Service" :amount 1000.0}]
:internal-notes "Customer requested rush delivery"})))
(testing "validates without internal notes"
(is (m/validate invoice.outgoing/OutgoingInvoiceData
{:invoice-number "INV-002"
:invoice-date "2026-03-14"
:total 500.0
:issuer {:name "Orcha GmbH"}
:line-items [{:description "Widgets" :quantity 5 :unit-price 100.0 :amount 500.0}]
:internal-notes nil}))))
Step 7: Run all schema tests
clj -X:test:silent :nses '[com.getorcha.schema.invoice.common-test com.getorcha.schema.invoice.outgoing-test]'
Step 8: Create schema.invoice.incoming
Create src/com/getorcha/schema/invoice/incoming.clj. This wraps CommonInvoiceData with the pipeline-specific types that remain in structured_data.clj:
Prepayment, Installment, TaxRateBreakdownAccountMatch, AccrualPeriod, AccrualMatch, VatValidation, CostCenterMatch, BuCode, ValidationCheck, ValidationResults, ServiceCategory, TaxIssue, FraudFlagType, FraudSeverity, FraudFlagIncomingLineItem — extends common LineItem with: debit-account, credit-account, accrual, cost-center, vat-validation, bu-code, page-locationIncomingInvoiceData — extends CommonInvoiceData (using mu/merge) with: confidence, document-type, invoice-subtype, invoice-description, service-category, tax-issues, fraud-flags, validation-results, missing-fields, financial-page-location. Override :line-items to use IncomingLineItem.Step 9: Update schema.structured-data dispatch
In src/com/getorcha/schema/structured_data.clj:
invoice.structured-data to invoice.incominginvoice.incoming instead"invoice" stays for now (will change in Task 3)Step 10: Update all requires of schema.invoice.structured-data
Search for all files requiring com.getorcha.schema.invoice.structured-data and update them to require com.getorcha.schema.invoice.incoming instead. The alias should be invoice.incoming.
Step 11: Delete src/com/getorcha/schema/invoice/structured_data.clj
Once all references point to incoming, remove the old file.
Step 12: Run full test suite
clj -X:test:silent 2>&1 | grep -A 5 -E "(FAIL in|ERROR in|Execution error|failed because|Ran .* tests)"
Step 13: Lint
clj-kondo --lint src test dev
Step 14: Commit
git add src/com/getorcha/schema/invoice/common.clj src/com/getorcha/schema/invoice/outgoing.clj src/com/getorcha/schema/invoice/incoming.clj test/com/getorcha/schema/invoice/common_test.clj test/com/getorcha/schema/invoice/outgoing_test.clj src/com/getorcha/schema/structured_data.clj
git rm src/com/getorcha/schema/invoice/structured_data.clj # if applicable
git add -u # catch files that changed their requires
git commit -m "refactor: split invoice schema into common/incoming/outgoing"
Rename invoice → incoming-invoice in PostgreSQL enum and all Clojure code. Add outgoing-invoice enum value. Make content_hash and file_path nullable. Add status column.
Files:
resources/migrations/YYYYMMDDHHMMSS-ar-document-type-rename.up.sqlresources/migrations/YYYYMMDDHHMMSS-ar-document-type-rename.down.sqlStep 1: Create the migration
Use the current timestamp for the migration filename (e.g., 20260314120000).
resources/migrations/YYYYMMDDHHMMSS-ar-document-type-rename.up.sql:
-- Add new enum values
ALTER TYPE document_type ADD VALUE IF NOT EXISTS 'incoming-invoice';
--;;
ALTER TYPE document_type ADD VALUE IF NOT EXISTS 'outgoing-invoice';
--;;
-- Rename existing invoice rows
UPDATE document SET type = 'incoming-invoice' WHERE type = 'invoice';
--;;
-- Make content_hash and file_path nullable for outgoing invoices
ALTER TABLE document ALTER COLUMN content_hash DROP NOT NULL;
--;;
ALTER TABLE document ALTER COLUMN file_path DROP NOT NULL;
--;;
-- Convert unique constraint on content_hash to partial index (exclude nulls)
ALTER TABLE document DROP CONSTRAINT IF EXISTS document_content_hash_key;
--;;
CREATE UNIQUE INDEX IF NOT EXISTS document_content_hash_unique ON document (content_hash) WHERE content_hash IS NOT NULL;
--;;
-- Add status column for AR workflow
CREATE TYPE ar_invoice_status AS ENUM ('draft', 'sent', 'paid');
--;;
ALTER TABLE document ADD COLUMN status ar_invoice_status;
Down migration reverses these changes (update incoming-invoice back to invoice, drop status column, restore NOT NULL, etc.).
Note: PostgreSQL doesn't support removing enum values. The down migration can update rows back but the enum values will persist. This is acceptable.
Step 2: Update init.sql for fresh installs
In resources/migrations/init.sql, update the document_type enum to include the new values and update any references from 'invoice' to 'incoming-invoice'. Add the ar_invoice_status type and status column to the document table. Make content_hash and file_path nullable. Replace the unique constraint with a partial unique index.
Step 3: Rename :invoice → :incoming-invoice across the codebase
This is a mechanical find-and-replace with care to avoid false positives. Do NOT change :invoice-number, :invoice-date, :invoice-subtype, :invoice-description, :invoice-splitting, or any field name that starts with invoice-.
Schema files:
src/com/getorcha/schema/document.clj:8 — enum: :invoice → :incoming-invoice, add :outgoing-invoicesrc/com/getorcha/schema/common.clj:137 — DocumentType: "invoice" → "incoming-invoice"src/com/getorcha/schema/matching.clj:89 — "invoice" → "incoming-invoice"src/com/getorcha/schema/invoice/incoming.clj — [:document-type ...] enum: "invoice" → "incoming-invoice"src/com/getorcha/schema/structured_data.clj:34 — dispatch: "invoice" → "incoming-invoice", add "outgoing-invoice" entryWorker files (defmethod dispatches):
src/com/getorcha/workers/ap/ingestion/classification.clj:24 — set: "invoice" → "incoming-invoice", line 203 comparisonsrc/com/getorcha/workers/ap/ingestion/extraction.clj:605 — defmethod: "invoice" → "incoming-invoice"src/com/getorcha/workers/ap/ingestion/validation.clj:831 — defmethod: "invoice" → "incoming-invoice"src/com/getorcha/workers/ap/ingestion/fraud_detection.clj:518 — defmethod: "invoice" → "incoming-invoice"src/com/getorcha/workers/ap/ingestion/post_process.clj:189 — defmethod: "invoice" → "incoming-invoice"Matching files:
src/com/getorcha/workers/ap/matching/candidates.clj:13-15 — all set pairs: "invoice" → "incoming-invoice"src/com/getorcha/workers/ap/matching/evidence.clj — ~12 occurrences in case branches and map keyssrc/com/getorcha/workers/ap/matching/normalize.clj — case branchessrc/com/getorcha/workers/ap/matching/searchable_text.clj — case branchessrc/com/getorcha/workers/ap/matching/llm_decision.clj — ~12 prompt pair entries and case dispatchUI files:
src/com/getorcha/app/http/ap.clj:32 — ap-types set: :invoice → :incoming-invoicesrc/com/getorcha/app/http/ap.clj — HoneySQL casts: "invoice" → "incoming-invoice" (search for ->cast "invoice")src/com/getorcha/app/http/documents/shared.clj — document-type-labels, document-type-icons, match-counterpart-types, match-type-abbreviations: :invoice → :incoming-invoicesrc/com/getorcha/app/http/documents/view/shared.clj — ap-types, counterpart-types, view dispatch, HoneySQL casts, embedded JS referencessrc/com/getorcha/link/mcp/tools/docs.clj:28 — enum: "invoice" → "incoming-invoice"src/com/getorcha/link/mcp/tools/docs/list.clj:16 — defmethod: :invoice → :incoming-invoiceTest files (mechanical rename):
Update all test files that reference "invoice" as a document type or :invoice as a keyword type. Main files:
test/com/getorcha/workers/ap/ingestion/classification_test.cljtest/com/getorcha/workers/ap/ingestion/validation_test.clj (~90 occurrences)test/com/getorcha/workers/ap/ingestion/extraction_test.cljtest/com/getorcha/workers/ap/ingestion/fraud_detection_test.cljtest/com/getorcha/workers/ap/matching/searchable_text_test.cljtest/com/getorcha/workers/ap/matching/evidence_test.cljtest/com/getorcha/workers/ap/matching/worker_test.cljtest/com/getorcha/workers/ap/ingestion_test.cljtest/com/getorcha/workers/ap/acquisition/triage_test.cljtest/com/getorcha/link/mcp_test.cljtest/com/getorcha/search_test.cljStep 4: Run full test suite
clj -X:test:silent 2>&1 | grep -A 5 -E "(FAIL in|ERROR in|Execution error|failed because|Ran .* tests)"
This will catch any missed renames. Fix until green.
Step 5: Lint
clj-kondo --lint src test dev
Step 6: Commit
git add resources/migrations/ src/ test/
git commit -m "refactor: rename document type invoice to incoming-invoice, add outgoing-invoice"
Create the multimethod-based template system and a default invoice template that renders hiccup.
Files:
src/com/getorcha/app/http/ar/template.cljtest/com/getorcha/app/http/ar/template_test.cljStep 1: Write the failing test
Create test/com/getorcha/app/http/ar/template_test.clj:
(ns com.getorcha.app.http.ar.template-test
(:require [clojure.test :refer [deftest is testing]]
[com.getorcha.app.http.ar.template :as ar.template]))
(deftest render-invoice-template-test
(testing "default template returns hiccup for minimal invoice"
(let [invoice-data {:tenant-id (random-uuid)
:invoice-number "INV-001"
:invoice-date "2026-03-14"
:total 1190.0
:currency "EUR"
:issuer {:name "Orcha GmbH" :address "Berlin, Germany"}
:recipient {:name "Client Corp" :address "New York, US"}
:line-items [{:type :product
:description "Consulting"
:quantity 10
:unit-price 100.0
:tax-rate 19.0
:amount 1000.0}]
:subtotal 1000.0
:tax-amount 190.0}
result (ar.template/render-invoice-template invoice-data)]
;; Result should be a hiccup vector
(is (vector? result))
;; Should start with an element tag
(is (keyword? (first result)))))
(testing "template renders section and note line types"
(let [invoice-data {:tenant-id (random-uuid)
:invoice-number "INV-002"
:invoice-date "2026-03-14"
:total 1000.0
:issuer {:name "Test Co"}
:line-items [{:type :section :description "Professional Services"}
{:type :product :description "Dev work" :quantity 8 :unit-price 125.0 :amount 1000.0}
{:type :note :description "Billed at agreed hourly rate"}]}
result (ar.template/render-invoice-template invoice-data)]
(is (vector? result)))))
(deftest invoice-html-test
(testing "converts template hiccup to XHTML string"
(let [invoice-data {:tenant-id (random-uuid)
:invoice-number "INV-001"
:invoice-date "2026-03-14"
:total 100.0
:issuer {:name "Test"}
:line-items []}
html (ar.template/invoice-html invoice-data)]
(is (string? html))
(is (clojure.string/includes? html "INV-001"))
(is (clojure.string/includes? html "<?xml")))))
Step 2: Run test to verify it fails
clj -X:test:silent :nses '[com.getorcha.app.http.ar.template-test]'
Step 3: Implement the template
Create src/com/getorcha/app/http/ar/template.clj.
This namespace contains:
(defmulti render-invoice-template :tenant-id) — dispatch on tenant(defmethod render-invoice-template :default ...) — built-in professional invoice template(defn invoice-html [invoice-data]) — wraps hiccup output in complete XHTML document with @page CSS for OpenHTMLToPDFThe default template should render a professional A4 invoice with:
Style with CSS 2.1 compatible rules (tables, floats, no flexbox/grid). Use @page { size: A4; margin: 15mm; } for consistent sizing.
The invoice-html function:
render-invoice-template to get hiccuphiccup2.core/htmlStep 4: Run tests
clj -X:test:silent :nses '[com.getorcha.app.http.ar.template-test]'
Step 5: Lint
clj-kondo --lint src test dev
Step 6: Commit
git add src/com/getorcha/app/http/ar/template.clj test/com/getorcha/app/http/ar/template_test.clj
git commit -m "feat: add default AR invoice template with hiccup multimethod"
Build the invoice creation/edit form with split-pane layout and live PDF preview.
Files:
src/com/getorcha/app/http/ar.cljtest/com/getorcha/app/http/ar_test.cljStep 1: Write handler tests
Create test/com/getorcha/app/http/ar_test.clj:
(ns com.getorcha.app.http.ar-test
(:require [clojure.test :refer [deftest is testing use-fixtures]]
[com.getorcha.test.fixtures :as fixtures]))
(use-fixtures :once fixtures/system-fixture)
(use-fixtures :each fixtures/rollback-fixture)
(deftest create-page-test
(testing "GET /ar/create returns 200 with form"
(let [resp (fixtures/request {:route :com.getorcha.app.http.ar/create
:method :get})]
(is (= 200 (:status resp))))))
(deftest preview-test
(testing "POST /ar/preview returns PDF bytes"
(let [resp (fixtures/request {:route :com.getorcha.app.http.ar/preview
:method :post
:content-type "application/x-www-form-urlencoded"
:body "invoice-number=INV-001&invoice-date=2026-03-14&total=1000"})]
(is (= 200 (:status resp)))
(is (= "application/pdf" (get-in resp [:headers "Content-Type"]))))))
(deftest save-invoice-test
(testing "POST /ar/invoices persists structured data"
(let [resp (fixtures/request {:route :com.getorcha.app.http.ar/save
:method :post
:content-type "application/x-www-form-urlencoded"
:body "invoice-number=INV-001&invoice-date=2026-03-14&total=1000&recipient-name=Client+Corp"})
;; Verify document was created
docs (fixtures/query "SELECT * FROM document WHERE type = 'outgoing-invoice'")]
(is (= 302 (:status resp))) ;; Redirect to edit page
(is (= 1 (count docs)))
(is (= "INV-001" (get-in (first docs) [:structured_data :invoice-number]))))))
These tests will need adjustment based on your actual fixtures API. They outline the expected behavior.
Step 2: Implement app.http.ar
Create src/com/getorcha/app/http/ar.clj with:
Namespace requires:
com.getorcha.app.http.ar.template for PDF renderingcom.getorcha.app.ui.layout for base page layoutcom.getorcha.app.ui.components for shared componentscom.getorcha.pdf.renderer for PDF generationcom.getorcha.db.sql for database accesshoney.sql for query buildinghiccup2.core for hiccup renderingring.util.response for HTTP responsesModule-level state:
(def ^:private renderer (pdf.renderer/open-html-to-pdf-renderer))
Helper: form-params->invoice-data
Coerces flat form params into the outgoing invoice structured data shape. Parses numbers, builds line items from indexed params (e.g., line-items[0][description]), sets issuer from tenant/legal entity config.
Handler: create-page (GET /ar/create)
Returns the split-pane form layout:
/ar/previewhx-post="/ar/preview" with hx-trigger="input changed throttle:300ms" targeting the iframeHandler: edit-page (GET /ar/invoices/:id)
Same form layout but pre-populated with existing invoice data from database.
Handler: preview (POST /ar/preview)
form-params->invoice-data(ar.template/invoice-html invoice-data) to get XHTML(pdf.renderer/render-to-pdf renderer xhtml {}) to get PDF bytesContent-Type: application/pdf and Content-Disposition: inlineHandler: save-invoice (POST /ar/invoices)
document table with type = 'outgoing-invoice', status = 'draft', structured_data = invoice-data-as-jsonHandler: update-invoice (PUT /ar/invoices/:id)
document.structured_data and document.updated_atUI: Form layout
The form hiccup structure:
[:div.ar-editor
[:div.ar-form-pane
;; Header fields (grid layout)
;; Line items table (editable)
;; Totals section
;; Notes section
;; Save button]
[:div.ar-preview-pane
[:iframe#preview-frame {:src "/ar/preview"}]]]
Key HTMX patterns:
hx-post="/ar/preview" hx-target="#preview-frame" hx-trigger="input changed throttle:300ms" hx-swap="innerHTML" — but since it's an iframe, use hx-include="closest form" and a different approach: the form posts to a preview endpoint, and the response sets the iframe src via an OOB swap or JS.hx-post="/ar/preview" on a hidden trigger div, and set the iframe src via HX-Trigger response header + a small JS listener that refreshes the iframe. Research HTMX source at /home/volrath/code/oss/htmx/ for idiomatic patterns.hx-post="/ar/line-items/add" targeting the line items containerhx-post="/ar/invoices" or standard POSTStep 3: Wire routes
Add to the routes function at the bottom of ar.clj:
(defn routes [_config]
["/ar"
[""
{:name ::list
:get {:handler #'list-invoices}}]
["/create"
{:name ::create
:get {:handler #'create-page}}]
["/preview"
{:name ::preview
:post {:handler #'preview}}]
["/invoices"
{:name ::save
:post {:handler #'save-invoice}}]
["/invoices/:id"
[""
{:name ::edit
:get {:handler #'edit-page}
:put {:handler #'update-invoice}}]]])
Step 4: Register routes in app.http
In src/com/getorcha/app/http.clj:
[com.getorcha.app.http.ar :as app.http.ar](app.http.ar/routes config) inside the authenticated routes blockStep 5: Add nav item
In src/com/getorcha/app/ui/layout.clj:
::app.http.ar/list:lucide:file-output or :lucide:sendStep 6: Add CSS
Add AR-specific styles to resources/app/public/css/style.css:
.ar-editor, .ar-form-pane, .ar-preview-pane)Step 7: Run tests
clj -X:test:silent :nses '[com.getorcha.app.http.ar-test]'
Step 8: Lint
clj-kondo --lint src test dev
Step 9: Commit
git add src/com/getorcha/app/http/ar.clj src/com/getorcha/app/http.clj src/com/getorcha/app/ui/layout.clj test/com/getorcha/app/http/ar_test.clj resources/app/public/css/style.css
git commit -m "feat: AR invoice create/edit form with live PDF preview"
Build the list page mirroring AP's structure.
Files:
src/com/getorcha/app/http/ar.clj (add list handlers)test/com/getorcha/app/http/ar_test.clj (add list tests)Step 1: Add list test
Add to test/com/getorcha/app/http/ar_test.clj:
(deftest list-invoices-test
(testing "GET /ar returns 200 with empty list"
(let [resp (fixtures/request {:route :com.getorcha.app.http.ar/list
:method :get})]
(is (= 200 (:status resp)))))
(testing "GET /ar shows created invoices"
;; Create an invoice first
(fixtures/request {:route :com.getorcha.app.http.ar/save
:method :post
:content-type "application/x-www-form-urlencoded"
:body "invoice-number=INV-001&invoice-date=2026-03-14&total=1000&recipient-name=Client+Corp"})
(let [resp (fixtures/request {:route :com.getorcha.app.http.ar/list
:method :get})]
(is (= 200 (:status resp)))
;; Response body should contain the invoice number
(is (clojure.string/includes? (str (:body resp)) "INV-001")))))
Step 2: Implement list-invoices handler
In src/com/getorcha/app/http/ar.clj, add:
Query function:
(defn ^:private query-outgoing-invoices
[db-pool legal-entity-ids {:keys [status page sort-by sort-dir]}]
;; HoneySQL query:
;; SELECT document.id, document.created_at, document.structured_data,
;; document.status, legal_entity.name
;; FROM document
;; JOIN legal_entity ON legal_entity.id = document.legal_entity_id
;; WHERE document.type = 'outgoing-invoice'
;; AND document.legal_entity_id IN (...)
;; AND (status filter if provided)
;; ORDER BY (sort column) (sort dir)
;; LIMIT 100 OFFSET (page * 100)
)
List page renderer:
Mirrors AP's list-page function. Renders:
/ar/createInvoice row renderer:
Similar to AP's document-row. Extracts fields from structured_data:
[:recipient :name]:invoice-number:total + :currency:due-dateStatus badge renders draft/sent/paid with appropriate colors.
Step 3: Run tests
clj -X:test:silent :nses '[com.getorcha.app.http.ar-test]'
Step 4: Lint
clj-kondo --lint src test dev
Step 5: Commit
git add src/com/getorcha/app/http/ar.clj test/com/getorcha/app/http/ar_test.clj
git commit -m "feat: AR overview/list screen mirroring AP structure"
Visual polish, edge cases, and end-to-end verification.
Step 1: Manual testing checklist
Start the system ((go) in REPL) and verify:
/ar shows empty list with "Create" button/ar/createStep 2: Fix CSS and layout issues
Address any visual issues found during manual testing. Ensure:
Step 3: Run full test suite one final time
clj -X:test:silent 2>&1 | grep -A 5 -E "(FAIL in|ERROR in|Execution error|failed because|Ran .* tests)"
Step 4: Lint
clj-kondo --lint src test dev
Step 5: Final commit
git add -u
git commit -m "feat: AR module polish and integration fixes"