For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the tenant AP approvals boolean with append-only tenant AP processing modes that drive human review, inline editing, automatic output, and manual export authorization.
Architecture: Add tenant_ap_processing_config as the single runtime AP policy source, with latest row winning and no-row defaulting to read_only. Centralize policy and output authorization in com.getorcha.ap.processing; app handlers and workers call it instead of each callsite directly checking tenant_ap_approval_config, approval rows, or DATEV eligibility. Keep the implemented document output dispatcher as the output execution path, adding only the missing processing_completed trigger and new callsites.
Tech Stack: Clojure, HoneySQL via com.getorcha.db.sql, PostgreSQL migrations/enums, Integrant workers, SQS document output helper, HTMX/Hiccup admin and document UI, clojure.test.
resources/migrations/20260501130000-add-ap-processing-modes.up.sql
document_output_trigger.processing_completed.ap_processing_mode and tenant_ap_processing_config.tenant_ap_approval_config.is_enabled.resources/migrations/20260501130000-add-ap-processing-modes.down.sql
tenant_ap_processing_config (cascades the index) and ap_processing_mode.document_output_trigger.processing_completed is intentionally not removed.src/com/getorcha/ap/processing.clj
test/com/getorcha/ap/processing_test.clj
src/com/getorcha/admin/http/tenants.clj
src/com/getorcha/admin/http/tenants/approval.clj
test/com/getorcha/admin/http/tenants/approval_test.clj
src/com/getorcha/workers/ap/ingestion.clj
human_review_export.test/com/getorcha/workers/ap/ingestion_test.clj
src/com/getorcha/app/http/documents/shared.clj
src/com/getorcha/app/http/documents/edits.clj
src/com/getorcha/app/http/documents/view/approval.clj
src/com/getorcha/app/http/documents/view/invoice.clj
src/com/getorcha/workers/ap/processors/matching/worker.clj
src/com/getorcha/integrations/ap/maesn.clj
export-eligible? so validation errors do not block DATEV dispatch.test/com/getorcha/app/http/documents/view/approval_test.cljtest/com/getorcha/app/http/documents/view/invoice_test.cljtest/com/getorcha/app/http/documents/edits_test.cljtest/com/getorcha/workers/ap/processors/matching/worker_test.cljtest/com/getorcha/workers/document_output_test.cljtest/com/getorcha/test/notification_helpers.cljtest/com/getorcha/test/notification_helpers_test.cljFiles:
Create: resources/migrations/20260501130000-add-ap-processing-modes.up.sql
Create: resources/migrations/20260501130000-add-ap-processing-modes.down.sql
Step 1: Write the migration
Create resources/migrations/20260501130000-add-ap-processing-modes.up.sql:
-- Up: AP processing modes
ALTER TYPE document_output_trigger ADD VALUE IF NOT EXISTS 'processing_completed';
--;;
CREATE TYPE ap_processing_mode AS ENUM (
'read_only',
'human_review_export',
'straight_through_export'
);
--;;
CREATE TABLE tenant_ap_processing_config (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenant(id) ON DELETE CASCADE,
mode ap_processing_mode NOT NULL,
created_by UUID REFERENCES "identity"(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
--;;
CREATE INDEX idx_tenant_ap_processing_config_latest
ON tenant_ap_processing_config (tenant_id, created_at DESC, id DESC);
--;;
INSERT INTO tenant_ap_processing_config (tenant_id, mode, created_by, created_at)
SELECT tenant.id,
CASE
WHEN COALESCE(tenant_ap_approval_config.is_enabled, false)
THEN 'human_review_export'::ap_processing_mode
ELSE 'read_only'::ap_processing_mode
END,
NULL,
now()
FROM tenant
LEFT JOIN tenant_ap_approval_config
ON tenant_ap_approval_config.tenant_id = tenant.id;
Create resources/migrations/20260501130000-add-ap-processing-modes.down.sql:
-- Down: AP processing modes
--
-- PostgreSQL cannot drop enum values from document_output_trigger without
-- recreating the type and rewriting dependent columns. Leave the added
-- processing_completed value in place on rollback; it is harmless when unused.
DROP TABLE IF EXISTS tenant_ap_processing_config;
--;;
DROP TYPE IF EXISTS ap_processing_mode;
Run:
clj -X:test:silent :nses '[com.getorcha.db.migrations-test]'
Expected: PASS.
git add resources/migrations/20260501130000-add-ap-processing-modes.up.sql resources/migrations/20260501130000-add-ap-processing-modes.down.sql
git commit -m "feat(ap): add processing mode schema"
Files:
Create: src/com/getorcha/ap/processing.clj
Create: test/com/getorcha/ap/processing_test.clj
Modify: src/com/getorcha/app/document_output.clj (add active-job, expose enum-name as public)
Step 1: Add document output active-job helper test
In test/com/getorcha/app/document_output_test.clj, add:
(deftest active-job-ignores-terminal-jobs
(let [tenant-id (helpers/create-tenant!)
user-id (helpers/create-identity!)
doc-id (helpers/create-completed-document! tenant-id user-id)
failed (document-output/create-job!
fixtures/*db*
{:tenant-id tenant-id
:document-id doc-id
:document-domain :ap
:trigger :manual
:requested-by user-id})]
(db.sql/execute-one!
fixtures/*db*
{:update :document-output-job
:set {:status (db.sql/->cast "failed" :document-output-status)}
:where [:= :id (:document-output-job/id failed)]})
(is (nil? (document-output/active-job fixtures/*db* doc-id)))
(let [pending (document-output/create-job!
fixtures/*db*
{:tenant-id tenant-id
:document-id doc-id
:document-domain :ap
:trigger :manual
:requested-by user-id})]
(is (= (:document-output-job/id pending)
(:document-output-job/id
(document-output/active-job fixtures/*db* doc-id)))))))
active-job and expose enum-nameIn src/com/getorcha/app/document_output.clj:
Drop ^:private from enum-name so callers (including ap.processing) can reuse it instead of redefining.
After latest-job, add:
(defn active-job
"Returns the newest pending/running output job for a document, if any.
Failed and dispatched jobs are terminal and do not block re-export."
[db document-id]
(db.sql/execute-one!
db
{:select [:*]
:from [:document-output-job]
:where [:and
[:= :document-id document-id]
[:in :status [(db.sql/->cast "pending" :document-output-status)
(db.sql/->cast "running" :document-output-status)]]]
:order-by [[:created-at :desc]]
:limit 1}))
Run:
clj -X:test:silent :nses '[com.getorcha.app.document-output-test]'
Expected: PASS.
Create test/com/getorcha/ap/processing_test.clj:
(ns com.getorcha.ap.processing-test
(:require [clojure.test :refer [deftest is testing use-fixtures]]
[com.getorcha.ap.processing :as ap.processing]
[com.getorcha.app.document-output :as document-output]
[com.getorcha.db.document-processor-run :as db.run]
[com.getorcha.db.sql :as db.sql]
[com.getorcha.test.fixtures :as fixtures]
[com.getorcha.test.notification-helpers :as helpers]))
(use-fixtures :once fixtures/with-running-system)
(use-fixtures :each fixtures/with-db-rollback)
(deftest current-mode-defaults-to-read-only
(let [tenant-id (helpers/create-tenant!)]
(is (= :read-only (ap.processing/current-mode fixtures/*db* tenant-id)))))
(deftest append-config!-is-append-only-and-latest-wins
(let [tenant-id (helpers/create-tenant!)
admin-id (helpers/create-identity!)]
(ap.processing/append-config! fixtures/*db*
{:tenant-id tenant-id
:mode :read-only
:created-by admin-id})
(ap.processing/append-config! fixtures/*db*
{:tenant-id tenant-id
:mode :straight-through-export
:created-by admin-id})
(is (= :straight-through-export
(ap.processing/current-mode fixtures/*db* tenant-id)))
(is (= 2 (:count (db.sql/execute-one!
fixtures/*db*
{:select [[[:count :*] :count]]
:from [:tenant-ap-processing-config]
:where [:= :tenant-id tenant-id]}))))))
(deftest human-review-output-requires-full-approval
(let [tenant-id (helpers/create-tenant!)
alice (helpers/create-identity!)
bob (helpers/create-identity!)
uploader (helpers/create-identity!)]
(ap.processing/append-config! fixtures/*db*
{:tenant-id tenant-id
:mode :human-review-export
:created-by alice})
(helpers/add-approver! tenant-id alice 1)
(helpers/add-approver! tenant-id bob 2)
(let [doc-id (helpers/create-completed-document!
tenant-id uploader
:structured-data {:document-type "invoice"}
:tenant-ap-approval-snapshot? true)]
(is (= :approval-pending
(:reason (ap.processing/authorize-output
fixtures/*db* doc-id :manual))))
(db.sql/execute!
fixtures/*db*
{:update :ap-invoice-approval
:set {:state (db.sql/->cast "approved" :ap-invoice-approval-state)
:decided-at [:now]
:decided-by alice}
:where [:= :document-id doc-id]})
(is (:authorized? (ap.processing/authorize-output
fixtures/*db* doc-id :manual))))))
(deftest straight-through-output-requires-terminal-matching-but-not-validation-success
(let [tenant-id (helpers/create-tenant!)
user-id (helpers/create-identity!)
doc-id (helpers/create-completed-document!
tenant-id user-id
:structured-data {:document-type "invoice"})]
(ap.processing/append-config! fixtures/*db*
{:tenant-id tenant-id
:mode :straight-through-export
:created-by user-id})
(is (= :processing-not-terminal
(:reason (ap.processing/authorize-output
fixtures/*db* doc-id :manual))))
(let [run-id (db.run/insert-running!
fixtures/*db*
{:document-id doc-id
:processor-id "matching"
:trigger-kind :manual
:document-version 0})]
(db.run/fail-run! fixtures/*db* run-id "matching failed"))
(is (:authorized? (ap.processing/authorize-output
fixtures/*db* doc-id :manual)))))
(deftest request-output!-enqueues-when-authorized
(let [tenant-id (helpers/create-tenant!)
user-id (helpers/create-identity!)
doc-id (helpers/create-completed-document!
tenant-id user-id
:structured-data {:document-type "invoice"})]
(ap.processing/append-config! fixtures/*db*
{:tenant-id tenant-id
:mode :straight-through-export
:created-by user-id})
(let [run-id (db.run/insert-running!
fixtures/*db*
{:document-id doc-id
:processor-id "matching"
:trigger-kind :manual
:document-version 0})]
(db.run/complete-run! fixtures/*db* run-id {:result {:status "skipped"}}))
(let [enqueued (atom [])]
(with-redefs [document-output/enqueue-job!
(fn [_ctx job-id] (swap! enqueued conj job-id) "message-id")]
(let [result (ap.processing/request-output!
{:db-pool fixtures/*db* :aws {}}
{:document-id doc-id
:trigger :processing-completed
:requested-by nil})]
(is (= :enqueued (:status result)))
(is (= 1 (count @enqueued)))
(is (= "processing_completed"
(:document-output-job/trigger (:job result)))))))))
com.getorcha.ap.processingCreate src/com/getorcha/ap/processing.clj:
(ns com.getorcha.ap.processing
"Tenant AP processing policy and output authorization."
(:require [clojure.string :as str]
[clojure.tools.logging :as log]
[com.getorcha.app.document-output :as document-output]
[com.getorcha.db :as db]
[com.getorcha.db.document-processor-run :as db.run]
[com.getorcha.db.sql :as db.sql]
[com.getorcha.schema.document :as schema.document]))
(def document-builder-fn
(db/schema-row-builder schema.document/Document))
(def ^:private default-mode :read-only)
(defn current-config
"Returns the latest AP processing config row for a tenant, or nil."
[db tenant-id]
(db.sql/execute-one!
db
{:select [:*]
:from [:tenant-ap-processing-config]
:where [:= :tenant-id tenant-id]
:order-by [[:created-at :desc] [:id :desc]]
:limit 1}))
(defn current-mode
"Returns the tenant's current AP processing mode keyword.
No row means :read-only."
[db tenant-id]
(or (some-> (current-config db tenant-id)
:tenant-ap-processing-config/mode
(str/replace "_" "-")
keyword)
default-mode))
(defn append-config!
"Appends a new AP processing config row and returns it."
[db {:keys [tenant-id mode created-by]}]
(db.sql/execute-one!
db
{:insert-into :tenant-ap-processing-config
:values [{:tenant-id tenant-id
:mode (db.sql/->cast (document-output/enum-name mode) :ap-processing-mode)
:created-by created-by}]
:returning [:*]}))
(defn approver-count
"Returns number of configured tenant AP approvers."
[db tenant-id]
(:count
(db.sql/execute-one!
db
{:select [[[:count :*] :count]]
:from [:tenant-ap-approver]
:where [:= :tenant-id tenant-id]})))
(defn load-document
"Loads a document using the canonical schema row builder."
[db document-id]
(db.sql/execute-one!
db
{:select [:*]
:from [:document]
:where [:= :id document-id]}
{:builder-fn document-builder-fn}))
(defn approval-rows
"Loads AP approval rows for a document."
[db document-id]
(db.sql/execute!
db
{:select [:*]
:from [:ap-invoice-approval]
:where [:= :document-id document-id]
:order-by [[:position :asc]]}))
(defn approval-state
"Returns aggregate approval state for a document's approval rows."
[rows]
(cond
(empty? rows) :none
(some #(= "rejected" (:ap-invoice-approval/state %)) rows) :rejected
(every? #(= "approved" (:ap-invoice-approval/state %)) rows) :fully-approved
:else :pending))
(defn approval-snapshotted?
"True when a document has approval rows."
[db document-id]
(pos? (:count
(db.sql/execute-one!
db
{:select [[[:count :*] :count]]
:from [:ap-invoice-approval]
:where [:= :document-id document-id]}))))
(defn invoice-document?
"True when the document is an invoice and has structured data."
[{:document/keys [type structured-data]}]
(and (= "invoice" (some-> type name))
(map? structured-data)))
(defn latest-ingestion-status
[db document-id]
(:ap-ingestion/status
(db.sql/execute-one!
db
{:select [:status]
:from [:ap-ingestion]
:where [:= :document-id document-id]
:order-by [[:created-at :desc]]
:limit 1})))
(defn matching-terminal?
"True when latest matching run is completed or failed.
A skipped matching result is represented as a completed run."
[db document-id]
(let [run (some #(when (= "matching" (:document-processor-run/processor-id %)) %)
(db.run/latest-runs-per-processor db document-id))]
(#{"completed" "failed"} (:document-processor-run/status run))))
(defn terminal-for-output?
"True when an invoice has completed ingestion and terminal matching."
[db document-id]
(and (= "completed" (latest-ingestion-status db document-id))
(matching-terminal? db document-id)))
(defn authorize-output
"Returns {:authorized? true} or {:authorized? false :reason kw}."
[db document-id trigger]
(if-let [doc (load-document db document-id)]
(let [tenant-id (:document/tenant-id doc)
mode (current-mode db tenant-id)]
(cond
(not (invoice-document? doc))
{:authorized? false :reason :not-exportable-invoice}
(= :read-only mode)
{:authorized? false :reason :read-only}
(= :human-review-export mode)
(let [state (approval-state (approval-rows db document-id))]
(if (= :fully-approved state)
{:authorized? true :mode mode :trigger trigger}
{:authorized? false
:reason (case state
:none :approval-not-snapshotted
:rejected :approval-rejected
:approval-pending)}))
(= :straight-through-export mode)
(if (terminal-for-output? db document-id)
{:authorized? true :mode mode :trigger trigger}
{:authorized? false :reason :processing-not-terminal})
:else
{:authorized? false :reason :unknown-mode}))
{:authorized? false :reason :document-not-found}))
(defn request-output!
"Authorizes, creates, and enqueues a document output job.
Returns:
- {:status :enqueued :job row}
- {:status :already-active :job row}
- {:status :forbidden :reason kw}
- {:status :enqueue-failed :job row :error message}"
[{:keys [db-pool] :as context} {:keys [document-id trigger requested-by]}]
(let [auth (authorize-output db-pool document-id trigger)]
(if-not (:authorized? auth)
{:status :forbidden :reason (:reason auth)}
(if-let [active (document-output/active-job db-pool document-id)]
{:status :already-active :job active}
(let [doc (load-document db-pool document-id)
job (document-output/create-job-if-not-active!
db-pool
{:tenant-id (:document/tenant-id doc)
:document-id document-id
:document-domain :ap
:trigger trigger
:document-version (:document/version doc)
:requested-by requested-by})]
(if (nil? job)
{:status :already-active
:job (document-output/active-job db-pool document-id)}
(try
(document-output/enqueue-job! context (:document-output-job/id job))
{:status :enqueued :job job}
(catch Exception e
(log/warn e "Failed to enqueue AP output job"
{:document-id document-id
:job-id (:document-output-job/id job)
:trigger trigger})
(document-output/mark-send-failed-if-pending!
db-pool (:document-output-job/id job) (ex-message e))
{:status :enqueue-failed
:job job
:error (ex-message e)}))))))))
Run:
clj -X:test:silent :nses '[com.getorcha.ap.processing-test com.getorcha.app.document-output-test]'
Expected: PASS.
git add src/com/getorcha/ap/processing.clj src/com/getorcha/app/document_output.clj test/com/getorcha/ap/processing_test.clj test/com/getorcha/app/document_output_test.clj
git commit -m "feat(ap): centralize processing policy"
Files:
Modify: src/com/getorcha/admin/http/tenants.clj
Modify: src/com/getorcha/admin/http/tenants/approval.clj
Modify: test/com/getorcha/admin/http/tenants/approval_test.clj
Step 1: Write failing admin mode tests
Replace toggle-rejects-without-approvers-test and toggle-enables-when-approvers-present-test with:
(deftest save-mode-rejects-human-review-without-approvers-test
(let [tenant-id (helpers/create-tenant!)
super (super-identity)
response (invoke-handler
#'approval/save-mode!
{:identity super
:parameters {:path {:id tenant-id}
:form {:mode "human_review_export"}}})]
(is (= 400 (:status response)))))
(deftest save-mode-appends-human-review-when-approvers-present-test
(let [tenant-id (helpers/create-tenant!)
super (super-identity)
alice (helpers/create-identity! :email "alice@t.com")]
(helpers/add-approver! tenant-id alice 1)
(let [response (invoke-handler
#'approval/save-mode!
{:identity super
:parameters {:path {:id tenant-id}
:form {:mode "human_review_export"}}})]
(is (= 200 (:status response)))
(is (= "human_review_export"
(:tenant-ap-processing-config/mode
(db.sql/execute-one!
fixtures/*db*
{:select [:mode]
:from [:tenant-ap-processing-config]
:where [:= :tenant-id tenant-id]
:order-by [[:created-at :desc] [:id :desc]]
:limit 1}))))))
Add:
(deftest save-mode-preserves-approver-roster-test
(let [tenant-id (helpers/create-tenant!)
super (super-identity)
alice (helpers/create-identity! :email "alice@keep.com")]
(helpers/add-approver! tenant-id alice 1)
(invoke-handler
#'approval/save-mode!
{:identity super
:parameters {:path {:id tenant-id}
:form {:mode "read_only"}}})
(invoke-handler
#'approval/save-mode!
{:identity super
:parameters {:path {:id tenant-id}
:form {:mode "straight_through_export"}}})
(is (= 1
(:count (db.sql/execute-one!
fixtures/*db*
{:select [[[:count :*] :count]]
:from [:tenant-ap-approver]
:where [:= :tenant-id tenant-id]}))))))
Add a regression test that saving straight_through_export changes policy only. It must not enqueue output for historical terminal invoices:
(deftest save-straight-through-does-not-request-historical-output-test
(let [tenant-id (helpers/create-tenant!)
super (super-identity)
calls (atom [])]
(with-redefs [ap.processing/request-output!
(fn [& args]
(swap! calls conj args)
{:status :enqueued})]
(let [response (invoke-handler
#'approval/save-mode!
{:identity super
:parameters {:path {:id tenant-id}
:form {:mode "straight_through_export"}}})]
(is (= 200 (:status response)))
(is (empty? @calls))))))
Add this require to the admin test namespace when adding the regression:
[com.getorcha.ap.processing :as ap.processing]
In src/com/getorcha/admin/http/tenants.clj, change:
["approvals" "AP Approvals"]
to:
["approvals" "AP Processing"]
In src/com/getorcha/admin/http/tenants/approval.clj:
[clojure.string :as string]
[com.getorcha.ap.processing :as ap.processing]
load-config with:(defn ^:private load-config-history
[db-pool tenant-id]
(db.sql/execute!
db-pool
{:select [:tenant-ap-processing-config.*
:identity.email
:identity.display-name]
:from [:tenant-ap-processing-config]
:left-join [[:identity] [:= :identity.id :tenant-ap-processing-config.created-by]]
:where [:= :tenant-ap-processing-config.tenant-id tenant-id]
:order-by [[:tenant-ap-processing-config.created-at :desc]
[:tenant-ap-processing-config.id :desc]]
:limit 5}))
approval-section to render AP Processing. Keep #approvals as the section id so the tenant page anchor and existing HTMX target continue working:(defn approval-section
"Render fn called from admin/http/tenants.clj's render-detail-page.
Renders AP processing mode plus reusable approver roster."
[db-pool tenant-id]
(let [history (load-config-history db-pool tenant-id)
current (first history)
mode (or (:tenant-ap-processing-config/mode current) "read_only")
approvers (load-approvers db-pool tenant-id)
members (load-eligible-members db-pool tenant-id)
active? (= mode "human_review_export")]
[:section#approvals.tenant-detail-section
[:h2 "AP Processing"]
[:p.section-hint
"Choose how AP invoices move through review and output. Approvals and inline edits are active only in Human review then export."]
[:form.tenant-form
{:hx-post (str "/organizations/-/tenants/" tenant-id "/approvals/mode")
:hx-target "#approvals"
:hx-swap "outerHTML"}
[:label {:for "ap-processing-mode"} "Mode"]
[:select#ap-processing-mode {:name "mode"}
[:option {:value "read_only" :selected (= mode "read_only")} "Read-only"]
[:option (cond-> {:value "human_review_export"
:selected (= mode "human_review_export")}
(empty? approvers) (assoc :disabled true))
"Human review then export"]
[:option {:value "straight_through_export" :selected (= mode "straight_through_export")} "Straight-through export"]]
[:button.btn.btn-secondary {:type "submit"} "Save mode"]]
(when (empty? approvers)
[:p.section-hint "Add at least one approver before selecting Human review then export."])
[:p.section-hint
(str "Current mode: "
(case mode
"read_only" "Read-only"
"human_review_export" "Human review then export"
"straight_through_export" "Straight-through export"
mode)
(when current
(str " set at " (:tenant-ap-processing-config/created-at current))))]
[:h3 "Approvers"]
[:p.section-hint
(if active?
"Approvers are active for new invoices."
"Approvers are preserved but inactive in the current mode.")]
(if (seq approvers)
[:ol.approver-list
(for [a approvers
:let [aid (:tenant-ap-approver/id a)]]
^{:key aid}
[:li
[:span.approver-position (str (:tenant-ap-approver/position a) ".")]
[:span.approver-name (or (:identity/display-name a) (:identity/email a))]
[:span.approver-actions
[:button.btn-icon
{:hx-post (str "/organizations/-/tenants/" tenant-id "/approvals/approvers/" aid "/move")
:hx-vals "{\"direction\": \"up\"}"
:hx-target "#approvals"
:hx-swap "outerHTML"
:title "Move up"}
[:iconify-icon {:icon "lucide:arrow-up"}]]
[:button.btn-icon
{:hx-post (str "/organizations/-/tenants/" tenant-id "/approvals/approvers/" aid "/move")
:hx-vals "{\"direction\": \"down\"}"
:hx-target "#approvals"
:hx-swap "outerHTML"
:title "Move down"}
[:iconify-icon {:icon "lucide:arrow-down"}]]
[:button.btn-icon.danger
{:hx-delete (str "/organizations/-/tenants/" tenant-id "/approvals/approvers/" aid)
:hx-target "#approvals"
:hx-swap "outerHTML"
:hx-confirm "Remove this approver?"
:title "Remove"}
[:iconify-icon {:icon "lucide:trash-2"}]]]])]
[:p.empty-state "No approvers configured."])
(when (seq members)
[:form.add-approver-form
{:hx-post (str "/organizations/-/tenants/" tenant-id "/approvals/approvers")
:hx-target "#approvals"
:hx-swap "outerHTML"}
[:select {:name "identity-id"}
(for [m members]
^{:key (:identity/id m)}
[:option {:value (str (:identity/id m))}
(or (:identity/display-name m) (:identity/email m))])]
[:button.btn.btn-secondary {:type "submit"} "Add approver"]])
(when (seq history)
[:div
[:h3 "Recent mode changes"]
[:ul.ap-processing-history
(for [row history]
^{:key (:tenant-ap-processing-config/id row)}
[:li
(str (:tenant-ap-processing-config/mode row)
" at "
(:tenant-ap-processing-config/created-at row)
" by "
(or (:identity/display-name row)
(:identity/email row)
"system"))])]])]))
Delete cancel-in-flight-approvals!. Mode changes no longer delete approval rows.
Replace toggle! with:
(defn ^:private save-mode!
"POST /organizations/-/tenants/:id/approvals/mode"
[{:keys [db-pool identity parameters] :as _request} respond _raise]
(let [tenant-id (get-in parameters [:path :id])
mode (keyword (some-> (get-in parameters [:form :mode])
(string/replace "_" "-")))
super-id (:identity/id identity)
result (db.sql/with-transaction [tx db-pool]
(let [approver-cnt (ap.processing/approver-count tx tenant-id)]
(cond
(and (= :human-review-export mode) (zero? approver-cnt))
:no-approvers
(not (contains? #{:read-only
:human-review-export
:straight-through-export}
mode))
:invalid-mode
:else
(do
(ap.processing/append-config!
tx
{:tenant-id tenant-id
:mode mode
:created-by super-id})
:saved))))]
(respond
(case result
:no-approvers
(ring.resp/bad-request "Add at least one approver before enabling human review.")
:invalid-mode
(ring.resp/bad-request "Invalid AP processing mode.")
(ring.resp/ok (layout/partial-content (approval-section db-pool tenant-id)))))))
routes:["/mode"
{:name ::save-mode
:post {:parameters {:path {:id :uuid}
:form [:map [:mode [:enum "read_only" "human_review_export" "straight_through_export"]]]}
:handler #'save-mode!}}]
Remove the old "/toggle" route from this namespace, unless product needs a temporary compatibility route. If retained temporarily, make it call save-mode! through explicit mode values and mark it private to tests only.
Run:
clj -X:test:silent :nses '[com.getorcha.admin.http.tenants.approval-test]'
Expected: PASS.
git add src/com/getorcha/admin/http/tenants.clj src/com/getorcha/admin/http/tenants/approval.clj test/com/getorcha/admin/http/tenants/approval_test.clj
git commit -m "feat(admin): configure ap processing mode"
Files:
Modify: src/com/getorcha/workers/ap/ingestion.clj
Modify: test/com/getorcha/workers/ap/ingestion_test.clj
Modify: src/com/getorcha/app/http/documents/shared.clj
Modify: src/com/getorcha/app/http/documents/edits.clj
Modify: test/com/getorcha/app/http/documents/edits_test.clj
Modify: test/com/getorcha/test/notification_helpers.clj
Modify: test/com/getorcha/test/notification_helpers_test.clj
Step 1: Update test helpers
In test/com/getorcha/test/notification_helpers.clj, add a require:
[com.getorcha.ap.processing :as ap.processing]
Add the mode helper. Accept kebab-case keywords (matching ap.processing/append-config!) and delegate so the SQL-cast normalization lives in one place:
(defn set-ap-processing-mode!
"Appends a tenant_ap_processing_config row for tests.
Accepts a kebab-case mode keyword (e.g. :human-review-export)."
[tenant-id mode]
(ap.processing/append-config! fixtures/*db*
{:tenant-id tenant-id
:mode mode
:created-by nil}))
Update enable-ap-approvals! to use the new helper and drop the legacy tenant_ap_approval_config write — Task 8 confirms no runtime code reads that table any more, so the dual write is dead:
(defn enable-ap-approvals!
"Switches a tenant to human-review mode for tests. Kept under the legacy name
so existing call sites do not need to change."
[tenant-id]
(set-ap-processing-mode! tenant-id :human-review-export)
nil)
Across the rest of this plan, every call to set-ap-processing-mode! uses kebab-case mode keywords (e.g. :read-only, :straight-through-export).
In test/com/getorcha/test/notification_helpers_test.clj, add:
(deftest set-ap-processing-mode-appends-row-test
(let [tenant-id (helpers/create-tenant!)]
(helpers/set-ap-processing-mode! tenant-id :straight-through-export)
(is (= "straight_through_export"
(:tenant-ap-processing-config/mode
(db.sql/execute-one!
fixtures/*db*
{:select [:mode]
:from [:tenant-ap-processing-config]
:where [:= :tenant-id tenant-id]
:order-by [[:created-at :desc] [:id :desc]]
:limit 1}))))))
In src/com/getorcha/workers/ap/ingestion.clj, add require:
[com.getorcha.ap.processing :as ap.processing]
Replace snapshot-approvers! with:
(defn ^:private snapshot-approvers!
"Inside an ingestion-completion transaction, copies the tenant's ordered
approver list into ap_invoice_approval rows for this document only when
the tenant's current AP processing mode is human_review_export."
[tx tenant-id document-id]
(when (= :human-review-export (ap.processing/current-mode tx tenant-id))
(let [approvers (db.sql/execute!
tx
{:select [:position :identity-id]
:from [:tenant-ap-approver]
:where [:= :tenant-id tenant-id]})]
(when (seq approvers)
(db.sql/execute!
tx
{:insert-into :ap-invoice-approval
:values (mapv (fn [a]
{:document-id document-id
:position (:tenant-ap-approver/position a)
:identity-id (:tenant-ap-approver/identity-id a)})
approvers)})))))
In test/com/getorcha/workers/ap/ingestion_test.clj, update snapshot-approvers-on-completion-test so the production-path assertions cover all processing modes:
(testing "snapshot-approvers! writes rows only in human review mode"
(let [tenant-id (helpers/create-tenant!)
uploader-id (helpers/create-identity!)
alice (helpers/create-identity! :email "alice@mode-test.com")]
(helpers/add-approver! tenant-id alice 1)
(helpers/set-ap-processing-mode! tenant-id :read-only)
(let [read-only-doc-id (helpers/create-completed-document! tenant-id uploader-id)]
(#'com.getorcha.workers.ap.ingestion/snapshot-approvers!
fixtures/*db* tenant-id read-only-doc-id)
(is (empty? (db.sql/execute!
fixtures/*db*
{:select [:id]
:from [:ap-invoice-approval]
:where [:= :document-id read-only-doc-id]}))))
(helpers/set-ap-processing-mode! tenant-id :straight-through-export)
(let [straight-through-doc-id (helpers/create-completed-document! tenant-id uploader-id)]
(#'com.getorcha.workers.ap.ingestion/snapshot-approvers!
fixtures/*db* tenant-id straight-through-doc-id)
(is (empty? (db.sql/execute!
fixtures/*db*
{:select [:id]
:from [:ap-invoice-approval]
:where [:= :document-id straight-through-doc-id]}))))
(helpers/set-ap-processing-mode! tenant-id :human-review-export)
(let [human-review-doc-id (helpers/create-completed-document! tenant-id uploader-id)]
(#'com.getorcha.workers.ap.ingestion/snapshot-approvers!
fixtures/*db* tenant-id human-review-doc-id)
(is (= 1
(:count (db.sql/execute-one!
fixtures/*db*
{:select [[[:count :*] :count]]
:from [:ap-invoice-approval]
:where [:= :document-id human-review-doc-id]})))))))
Remove assertions that rely on tenant_ap_approval_config.is_enabled as the runtime switch. Keep existing completion-path tests that validate snapshots are created during complete-ingestion!; make them set :human-review-export through helpers/enable-ap-approvals! or helpers/set-ap-processing-mode!.
In src/com/getorcha/app/http/documents/shared.clj, replace approvals-enabled-for-document? with:
(defn approvals-enabled-for-document?
"Returns true when `document-id` has approval rows.
Inline editing is tied to the per-invoice human-review snapshot, not the
tenant's current mode. Returns true when the document does not exist so
callers can fall through to their own 404 handling."
[db-pool document-id]
(let [row (db.sql/execute-one!
db-pool
{:select [[[:count :ap-invoice-approval.id] :count]
[:document.id :document-id]]
:from [:document]
:left-join [[:ap-invoice-approval]
[:= :ap-invoice-approval.document-id :document.id]]
:where [:= :document.id document-id]
:group-by [:document.id]})]
(or (nil? row)
(pos? (:count row)))))
In src/com/getorcha/app/http/documents/edits.clj, update docstrings and the 403 text:
(defn ^:private approvals-enabled?
"Returns true when this document has approval rows. Inline editing is active
only for invoices snapshotted into human review."
[db-pool document-id]
(documents.shared/approvals-enabled-for-document? db-pool document-id))
and:
(respond (ring.resp/forbidden "human review not enabled for this invoice"))
In test/com/getorcha/app/http/documents/edits_test.clj, replace tests that toggle tenant_ap_approval_config with setup that creates or omits ap_invoice_approval rows.
For the blocked case, assert no rows:
(deftest scalar-edit-blocked-when-invoice-has-no-approval-snapshot-test
(testing "scalar-edit returns 403 when the invoice has no approval rows"
(let [tenant-id (helpers/create-tenant!)
user-id (helpers/create-identity!)
doc-id (helpers/create-completed-document!
tenant-id user-id
:structured-data {:document-type "invoice"})]
;; Invoke the existing scalar edit helper in this namespace.
;; Expected response remains 403; expected body contains:
;; human review not enabled for this invoice
)))
Keep the existing invocation style in the file; only change the setup and expected error text.
For the allowed case, add approvers and create approval snapshots:
(helpers/add-approver! tenant-id user-id 1)
(let [doc-id (helpers/create-completed-document!
tenant-id user-id
:structured-data {:document-type "invoice"}
:tenant-ap-approval-snapshot? true)]
;; Existing successful scalar edit assertion remains.
)
Run:
clj -X:test:silent :nses '[com.getorcha.test.notification-helpers-test com.getorcha.workers.ap.ingestion-test com.getorcha.app.http.documents.edits-test]'
Expected: PASS.
git add src/com/getorcha/workers/ap/ingestion.clj src/com/getorcha/app/http/documents/shared.clj src/com/getorcha/app/http/documents/edits.clj test/com/getorcha/workers/ap/ingestion_test.clj test/com/getorcha/app/http/documents/edits_test.clj test/com/getorcha/test/notification_helpers.clj test/com/getorcha/test/notification_helpers_test.clj
git commit -m "feat(ap): gate edits by human review snapshot"
Files:
Modify: src/com/getorcha/app/http/documents/view/approval.clj
Modify: src/com/getorcha/app/http/documents/view/invoice.clj
Modify: test/com/getorcha/app/http/documents/view/approval_test.clj
Modify: test/com/getorcha/app/http/documents/view/invoice_test.clj
Step 1: Update final approval tests
In test/com/getorcha/app/http/documents/view/approval_test.clj, add a test that read-only mode does not enqueue:
(deftest final-approval-does-not-enqueue-when-current-mode-read-only-test
(let [enqueued (atom [])
{:keys [doc-id alice bob approval-rows tenant-id]} (setup-doc-with-approvers!)]
(helpers/set-ap-processing-mode! tenant-id :read-only)
(with-redefs [document-output/enqueue-job!
(fn [_ctx job-id] (swap! enqueued conj job-id) "msg-1")]
(invoke-handler
#'com.getorcha.app.http.documents.view.approval/approve!
{:identity {:identity/id alice :identity/is-super-admin false}
:parameters {:path {:document-id doc-id
:approval-id (:ap-invoice-approval/id (first approval-rows))}}})
(invoke-handler
#'com.getorcha.app.http.documents.view.approval/approve!
{:aws {}
:db-pool fixtures/*db*
:identity {:identity/id bob :identity/is-super-admin false}
:parameters {:path {:document-id doc-id
:approval-id (:ap-invoice-approval/id (second approval-rows))}}})
(is (empty? @enqueued)))))
Update the existing final-approval-creates-and-enqueues-output-job-test setup to ensure current mode is human-review-export:
(helpers/set-ap-processing-mode! tenant-id :human-review-export)
In src/com/getorcha/app/http/documents/view/approval.clj:
[com.getorcha.ap.processing :as ap.processing]
Remove direct document-output/create-job-if-not-active!, enqueue-job!, and send-failure logic from approve!.
After the transaction commits and fully-approved? is true, call:
(when fully-approved?
(let [result (ap.processing/request-output!
request
{:document-id document-id
:trigger :approval-completed
:requested-by (:identity/id identity)})]
(when (= :enqueue-failed (:status result))
(log/warn "Output enqueue after final approval failed"
{:document-id document-id
:error (:error result)}))))
Keep approval state changes committed even when output request is forbidden, already active, or enqueue fails.
In test/com/getorcha/app/http/documents/view/invoice_test.clj, add require:
[com.getorcha.ap.processing :as ap.processing]
Then add:
(deftest export-datev-forbidden-in-read-only-mode
(let [document-id #uuid "019dce7b-aefc-709d-b04b-3c989d7e9100"
tenant-id #uuid "00000000-0000-0000-0000-000000000001"
captured (atom nil)]
(with-redefs [ap.processing/request-output!
(fn [& _] {:status :forbidden :reason :read-only})
db.sql/execute-one! (fn [& _]
{:document/id document-id
:document/tenant-id tenant-id
:document/version 3})
shared/tenant-ids (constantly [tenant-id])
maesn/get-latest-export-audit (constantly nil)
view.invoice/datev-export-section
(fn [_router _doc-id _audit can-export? _pending? job]
(reset! captured {:can-export? can-export? :job job})
[:section])]
(#'view.invoice/export-datev
{:db-pool ::db
:identity {:identity/id #uuid "00000000-0000-0000-0000-000000000010"}
:parameters {:path {:document-id document-id}}
::reitit/router test-router}
(fn [response] (is (= 403 (:status response))))
(fn [_error]))
(is (false? (:can-export? @captured)))
(is (= "failed" (get-in @captured [:job :document-output-job/status])))
(is (= "read-only" (get-in @captured [:job :document-output-job/last-error]))))))
Also add:
(deftest export-datev-forbidden-while-human-review-approval-pending
(let [document-id #uuid "019dce7b-aefc-709d-b04b-3c989d7e9101"
tenant-id #uuid "00000000-0000-0000-0000-000000000001"
captured (atom nil)]
(with-redefs [ap.processing/request-output!
(fn [& _] {:status :forbidden :reason :approval-pending})
db.sql/execute-one! (fn [& _]
{:document/id document-id
:document/tenant-id tenant-id
:document/version 3})
shared/tenant-ids (constantly [tenant-id])
maesn/get-latest-export-audit (constantly nil)
view.invoice/datev-export-section
(fn [_router _doc-id _audit can-export? _pending? job]
(reset! captured {:can-export? can-export? :job job})
[:section])]
(#'view.invoice/export-datev
{:db-pool ::db
:identity {:identity/id #uuid "00000000-0000-0000-0000-000000000010"}
:parameters {:path {:document-id document-id}}
::reitit/router test-router}
(fn [response] (is (= 403 (:status response))))
(fn [_error]))
(is (false? (:can-export? @captured)))
(is (= "failed" (get-in @captured [:job :document-output-job/status])))
(is (= "approval-pending"
(get-in @captured [:job :document-output-job/last-error]))))))
Keep existing uniqueness/failure tests, but update them to stub ap.processing/request-output! instead of document-output/create-and-enqueue-job!.
Add a render test asserting that approvers-section is not emitted in straight_through_export even when historical rows exist. Use the existing invoice render entry point in this namespace; assert that the rendered hiccup does not contain :section#section-approvers.
(deftest straight-through-export-hides-approvers-section-with-historical-rows
(let [tenant-id (helpers/create-tenant!)
user-id (helpers/create-identity!)
;; Snapshot approvals while in human-review, then switch to straight-through.
_ (helpers/add-approver! tenant-id user-id 1)
_ (helpers/set-ap-processing-mode! tenant-id :human-review-export)
doc-id (helpers/create-completed-document!
tenant-id user-id
:structured-data {:document-type "invoice"}
:tenant-ap-approval-snapshot? true)]
(helpers/set-ap-processing-mode! tenant-id :straight-through-export)
(let [hiccup (render-invoice-detail fixtures/*db* doc-id user-id)]
(is (not (contains-element? hiccup :section#section-approvers))
"approvers section must not render in straight_through_export"))))
render-invoice-detail and contains-element? are existing test helpers in this namespace; if absent, inline a clojure.walk/postwalk check that searches the hiccup for the element id.
Add a tenant-scoping regression. request-output! must not be reached when the document belongs to a tenant the requester cannot access:
(deftest export-datev-rejects-document-from-inaccessible-tenant
(let [document-id #uuid "019dce7b-aefc-709d-b04b-3c989d7e9102"
request-output-called? (atom false)]
(with-redefs [shared/tenant-ids (constantly [#uuid "00000000-0000-0000-0000-000000000999"])
db.sql/execute-one! (constantly nil)
ap.processing/request-output!
(fn [& _] (reset! request-output-called? true) {:status :enqueued})]
(try
(#'view.invoice/export-datev
{:db-pool ::db
:identity {:identity/id #uuid "00000000-0000-0000-0000-000000000010"}
:parameters {:path {:document-id document-id}}
::reitit/router test-router}
(fn [_] (is false "respond should not be called"))
(fn [_] (is false "raise should not be called")))
(catch clojure.lang.ExceptionInfo e
(is (= 404 (:status (ex-data e)))))))
(is (false? @request-output-called?))))
In src/com/getorcha/app/http/documents/view/invoice.clj:
[com.getorcha.ap.processing :as ap.processing]
export-datev with the tenant-scoped pre-check followed by the shared request:(let [document-id (get-in parameters [:path :document-id])
;; Tenant access check. Without this, any authenticated identity could
;; trigger an export by guessing a document UUID, since
;; ap.processing/authorize-output only consults the document's own
;; tenant_id, not the requesting identity's accessible tenants.
accessible? (boolean
(db.sql/execute-one!
db-pool
{:select [:id]
:from [:document]
:where [:and
[:= :id document-id]
[:in :tenant-id (shared/tenant-ids request)]]}))]
(when-not accessible?
(ring.resp/not-found! {:error "Document not found"}))
(let [result (ap.processing/request-output!
request
{:document-id document-id
:trigger :manual
:requested-by (:identity/id (:identity request))})
audit (maesn/get-latest-export-audit db-pool document-id)]
(case (:status result)
:enqueued
(respond (ring.resp/ok
(datev-export-section router document-id audit false false (:job result))))
:already-active
(respond (ring.resp/ok
(datev-export-section router document-id audit false false (:job result))))
:enqueue-failed
(respond (ring.resp/ok
(datev-export-section
router document-id audit false false
{:document-output-job/status "failed"
:document-output-job/last-error (:error result)})))
:forbidden
(respond (ring.resp/forbidden
(datev-export-section
router document-id audit false false
{:document-output-job/status "failed"
:document-output-job/last-error (name (:reason result))}))))))
export-eligible-by-approval? with a call to ap.processing/authorize-output when db-pool and document-id exist. Use the result to set can-export?.(let [auth (when (and db-pool document-id)
(ap.processing/authorize-output db-pool document-id :manual))
pending-approval? (= :approval-pending (:reason auth))]
(datev-export-section router document-id export-audit
(and can-export? (:authorized? auth))
pending-approval? output-job))
straight_through_export regardless of historical rows. The spec's Document UI section is asymmetric on purpose: read_only keeps historical approval data visible (its own exception), but straight_through_export removes approval controls entirely so users do not act on stale state from a previous mode.In src/com/getorcha/app/http/documents/view/invoice.clj, replace:
;; Approvers section (rendered when rows exist for this document)
(view.approval/approvers-section document-id approval-rows identity)
with:
;; Approvers section: hidden in straight_through_export even if historical
;; rows exist; in other modes, approvers-section already returns nil when
;; rows are empty.
(let [tenant-mode (when (and db-pool document-id)
(ap.processing/current-mode db-pool tenant-id))]
(when-not (= :straight-through-export tenant-mode)
(view.approval/approvers-section document-id approval-rows identity)))
tenant-id is already destructured from document in this render fn; if not present in scope, derive it from the loaded document. Add [com.getorcha.ap.processing :as ap.processing] to the namespace requires (it is already needed for the export authorization change above).
Run:
clj -X:test:silent :nses '[com.getorcha.app.http.documents.view.approval-test com.getorcha.app.http.documents.view.invoice-test]'
Expected: PASS.
git add src/com/getorcha/app/http/documents/view/approval.clj src/com/getorcha/app/http/documents/view/invoice.clj test/com/getorcha/app/http/documents/view/approval_test.clj test/com/getorcha/app/http/documents/view/invoice_test.clj
git commit -m "feat(ap): authorize output requests by processing mode"
Files:
Modify: src/com/getorcha/workers/ap/processors/matching/worker.clj
Modify: test/com/getorcha/workers/ap/processors/matching/worker_test.clj
Step 1: Write matching worker output tests
In test/com/getorcha/workers/ap/processors/matching/worker_test.clj, add:
(deftest process-document-with-output-requests-after-success-test
(let [tenant-id (helpers/create-tenant!)
doc-id (helpers/create-document! tenant-id)
calls (atom [])]
(db.sql/execute-one!
fixtures/*db*
{:update :document
:set {:structured-data [:lift {:document-type "invoice"}]
:type (db.sql/->cast "invoice" :document-type)}
:where [:= :id doc-id]})
(with-redefs [app.ingestion/fire-diagnostics-recomputed! (fn [& _] nil)
matching/->matching (fn [] (->NoopProcessor :matching))
com.getorcha.ap.processing/request-output!
(fn [_ctx attrs] (swap! calls conj attrs) {:status :enqueued})]
(worker/process-document-with-output!
{:db-pool fixtures/*db* :llm-config {} :aws {}}
doc-id))
(is (= [{:document-id doc-id
:trigger :processing-completed
:requested-by nil}]
@calls))))
Add:
(deftest process-document-with-output-does-not-request-from-notify-finally-on-transient-failure-test
(let [tenant-id (helpers/create-tenant!)
doc-id (helpers/create-document! tenant-id)
calls (atom [])]
(with-redefs [app.ingestion/fire-diagnostics-recomputed! (fn [& _] nil)
com.getorcha.ap.processing/request-output!
(fn [& _] (swap! calls conj :called))]
(try
(worker/process-document-with-output!
{:db-pool fixtures/*db* :llm-config {} :aws {}}
doc-id)
(catch Exception _)))
(is (empty? @calls))))
In src/com/getorcha/workers/ap/processors/matching/worker.clj, add require:
[com.getorcha.ap.processing :as ap.processing]
Add a new public helper next to process-document-with-notify!:
(defn process-document-with-output!
"Runs matching, fires diagnostics recompute, then requests straight-through
output on successful terminal matching. Does not request output from the
notify finally path."
[context document-id]
(process-document-with-notify! context document-id)
(ap.processing/request-output!
context
{:document-id document-id
:trigger :processing-completed
:requested-by nil}))
In the SQS success path, replace:
(process-document-with-notify! config document-id)
with:
(process-document-with-output! config document-id)
Change handle-failure! to return a terminal marker:
(cond
(not transient?)
(do
(write-failed-run! db-pool document-id error-msg)
(aws/delete-message! sqs-client queue-url message)
:terminal-failed)
(< receive-count max-receive-count)
(do
(log/info "Extending message visibility for retry"
{:document-id document-id
:receive-count receive-count
:delay-seconds (backoff-seconds receive-count)})
(aws/extend-visibility! sqs-client queue-url message
(backoff-seconds receive-count))
:retrying)
:else
(do
(write-failed-run! db-pool document-id error-msg)
:terminal-failed))
In the SQS catch path, after handle-failure!:
(when (= :terminal-failed (handle-failure! config message document-id t))
(ap.processing/request-output!
config
{:document-id document-id
:trigger :processing-completed
:requested-by nil}))
Ensure the code calls handle-failure! once and stores its return value.
Run:
clj -X:test:silent :nses '[com.getorcha.workers.ap.processors.matching.worker-test]'
Expected: PASS.
git add src/com/getorcha/workers/ap/processors/matching/worker.clj test/com/getorcha/workers/ap/processors/matching/worker_test.clj
git commit -m "feat(ap): request straight-through output after matching"
Files:
Modify: src/com/getorcha/integrations/ap/maesn.clj
Modify: test/com/getorcha/workers/document_output_test.clj
Step 1: Add DATEV eligibility regression test
In test/com/getorcha/workers/document_output_test.clj, add:
(deftest dispatch-job!-does-not-block-validation-errors
(let [calls (atom 0)
job (output-job! "pending")]
(db.sql/execute-one!
fixtures/*db*
{:update :document
:set {:diagnostics [:lift {:validations {:required-fields {:status "error"}}}]}
:where [:= :id (:document-id job)]})
(with-redefs [maesn/connected-datev-integration
(fn [& _] {:tenant-datev-integration/disconnect-reason nil})
maesn/create-booking-proposal!
(fn [& _] (swap! calls inc) {:task-id "task-1"})]
(worker/dispatch-job!
{:aws {} :db-pool fixtures/*db* :integrations {}}
(:document-output-job/id job)
600)
(is (= 1 @calls)))))
If connected-datev-integration cannot be redefined because of visibility, keep this regression in a new test/com/getorcha/integrations/ap/maesn_test.clj focused directly on export-eligible?.
maesn/export-eligible?In src/com/getorcha/integrations/ap/maesn.clj, replace the function with:
(defn export-eligible?
"True when the document has a usable DATEV integration.
Validation, matching, and reconciliation diagnostics do not block export;
they are output content and should be reflected in the cover page/audit."
[db-pool {:document/keys [tenant-id structured-data] :as _document}]
(let [integration (connected-datev-integration {:db-pool db-pool} tenant-id)]
(and (map? structured-data)
(some? integration)
(nil? (:tenant-datev-integration/disconnect-reason integration)))))
Run:
clj -X:test:silent :nses '[com.getorcha.workers.document-output-test]'
Expected: PASS.
git add src/com/getorcha/integrations/ap/maesn.clj test/com/getorcha/workers/document_output_test.clj
git commit -m "fix(datev): allow export with diagnostics errors"
Files:
Modify all runtime files still referencing tenant-ap-approval-config or tenant_ap_approval_config
Tests may keep migration/backfill assertions and helper compatibility writes only.
Step 1: Search runtime references
Run:
rg -n "tenant-ap-approval-config|tenant_ap_approval_config|is-enabled" src/com/getorcha
Expected remaining allowed references after earlier tasks:
migration SQL only, outside src
comments/docstrings only if they explicitly say legacy
no runtime source code reads policy from tenant_ap_approval_config
Step 2: Replace any remaining runtime policy reads
Use these replacements:
ap.processing/current-mode.documents.shared/approvals-enabled-for-document?, which now checks approval rows.ap.processing/authorize-output or ap.processing/request-output!.Do not delete tenant_ap_approval_config in this implementation. It remains unused rollback scaffolding and a migration backfill source.
Run:
clj -X:test:silent :nses '[com.getorcha.admin.http.tenants.approval-test com.getorcha.app.http.documents.edits-test com.getorcha.app.http.documents.view.approval-test com.getorcha.app.http.documents.view.invoice-test com.getorcha.workers.ap.processors.matching.worker-test com.getorcha.workers.document-output-test com.getorcha.ap.processing-test]'
Expected: PASS.
git add src test
git commit -m "refactor(ap): remove legacy approval config policy reads"
Files:
Verify all modified files.
Step 1: Run migration tests
clj -X:test:silent :nses '[com.getorcha.db.migrations-test]'
Expected: PASS.
clj -X:test:silent :nses '[com.getorcha.ap.processing-test com.getorcha.admin.http.tenants.approval-test com.getorcha.app.document-output-test com.getorcha.app.http.documents.edits-test com.getorcha.app.http.documents.view.approval-test com.getorcha.app.http.documents.view.invoice-test com.getorcha.workers.ap.processors.matching.worker-test com.getorcha.workers.document-output-test]'
Expected: PASS.
clj-kondo --lint src test dev
Expected: no findings.
clj -X:test
Expected: PASS.
rg -n "tenant-ap-approval-config|tenant_ap_approval_config|is-enabled" src/com/getorcha
Expected: no runtime policy reads. If comments remain, they must describe legacy state.
rg -n "processing-completed|processing_completed" src test resources/migrations
Expected:
migration adds processing_completed enum value
AP processing/matching code inserts :processing-completed
tests assert trigger "processing_completed"
Step 7: Final commit if verification fixes were needed
If any verification fixes changed files:
git status --short
git add src test resources/migrations
git commit -m "test(ap): verify processing mode integration"
Before running git add, inspect git status --short and only stage files
modified by this implementation. If no files changed, skip this commit.