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.

Line-Item Reconciliation Implementation Plan

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

Goal: After documents are matched into a cluster, run a single LLM call to compare their contents and surface discrepancies (price, quantity, missing items, totals).

Architecture: New document_cluster table replaces bare UUID clustering. Reconciliation runs as the final step of the matching pipeline — after match-document!, before set-matching-status! "succeeded". One Sonnet call per affected cluster. Results stored as JSONB on document_cluster. UI renders cluster-level issues on each document's detail page.

Tech Stack: Clojure, PostgreSQL (Migratus migrations), Malli schemas, HTMX/Hiccup (UI), Claude Sonnet (LLM via com.getorcha.workers.llm/generate)

Design doc: docs/plans/2026-03-02-line-item-reconciliation-design.md


Task 1: Migration — Create document_cluster table and migrate existing data

Files:

Use bb migrate create "add-document-cluster-table" to generate the timestamp.

Step 1: Write the up migration

-- Create the document_cluster table
CREATE TABLE document_cluster (
  id               uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  reconciliation   jsonb,
  reconciled_at    timestamptz,
  created_at       timestamptz NOT NULL DEFAULT now(),
  updated_at       timestamptz NOT NULL DEFAULT now()
);
--;;
-- Backfill: create a document_cluster row for each existing distinct cluster_id
INSERT INTO document_cluster (id)
SELECT DISTINCT cluster_id FROM document WHERE cluster_id IS NOT NULL;
--;;
-- Add FK constraint (existing cluster_ids now reference document_cluster rows)
ALTER TABLE document
  ADD CONSTRAINT fk_document_cluster
  FOREIGN KEY (cluster_id) REFERENCES document_cluster(id) ON DELETE SET NULL;

Step 2: Write the down migration

ALTER TABLE document DROP CONSTRAINT IF EXISTS fk_document_cluster;
--;;
DROP TABLE IF EXISTS document_cluster;

Step 3: Run the migration locally

Run: bb migrate Expected: Migration applies without errors.

Step 4: Verify in psql

Run: psql -h localhost -U postgres -d orcha -c "\d document_cluster" Expected: Table with columns id, reconciliation, reconciled_at, created_at, updated_at.

Run: psql -h localhost -U postgres -d orcha -c "SELECT count(*) FROM document_cluster" Expected: Count matches SELECT count(DISTINCT cluster_id) FROM document WHERE cluster_id IS NOT NULL.

Step 5: Commit

git add resources/migrations/*add-document-cluster-table*
git commit -m "feat: add document_cluster table with FK from document"

Task 2: Malli schemas for reconciliation

Files:

Step 1: Write the schemas

Add at the end of src/com/getorcha/schema/matching.clj:

;; Reconciliation Schemas
;; -----------------------------------------------------------------------------

(def ReconciliationIssue
  "A single issue found during cluster reconciliation."
  [:map
   [:severity [:enum "warning" "error"]]
   [:category :string]
   [:summary :string]
   [:document-ids [:vector :string]]
   [:details {:optional true}
    [:vector
     [:map
      [:field :string]
      [:expected [:maybe :string]]
      [:actual [:maybe :string]]]]]])


(def ReconciliationResponse
  "LLM response for cluster reconciliation."
  [:map
   [:status [:enum "reconciled" "discrepancies"]]
   [:summary :string]
   [:issues [:vector ReconciliationIssue]]])

Step 2: Run linter

Run: clj-kondo --lint src/com/getorcha/schema/matching.clj Expected: No new warnings.

Step 3: Commit

git add src/com/getorcha/schema/matching.clj
git commit -m "feat: add Malli schemas for reconciliation response"

Task 3: DB operations for document_cluster

Files:

Step 1: Write the failing tests

Add to test/com/getorcha/db/document_matching_test.clj:

(deftest create-document-cluster-test
  (testing "creates a document_cluster row and returns it"
    (let [cluster (db.matching/create-document-cluster! fixtures/*db*)]
      (is (uuid? (:document-cluster/id cluster)))
      (is (some? (:document-cluster/created-at cluster))))))


(deftest set-reconciliation-test
  (testing "stores reconciliation JSONB on a document_cluster"
    (let [cluster    (db.matching/create-document-cluster! fixtures/*db*)
          cluster-id (:document-cluster/id cluster)
          result     {:status  "reconciled"
                      :summary "All items reconciled"
                      :issues  []}]
      (db.matching/set-reconciliation! fixtures/*db* cluster-id result)
      (let [updated (db.matching/get-document-cluster fixtures/*db* cluster-id)]
        (is (= "reconciled" (get-in updated [:document-cluster/reconciliation :status])))
        (is (some? (:document-cluster/reconciled-at updated)))))))


(deftest get-document-cluster-test
  (testing "returns nil for non-existent cluster"
    (is (nil? (db.matching/get-document-cluster fixtures/*db* (random-uuid))))))

Step 2: Run tests to verify they fail

Run: clj -X:test:silent :nses '[com.getorcha.db.document-matching-test]' Expected: FAIL — functions don't exist yet.

Step 3: Implement the DB functions

Add to src/com/getorcha/db/document_matching.clj:

(defn create-document-cluster!
  "Create a new document_cluster row. Returns the created row."
  [db]
  (db.sql/execute-one! db
    {:insert-into :document-cluster
     :values      [{}]
     :returning   [:*]}))


(defn get-document-cluster
  "Get a document_cluster by ID. Returns nil if not found."
  [db cluster-id]
  (db.sql/execute-one! db
    {:select [:*]
     :from   [:document-cluster]
     :where  [:= :id cluster-id]}))


(defn set-reconciliation!
  "Store reconciliation result on a document_cluster."
  [db cluster-id result]
  (db.sql/execute! db
    {:update :document-cluster
     :set    {:reconciliation [:lift result]
              :reconciled-at  [:raw "now()"]}
     :where  [:= :id cluster-id]}))


(defn delete-document-cluster!
  "Delete a document_cluster by ID."
  [db cluster-id]
  (db.sql/execute! db
    {:delete-from :document-cluster
     :where       [:= :id cluster-id]}))

Step 4: Run tests to verify they pass

Run: clj -X:test:silent :nses '[com.getorcha.db.document-matching-test]' Expected: All pass.

Step 5: Commit

git add src/com/getorcha/db/document_matching.clj test/com/getorcha/db/document_matching_test.clj
git commit -m "feat: add document_cluster DB operations"

Task 4: Update assign-cluster! to use document_cluster table

This is the critical refactor. assign-cluster! in src/com/getorcha/workers/matching/core.clj currently generates bare UUIDs. It must now create/merge document_cluster rows.

Files:

Step 1: Update assign-cluster!

Replace the existing assign-cluster! in src/com/getorcha/workers/matching/core.clj:

(defn assign-cluster!
  "Assign cluster to matched documents.

   Cases:
   - Neither has a cluster: create a new document_cluster, set both
   - One has a cluster: add the other to it
   - Both have different clusters: merge cluster-b into cluster-a, delete cluster-b
   - Both share the same cluster: no-op"
  [db doc-a-id doc-b-id]
  (let [docs      (db.matching/get-documents-by-ids db [doc-a-id doc-b-id])
        doc-a     (first (filter #(= doc-a-id (:document/id %)) docs))
        doc-b     (first (filter #(= doc-b-id (:document/id %)) docs))
        cluster-a (:document/cluster-id doc-a)
        cluster-b (:document/cluster-id doc-b)]
    (cond
      (and cluster-a cluster-b (= cluster-a cluster-b))
      nil

      (and cluster-a cluster-b)
      (let [docs-to-move (db.matching/get-cluster-documents db cluster-b)]
        (db.matching/set-cluster-id! db (mapv :document/id docs-to-move) cluster-a)
        (db.matching/delete-document-cluster! db cluster-b))

      cluster-a
      (db.matching/set-cluster-id! db [doc-b-id] cluster-a)

      cluster-b
      (db.matching/set-cluster-id! db [doc-a-id] cluster-b)

      :else
      (let [{:document-cluster/keys [id]} (db.matching/create-document-cluster! db)]
        (db.matching/set-cluster-id! db [doc-a-id doc-b-id] id)))))

Key changes from the original:

Step 2: Run existing matching integration tests

Run: clj -X:test:silent :nses '[com.getorcha.workers.matching.integration-test]' Expected: All existing tests pass. The tests check for cluster-id presence and equality, which still works. The new behavior is that cluster_id now references a document_cluster row.

Step 3: Run all matching tests

Run: clj -X:test:silent :nses '[com.getorcha.workers.matching.core-test]' Expected: All pass.

Step 4: Commit

git add src/com/getorcha/workers/matching/core.clj src/com/getorcha/db/document_matching.clj
git commit -m "feat: assign-cluster! creates document_cluster rows instead of bare UUIDs"

Task 5: Make format-document-summary public

Files:

Step 1: Remove ^:private from format-document-summary

In src/com/getorcha/workers/matching/llm_decision.clj line 39, change:

(defn ^:private format-document-summary

to:

(defn format-document-summary

Also make format-line-items public since it's used by format-document-summary:

Line 25, change ^:private to public:

(defn format-line-items

Step 2: Check if tests reference the private var

Look at test/com/getorcha/workers/matching/llm_decision_test.clj. If tests call #'llm-decision/format-document-summary, update them to call llm-decision/format-document-summary directly.

Step 3: Run tests

Run: clj -X:test:silent :nses '[com.getorcha.workers.matching.llm-decision-test]' Expected: All pass.

Step 4: Run linter

Run: clj-kondo --lint src/com/getorcha/workers/matching/llm_decision.clj Expected: No new warnings.

Step 5: Commit

git add src/com/getorcha/workers/matching/llm_decision.clj test/com/getorcha/workers/matching/llm_decision_test.clj
git commit -m "refactor: make format-document-summary public for reconciliation reuse"

Task 6: Reconciliation namespace — LLM prompt and core logic

Files:

Step 1: Write the failing test

Create test/com/getorcha/workers/matching/reconciliation_test.clj:

(ns com.getorcha.workers.matching.reconciliation-test
  (:require [clojure.test :refer [deftest is testing use-fixtures]]
            [com.getorcha.schema.matching :as schema.matching]
            [com.getorcha.workers.matching.reconciliation :as reconciliation]
            [malli.core :as m]))


(deftest build-reconciliation-prompt-test
  (testing "produces system + user prompt with all documents and their IDs"
    (let [docs [{:document/id   (random-uuid)
                 :document/type "invoice"
                 :document/structured-data
                 {:invoice-number "INV-001"
                  :issuer         {:name "ACME Corp"}
                  :total          10000
                  :line-items     [{:description "Widget" :quantity 100 :unit-price 100 :amount 10000}]}}
                {:document/id   (random-uuid)
                 :document/type "purchase-order"
                 :document/structured-data
                 {:po-number   "PO-001"
                  :supplier    {:name "ACME Corp"}
                  :total-value 10000
                  :line-items  [{:description "Widget" :quantity 100 :unit-price 100 :amount 10000}]}}]
          {:keys [system user]} (reconciliation/build-reconciliation-prompt docs)]
      (is (string? system))
      (is (string? user))
      ;; Both document IDs should appear in the user prompt
      (is (re-find (re-pattern (str (:document/id (first docs)))) user))
      (is (re-find (re-pattern (str (:document/id (second docs)))) user))
      ;; Document summaries should appear
      (is (re-find #"INV-001" user))
      (is (re-find #"PO-001" user)))))


(deftest parse-reconciliation-response-test
  (testing "validates a correct reconciliation response"
    (let [response "{\"status\": \"discrepancies\", \"summary\": \"1 issue found\", \"issues\": [{\"severity\": \"warning\", \"category\": \"price-discrepancy\", \"summary\": \"Unit price mismatch\", \"document_ids\": [\"abc\", \"def\"]}]}"
          parsed   (reconciliation/parse-reconciliation-response response)]
      (is (m/validate schema.matching/ReconciliationResponse parsed))))

  (testing "throws on invalid response"
    (is (thrown? Exception
          (reconciliation/parse-reconciliation-response "{\"bad\": true}")))))

Step 2: Run test to verify it fails

Run: clj -X:test:silent :nses '[com.getorcha.workers.matching.reconciliation-test]' Expected: FAIL — namespace doesn't exist.

Step 3: Write the reconciliation namespace

Create src/com/getorcha/workers/matching/reconciliation.clj:

(ns com.getorcha.workers.matching.reconciliation
  "Cluster-level line-item reconciliation.

   After documents are matched into a cluster, this module runs a single LLM call
   to compare their contents and surface discrepancies (price, quantity, missing
   items, totals). Results are stored on the `document_cluster` row."
  (:require [clojure.tools.logging :as log]
            [com.getorcha.db.document-matching :as db.matching]
            [com.getorcha.schema.matching :as schema.matching]
            [com.getorcha.workers.llm :as llm]
            [com.getorcha.workers.matching.llm-decision :as llm-decision]
            [malli.core :as m]))


(def ^:private reconciliation-system-prompt
  "You are a financial document reconciliation assistant.

You will receive a cluster of related financial documents (invoices, purchase orders,
contracts, goods received notes). Your task is to verify that their contents are
consistent with each other.

Check for:
- **Price discrepancies**: Does the invoice charge the same unit prices as the PO?
- **Quantity mismatches**: Does the invoiced quantity match what was ordered (PO) and
  what was received (GRN)?
- **Missing or extra line items**: Do all invoice line items have a counterpart on the PO?
- **Total inconsistencies**: Does the invoice total align with contract values?
- **Cross-document discrepancies**: Is the invoice billing for quantities the GRN says
  were never delivered?

Rules:
- Use a 0.5% tolerance for amount comparisons (e.g., 100.00 vs 100.40 is acceptable)
- Match line items by article code first, then by description similarity
- When quantities differ, always flag it — even small differences matter for AP teams
- Reference documents by their ID when reporting issues

Return JSON:
{
  \"status\": \"reconciled\" or \"discrepancies\",
  \"summary\": \"One sentence cluster-level summary\",
  \"issues\": [
    {
      \"severity\": \"warning\" or \"error\",
      \"category\": \"price-discrepancy\" | \"quantity-mismatch\" | \"unmatched-item\" | \"total-inconsistency\" | \"other\",
      \"summary\": \"Human-readable description referencing specific documents and values\",
      \"document_ids\": [\"<uuid>\", \"<uuid>\"],
      \"details\": [{\"field\": \"unit-price\", \"expected\": \"100.00\", \"actual\": \"120.00\"}]
    }
  ]
}

If everything checks out, return {\"status\": \"reconciled\", \"summary\": \"...\", \"issues\": []}.
If there are no line items to compare (e.g., only contracts and invoices with just totals),
compare what you can (totals, dates, scope) and report accordingly.")


(defn build-reconciliation-prompt
  "Build the LLM prompt for cluster reconciliation.
   Takes a seq of document DB rows with `:document/id`, `:document/type`,
   and `:document/structured-data`."
  [documents]
  {:system reconciliation-system-prompt
   :user   (str "## Documents in this cluster\n\n"
                 (->> documents
                      (map (fn [doc]
                             (str "### Document " (:document/id doc) "\n"
                                  (llm-decision/format-document-summary doc))))
                      (interpose "\n\n---\n\n")
                      (apply str))
                 "\n\n## Task\n"
                 "Compare these documents and report any discrepancies. "
                 "Return JSON as specified.")})


(defn parse-reconciliation-response
  "Parse and validate LLM JSON response for reconciliation.
   Converts snake_case keys to kebab-case for Malli validation.
   Throws on malformed JSON or schema validation failure."
  [response]
  (let [parsed (-> (llm/parse-json-response response)
                   (update :issues (fn [issues]
                                     (mapv (fn [issue]
                                             (cond-> issue
                                               (:document_ids issue)
                                               (-> (assoc :document-ids (:document_ids issue))
                                                   (dissoc :document_ids))))
                                           issues))))]
    (when-not (m/validate schema.matching/ReconciliationResponse parsed)
      (throw
       (ex-info "Reconciliation response failed schema validation"
                {:kind             ::schema-validation-failure
                 :errors           (m/explain schema.matching/ReconciliationResponse parsed)
                 :response-preview (subs response 0 (min 200 (count response)))})))
    parsed))


(def ^:dynamic *retry-backoff-ms*
  "Backoff base in ms between LLM retry attempts. Override in tests."
  2000)


(defn ^:private transient-reconciliation-error?
  "Returns true if the error is likely transient and worth retrying."
  [e]
  (let [{:keys [kind status]} (ex-data e)]
    (case kind
      :com.getorcha.workers.llm/api-error      (or (= 429 status) (<= 500 status 599))
      :com.getorcha.workers.llm/empty-response true
      :com.getorcha.workers.llm/json-parse-error true
      ::schema-validation-failure              true
      false)))


(defn ^:private with-retry
  "Calls f, retrying up to max-attempts on transient errors."
  [f max-attempts]
  (loop [attempt 1]
    (let [result (try {:ok (f)} (catch Exception e {:error e}))]
      (if-let [v (:ok result)]
        v
        (let [e (:error result)]
          (if (or (not (transient-reconciliation-error? e)) (>= attempt max-attempts))
            (throw e)
            (do
              (log/warn e "Reconciliation LLM call failed, retrying"
                        {:attempt attempt :max max-attempts})
              (Thread/sleep ^long (* *retry-backoff-ms* (long (Math/pow 2 (dec attempt)))))
              (recur (inc attempt)))))))))


(defn reconcile-cluster!
  "Run reconciliation for a cluster. Loads all documents, calls LLM, stores result.

   Arguments:
     db         - Database connection
     llm-config - LLM provider config map (the `:matching` value, e.g. Gemini Flash)
     cluster-id - UUID of the document_cluster to reconcile"
  [db llm-config cluster-id]
  (let [documents (db.matching/get-cluster-documents db cluster-id)]
    (if (< (count documents) 2)
      (log/debug "Cluster has fewer than 2 documents, skipping reconciliation"
                 {:cluster-id cluster-id :document-count (count documents)})
      (let [{:keys [system user]}                          (build-reconciliation-prompt documents)
            prompt                                         (str system "\n\n" user)
            {:keys [text input-tokens output-tokens model]} (with-retry #(llm/generate llm-config prompt) 3)
            result                                         (parse-reconciliation-response text)]
        (log/info "Reconciliation complete"
                  {:cluster-id    cluster-id
                   :status        (:status result)
                   :issue-count   (count (:issues result))
                   :input-tokens  input-tokens
                   :output-tokens output-tokens
                   :model         model})
        (db.matching/set-reconciliation! db cluster-id result)))))

Step 4: Run tests

Run: clj -X:test:silent :nses '[com.getorcha.workers.matching.reconciliation-test]' Expected: All pass.

Step 5: Run linter

Run: clj-kondo --lint src/com/getorcha/workers/matching/reconciliation.clj Expected: No warnings.

Step 6: Commit

git add src/com/getorcha/workers/matching/reconciliation.clj test/com/getorcha/workers/matching/reconciliation_test.clj
git commit -m "feat: add reconciliation namespace with LLM prompt and response parsing"

Task 7: Wire reconciliation into process-document!

Files:

Step 1: Write the failing test

Add to test/com/getorcha/workers/matching/worker_test.clj:

(deftest process-document-runs-reconciliation-test
  (testing "calls reconcile-cluster! for affected clusters after matching"
    (let [le-id           (helpers/create-legal-entity!)
          doc-id          (helpers/create-document! le-id)
          reconciled      (atom [])
          fake-cluster-id (random-uuid)
          config          {:db-pool       fixtures/*db*
                           :llm-config    {:matching {:provider :test}}
                           :search-config {}
                           :notifications {}}]
      (db.sql/execute-one! fixtures/*db*
        {:update :document
         :set    {:type            (db.sql/->cast :invoice :document-type)
                  :structured-data [:lift {:issuer {:name "X"}}]}
         :where  [:= :id doc-id]})
      (with-redefs [com.getorcha.workers.matching.core/match-document!
                    (fn [db _ _ _]
                      ;; Simulate matching: create a cluster and assign the doc
                      (let [{:document-cluster/keys [id]} (db.matching/create-document-cluster! db)]
                        (db.matching/set-cluster-id! db [doc-id] id)))
                    com.getorcha.workers.matching.reconciliation/reconcile-cluster!
                    (fn [_ _ cluster-id]
                      (swap! reconciled conj cluster-id))]
        (#'worker/process-document! config doc-id))
      (is (= 1 (count @reconciled))
          "Should reconcile exactly one cluster")
      (is (= "succeeded" (:document/matching-status (get-document-status doc-id)))))))

Step 2: Run test to verify it fails

Run: clj -X:test:silent :nses '[com.getorcha.workers.matching.worker-test]' Expected: FAIL — reconciliation not wired in yet.

Step 3: Update process-document!

In src/com/getorcha/workers/matching/worker.clj:

Add to requires:

[com.getorcha.workers.matching.reconciliation :as reconciliation]

Replace the process-document! function:

(defn ^:private process-document!
  "Processes a single document through the matching pipeline.
   Sets matching_status to in-progress at start and succeeded on completion.
   Runs reconciliation on all affected clusters after matching."
  [{:keys [db-pool llm-config search-config notifications aws] :as _context} document-id]
  (let [doc (fetch-document db-pool document-id)]
    (when (nil? doc)
      (throw (ex-info "Document not found for matching"
                      {:document-id document-id
                       :kind        ::document-not-found})))
    (when (nil? (:document/structured-data doc))
      (throw (ex-info "Document missing structured data, skipping matching"
                      {:document-id document-id
                       :kind        ::missing-structured-data})))
    (log/info "Processing document for matching"
              {:document-id     document-id
               :document-type   (:document/type doc)
               :legal-entity-id (:document/legal-entity-id doc)})
    (mdc/put! :legal-entity-id (:document/legal-entity-id doc))
    (xray/add-annotation! "legal_entity_id" (str (:document/legal-entity-id doc)))
    (db.matching/set-matching-status! db-pool document-id
      {:status             "in-progress"
       :increment-attempts true})
    (let [cluster-before (:document/cluster-id doc)
          _              (matching/match-document! db-pool search-config (:matching llm-config) doc)
          doc-after      (fetch-document db-pool document-id)
          cluster-after  (:document/cluster-id doc-after)
          affected       (disj (cond-> #{}
                                 cluster-before (conj cluster-before)
                                 cluster-after  (conj cluster-after))
                               nil)]
      (doseq [cluster-id affected]
        (try
          (reconciliation/reconcile-cluster! db-pool (:matching llm-config) cluster-id)
          (catch Exception e
            (log/warn e "Reconciliation failed for cluster, continuing"
                      {:cluster-id  cluster-id
                       :document-id document-id})
            (try
              (notifications/notify-admins!
               {:db-pool       db-pool
                :aws           aws
                :notifications notifications}
               {:legal-entity/id (:document/legal-entity-id doc)}
               {:kind  :reconciliation/failure
                :title (str "Reconciliation failed for cluster " cluster-id)
                :body  (str "Document: " document-id
                            "\nCluster: " cluster-id
                            "\nError: " (ex-message e))})
              (catch Exception notify-err
                (log/warn notify-err "Failed to send reconciliation failure notification")))))))
    (db.matching/set-matching-status! db-pool document-id {:status "succeeded"})
    (log/info "Document matching complete" {:document-id document-id})))

Step 4: Run all worker tests

Run: clj -X:test:silent :nses '[com.getorcha.workers.matching.worker-test]' Expected: All pass (including the new test and existing tests).

Step 5: Run matching integration tests

Run: clj -X:test:silent :nses '[com.getorcha.workers.matching.integration-test]' Expected: All pass. Integration tests don't provide LLM config so reconcile-cluster! will be called with nil — need to handle this. Check: if llm-config is nil, reconcile-cluster! should skip (the tests pass nil for llm-config and (:matching nil) = nil). Add a guard to reconcile-cluster!:

;; At the top of reconcile-cluster!
(when (nil? llm-config)
  (log/debug "No LLM config provided, skipping reconciliation" {:cluster-id cluster-id})
  (return))

Actually, use an early return pattern:

(defn reconcile-cluster!
  [db llm-config cluster-id]
  (if (nil? llm-config)
    (log/debug "No LLM config, skipping reconciliation" {:cluster-id cluster-id})
    (let [documents (db.matching/get-cluster-documents db cluster-id)]
      ...)))

Step 6: Run full matching test suite

Run: clj -X:test:silent :nses '[com.getorcha.workers.matching.integration-test com.getorcha.workers.matching.worker-test com.getorcha.workers.matching.core-test]' Expected: All pass.

Step 7: Commit

git add src/com/getorcha/workers/matching/worker.clj test/com/getorcha/workers/matching/worker_test.clj src/com/getorcha/workers/matching/reconciliation.clj
git commit -m "feat: wire reconciliation into matching pipeline after match-document!"

Task 8: Integration test for reconciliation

Files:

This tests the full flow: create documents, match them, verify reconciliation is stored on the document_cluster.

Step 1: Write the integration test

(ns com.getorcha.workers.matching.reconciliation-integration-test
  "Integration test for reconciliation — verifies that after matching,
   document_cluster.reconciliation is populated."
  (:require [clojure.test :refer [deftest is testing use-fixtures]]
            [com.getorcha.db.document-matching :as db.matching]
            [com.getorcha.db.sql :as db.sql]
            [com.getorcha.search :as search]
            [com.getorcha.test.fixtures :as fixtures]
            [com.getorcha.test.notification-helpers :as helpers]
            [com.getorcha.workers.matching.reconciliation :as reconciliation]))


(use-fixtures :once fixtures/with-running-system)
(use-fixtures :each fixtures/with-db-rollback)


(defn ^:private create-document-with-type!
  [legal-entity-id type structured-data]
  (let [doc-id (helpers/create-document! legal-entity-id)]
    (db.sql/execute-one!
     fixtures/*db*
     {:update :document
      :set    {:type            (db.sql/->cast type :document-type)
               :structured-data [:lift structured-data]}
      :where  [:= :id doc-id]})
    doc-id))


(defn ^:private get-document [doc-id]
  (db.sql/execute-one! fixtures/*db*
    {:select [:*] :from [:document] :where [:= :id doc-id]}))


(deftest reconcile-cluster-with-matching-documents-test
  (testing "reconciliation stores result on document_cluster after LLM call"
    (let [le-id  (helpers/create-legal-entity!)
          inv-id (create-document-with-type!
                   le-id :invoice
                   {:invoice-number "INV-REC-001"
                    :issuer         {:name "ACME Corp" :vat-id "DE123"}
                    :total          10000
                    :line-items     [{:description "Widget" :quantity 100
                                      :unit-price 100 :amount 10000}]})
          po-id  (create-document-with-type!
                   le-id :purchase-order
                   {:po-number   "PO-REC-001"
                    :supplier    {:name "ACME Corp" :vat-id "DE123"}
                    :total-value 10000
                    :line-items  [{:description "Widget" :quantity 100
                                   :unit-price 100 :amount 10000}]})
          ;; Create a cluster and assign both docs
          {:document-cluster/keys [id]} (db.matching/create-document-cluster! fixtures/*db*)]
      (db.matching/set-cluster-id! fixtures/*db* [inv-id po-id] id)

      ;; Stub the LLM to return a clean reconciliation
      (with-redefs [com.getorcha.workers.llm/generate
                    (fn [_ _]
                      {:text          "{\"status\": \"reconciled\", \"summary\": \"All items match\", \"issues\": []}"
                       :input-tokens  100
                       :output-tokens 50
                       :model         "test"})]
        (reconciliation/reconcile-cluster! fixtures/*db* {:provider :test} id))

      ;; Verify reconciliation was stored
      (let [cluster (db.matching/get-document-cluster fixtures/*db* id)]
        (is (= "reconciled" (get-in cluster [:document-cluster/reconciliation :status])))
        (is (some? (:document-cluster/reconciled-at cluster)))))))


(deftest reconcile-cluster-with-discrepancies-test
  (testing "reconciliation stores discrepancy issues from LLM"
    (let [le-id  (helpers/create-legal-entity!)
          inv-id (create-document-with-type!
                   le-id :invoice
                   {:invoice-number "INV-DISC-001"
                    :issuer         {:name "ACME Corp"}
                    :total          12000
                    :line-items     [{:description "Widget" :quantity 100
                                      :unit-price 120 :amount 12000}]})
          po-id  (create-document-with-type!
                   le-id :purchase-order
                   {:po-number   "PO-DISC-001"
                    :supplier    {:name "ACME Corp"}
                    :total-value 10000
                    :line-items  [{:description "Widget" :quantity 100
                                   :unit-price 100 :amount 10000}]})
          {:document-cluster/keys [id]} (db.matching/create-document-cluster! fixtures/*db*)
          llm-response (str "{\"status\": \"discrepancies\","
                            "\"summary\": \"Price discrepancy found\","
                            "\"issues\": [{\"severity\": \"error\","
                            "\"category\": \"price-discrepancy\","
                            "\"summary\": \"Invoice charges 120/unit vs PO 100/unit\","
                            "\"document_ids\": [\"" inv-id "\", \"" po-id "\"],"
                            "\"details\": [{\"field\": \"unit-price\","
                            "\"expected\": \"100.00\", \"actual\": \"120.00\"}]}]}")]
      (db.matching/set-cluster-id! fixtures/*db* [inv-id po-id] id)

      (with-redefs [com.getorcha.workers.llm/generate
                    (fn [_ _] {:text llm-response :input-tokens 200 :output-tokens 100 :model "test"})]
        (reconciliation/reconcile-cluster! fixtures/*db* {:provider :test} id))

      (let [cluster (db.matching/get-document-cluster fixtures/*db* id)
            recon   (:document-cluster/reconciliation cluster)]
        (is (= "discrepancies" (:status recon)))
        (is (= 1 (count (:issues recon))))
        (is (= "error" (-> recon :issues first :severity)))))))


(deftest reconcile-cluster-skips-single-document-test
  (testing "clusters with fewer than 2 documents are skipped"
    (let [le-id  (helpers/create-legal-entity!)
          doc-id (create-document-with-type!
                   le-id :invoice {:invoice-number "SOLO" :issuer {:name "X"}})
          {:document-cluster/keys [id]} (db.matching/create-document-cluster! fixtures/*db*)]
      (db.matching/set-cluster-id! fixtures/*db* [doc-id] id)
      ;; Should not throw, should not call LLM
      (reconciliation/reconcile-cluster! fixtures/*db* {:provider :test} id)
      ;; Reconciliation should remain nil
      (let [cluster (db.matching/get-document-cluster fixtures/*db* id)]
        (is (nil? (:document-cluster/reconciliation cluster)))))))

Step 2: Run the tests

Run: clj -X:test:silent :nses '[com.getorcha.workers.matching.reconciliation-integration-test]' Expected: All pass.

Step 3: Commit

git add test/com/getorcha/workers/matching/reconciliation_integration_test.clj
git commit -m "test: add reconciliation integration tests"

Task 9: Update get-matched-documents to include reconciliation data

The UI needs reconciliation data when rendering the matches section. The existing get-matched-documents query needs to join document_cluster to fetch reconciliation.

Files:

Step 1: Add a function to get cluster reconciliation for a document

Add to src/com/getorcha/db/document_matching.clj:

(defn get-cluster-reconciliation
  "Get the reconciliation data for a document's cluster.
   Returns nil if the document has no cluster or no reconciliation."
  [db document-id]
  (db.sql/execute-one! db
    {:select    [:document-cluster.reconciliation
                 :document-cluster.reconciled-at]
     :from      [:document]
     :join      [[:document-cluster] [:= :document.cluster-id :document-cluster.id]]
     :where     [:= :document.id document-id]}))

Step 2: Run linter

Run: clj-kondo --lint src/com/getorcha/db/document_matching.clj Expected: No warnings.

Step 3: Commit

git add src/com/getorcha/db/document_matching.clj
git commit -m "feat: add get-cluster-reconciliation query"

Task 10: UI — Reconciliation badge and issues list

Files:

Step 1: Add reconciliation status badge component

In src/com/getorcha/erp/http/documents/view/shared.clj, add a helper function near the match card functions (around line 200):

(defn ^:private reconciliation-badge
  "Renders the reconciliation status badge for the matches section."
  [reconciliation]
  (when reconciliation
    (let [{:keys [status issues]} reconciliation
          issue-count             (count issues)]
      (case status
        "reconciled"
        [:span.reconciliation-badge.reconciliation-ok
         [:iconify-icon {:icon "lucide:check-circle" :style "font-size: 14px;"}]
         "Reconciled"]

        "discrepancies"
        [:span.reconciliation-badge.reconciliation-warning
         [:iconify-icon {:icon "lucide:alert-triangle" :style "font-size: 14px;"}]
         (str issue-count " issue" (when (not= 1 issue-count) "s") " found")]

        nil))))

Step 2: Add issue rendering functions

(defn ^:private document-reference
  "Renders a document reference — link for other docs, plain text for current doc."
  [router current-doc-id doc-id doc-type-label]
  (if (= (str current-doc-id) (str doc-id))
    [:span.reconciliation-self-ref (str "this " doc-type-label)]
    [:a {:href (erp.http.routes/path-for router
                 :com.getorcha.erp.http.documents.view/detail
                 {:document-id doc-id})}
     doc-type-label]))


(defn ^:private reconciliation-issue
  "Renders a single reconciliation issue."
  [router current-doc-id cluster-documents issue]
  (let [{:keys [severity summary details]} issue]
    [:div.reconciliation-issue
     [:div.reconciliation-issue-header
      [:iconify-icon {:icon  (if (= severity "error") "lucide:x-circle" "lucide:alert-triangle")
                      :class (str "reconciliation-severity-" severity)
                      :style "font-size: 14px;"}]
      [:span summary]]
     (when (seq details)
       [:div.reconciliation-issue-details
        [:table.reconciliation-table
         [:thead
          [:tr [:th "Field"] [:th "Expected"] [:th "Actual"]]]
         [:tbody
          (for [{:keys [field expected actual]} details]
            [:tr
             [:td field]
             [:td (or expected "—")]
             [:td (or actual "—")]])]]])]))


(defn ^:private reconciliation-section
  "Renders the reconciliation issues list below match cards."
  [router current-doc-id cluster-documents reconciliation]
  (when-let [{:keys [issues]} reconciliation]
    (when (seq issues)
      [:div.reconciliation-issues
       (for [issue issues]
         (reconciliation-issue router current-doc-id cluster-documents issue))])))

Step 3: Update matches-section to include reconciliation

Modify the matches-section function to accept and render reconciliation data. The function currently has signature:

(defn ^:private matches-section
  [router document-id doc-type matches matching-status]

Change to:

(defn ^:private matches-section
  [router document-id doc-type matches matching-status reconciliation]

Inside the collapsible-section call, change the title from "Matches" to include the badge:

(erp.ui.components/collapsible-section
 (list "Matches" (reconciliation-badge reconciliation))
 "section-matches"
 (list
   ;; ... existing SSE + match cards ...
   (reconciliation-section router document-id nil reconciliation)))

Step 4: Update all callers of matches-section

Find all places that call matches-section and add the reconciliation parameter. This includes:

In the SSE handler (around line 940), update:

:matching
(let [document (db.sql/execute-one! db-pool
                  {:select [:type] :from [:document] :where [:= :id document-id]})
      doc-type       (some-> (:document/type document) keyword)
      matches        (db.matching/get-matched-documents db-pool document-id)
      reconciliation (some-> (db.matching/get-cluster-reconciliation db-pool document-id)
                             :document-cluster/reconciliation)]
  (when doc-type
    {:event "matching-complete"
     :data  (hiccup/html
              (matches-section router document-id doc-type matches nil reconciliation))}))

Step 5: Add CSS

Add to resources/erp/public/css/style.css:

/* Reconciliation */
.reconciliation-badge {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font-size: 12px;
  font-weight: 500;
  padding: 2px 8px;
  border-radius: 4px;
  margin-left: 8px;
}

.reconciliation-ok {
  color: #3fb950;
  background: rgba(63, 185, 80, 0.1);
}

.reconciliation-warning {
  color: #d29922;
  background: rgba(210, 153, 34, 0.1);
}

.reconciliation-issues {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-top: 12px;
}

.reconciliation-issue {
  background: #161b22;
  border: 1px solid #30363d;
  border-radius: 6px;
  padding: 12px;
}

.reconciliation-issue-header {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
}

.reconciliation-severity-error {
  color: #f85149;
}

.reconciliation-severity-warning {
  color: #d29922;
}

.reconciliation-issue-details {
  margin-top: 8px;
}

.reconciliation-table {
  width: 100%;
  font-size: 12px;
  border-collapse: collapse;
}

.reconciliation-table th {
  text-align: left;
  color: #8b949e;
  padding: 4px 8px;
  border-bottom: 1px solid #30363d;
}

.reconciliation-table td {
  padding: 4px 8px;
  border-bottom: 1px solid #21262d;
}

.reconciliation-self-ref {
  font-style: italic;
  color: #8b949e;
}

Step 6: Verify manually

Start the dev server, navigate to a document detail page that has matches. The reconciliation badge should appear (or not, if no reconciliation has run). To test with data, either:

Step 7: Run linter

Run: clj-kondo --lint src/com/getorcha/erp/http/documents/view/shared.clj Expected: No warnings.

Step 8: Commit

git add src/com/getorcha/erp/http/documents/view/shared.clj resources/erp/public/css/style.css src/com/getorcha/db/document_matching.clj
git commit -m "feat: add reconciliation badge and issues list to matches section UI"

Task 11: Add llm-config key for reconciliation

The matching worker currently uses :fast (Gemini Flash) for LLM match decisions. Reconciliation should use :main (Claude Sonnet) as specified in the design doc. The config needs a :reconciliation key alongside :matching.

Files:

Step 1: Add reconciliation LLM config

In resources/com/getorcha/config.edn, in the matching orchestrator config, update llm-config:

:llm-config {:matching       #ref [:com.getorcha/llm :fast]
             :reconciliation #ref [:com.getorcha/llm :main]}

Step 2: Update process-document! to use reconciliation config

In worker.clj, change the reconcile-cluster! call to use :reconciliation instead of :matching:

(reconciliation/reconcile-cluster! db-pool (:reconciliation llm-config) cluster-id)

Step 3: Run all tests

Run: clj -X:test:silent :nses '[com.getorcha.workers.matching.worker-test com.getorcha.workers.matching.integration-test]' Expected: All pass. Test config should also have the :reconciliation key or tests pass nil which triggers the skip guard.

Step 4: Commit

git add resources/com/getorcha/config.edn src/com/getorcha/workers/matching/worker.clj
git commit -m "feat: add separate Sonnet LLM config for reconciliation"

Task 12: Run full test suite and lint

Step 1: Run linter on all source

Run: clj-kondo --lint src test dev Expected: No new warnings.

Step 2: Run all matching-related tests

Run: clj -X:test:silent :nses '[com.getorcha.workers.matching.integration-test com.getorcha.workers.matching.worker-test com.getorcha.workers.matching.core-test com.getorcha.workers.matching.reconciliation-test com.getorcha.workers.matching.reconciliation-integration-test com.getorcha.db.document-matching-test]' Expected: All pass.

Step 3: Run full test suite

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

Step 4: Commit any fixes if needed


Summary of files changed

New files:

Modified files: