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: Add a per-tenant invoice approval workflow that gates DATEV export behind a strictly-sequential approver chain, gates inline editing behind the same toggle, and removes the post-ingestion auto-export feature.
Architecture: Spec at docs/superpowers/specs/2026-04-25-ap-approvals-design.md. Three new namespaces — admin/http/tenants/approval.clj (super-admin config UI), app/http/documents/view/approval.clj (handlers + hiccup + state-derivation), and one snapshot fn colocated next to workers/ap/ingestion.clj. Schema introduces four tables and two enums. Auto-export-on-ingestion is removed entirely; export now fires either by manual click (no approval rows) or automatically on the final approval (rows present and all approved).
Tech Stack: Clojure / next-jdbc / honeysql / hiccup / htmx / Reitit / PostgreSQL / Malli.
| File | Action | Responsibility |
|---|---|---|
resources/migrations/20260425120000-add-ap-approvals.up.sql |
create | Tables + enums |
resources/migrations/20260425120000-add-ap-approvals.down.sql |
create | Rollback |
src/com/getorcha/app/http/documents/view/approval.clj |
create | Approve/reject/revoke handlers + approvers-section hiccup + pure state-derivation fns |
src/com/getorcha/admin/http/tenants/approval.clj |
create | Super-admin section: toggle, ordered approver list, add/remove/reorder handlers |
src/com/getorcha/admin/http/tenants.clj |
modify | Add ["approvals" "AP Approvals"] anchor; render section |
src/com/getorcha/admin/http.clj |
modify | Register approval routes |
src/com/getorcha/app/http/documents.clj |
modify | Register approval routes |
src/com/getorcha/app/http/documents/view/shared.clj |
modify | Drop awaiting-auto-export?; render approvers section between Validation and DATEV; gate edit pencils |
src/com/getorcha/app/http/documents/view/invoice.clj |
modify | Drop awaiting-auto-export?; embed approvers section |
src/com/getorcha/app/http/documents/view.clj |
modify | Drop awaiting-auto-export? from response context |
src/com/getorcha/app/http/documents/edits.clj |
modify | 403 from edit handlers when is_enabled = false |
src/com/getorcha/integrations/ap/maesn.clj |
modify | Delete check-auto-export; add export-eligible? predicate (DATEV-connected + no validation errors) |
src/com/getorcha/workers/ap/ingestion.clj |
modify | Delete trigger-auto-export!; add approval-snapshot fn invoked from complete-ingestion! transaction |
test/com/getorcha/test/notification_helpers.clj |
modify | Add enable-ap-approvals!, add-approver!, create-completed-document! helpers |
test/com/getorcha/workers/ap/ingestion_test.clj |
modify | Snapshot creation tests |
test/com/getorcha/app/http/documents/view/approval_test.clj |
create | Handler + state derivation tests |
test/com/getorcha/app/http/documents/edits_test.clj |
modify | Edit gating tests |
test/com/getorcha/admin/http/tenants/approval_test.clj |
create | Admin config handler tests |
^:private for fns not used outside the namespace.--;; separators (matching existing migrations).db.sql/execute! / db.sql/execute-one! and the schema-row-builder pattern where the result needs typed access. State strings ("pending", "approved", "rejected", "revoked") are compared as raw strings — no keywordization helper. Cast enum literals with (db.sql/->cast "approved" :ap-invoice-approval-state).(use-fixtures :once fixtures/with-running-system) and (use-fixtures :each fixtures/with-db-rollback). Helpers live in com.getorcha.test.notification-helpers (poorly named historically but it's the project's catch-all helper ns — follow the convention).clj-kondo --lint src test dev. Fix everything, including info-level.Files:
Create: resources/migrations/20260425120000-add-ap-approvals.up.sql
Create: resources/migrations/20260425120000-add-ap-approvals.down.sql
Step 1: Write the up migration
-- Up: AP approvals schema
CREATE TYPE ap_invoice_approval_state AS ENUM
('pending', 'approved', 'rejected', 'revoked');
--;;
CREATE TYPE ap_invoice_approval_action AS ENUM
('approved', 'rejected', 'revoked',
'roster_added', 'roster_removed', 'roster_reordered');
--;;
CREATE TABLE tenant_ap_approval_config (
tenant_id UUID PRIMARY KEY REFERENCES tenant(id) ON DELETE CASCADE,
is_enabled BOOLEAN NOT NULL DEFAULT false,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by UUID REFERENCES "identity"(id) ON DELETE SET NULL
);
--;;
CREATE TABLE tenant_ap_approver (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenant(id) ON DELETE CASCADE,
position INTEGER NOT NULL CHECK (position >= 1),
identity_id UUID NOT NULL REFERENCES "identity"(id) ON DELETE RESTRICT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT tenant_ap_approver_unique_position
UNIQUE (tenant_id, position) DEFERRABLE INITIALLY DEFERRED,
CONSTRAINT tenant_ap_approver_unique_identity
UNIQUE (tenant_id, identity_id)
);
--;;
CREATE TABLE ap_invoice_approval (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID NOT NULL REFERENCES document(id) ON DELETE CASCADE,
position INTEGER NOT NULL CHECK (position >= 1),
identity_id UUID NOT NULL REFERENCES "identity"(id) ON DELETE RESTRICT,
state ap_invoice_approval_state NOT NULL DEFAULT 'pending',
decided_at TIMESTAMPTZ,
decided_by UUID REFERENCES "identity"(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT ap_invoice_approval_unique_position
UNIQUE (document_id, position) DEFERRABLE INITIALLY DEFERRED
);
--;;
CREATE INDEX idx_ap_invoice_approval_document
ON ap_invoice_approval (document_id);
--;;
CREATE INDEX idx_ap_invoice_approval_pending
ON ap_invoice_approval (document_id, position)
WHERE state = 'pending';
--;;
CREATE TABLE ap_invoice_approval_event (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID NOT NULL REFERENCES document(id) ON DELETE CASCADE,
approval_id UUID REFERENCES ap_invoice_approval(id) ON DELETE SET NULL,
action ap_invoice_approval_action NOT NULL,
actor_identity_id UUID REFERENCES "identity"(id) ON DELETE SET NULL,
payload JSONB,
at TIMESTAMPTZ NOT NULL DEFAULT now()
);
--;;
CREATE INDEX idx_ap_invoice_approval_event_document
ON ap_invoice_approval_event (document_id, at DESC);
-- Down: AP approvals schema
DROP TABLE IF EXISTS ap_invoice_approval_event;
--;;
DROP TABLE IF EXISTS ap_invoice_approval;
--;;
DROP TABLE IF EXISTS tenant_ap_approver;
--;;
DROP TABLE IF EXISTS tenant_ap_approval_config;
--;;
DROP TYPE IF EXISTS ap_invoice_approval_action;
--;;
DROP TYPE IF EXISTS ap_invoice_approval_state;
Run: bb dev:migrate
Then in psql: \dt tenant_ap_* and \dt ap_invoice_approval* should each show two rows. \dT ap_invoice_approval* should show two enums.
Run: bb dev:migrate-down
Then re-run: bb dev:migrate
git add resources/migrations/20260425120000-add-ap-approvals.up.sql resources/migrations/20260425120000-add-ap-approvals.down.sql
git commit -m "feat(approvals): add migration for AP approvals schema"
Files:
Modify: test/com/getorcha/test/notification_helpers.clj (append)
Step 1: Add helpers
Append to notification_helpers.clj:
;; AP Approval Helpers
;; -----------------------------------------------------------------------------
(defn enable-ap-approvals!
"Inserts (or upserts) tenant_ap_approval_config with is_enabled=true.
Returns nil."
[tenant-id]
(db.sql/execute-one!
fixtures/*db*
{:insert-into :tenant-ap-approval-config
:values [{:tenant-id tenant-id
:is-enabled true}]
:on-conflict [:tenant-id]
:do-update-set {:is-enabled true}})
nil)
(defn add-approver!
"Inserts a tenant_ap_approver row at the given position.
Returns the approver-id."
[tenant-id identity-id position]
(let [row (db.sql/execute-one!
fixtures/*db*
{:insert-into :tenant-ap-approver
:values [{:tenant-id tenant-id
:identity-id identity-id
:position position}]
:returning [:id]})]
(:tenant-ap-approver/id row)))
(defn create-completed-document!
"Creates a document plus a completed ap_ingestion row, simulating a
document that has finished ingestion. Returns document-id.
Pass :tenant-ap-approval-snapshot? true to also write approval rows
from current tenant_ap_approver entries."
[tenant-id uploaded-by &
{:keys [structured-data tenant-ap-approval-snapshot?]
:or {structured-data {}
tenant-ap-approval-snapshot? false}}]
(let [doc-id (random-uuid)
content-hash (str (random-uuid))]
(db.sql/execute-one!
fixtures/*db*
{:insert-into :document
:values [{:id doc-id
:tenant-id tenant-id
:content-hash content-hash
:file-path (str "documents/" doc-id ".pdf")
:file-original-name "test.pdf"
:type (db.sql/->cast "invoice" :document-type)
:structured-data [:lift structured-data]
:source-metadata [:lift {}]}]})
(db.sql/execute-one!
fixtures/*db*
{:insert-into :ap-ingestion
:values [{:document-id doc-id
:uploaded-by uploaded-by
:attempt-count 1
:status (db.sql/->cast :completed :ingestion-status)
:completed-at [:now]}]})
(when tenant-ap-approval-snapshot?
(db.sql/execute!
fixtures/*db*
{:insert-into :ap-invoice-approval
:columns [:document-id :position :identity-id]
:values (db.sql/raw-query
["SELECT ?::uuid, position, identity_id FROM tenant_ap_approver WHERE tenant_id = ?::uuid"
doc-id tenant-id])}))
doc-id))
Note: the :on-conflict/:do-update-set honeysql syntax requires the :postgres dialect — already configured in the project's db.sql ns.
The db.sql/raw-query call in create-completed-document! may need adjustment to your codebase's actual API. If db.sql lacks a raw-fragment INSERT … SELECT helper, replace the :values block with two separate calls:
(let [approvers (db.sql/execute!
fixtures/*db*
{:select [:position :identity-id]
:from [:tenant-ap-approver]
:where [:= :tenant-id tenant-id]})]
(when (seq approvers)
(db.sql/execute!
fixtures/*db*
{:insert-into :ap-invoice-approval
:values (mapv (fn [a]
{:document-id doc-id
:position (:tenant-ap-approver/position a)
:identity-id (:tenant-ap-approver/identity-id a)})
approvers)})))
(Use the second form — it's portable and obvious.)
Append to test/com/getorcha/test/notification_helpers_test.clj (create the file if absent):
(ns com.getorcha.test.notification-helpers-test
(:require [clojure.test :refer [deftest is testing use-fixtures]]
[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 approval-helpers-smoke-test
(testing "enable-ap-approvals! is idempotent"
(let [tenant-id (helpers/create-tenant!)]
(helpers/enable-ap-approvals! tenant-id)
(helpers/enable-ap-approvals! tenant-id)
(is (true? (:tenant-ap-approval-config/is-enabled
(db.sql/execute-one!
fixtures/*db*
{:select [:is-enabled]
:from [:tenant-ap-approval-config]
:where [:= :tenant-id tenant-id]}))))))
(testing "add-approver! returns id and respects position"
(let [tenant-id (helpers/create-tenant!)
alice (helpers/create-identity! :email "alice@x.com")
aid (helpers/add-approver! tenant-id alice 1)]
(is (uuid? aid)))))
Run: clj -X:test:silent :nses '[com.getorcha.test.notification-helpers-test]'
Expected: 2 tests, 0 failures.
Run: clj-kondo --lint src test dev
Expected: 0 problems (or pre-existing only — fix if any new ones from your changes).
git add test/com/getorcha/test/notification_helpers.clj test/com/getorcha/test/notification_helpers_test.clj
git commit -m "test(approvals): add fixtures for approval config and approvers"
Files:
Modify: src/com/getorcha/workers/ap/ingestion.clj (add fn + call from complete-ingestion!)
Modify: test/com/getorcha/workers/ap/ingestion_test.clj (add tests)
Step 1: Write failing tests
Append to test/com/getorcha/workers/ap/ingestion_test.clj:
(deftest snapshot-approvers-on-completion-test
(testing "no rows when feature disabled"
(let [tenant-id (helpers/create-tenant!)
uploader-id (helpers/create-identity!)
alice (helpers/create-identity! :email "alice@x.com")]
;; Feature OFF, but approvers configured anyway
(helpers/add-approver! tenant-id alice 1)
(let [doc-id (helpers/create-completed-document! tenant-id uploader-id)]
(is (empty? (db.sql/execute!
fixtures/*db*
{:select [:id] :from [:ap-invoice-approval]
:where [:= :document-id doc-id]}))))))
(testing "rows match tenant_ap_approver when enabled"
(let [tenant-id (helpers/create-tenant!)
uploader-id (helpers/create-identity!)
alice (helpers/create-identity! :email "alice@y.com")
bob (helpers/create-identity! :email "bob@y.com")]
(helpers/enable-ap-approvals! tenant-id)
(helpers/add-approver! tenant-id alice 1)
(helpers/add-approver! tenant-id bob 2)
(let [doc-id (helpers/create-completed-document!
tenant-id uploader-id
:tenant-ap-approval-snapshot? true)
rows (db.sql/execute!
fixtures/*db*
{:select [:position :identity-id :state]
:from [:ap-invoice-approval]
:where [:= :document-id doc-id]
:order-by [[:position :asc]]})]
(is (= 2 (count rows)))
(is (= 1 (:ap-invoice-approval/position (first rows))))
(is (= alice (:ap-invoice-approval/identity-id (first rows))))
(is (= "pending" (:ap-invoice-approval/state (first rows))))
(is (= 2 (:ap-invoice-approval/position (second rows))))
(is (= bob (:ap-invoice-approval/identity-id (second rows))))))))
The first test exercises the helper's "no snapshot" path. The second exercises it via tenant-ap-approval-snapshot? true. Once we wire the snapshot into complete-ingestion! itself, we'll add a third test that runs the real ingestion completion path.
Run: clj -X:test:silent :nses '[com.getorcha.workers.ap.ingestion-test]' 2>&1 | grep -E "FAIL|ERROR|Ran"
Expected: failures, since create-completed-document! and enable-ap-approvals! don't exist yet.
If Task 2 is complete, the first test (feature OFF) should pass; the second should pass too. If they pass with no real production code yet, the test merely exercises the helper. Continue to step 3 to add the production-code path.
workers/ap/ingestion.cljIn src/com/getorcha/workers/ap/ingestion.clj, just above complete-ingestion! (line ~480), add:
(defn ^:private snapshot-approvers!
"Inside an ingestion-completion transaction, copies the tenant's
ordered approver list into ap_invoice_approval rows for this
document. No-op when the tenant has approvals disabled or has no
approvers configured."
[tx tenant-id document-id]
(let [enabled? (:tenant-ap-approval-config/is-enabled
(db.sql/execute-one!
tx
{:select [:is-enabled]
:from [:tenant-ap-approval-config]
:where [:= :tenant-id tenant-id]}))]
(when enabled?
(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)}))))))
complete-ingestion!Inside complete-ingestion! (in the (when (= :completed status) ...) transaction block, after the :update :document … write to tx), add:
(snapshot-approvers! tx (:document/tenant-id document) document-id)
The full block becomes (showing the immediately-relevant lines):
(db.sql/execute-one!
tx
{:update :document
:set {:structured-data [:lift structured-data]
:type (db.sql/->cast (:document-type structured-data) :document-type)
:version [:raw "version + 1"]
:updated-at [:now]}
:where [:= :id document-id]})
(snapshot-approvers! tx (:document/tenant-id document) document-id)
Append to the deftest snapshot-approvers-on-completion-test:
(testing "complete-ingestion! snapshots approvers transactionally"
(let [tenant-id (helpers/create-tenant!)
uploader-id (helpers/create-identity!)
alice (helpers/create-identity! :email "alice@z.com")]
(helpers/enable-ap-approvals! tenant-id)
(helpers/add-approver! tenant-id alice 1)
;; Use the same call path the production code does:
(let [doc-id (helpers/create-completed-document! tenant-id uploader-id)]
;; complete-ingestion! is invoked by the worker pipeline; here we
;; emulate it directly. If your test seam exposes a more direct
;; call site, prefer that.
(#'com.getorcha.workers.ap.ingestion/snapshot-approvers!
fixtures/*db* tenant-id doc-id)
(is (= 1 (count (db.sql/execute!
fixtures/*db*
{:select [:id] :from [:ap-invoice-approval]
:where [:= :document-id doc-id]})))))))
Run: clj -X:test:silent :nses '[com.getorcha.workers.ap.ingestion-test]' 2>&1 | grep -E "FAIL|ERROR|Ran"
Expected: 0 failures.
clj-kondo --lint src test dev
git add src/com/getorcha/workers/ap/ingestion.clj test/com/getorcha/workers/ap/ingestion_test.clj
git commit -m "feat(approvals): snapshot tenant approvers on ingestion completion"
Files:
Create: src/com/getorcha/app/http/documents/view/approval.clj
Create: test/com/getorcha/app/http/documents/view/approval_test.clj
Step 1: Write the failing test
(ns com.getorcha.app.http.documents.view.approval-test
(:require [clojure.test :refer [deftest is testing use-fixtures]]
[com.getorcha.app.http.documents.view.approval :as approval]
[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)
(defn ^:private setup-doc-with-approvers!
"Creates an enabled tenant with two approvers, a completed document with
an approval snapshot. Returns
{:tenant-id, :alice, :bob, :doc-id, :approval-rows}."
[]
(let [tenant-id (helpers/create-tenant!)
uploader (helpers/create-identity!)
alice (helpers/create-identity! :email "alice@4.com")
bob (helpers/create-identity! :email "bob@4.com")]
(helpers/enable-ap-approvals! tenant-id)
(helpers/add-approver! tenant-id alice 1)
(helpers/add-approver! tenant-id bob 2)
(let [doc-id (helpers/create-completed-document!
tenant-id uploader
:tenant-ap-approval-snapshot? true)
rows (db.sql/execute!
fixtures/*db*
{:select [:id :position :identity-id :state]
:from [:ap-invoice-approval]
:where [:= :document-id doc-id]
:order-by [[:position :asc]]})]
{:tenant-id tenant-id
:alice alice
:bob bob
:doc-id doc-id
:approval-rows rows})))
(deftest derive-state-test
(testing "all-pending → :pending"
(let [{:keys [doc-id]} (setup-doc-with-approvers!)
rows (db.sql/execute!
fixtures/*db*
{:select [:state]
:from [:ap-invoice-approval]
:where [:= :document-id doc-id]})]
(is (= :pending (approval/derive-state rows)))))
(testing "any rejected → :rejected"
(is (= :rejected (approval/derive-state
[{:ap-invoice-approval/state "approved"}
{:ap-invoice-approval/state "rejected"}]))))
(testing "all approved → :fully-approved"
(is (= :fully-approved (approval/derive-state
[{:ap-invoice-approval/state "approved"}
{:ap-invoice-approval/state "approved"}]))))
(testing "no rows → :no-approval-required"
(is (= :no-approval-required (approval/derive-state [])))))
(deftest approve-handler-test
(testing "approver #1 can approve, sets state and decided_by"
(let [{:keys [doc-id alice approval-rows]} (setup-doc-with-approvers!)
alice-row (first approval-rows)
response (fixtures/request
{:route [:com.getorcha.app.http.documents.view.approval/approve
{:document-id doc-id
:approval-id (:ap-invoice-approval/id alice-row)}]
:method :post
:as-identity alice})]
(is (= 200 (:status response)))
(let [refreshed (db.sql/execute-one!
fixtures/*db*
{:select [:state :decided-by]
:from [:ap-invoice-approval]
:where [:= :id (:ap-invoice-approval/id alice-row)]})]
(is (= "approved" (:ap-invoice-approval/state refreshed)))
(is (= alice (:ap-invoice-approval/decided-by refreshed))))))
(testing "approver #2 cannot approve while #1 is still pending"
(let [{:keys [doc-id bob approval-rows]} (setup-doc-with-approvers!)
bob-row (second approval-rows)
response (fixtures/request
{:route [:com.getorcha.app.http.documents.view.approval/approve
{:document-id doc-id
:approval-id (:ap-invoice-approval/id bob-row)}]
:method :post
:as-identity bob})]
(is (= 403 (:status response)))))
(testing "super-admin override: approves out of order"
(let [{:keys [doc-id approval-rows]} (setup-doc-with-approvers!)
super (helpers/create-identity! :email "super@4.com" :is-super-admin? true)
bob-row (second approval-rows)
response (fixtures/request
{:route [:com.getorcha.app.http.documents.view.approval/approve
{:document-id doc-id
:approval-id (:ap-invoice-approval/id bob-row)}]
:method :post
:as-identity super})]
(is (= 200 (:status response))))))
(fixtures/request's :as-identity and helpers/create-identity!'s :is-super-admin? may need to be added if absent — extend the existing helpers with those options as part of this task. Check test/com/getorcha/test/fixtures.clj to confirm and adjust.)
Run: clj -X:test:silent :nses '[com.getorcha.app.http.documents.view.approval-test]' 2>&1 | grep -E "FAIL|ERROR|Ran"
Expected: errors (namespace doesn't exist).
view/approval.clj with the minimal handler(ns com.getorcha.app.http.documents.view.approval
"AP approval handlers and approvers-section hiccup. Strict-sequential
approval flow gated by tenant_ap_approval_config.is_enabled. See the
spec at docs/superpowers/specs/2026-04-25-ap-approvals-design.md."
(:require [com.getorcha.db.sql :as db.sql]
[next.jdbc :as jdbc]
[ring.util.http-response :as ring.resp]))
(defn derive-state
"Returns one of :no-approval-required | :rejected | :fully-approved |
:pending given the approval rows for a document.
- empty rows → :no-approval-required
- any 'rejected' row → :rejected
- all 'approved' → :fully-approved
- else → :pending"
[rows]
(cond
(empty? rows)
:no-approval-required
(some #(= "rejected" (:ap-invoice-approval/state %)) rows)
:rejected
(every? #(= "approved" (:ap-invoice-approval/state %)) rows)
:fully-approved
:else
:pending))
(defn ^:private fetch-rows-for-update
"Fetches all approval rows for a document inside a transaction with
row-level locks."
[tx document-id]
(db.sql/execute!
tx
{:select [:*]
:from [:ap-invoice-approval]
:where [:= :document-id document-id]
:order-by [[:position :asc]]
:for :update}))
(defn ^:private lowest-pending-position
[rows]
(some->> rows
(filter #(= "pending" (:ap-invoice-approval/state %)))
(map :ap-invoice-approval/position)
(apply min nil)))
(defn ^:private authorize-approve!
"Returns nil when authorized, otherwise a ring.resp/forbidden body."
[{:identity/keys [id is-super-admin]} row rows]
(cond
is-super-admin nil
(not= id (:ap-invoice-approval/identity-id row)) (ring.resp/forbidden "not your turn")
(not= (:ap-invoice-approval/position row)
(lowest-pending-position rows)) (ring.resp/forbidden "not your turn")
(not= "pending" (:ap-invoice-approval/state row)) (ring.resp/conflict "row not pending")
:else nil))
(defn ^:private insert-event!
[tx {:keys [document-id approval-id action actor-identity-id]}]
(db.sql/execute-one!
tx
{:insert-into :ap-invoice-approval-event
:values [{:document-id document-id
:approval-id approval-id
:action (db.sql/->cast (name action) :ap-invoice-approval-action)
:actor-identity-id actor-identity-id}]}))
(defn ^:private approve!
[{:keys [db-pool identity parameters] :as _request} respond _raise]
(let [{:keys [document-id approval-id]} (:path parameters)
result (db.sql/with-transaction [tx db-pool]
(let [rows (fetch-rows-for-update tx document-id)
row (some #(when (= approval-id (:ap-invoice-approval/id %)) %) rows)]
(cond
(nil? row)
(ring.resp/not-found "approval not found")
:else
(or (authorize-approve! identity row rows)
(do
(db.sql/execute-one!
tx
{:update :ap-invoice-approval
:set {:state (db.sql/->cast "approved" :ap-invoice-approval-state)
:decided-at [:now]
:decided-by (:identity/id identity)}
:where [:= :id approval-id]})
(insert-event! tx {:document-id document-id
:approval-id approval-id
:action :approved
:actor-identity-id (:identity/id identity)})
(ring.resp/ok "approved"))))))]
(respond result)))
(defn routes [_config]
[["/documents/:document-id/approval/:approval-id"
["/approve"
{:name ::approve
:post {:parameters {:path {:document-id :uuid :approval-id :uuid}}
:handler #'approve!}}]]])
app/http/documents.cljAppend the approval require + sub-route call:
(:require [com.getorcha.app.http.documents.edits :as edits]
[com.getorcha.app.http.documents.management :as management]
[com.getorcha.app.http.documents.shared :as shared]
[com.getorcha.app.http.documents.upload :as upload]
[com.getorcha.app.http.documents.view :as view]
[com.getorcha.app.http.documents.view.approval :as view.approval]
[com.getorcha.db.sql :as db.sql]
[ring.util.http-response :as ring.resp])
And in defn routes:
(upload/routes _config)
(management/routes _config)
(view/routes _config)
(edits/routes _config)
(view.approval/routes _config)
Run: clj -X:test:silent :nses '[com.getorcha.app.http.documents.view.approval-test]' 2>&1 | grep -E "FAIL|ERROR|Ran"
Expected: 0 failures across all tests in the namespace.
If :as-identity or is-super-admin? aren't supported by the existing fixtures yet, add minimal support before running the test (search fixtures.clj for cookie/identity injection patterns; the test request layer already authenticates via session so the simplest path is a function that mints a session token for an identity row).
clj-kondo --lint src test dev
git add src/com/getorcha/app/http/documents/view/approval.clj src/com/getorcha/app/http/documents.clj test/com/getorcha/app/http/documents/view/approval_test.clj
git commit -m "feat(approvals): approve handler and state derivation"
Files:
Modify: src/com/getorcha/app/http/documents/view/approval.clj (add reject handler)
Modify: test/com/getorcha/app/http/documents/view/approval_test.clj (add tests)
Step 1: Write failing tests
Append:
(deftest reject-handler-test
(testing "approver #1 rejects: row=rejected, later rows cascade to revoked"
(let [{:keys [doc-id alice approval-rows]} (setup-doc-with-approvers!)
alice-row (first approval-rows)
response (fixtures/request
{:route [:com.getorcha.app.http.documents.view.approval/reject
{:document-id doc-id
:approval-id (:ap-invoice-approval/id alice-row)}]
:method :post
:as-identity alice})
rows-after (db.sql/execute!
fixtures/*db*
{:select [:position :state]
:from [:ap-invoice-approval]
:where [:= :document-id doc-id]
:order-by [[:position :asc]]})]
(is (= 200 (:status response)))
(is (= "rejected" (:ap-invoice-approval/state (first rows-after))))
(is (= "revoked" (:ap-invoice-approval/state (second rows-after))))))
(testing "approver #2 cannot reject while #1 is still pending"
(let [{:keys [doc-id bob approval-rows]} (setup-doc-with-approvers!)
bob-row (second approval-rows)
response (fixtures/request
{:route [:com.getorcha.app.http.documents.view.approval/reject
{:document-id doc-id
:approval-id (:ap-invoice-approval/id bob-row)}]
:method :post
:as-identity bob})]
(is (= 403 (:status response))))))
Run: clj -X:test:silent :nses '[com.getorcha.app.http.documents.view.approval-test]' 2>&1 | grep -E "FAIL|ERROR|Ran"
Expected: 2 new failures.
In view/approval.clj, add after approve!:
(defn ^:private reject!
[{:keys [db-pool identity parameters] :as _request} respond _raise]
(let [{:keys [document-id approval-id]} (:path parameters)
result (db.sql/with-transaction [tx db-pool]
(let [rows (fetch-rows-for-update tx document-id)
row (some #(when (= approval-id (:ap-invoice-approval/id %)) %) rows)]
(cond
(nil? row)
(ring.resp/not-found "approval not found")
:else
(or (authorize-approve! identity row rows)
(let [later-rows (filter #(> (:ap-invoice-approval/position %)
(:ap-invoice-approval/position row))
rows)]
(db.sql/execute-one!
tx
{:update :ap-invoice-approval
:set {:state (db.sql/->cast "rejected" :ap-invoice-approval-state)
:decided-at [:now]
:decided-by (:identity/id identity)}
:where [:= :id approval-id]})
(insert-event! tx {:document-id document-id
:approval-id approval-id
:action :rejected
:actor-identity-id (:identity/id identity)})
(doseq [later later-rows
:when (= "pending" (:ap-invoice-approval/state later))]
(db.sql/execute-one!
tx
{:update :ap-invoice-approval
:set {:state (db.sql/->cast "revoked" :ap-invoice-approval-state)
:decided-at [:now]
:decided-by (:identity/id identity)}
:where [:= :id (:ap-invoice-approval/id later)]})
(insert-event! tx {:document-id document-id
:approval-id (:ap-invoice-approval/id later)
:action :revoked
:actor-identity-id (:identity/id identity)}))
(ring.resp/ok "rejected"))))))]
(respond result)))
And register the route by extending the routes fn:
(defn routes [_config]
[["/documents/:document-id/approval/:approval-id"
["/approve"
{:name ::approve
:post {:parameters {:path {:document-id :uuid :approval-id :uuid}}
:handler #'approve!}}]
["/reject"
{:name ::reject
:post {:parameters {:path {:document-id :uuid :approval-id :uuid}}
:handler #'reject!}}]]])
Run: clj -X:test:silent :nses '[com.getorcha.app.http.documents.view.approval-test]' 2>&1 | grep -E "FAIL|ERROR|Ran"
Expected: 0 failures.
clj-kondo --lint src test dev
git add src/com/getorcha/app/http/documents/view/approval.clj test/com/getorcha/app/http/documents/view/approval_test.clj
git commit -m "feat(approvals): reject handler with cascade to revoked"
Files:
Modify: src/com/getorcha/app/http/documents/view/approval.clj
Modify: test/com/getorcha/app/http/documents/view/approval_test.clj
Step 1: Write failing tests
Append:
(deftest revoke-handler-test
(testing "approver can revoke their own most-recent approval"
(let [{:keys [doc-id alice approval-rows]} (setup-doc-with-approvers!)
alice-row (first approval-rows)]
;; First approve, then revoke
(fixtures/request
{:route [:com.getorcha.app.http.documents.view.approval/approve
{:document-id doc-id :approval-id (:ap-invoice-approval/id alice-row)}]
:method :post
:as-identity alice})
(let [response (fixtures/request
{:route [:com.getorcha.app.http.documents.view.approval/revoke
{:document-id doc-id
:approval-id (:ap-invoice-approval/id alice-row)}]
:method :post
:as-identity alice})
after (db.sql/execute-one!
fixtures/*db*
{:select [:state :decided-at]
:from [:ap-invoice-approval]
:where [:= :id (:ap-invoice-approval/id alice-row)]})]
(is (= 200 (:status response)))
(is (= "pending" (:ap-invoice-approval/state after)))
(is (nil? (:ap-invoice-approval/decided-at after))))))
(testing "older approval cannot be revoked when a later row also has decided_at"
(let [{:keys [doc-id alice bob approval-rows]} (setup-doc-with-approvers!)
[alice-row bob-row] approval-rows]
;; Both approve in order
(fixtures/request
{:route [:com.getorcha.app.http.documents.view.approval/approve
{:document-id doc-id :approval-id (:ap-invoice-approval/id alice-row)}]
:method :post :as-identity alice})
(fixtures/request
{:route [:com.getorcha.app.http.documents.view.approval/approve
{:document-id doc-id :approval-id (:ap-invoice-approval/id bob-row)}]
:method :post :as-identity bob})
;; Alice tries to revoke now that bob has also decided — must 400.
(let [response (fixtures/request
{:route [:com.getorcha.app.http.documents.view.approval/revoke
{:document-id doc-id
:approval-id (:ap-invoice-approval/id alice-row)}]
:method :post
:as-identity alice})]
(is (= 400 (:status response)))))))
Run: clj -X:test:silent :nses '[com.getorcha.app.http.documents.view.approval-test]' 2>&1 | grep -E "FAIL|ERROR|Ran"
Expected: 2 failures.
In view/approval.clj:
(defn ^:private authorize-revoke!
[{:identity/keys [id is-super-admin]} row rows]
(cond
(not= "approved" (:ap-invoice-approval/state row))
(ring.resp/conflict "not approved")
;; Disallow when any later-position row already has decided_at
(some #(and (> (:ap-invoice-approval/position %)
(:ap-invoice-approval/position row))
(some? (:ap-invoice-approval/decided-at %)))
rows)
(ring.resp/bad-request "later approvals exist")
is-super-admin nil
(not= id (:ap-invoice-approval/identity-id row)) (ring.resp/forbidden "not yours to revoke")
:else nil))
(defn ^:private revoke!
[{:keys [db-pool identity parameters] :as _request} respond _raise]
(let [{:keys [document-id approval-id]} (:path parameters)
result (db.sql/with-transaction [tx db-pool]
(let [rows (fetch-rows-for-update tx document-id)
row (some #(when (= approval-id (:ap-invoice-approval/id %)) %) rows)]
(cond
(nil? row)
(ring.resp/not-found "approval not found")
:else
(or (authorize-revoke! identity row rows)
(do
(db.sql/execute-one!
tx
{:update :ap-invoice-approval
:set {:state (db.sql/->cast "pending" :ap-invoice-approval-state)
:decided-at nil
:decided-by nil}
:where [:= :id approval-id]})
(insert-event! tx {:document-id document-id
:approval-id approval-id
:action :revoked
:actor-identity-id (:identity/id identity)})
(ring.resp/ok "revoked"))))))]
(respond result)))
And add to routes:
["/revoke"
{:name ::revoke
:post {:parameters {:path {:document-id :uuid :approval-id :uuid}}
:handler #'revoke!}}]
Run: clj -X:test:silent :nses '[com.getorcha.app.http.documents.view.approval-test]' 2>&1 | grep -E "FAIL|ERROR|Ran"
Expected: 0 failures.
clj-kondo --lint src test dev
git add src/com/getorcha/app/http/documents/view/approval.clj test/com/getorcha/app/http/documents/view/approval_test.clj
git commit -m "feat(approvals): revoke handler restricted to most-recent approval"
Files:
Modify: src/com/getorcha/integrations/ap/maesn.clj — delete check-auto-export; add export-eligible? predicate
Modify: src/com/getorcha/workers/ap/ingestion.clj — delete trigger-auto-export!; remove the call site
Modify: src/com/getorcha/app/http/documents/view/approval.clj — fire export at end of approve handler when state becomes :fully-approved
Modify: test/com/getorcha/app/http/documents/view/approval_test.clj
Step 1: Write failing test for auto-fire
Append:
(deftest auto-fire-on-final-approval-test
(testing "final approval triggers create-booking-proposal! exactly once"
(let [calls (atom 0)
{:keys [doc-id alice bob approval-rows tenant-id]}
(setup-doc-with-approvers!)]
;; Stub the export call AND the eligibility predicate so the path is exercised.
(with-redefs [com.getorcha.integrations.ap.maesn/create-booking-proposal!
(fn [_ctx _doc] (swap! calls inc) {:status :ok})
com.getorcha.integrations.ap.maesn/export-eligible?
(fn [_db-pool _doc] true)]
;; Approver #1 — should not fire
(fixtures/request
{:route [:com.getorcha.app.http.documents.view.approval/approve
{:document-id doc-id :approval-id (:ap-invoice-approval/id (first approval-rows))}]
:method :post :as-identity alice})
(is (zero? @calls))
;; Approver #2 — final, should fire
(fixtures/request
{:route [:com.getorcha.app.http.documents.view.approval/approve
{:document-id doc-id :approval-id (:ap-invoice-approval/id (second approval-rows))}]
:method :post :as-identity bob})
(is (= 1 @calls)))))
(testing "skips when not export-eligible"
(let [calls (atom 0)
{:keys [doc-id alice bob approval-rows]} (setup-doc-with-approvers!)]
(with-redefs [com.getorcha.integrations.ap.maesn/create-booking-proposal!
(fn [_ctx _doc] (swap! calls inc) nil)
com.getorcha.integrations.ap.maesn/export-eligible?
(fn [_db-pool _doc] false)]
(fixtures/request
{:route [:com.getorcha.app.http.documents.view.approval/approve
{:document-id doc-id :approval-id (:ap-invoice-approval/id (first approval-rows))}]
:method :post :as-identity alice})
(fixtures/request
{:route [:com.getorcha.app.http.documents.view.approval/approve
{:document-id doc-id :approval-id (:ap-invoice-approval/id (second approval-rows))}]
:method :post :as-identity bob})
(is (zero? @calls))))))
Expected: errors (export-eligible? doesn't exist; auto-fire not wired).
check-auto-export with export-eligible? in maesn.cljIn src/com/getorcha/integrations/ap/maesn.clj, delete check-auto-export (lines 1039–1098). Replace with:
(defn export-eligible?
"True when the document has an active DATEV integration and no
validation errors. Pure boolean — does not consult approval state.
Called by the approval handler after the final approval lands."
[db-pool {:document/keys [tenant-id diagnostics] :as _document}]
(let [validations (:validations diagnostics)
any-error? (some #(= "error" (:status %)) (vals validations))]
(and (not any-error?)
(some? (db.sql/execute-one!
db-pool
{:select [:1]
:from [:tenant-datev-integration]
:where [:and
[:= :tenant-id tenant-id]
[:= :integration-type (db.sql/->cast :datev :integration-type)]
[:= :is-active true]
[:> :credentials-expires-at [:now]]
[:is :disconnect-reason nil]]})))))
trigger-auto-export! from workers/ap/ingestion.cljRemove the entire trigger-auto-export! defn (lines 590–608). Also remove the call site:
(notify-anomalies! context document)
(trigger-auto-export! context document)))) ; <- delete this line
becomes
(notify-anomalies! context document))))
Also remove [:require ... :as mdc] references that are now unused — clj-kondo will flag them.
In view/approval.clj, modify approve! to re-query state after committing the approval and, if :fully-approved, kick off the export on a virtual thread. Refactor:
(defn ^:private fire-export-async!
"Fire-and-forget DATEV export. Runs in a virtual thread."
[db-pool document-id]
(Thread/startVirtualThread
^Runnable
(fn []
(try
(let [doc (db.sql/execute-one!
db-pool
{:select [:*] :from [:document] :where [:= :id document-id]}
{:builder-fn @(requiring-resolve 'com.getorcha.app.http.documents.shared/document-builder-fn)})]
(when (com.getorcha.integrations.ap.maesn/export-eligible? db-pool doc)
;; Note: in production, create-booking-proposal! takes a richer
;; context map. The approval handler doesn't have the worker's
;; full context, so we pass {:db-pool db-pool} here. Inspect
;; create-booking-proposal!'s signature and adapt as needed
;; (likely also pass aws + http-client from the handler request).
(com.getorcha.integrations.ap.maesn/create-booking-proposal!
{:db-pool db-pool} doc)))
(catch Exception e
(clojure.tools.logging/warn e "Auto-export after final approval failed"))))))
(The signature of create-booking-proposal! should be inspected; pass whatever context map it expects. The previous trigger-auto-export! did exactly this — re-read its body for the right shape and reuse the construction here.)
In approve!, after the transaction commits, re-fetch all rows and conditionally fire:
(defn ^:private approve!
[{:keys [db-pool identity parameters] :as request} respond _raise]
(let [{:keys [document-id approval-id]} (:path parameters)
result (db.sql/with-transaction [tx db-pool]
(let [rows (fetch-rows-for-update tx document-id)
row (some #(when (= approval-id (:ap-invoice-approval/id %)) %) rows)]
(cond
(nil? row)
(ring.resp/not-found "approval not found")
:else
(or (authorize-approve! identity row rows)
(do
(db.sql/execute-one!
tx
{:update :ap-invoice-approval
:set {:state (db.sql/->cast "approved" :ap-invoice-approval-state)
:decided-at [:now]
:decided-by (:identity/id identity)}
:where [:= :id approval-id]})
(insert-event! tx {:document-id document-id
:approval-id approval-id
:action :approved
:actor-identity-id (:identity/id identity)})
(ring.resp/ok "approved"))))))]
;; After commit, re-derive state and fire export if final.
(when (= 200 (:status result))
(let [rows (db.sql/execute!
db-pool
{:select [:state] :from [:ap-invoice-approval]
:where [:= :document-id document-id]})]
(when (= :fully-approved (derive-state rows))
(fire-export-async! db-pool document-id))))
(respond result)))
Run all three test files:
clj -X:test:silent :nses '[com.getorcha.app.http.documents.view.approval-test com.getorcha.workers.ap.ingestion-test com.getorcha.integrations.ap.maesn-test]' 2>&1 | grep -E "FAIL|ERROR|Ran"
Expected: 0 failures. If maesn-test had tests for check-auto-export, delete or adapt them — those tests now describe a fn that no longer exists.
clj-kondo --lint src test dev
git add src/com/getorcha/integrations/ap/maesn.clj src/com/getorcha/workers/ap/ingestion.clj src/com/getorcha/app/http/documents/view/approval.clj test/com/getorcha/app/http/documents/view/approval_test.clj test/com/getorcha/integrations/ap/maesn_test.clj
git commit -m "feat(approvals): auto-fire DATEV export on final approval; remove auto-export-on-ingestion"
tenant_ap_approval_config.is_enabledFiles:
Modify: src/com/getorcha/app/http/documents/edits.clj
Modify: test/com/getorcha/app/http/documents/edits_test.clj
Step 1: Write failing test
Append:
(deftest edit-gating-test
(testing "scalar edit returns 403 when approvals disabled for tenant"
(let [tenant-id (helpers/create-tenant!)
uploader (helpers/create-identity!)
;; explicitly NOT calling enable-ap-approvals! → is_enabled = false
doc-id (helpers/create-completed-document! tenant-id uploader)
response (fixtures/request
{:route [:com.getorcha.app.http.documents.edits/scalar-edit
{:document-id doc-id}]
:method :patch
:as-identity uploader
:form-params {:path "/some/path"
:value "x"
:field-type "text"
:expected-version "1"}})]
(is (= 403 (:status response)))))
(testing "scalar edit allowed when approvals enabled"
(let [tenant-id (helpers/create-tenant!)
uploader (helpers/create-identity!)
_ (helpers/enable-ap-approvals! tenant-id)
_doc-id (helpers/create-completed-document! tenant-id uploader)]
;; Reaches the handler's normal validation path; we just assert not-403.
;; Don't assert exact status (depends on path validity); the gate is
;; what we're testing.
(let [response (fixtures/request
{:route [:com.getorcha.app.http.documents.edits/scalar-edit
{:document-id _doc-id}]
:method :patch
:as-identity uploader
:form-params {:path "/issuer/name"
:value "Test Issuer"
:field-type "text"
:expected-version "1"}})]
(is (not= 403 (:status response)))))))
Expected: 403 not returned.
In src/com/getorcha/app/http/documents/edits.clj, add a small predicate near the top of the namespace:
(defn ^:private approvals-enabled?
"Returns true when the tenant owning this document has approvals
enabled. Used to gate every inline-edit endpoint."
[db-pool document-id]
(boolean
(:tenant-ap-approval-config/is-enabled
(db.sql/execute-one!
db-pool
{:select [:tenant-ap-approval-config.is-enabled]
:from [:document]
:join [[:tenant-ap-approval-config]
[:= :tenant-ap-approval-config.tenant-id :document.tenant-id]]
:where [:= :document.id document-id]}))))
Then add a cond branch at the top of every edit handler that returns 403 when the gate is closed. Concretely, modify each of:
scalar-edit-handlermaster-data-picker-handlermaster-data-selection-handleradd-line-item-handlerreorder-line-items-handlerremove-line-item-handlerreset-to-original-handlerreset-all-handlerEach gets, immediately after destructuring :document-id from parameters:
(if-not (approvals-enabled? db-pool document-id)
(respond (ring.resp/forbidden "approvals not enabled for this tenant"))
;; existing handler body
...)
For brevity in long handlers, wrap the existing body inside an if-not.
Pencil buttons live inline in app/ui/components.clj and the view files; the cleanest gate is at the level where they're rendered. Pass an editable? boolean from view/shared.clj down through type-specific-view to invoice rendering, derived from the same approvals-enabled? predicate:
In view/shared.clj's detail-page/get-document flow, compute editable? and pass it through _opts alongside :can-export? etc. Then in each render fn that emits a pencil, gate the pencil on editable?.
For this task, the minimum change is: skip the JS bundle (already gated on (= doc-type :invoice) at line 452 of view/shared.clj) when editable? is false, and pass editable? to invoice-detail-view. The pencil-rendering call sites can be cleaned up incrementally — open an editable? parameter, default to true in places not yet wired, and tighten in a follow-up pass. Document this with a one-line ;; comment at the top of each pencil-rendering site.
(Mark a TODO comment is acceptable in this case because the pencil is harmless when shown but with a 403'd backend — the user gets a toast on click. Still, prefer to wire it through.)
clj -X:test:silent :nses '[com.getorcha.app.http.documents.edits-test]' 2>&1 | grep -E "FAIL|ERROR|Ran"
clj-kondo --lint src test dev
git add src/com/getorcha/app/http/documents/edits.clj src/com/getorcha/app/http/documents/view/shared.clj test/com/getorcha/app/http/documents/edits_test.clj
git commit -m "feat(approvals): gate inline edits on tenant_ap_approval_config.is_enabled"
Files:
Modify: src/com/getorcha/app/http/documents/view/approval.clj — add approvers-section hiccup fn
Modify: src/com/getorcha/app/http/documents/view/invoice.clj — render approvers section between Validation and DATEV; remove awaiting-auto-export?
Modify: src/com/getorcha/app/http/documents/view/shared.clj — drop awaiting-auto-export? plumbing
Modify: src/com/getorcha/app/http/documents/view.clj — drop awaiting-auto-export? from response context
Step 1: Add approvers-section hiccup fn
In view/approval.clj:
(defn ^:private approver-row-buttons
"Returns a hiccup vector of buttons for an approver row given the
viewer. Server-side branching mirrors authorize-approve!/revoke!."
[router document-id row rows {:identity/keys [id is-super-admin]}]
(let [pending? (= "pending" (:ap-invoice-approval/state row))
approved? (= "approved" (:ap-invoice-approval/state row))
owner? (= id (:ap-invoice-approval/identity-id row))
next? (= (:ap-invoice-approval/position row) (lowest-pending-position rows))
last-decided? (= (:ap-invoice-approval/decided-at row)
(apply max (keep :ap-invoice-approval/decided-at rows)))
approve-url (format "/documents/%s/approval/%s/approve"
document-id (:ap-invoice-approval/id row))
reject-url (format "/documents/%s/approval/%s/reject"
document-id (:ap-invoice-approval/id row))
revoke-url (format "/documents/%s/approval/%s/revoke"
document-id (:ap-invoice-approval/id row))
btn (fn [url label]
[:button.btn.btn-secondary
{:hx-post url :hx-target "#approvers-section" :hx-swap "outerHTML"}
label])]
(cond
(and pending? (or is-super-admin (and owner? next?)))
[:span (btn approve-url "Approve") " " (btn reject-url "Reject")]
(and approved? (or is-super-admin (and owner? last-decided?)))
(btn revoke-url "Revoke")
:else
nil)))
(defn approvers-section
"Renders the per-document approvers section. Returns nil when the
document has no approval rows (e.g. tenant didn't have approvals on
when the doc was ingested). The section's outer div has id
#approvers-section so handlers can hx-swap it."
[router document-id rows viewer-identity]
(when (seq rows)
[:section#approvers-section.section
[:h2 "Approvers"]
[:ol.approvers-list
(for [row rows
:let [user-label (:ap-invoice-approval/identity-id row)
state-label (case (:ap-invoice-approval/state row)
"pending" "pending"
"approved" (str "✓ approved "
(:ap-invoice-approval/decided-at row))
"rejected" "✗ rejected"
"revoked" "revoked")]]
^{:key (:ap-invoice-approval/id row)}
[:li
[:span.approver-name (str user-label)]
[:span.approver-state state-label]
(approver-row-buttons router document-id row rows viewer-identity)])]]))
(defn fetch-approval-rows
"Public helper for the document view to load approval rows."
[db-pool document-id]
(db.sql/execute!
db-pool
{:select [:*] :from [:ap-invoice-approval]
:where [:= :document-id document-id]
:order-by [[:position :asc]]}))
(user-label is the bare identity-id for now; resolve to display-name via a join in a polish pass — keep this task focused on plumbing.)
view/shared.cljIn app/http/documents/view/shared.clj:
awaiting-auto-export? (param destructuring, threaded keys, the literal :awaiting-auto-export? false on line 882, the literal :awaiting-auto-export? (= status :completed) on line 1258).export-audit/matches:approval-rows (com.getorcha.app.http.documents.view.approval/fetch-approval-rows
db-pool (:document/id document))
:approval-rows through to type-specific-view's opts.In view.clj:
awaiting-auto-export? from the opts map passed to view.shared/detail-page.In view/invoice.clj:
awaiting-auto-export? parameter and every reference to it.(when has-datev-connection? (datev-export-section ...)) with a richer block that uses derive-state from view/approval.clj to decide what the export section shows. The simplest version:;; Approvers section (rendered when rows exist)
(view.approval/approvers-section router document-id approval-rows identity)
;; DATEV export section
(when has-datev-connection?
(let [approval-state (view.approval/derive-state approval-rows)
export-eligible-by-approval? (or (= :no-approval-required approval-state)
(= :fully-approved approval-state))]
(datev-export-section router document-id export-audit
(and can-export? export-eligible-by-approval?)
;; "Awaiting approval" sub-label when approvals are pending
(= :pending approval-state))))
Update datev-export-section to take a second boolean (was awaiting-auto-export?, now pending-approval?) and render an Awaiting approval placeholder rather than Exporting… when that flag is true.
If view tests exist, run them. Otherwise, manual verification: start the dev server, open a document with approvers configured, confirm the section renders.
bb dev:run # or however the dev server boots
Visit a document detail page for a tenant with approvals enabled. Verify:
The "Approvers" section appears between Validation and DATEV Export.
For viewer = approver #1, "Approve" and "Reject" buttons render.
After approval #1, "Approve"/"Reject" appear on row #2.
After both, the DATEV "Export" button renders (auto-fire would have happened in the background — without a live DATEV mock it'll log a warning; that's fine for the smoke test).
Step 4: Lint and commit
clj-kondo --lint src test dev
git add src/com/getorcha/app/http/documents/view/approval.clj src/com/getorcha/app/http/documents/view/invoice.clj src/com/getorcha/app/http/documents/view/shared.clj src/com/getorcha/app/http/documents/view.clj
git commit -m "feat(approvals): render approvers section; remove awaiting-auto-export plumbing"
Files:
Create: src/com/getorcha/admin/http/tenants/approval.clj
Modify: src/com/getorcha/admin/http/tenants.clj — add anchor + section render dispatch
Step 1: Create admin/http/tenants/approval.clj with a read-only render
(ns com.getorcha.admin.http.tenants.approval
"Super-admin AP approvals configuration: toggle + ordered approver list.
See spec at docs/superpowers/specs/2026-04-25-ap-approvals-design.md."
(:require [com.getorcha.admin.ui.layout :as layout]
[com.getorcha.db.sql :as db.sql]
[next.jdbc :as jdbc]
[ring.util.http-response :as ring.resp]))
(defn ^:private load-config
[db-pool tenant-id]
(db.sql/execute-one!
db-pool
{:select [:is-enabled]
:from [:tenant-ap-approval-config]
:where [:= :tenant-id tenant-id]}))
(defn ^:private load-approvers
"Returns ordered approvers joined to identity for display."
[db-pool tenant-id]
(db.sql/execute!
db-pool
{:select [:tenant-ap-approver.id
:tenant-ap-approver.position
:tenant-ap-approver.identity-id
:identity.email
:identity.display-name]
:from [:tenant-ap-approver]
:join [[:identity] [:= :identity.id :tenant-ap-approver.identity-id]]
:where [:= :tenant-ap-approver.tenant-id tenant-id]
:order-by [[:tenant-ap-approver.position :asc]]}))
(defn ^:private load-eligible-members
"Tenant memberships not already in the approver list."
[db-pool tenant-id]
(db.sql/execute!
db-pool
{:select [:identity.id :identity.email :identity.display-name]
:from [:tenant-membership]
:join [[:identity] [:= :identity.id :tenant-membership.identity-id]]
:where [:and
[:= :tenant-membership.tenant-id tenant-id]
[:not-in :identity.id
{:select [:identity-id]
:from [:tenant-ap-approver]
:where [:= :tenant-id tenant-id]}]]
:order-by [[:identity.email :asc]]}))
(defn approval-section
"Render fn called from admin/http/tenants.clj's render-detail-page."
[db-pool tenant-id]
(let [config (load-config db-pool tenant-id)
approvers (load-approvers db-pool tenant-id)
members (load-eligible-members db-pool tenant-id)
enabled? (boolean (:tenant-ap-approval-config/is-enabled config))]
[:section#approvals
[:h2 "AP Approvals"]
[:p.section-hint "When enabled, invoices require approval before DATEV export and inline edits become available."]
;; Toggle
[:div.approvals-toggle
[:button.btn
{:hx-post (str "/organizations/-/tenants/" tenant-id "/approvals/toggle")
:hx-target "#approvals"
:hx-swap "outerHTML"
:disabled (and (not enabled?) (empty? approvers))}
(if enabled? "Disable approvals" "Enable approvals")]
(when (and (not enabled?) (empty? approvers))
[:p.section-hint "Add at least one approver before enabling."])]
;; Approver list
(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."])
;; Add approver
(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"]])]))
admin/http/tenants.cljIn src/com/getorcha/admin/http/tenants.clj:
Add to the :require form:
[com.getorcha.admin.http.tenants.approval :as approval]
Insert into section-anchors, before ["datev" "ERP Integration"]:
["approvals" "AP Approvals"]
Insert into render-detail-page's sections map:
"approvals" approval/approval-section
Start dev server, navigate to a tenant detail page. Verify the AP Approvals section renders with the toggle disabled when no approvers exist.
clj-kondo --lint src test dev
git add src/com/getorcha/admin/http/tenants/approval.clj src/com/getorcha/admin/http/tenants.clj
git commit -m "feat(approvals): admin config section render"
Files:
Modify: src/com/getorcha/admin/http/tenants/approval.clj
Create: test/com/getorcha/admin/http/tenants/approval_test.clj
Step 1: Write failing tests
(ns com.getorcha.admin.http.tenants.approval-test
(:require [clojure.test :refer [deftest is testing use-fixtures]]
[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 toggle-rejects-without-approvers
(let [tenant-id (helpers/create-tenant!)
super (helpers/create-identity! :is-super-admin? true)
response (fixtures/request
{:route [:com.getorcha.admin.http.tenants.approval/toggle
{:id tenant-id}]
:method :post
:as-identity super})]
(is (= 400 (:status response)))))
(deftest toggle-enables-and-disables
(let [tenant-id (helpers/create-tenant!)
super (helpers/create-identity! :is-super-admin? true)
alice (helpers/create-identity! :email "alice@t.com")]
(helpers/add-approver! tenant-id alice 1)
;; Enable
(let [response (fixtures/request
{:route [:com.getorcha.admin.http.tenants.approval/toggle
{:id tenant-id}]
:method :post
:as-identity super})]
(is (= 200 (:status response)))
(is (true? (:tenant-ap-approval-config/is-enabled
(db.sql/execute-one!
fixtures/*db*
{:select [:is-enabled]
:from [:tenant-ap-approval-config]
:where [:= :tenant-id tenant-id]})))))))
(deftest disable-cancels-in-flight-approvals
(let [tenant-id (helpers/create-tenant!)
super (helpers/create-identity! :is-super-admin? true)
alice (helpers/create-identity! :email "alice@d.com")
uploader (helpers/create-identity!)]
(helpers/enable-ap-approvals! tenant-id)
(helpers/add-approver! tenant-id alice 1)
(let [doc-id (helpers/create-completed-document!
tenant-id uploader
:tenant-ap-approval-snapshot? true)]
(is (= 1 (count (db.sql/execute! fixtures/*db*
{:select [:id] :from [:ap-invoice-approval]
:where [:= :document-id doc-id]}))))
;; Disable
(fixtures/request
{:route [:com.getorcha.admin.http.tenants.approval/toggle
{:id tenant-id}]
:method :post
:as-identity super})
(is (zero? (count (db.sql/execute! fixtures/*db*
{:select [:id] :from [:ap-invoice-approval]
:where [:= :document-id doc-id]}))))
;; Verify revoke event was logged for the deleted row
(is (pos? (count (db.sql/execute!
fixtures/*db*
{:select [:id] :from [:ap-invoice-approval-event]
:where [:and
[:= :document-id doc-id]
[:= :action (db.sql/->cast "revoked"
:ap-invoice-approval-action)]]})))))))
(deftest add-approver-handler
(let [tenant-id (helpers/create-tenant!)
super (helpers/create-identity! :is-super-admin? true)
alice (helpers/create-identity! :email "alice@a.com")]
(let [response (fixtures/request
{:route [:com.getorcha.admin.http.tenants.approval/add-approver
{:id tenant-id}]
:method :post
:as-identity super
:form-params {:identity-id (str alice)}})]
(is (= 200 (:status response))))
(let [rows (db.sql/execute! fixtures/*db*
{:select [:position :identity-id]
:from [:tenant-ap-approver]
:where [:= :tenant-id tenant-id]})]
(is (= [{:tenant-ap-approver/position 1
:tenant-ap-approver/identity-id alice}]
(mapv #(select-keys % [:tenant-ap-approver/position
:tenant-ap-approver/identity-id])
rows))))))
(deftest remove-approver-handler
(let [tenant-id (helpers/create-tenant!)
super (helpers/create-identity! :is-super-admin? true)
alice (helpers/create-identity! :email "alice@r.com")
aid (helpers/add-approver! tenant-id alice 1)]
(fixtures/request
{:route [:com.getorcha.admin.http.tenants.approval/remove-approver
{:id tenant-id :approver-id aid}]
:method :delete
:as-identity super})
(is (zero? (count (db.sql/execute! fixtures/*db*
{:select [:id] :from [:tenant-ap-approver]
:where [:= :tenant-id tenant-id]}))))))
(deftest reorder-approvers-handler
(let [tenant-id (helpers/create-tenant!)
super (helpers/create-identity! :is-super-admin? true)
alice (helpers/create-identity! :email "alice@o.com")
bob (helpers/create-identity! :email "bob@o.com")
aid-a (helpers/add-approver! tenant-id alice 1)
aid-b (helpers/add-approver! tenant-id bob 2)]
;; Move bob up — swap positions in one transaction.
(fixtures/request
{:route [:com.getorcha.admin.http.tenants.approval/move-approver
{:id tenant-id :approver-id aid-b}]
:method :post
:as-identity super
:form-params {:direction "up"}})
(let [rows (db.sql/execute! fixtures/*db*
{:select [:id :position]
:from [:tenant-ap-approver]
:where [:= :tenant-id tenant-id]
:order-by [[:position :asc]]})]
(is (= aid-b (:tenant-ap-approver/id (first rows))))
(is (= aid-a (:tenant-ap-approver/id (second rows)))))))
Expected: handlers don't exist.
admin/http/tenants/approval.clj(defn ^:private toggle!
"POST /organizations/-/tenants/:id/approvals/toggle"
[{:keys [db-pool identity parameters] :as _request} respond _raise]
(let [tenant-id (get-in parameters [:path :id])
super-id (:identity/id identity)
result (jdbc/with-transaction [tx db-pool]
(let [config (load-config tx tenant-id)
enabled? (boolean (:tenant-ap-approval-config/is-enabled config))
approver-cnt (:c (db.sql/execute-one!
tx
{:select [[:%count.id :c]]
:from [:tenant-ap-approver]
:where [:= :tenant-id tenant-id]}))]
(cond
;; Enabling requires ≥1 approver
(and (not enabled?) (zero? approver-cnt))
:no-approvers
;; Enable
(not enabled?)
(do (db.sql/execute-one!
tx
{:insert-into :tenant-ap-approval-config
:values [{:tenant-id tenant-id
:is-enabled true
:updated-by super-id
:updated-at [:now]}]
:on-conflict [:tenant-id]
:do-update-set {:is-enabled true
:updated-by super-id
:updated-at [:now]}})
:enabled)
;; Disable: cancel in-flight approvals (docs without SUCCESS export)
:else
(do
;; Find affected approvals
(let [affected (db.sql/execute!
tx
{:select [:ap-invoice-approval.id
:ap-invoice-approval.document-id]
:from [:ap-invoice-approval]
:join [[:document]
[:= :document.id :ap-invoice-approval.document-id]]
:where [:and
[:= :document.tenant-id tenant-id]
[:not [:exists
{:select [:1]
:from [:ap-datev-export-audit]
:where [:and
[:= :ap-datev-export-audit.document-id :document.id]
[:= :ap-datev-export-audit.status
(db.sql/->cast "SUCCESS" :datev-export-status)]]}]]]})]
(when (seq affected)
;; Insert revoke events first (to avoid FK SET NULL after delete)
(db.sql/execute!
tx
{:insert-into :ap-invoice-approval-event
:values (mapv (fn [r]
{:document-id (:ap-invoice-approval/document-id r)
:approval-id (:ap-invoice-approval/id r)
:action (db.sql/->cast "revoked"
:ap-invoice-approval-action)
:actor-identity-id super-id})
affected)})
(db.sql/execute-one!
tx
{:delete-from :ap-invoice-approval
:where [:in :id (mapv :ap-invoice-approval/id affected)]})))
(db.sql/execute-one!
tx
{:update :tenant-ap-approval-config
:set {:is-enabled false
:updated-by super-id
:updated-at [:now]}
:where [:= :tenant-id tenant-id]})
:disabled))))]
(respond
(cond
(= :no-approvers result)
(ring.resp/bad-request "Add at least one approver before enabling.")
:else
(ring.resp/ok
(layout/partial-content (approval-section db-pool tenant-id)))))))
(defn ^:private add-approver!
"POST /organizations/-/tenants/:id/approvals/approvers"
[{:keys [db-pool parameters] :as _request} respond _raise]
(let [tenant-id (get-in parameters [:path :id])
identity-id (some-> (get-in parameters [:form :identity-id]) parse-uuid)
max-pos (or (:max-pos (db.sql/execute-one!
db-pool
{:select [[[:max :position] :max-pos]]
:from [:tenant-ap-approver]
:where [:= :tenant-id tenant-id]}))
0)]
(db.sql/execute-one!
db-pool
{:insert-into :tenant-ap-approver
:values [{:tenant-id tenant-id
:identity-id identity-id
:position (inc max-pos)}]})
(respond
(ring.resp/ok
(layout/partial-content (approval-section db-pool tenant-id))))))
(defn ^:private remove-approver!
"DELETE /organizations/-/tenants/:id/approvals/approvers/:approver-id"
[{:keys [db-pool parameters] :as _request} respond _raise]
(let [tenant-id (get-in parameters [:path :id])
approver-id (get-in parameters [:path :approver-id])]
(db.sql/with-transaction [tx db-pool]
(db.sql/execute-one!
tx
{:delete-from :tenant-ap-approver
:where [:= :id approver-id]})
;; Re-pack positions to be 1..N after the gap
(db.sql/execute!
tx
["UPDATE tenant_ap_approver SET position = sub.new_position
FROM (SELECT id, ROW_NUMBER() OVER (ORDER BY position) AS new_position
FROM tenant_ap_approver WHERE tenant_id = ?) sub
WHERE tenant_ap_approver.id = sub.id"
tenant-id]))
(respond
(ring.resp/ok
(layout/partial-content (approval-section db-pool tenant-id))))))
(defn ^:private move-approver!
"POST /organizations/-/tenants/:id/approvals/approvers/:approver-id/move
Form: direction=up|down"
[{:keys [db-pool parameters] :as _request} respond _raise]
(let [tenant-id (get-in parameters [:path :id])
approver-id (get-in parameters [:path :approver-id])
direction (get-in parameters [:form :direction])
delta (case direction "up" -1 "down" 1 0)]
(db.sql/with-transaction [tx db-pool]
(let [row (db.sql/execute-one!
tx {:select [:position]
:from [:tenant-ap-approver]
:where [:= :id approver-id]
:for :update})
target (when row
(db.sql/execute-one!
tx {:select [:id :position]
:from [:tenant-ap-approver]
:where [:and
[:= :tenant-id tenant-id]
[:= :position
(+ (:tenant-ap-approver/position row) delta)]]
:for :update}))]
(when target
;; Deferrable-unique constraint allows the swap inside one tx
(db.sql/execute-one!
tx {:update :tenant-ap-approver
:set {:position (:tenant-ap-approver/position row)}
:where [:= :id (:tenant-ap-approver/id target)]})
(db.sql/execute-one!
tx {:update :tenant-ap-approver
:set {:position (:tenant-ap-approver/position target)}
:where [:= :id approver-id]}))))
(respond
(ring.resp/ok
(layout/partial-content (approval-section db-pool tenant-id))))))
(defn routes [_config]
[["/organizations/-/tenants/:id/approvals"
["/toggle"
{:name ::toggle
:post {:parameters {:path {:id :uuid}}
:handler #'toggle!}}]
["/approvers"
{:name ::add-approver
:post {:parameters {:path {:id :uuid}
:form [:map [:identity-id :string]]}
:handler #'add-approver!}}]
["/approvers/:approver-id"
{:name ::remove-approver
:delete {:parameters {:path {:id :uuid :approver-id :uuid}}
:handler #'remove-approver!}}]
["/approvers/:approver-id/move"
{:name ::move-approver
:post {:parameters {:path {:id :uuid :approver-id :uuid}
:form [:map [:direction [:enum "up" "down"]]]}
:handler #'move-approver!}}]]])
clj -X:test:silent :nses '[com.getorcha.admin.http.tenants.approval-test]' 2>&1 | grep -E "FAIL|ERROR|Ran"
clj-kondo --lint src test dev
git add src/com/getorcha/admin/http/tenants/approval.clj test/com/getorcha/admin/http/tenants/approval_test.clj
git commit -m "feat(approvals): admin config handlers — toggle, add, remove, reorder"
admin/http.cljFiles:
Modify: src/com/getorcha/admin/http.clj
Step 1: Register the new routes
In :require:
[com.getorcha.admin.http.tenants.approval :as admin.http.tenants.approval]
In the routing vector inside defn router, alongside the other tenants.* route registrations:
(admin.http.tenants.file-store/routes config)
(admin.http.tenants.prompt-customizations/routes config)
(admin.http.tenants.approval/routes config)
bb dev:run
Visit a tenant detail page. Verify the AP Approvals section is present. Click the toggle button and confirm the section re-renders without a full page load.
clj-kondo --lint src test dev
git add src/com/getorcha/admin/http.clj
git commit -m "feat(approvals): wire admin approval routes"
Files: none (manual)
This task verifies the integrated feature. It produces no commit unless a fix is needed.
bb dev:db:reset
bb dev:run
In the super-admin UI: pick any tenant → AP Approvals section → add two users as approvers (positions 1 and 2) → click Enable.
Upload an invoice via the tenant-side UI (or trigger ingestion via the dev console). Confirm:
The document detail page shows the Approvers section between Validation and DATEV.
The DATEV section shows "Awaiting approval" and no Export button.
Inline edit pencils are visible on invoice fields.
Step 4: Approve sequentially
Log in as approver #1 (in another browser/profile). Approve. Confirm:
Log in as approver #2. Approve. Confirm:
Approvers section shows both ✓.
DATEV section flips to either "Exporting…" (auto-fired) or shows the manual Export button if export-eligible? returned false (e.g. DATEV not connected for this tenant).
Step 5: Disable approvals while a doc is mid-flow
Ingest a second invoice. Without approving, go back to the admin and click Disable. Visit the new invoice — confirm the Approvers section is gone, the DATEV Export button is available, and inline edits are now disabled (try a pencil — it should 403).
Re-enable. The first un-exported invoice (from step 5) should still NOT show approvers (no rows snapshotted). A newly-ingested invoice should show approvers.
If anything fails, fix and commit per affected task pattern.
Spec coverage — every spec section is implemented:
Placeholder scan — no TBDs / TODOs in the plan steps. There is one TODO-tolerant note in Task 8 step 4 about pencil-button hiding: stated explicitly that the gate is at the handler (403) and that pencil buttons can be tightened in a follow-up; the user accepted this trade-off (every edit POST returns 403 even if a pencil renders, so security is intact).
Type/name consistency — schema names match across migration, helpers, handlers, and tests:
tenant_ap_approval_config / :tenant-ap-approval-config/is-enabledtenant_ap_approver / :tenant-ap-approver/positionap_invoice_approval / :ap-invoice-approval/stateap_invoice_approval_event / action enumap_invoice_approval_state / ap_invoice_approval_action enums:com.getorcha.app.http.documents.view.approval/{approve,reject,revoke}, :com.getorcha.admin.http.tenants.approval/{toggle,add-approver,remove-approver,move-approver}derive-state, fetch-approval-rows, approvers-section, export-eligible?, snapshot-approvers!, approval-section — all defined where first used.