AP Approvals Implementation Plan

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 structure

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

Conventions used throughout


Task 1: Migration — schema for approvals

Files:

-- 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"

Task 2: Test helpers for approvals

Files:

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"

Task 3: Snapshot approver list at ingestion completion

Files:

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.

In 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)}))))))

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"

Task 4: Approval state-derivation + Approve handler

Files:

(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).

(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!}}]]])

Append 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"

Task 5: Reject handler with cascade-to-revoked

Files:

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"

Task 6: Revoke handler (most-recent-only)

Files:

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"

Task 7: Auto-fire DATEV export on final approval; remove old auto-export

Files:

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).

In 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]]})))))

Remove 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"

Task 8: Inline-edit gating on tenant_ap_approval_config.is_enabled

Files:

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:

Each 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"

Task 9: Approvers section hiccup + DATEV section state machine

Files:

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.)

In app/http/documents/view/shared.clj:

  1. Drop every reference to 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).
  2. Add a load of approval rows alongside export-audit/matches:
approval-rows (com.getorcha.app.http.documents.view.approval/fetch-approval-rows
               db-pool (:document/id document))
  1. Pass :approval-rows through to type-specific-view's opts.

In view.clj:

  1. Remove awaiting-auto-export? from the opts map passed to view.shared/detail-page.

In view/invoice.clj:

  1. Drop the awaiting-auto-export? parameter and every reference to it.
  2. Replace the (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:

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"

Task 10: Admin config UI — toggle and approver list (read + render)

Files:

(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"]])]))

In src/com/getorcha/admin/http/tenants.clj:

  1. Add to the :require form:

    [com.getorcha.admin.http.tenants.approval :as approval]
    
  2. Insert into section-anchors, before ["datev" "ERP Integration"]:

    ["approvals" "AP Approvals"]
    
  3. 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"

Task 11: Admin handlers — toggle, add, remove, reorder

Files:

(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.

(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"

Task 12: Wire admin routes into admin/http.clj

Files:

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"

Task 13: End-to-end smoke test of the full flow

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:

Log in as approver #1 (in another browser/profile). Approve. Confirm:

Log in as approver #2. Approve. Confirm:

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.


Self-review notes

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: