AP Approvals — Design

Status: Draft Date: 2026-04-25

Summary

Introduce a per-tenant invoice approval workflow that gates DATEV export behind a strictly-sequential, ordered list of named approvers. The same tenant-level toggle also gates inline editing of invoice fields. The existing post-ingestion auto-export to DATEV is removed entirely; export is either a manual button click (toggle off) or fires automatically when the last approver approves (toggle on).

Decisions

These were resolved during brainstorming and are not open for re-examination in implementation:

Architecture

The feature is one tenant-level boolean (tenant_ap_approval_config.is_enabled) that gates four orthogonal behaviors:

Behavior Toggle OFF Toggle ON
Inline edits on invoice fields disabled (read-only) enabled
Pre-export approval chain none required
Auto-export after ingestion removed entirely (both modes) n/a
DATEV export trigger manual button click last approval lands

Three new namespaces:

Auto-export removal is a separate, mechanical change (delete code, update view callsites) that lands in the same migration as the toggle introduction.

Data Model

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');
-- last three: schema-only for v1; handlers do not emit them yet.

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

decided_by is distinct from identity_id: the row's owning approver is identity_id; decided_by records who actually made the decision (differs only when a super-admin overrides). payload is NULL for v1 state-transition events; it carries roster-diff details when those actions land in a future iteration.

approval_id is nullable so future roster events can reference a doc without an associated approval row (e.g. logging a deletion after the row is gone).

DEFERRABLE INITIALLY DEFERRED on the position uniqueness constraints lets a single transaction reorder rows without intermediate-state collisions.

The "current state of the invoice" is derived inline at callsites against the rows; no aggregate column on document. Comparisons use raw state strings (e.g. (= "rejected" state)), not keywordized values — no helper layer.

Super-admin config UI

Adds an ["approvals" "AP Approvals"] entry to section-anchors in admin/http/tenants.clj, slotted before "datev". The section itself is rendered by admin/http/tenants/approval.clj.

The section contains:

Disable transition (is_enabled ON → OFF) runs in a single transaction:

  1. Delete ap_invoice_approval rows for documents owned by this tenant that have no successful DATEV export — i.e. no ap_datev_export_audit row with status = 'SUCCESS'. (Same "already-exported" predicate the existing get-last-successful-export uses.)
  2. Insert one ap_invoice_approval_event(action='revoked', actor_identity_id=<super-admin>) per affected approval row.
  3. Update tenant_ap_approval_config.is_enabled = false.

After disable, manual export becomes available immediately on those documents (subject to the existing DATEV-connected and no-validation- errors gates).

Reordering uses two arrow buttons that swap a row's position with its neighbor inside one transaction with the deferred constraint. Removal of an approver only mutates tenant_ap_approver — in-flight invoices are unaffected (snapshot semantics).

Document view UI changes

In app/http/documents/view/{invoice,shared}.clj:

New approvers section

Rendered between the Validation section and the DATEV Export section, only when the document has at least one ap_invoice_approval row. (Equivalently: the snapshot was taken at ingestion time. Pre-existing docs from before the tenant enabled the feature have no rows and don't render the section.) Layout:

Approvers
─────────
1. Alice    ✓ approved 2026-04-25 by Alice
2. Bob      [Approve] [Reject]              ← Bob is current viewer
3. Carol    pending

Per-row buttons are computed server-side at render time:

Condition Buttons
Viewer matches identity_id, row is pending, row is the lowest-pending position Approve, Reject
Viewer matches identity_id, row is approved, no later row has decided_at Revoke
Viewer is super-admin Approve, Reject, Revoke (sequential gate not enforced for super-admins)
Otherwise none

Buttons hx-post to /documents/:document-id/approval/:approval-id/{approve,reject,revoke}. The response replaces the approvers section and OOB-swaps the DATEV Export section (which may have flipped state).

Inline-edit gating

A small predicate (approvals-enabled? or similar) checks tenant_ap_approval_config.is_enabled for the document's tenant. Every existing inline-edit POST handler short-circuits with 403 when the predicate is false; every existing pencil-edit button skips rendering when the predicate is false. The check is centralized in one helper used by every editable section.

Auto-export removal in views

All references to awaiting-auto-export? in app/http/documents/view/{invoice,shared}.clj are removed. The DATEV Export section ends up in three states, keyed off the document's approval-row presence and aggregate state:

Approval handlers and export trigger

app/http/documents/view/approval.clj exposes three handlers and the pure derivation fns used by the renderer.

Approve

POST /documents/:document-id/approval/:approval-id/approve

Single transaction:

  1. SELECT ... FOR UPDATE the targeted approval row.
  2. Verify state is pending.
  3. Authorize: viewer is super-admin, OR (viewer's identity matches identity_id AND row's position equals the lowest pending position for this document).
  4. UPDATE ap_invoice_approval SET state='approved', decided_at=now(), decided_by=<viewer> for this row.
  5. Insert one ap_invoice_approval_event(action='approved', ...).
  6. Re-query all rows for the document. If every row is approved, fire-and-forget on a virtual thread: maesn/create-booking-proposal!, gated by the existing DATEV-connected and no-validation-errors checks (these gates are moved out of check-auto-export, which is being deleted, into a small predicate here). The payload-hash change-detection from the old check-auto-export is dropped — final approval is the trigger; there is no concurrent re-trigger to dedupe beyond what the row-level FOR UPDATE already prevents.

The HTTP response contains the new approvers-section HTML plus an OOB-swap of the DATEV Export section.

Reject

POST /documents/:document-id/approval/:approval-id/reject

Same transaction shape as approve.

  1. Authorize identically.
  2. Set the targeted row to rejected.
  3. Set every later-position row (position > rejected_row.position, currently pending) to revoked. Each cascade emits its own ap_invoice_approval_event(action='revoked', actor_identity_id=<viewer>).
  4. Insert one action='rejected' event for the targeted row.

No export fires. The response replaces the approvers section.

Revoke

POST /documents/:document-id/approval/:approval-id/revoke

  1. Authorize: viewer is super-admin, OR viewer matches identity_id.
  2. Allowed only when row state is approved AND no later row has decided_at set (i.e. this is the most-recent approval). Otherwise 400.
  3. UPDATE ... SET state='pending', decided_at=NULL, decided_by=NULL.
  4. Insert action='revoked' event with actor_identity_id=<viewer>.

The "only most-recent approval" restriction is a v1 simplification. When per-invoice roster editing arrives later, this can lift.

Snapshot at ingestion completion

A small fn colocated next to workers/ap/ingestion.clj, invoked from complete-ingestion! inside the same transaction that finalizes the document to :completed:

INSERT INTO ap_invoice_approval (document_id, position, identity_id)
SELECT ?, position, identity_id
FROM tenant_ap_approver
WHERE tenant_id = ?

Skipped entirely when tenant_ap_approval_config.is_enabled = false for the document's tenant, or when the row doesn't exist.

trigger-auto-export! and maesn/check-auto-export are deleted. notify-anomalies! stays as-is. The DATEV-connected and no-validation-errors predicates from check-auto-export move into a small helper alongside the approve handler so the auto-fire on final approval can reuse them.

Migration plan / feature rollout

A single up migration adds the four tables and two enums. No existing table needs altering — document does not get an aggregate column.

The migration ships together with the auto-export-removal code change in the same PR. Order of operations on deploy:

  1. Migration runs (new tables/enums exist, no data yet).
  2. New code starts — auto-export is gone for all tenants; tenant_ap_approval_config defaults to is_enabled = false for every tenant (no rows yet, predicate treats absence as false).
  3. Super-admin enables the feature for individual tenants by adding approvers and flipping the toggle on the tenant detail page.

No data backfill. No flag-flip ordering risk: pre-existing un-exported invoices remain manually exportable on day one for every tenant.

Testing

Test files mirror source layout per project convention.

test/com/getorcha/app/http/documents/view/approval_test.clj:

test/com/getorcha/admin/http/tenants/approval_test.clj:

Out of scope (deferred)

These are intentionally not built in v1 but the design accommodates them without a refactor: