Status: Draft Date: 2026-04-25
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).
These were resolved during brainstorming and are not open for re-examination in implementation:
/organizations/-/tenants/:id. Tenant-side users do not configure it.revoked); revoke is
allowed only on the most recent approval. There is no v1 UI for
surfacing rejected invoices separately — that lands later.workers/ap/ingestion.clj's trigger-auto-export! and
maesn/check-auto-export are deleted, along with the
awaiting-auto-export? flag threaded through the document view.tenant_ap_approver rows are copied into
ap_invoice_approval rows for that document. Subsequent changes to the
tenant-level approver list do not affect in-flight invoices.ap_invoice_approval rows is treated as "not gated by approval"
regardless of the tenant's current is_enabled value. On disable:
in-flight approval rows are deleted and the manual-export path
becomes available immediately.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:
com.getorcha.app.http.documents.view.approval — HTTP handlers for
approve/reject/revoke and the hiccup that renders the approvers
section. Pure state-derivation fns are colocated here.com.getorcha.admin.http.tenants.approval — super-admin config UI
(toggle + ordered approver picker). Mirrors the existing
admin.http.tenants.file_store pattern.workers/ap/ingestion.clj
and is invoked from complete-ingestion!. It is not in a
workers/ namespace — it's just colocated source for proximity to its
one caller.Auto-export removal is a separate, mechanical change (delete code, update view callsites) that lands in the same migration as the toggle introduction.
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.
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:
POSTs to /organizations/-/tenants/:id/approvals/toggle. Server
rejects ON-toggle if the approver list is empty.(position, user, controls) rows
rendered from tenant_ap_approver joined to identity. Controls per
row: up arrow, down arrow, remove. Each interaction POSTs and the
whole section is replaced by the response.tenant_membership users not already in the approver list, plus an
"Add" button.Disable transition (is_enabled ON → OFF) runs in a single
transaction:
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.)ap_invoice_approval_event(action='revoked', actor_identity_id=<super-admin>) per affected approval row.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).
In app/http/documents/view/{invoice,shared}.clj:
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).
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.
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:
OPEN/IN_PROGRESS status if the auto-fire is still in flight.app/http/documents/view/approval.clj exposes three handlers and the
pure derivation fns used by the renderer.
POST /documents/:document-id/approval/:approval-id/approve
Single transaction:
SELECT ... FOR UPDATE the targeted approval row.pending.identity_id AND row's position equals the lowest pending position
for this document).UPDATE ap_invoice_approval SET state='approved', decided_at=now(), decided_by=<viewer> for this row.ap_invoice_approval_event(action='approved', ...).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.
POST /documents/:document-id/approval/:approval-id/reject
Same transaction shape as approve.
rejected.position > rejected_row.position,
currently pending) to revoked. Each cascade emits its own
ap_invoice_approval_event(action='revoked', actor_identity_id=<viewer>).action='rejected' event for the targeted row.No export fires. The response replaces the approvers section.
POST /documents/:document-id/approval/:approval-id/revoke
identity_id.approved AND no later row has
decided_at set (i.e. this is the most-recent approval). Otherwise
400.UPDATE ... SET state='pending', decided_at=NULL, decided_by=NULL.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.
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.
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:
tenant_ap_approval_config defaults to is_enabled = false for
every tenant (no rows yet, predicate treats absence as false).No data backfill. No flag-flip ordering risk: pre-existing un-exported invoices remain manually exportable on day one for every tenant.
Test files mirror source layout per project convention.
test/com/getorcha/app/http/documents/view/approval_test.clj:
create-booking-proposal! exactly once
(verified with with-redefs). No fire when validation errors are
present or DATEV is not connected.revoked and prevents export.test/com/getorcha/admin/http/tenants/approval_test.clj:
These are intentionally not built in v1 but the design accommodates them without a refactor:
ap_invoice_approval rows are already mutable; the
ap_invoice_approval_action enum already includes
roster_added | roster_removed | roster_reordered. Add the handler
and the audit-event emission later.