An invoice approval system based on a configurable decision matrix. Invoices are split into cost center groups, and each group follows its own approval chain determined by the group's total amount. Approvals happen via Microsoft Teams (Adaptive Cards) or email (secure links), supporting both internal Orcha users and external approvers without accounts.
Decision: Per cost center group.
Line items on an invoice are grouped by their assigned cost center. Each group gets its own independent approval flow. A single invoice with 3 different cost centers produces 3 separate approval requests.
Decision: Automatic after AI extraction.
Approval is initiated automatically once the ingestion pipeline completes (OCR, LLM extraction, post-processing, validation). No human review step is required before approval begins.
Decision: External approvers allowed.
Approvers do not need an Orcha account. They receive a secure token-based link via Teams or email and can approve/reject without logging in. Internal Orcha users can also approve via the web app.
Decision: Admin-managed initially.
The Orcha admin team configures approval matrices per tenant. Tenant self-service UI is a future enhancement.
Decision: Amount-tier based, per cost center, up to 5 levels.
Each cost center has an explicit approver chain. Amount thresholds determine how many levels are activated:
| Amount Range | Required Levels | Example |
|---|---|---|
| < EUR 1,000 | 1 | Cost center owner |
| EUR 1,000 - EUR 10,000 | 2 | + Department head |
| > EUR 10,000 | 3 | + CFO |
Each level can have multiple approvers (all must approve within that level).
Decision: Always parallel.
All required approvers across all required levels are notified simultaneously. All must approve. There is no sequential ordering between levels.
Decision: Back to AP team.
Any single rejection from any approver cancels the entire approval request for that cost center group. The AP team (a configurable email per tenant) receives the rejection with the reason. They can correct the issue and re-trigger approval.
Decision: Allowed, with routing-aware restart logic.
Decision: Route to AP team first, then approve.
If line items have no cost center (AI couldn't determine it), they are routed to the AP team for manual assignment. Once a cost center is assigned, the normal approval flow kicks in.
Decision: Auto-export to DATEV.
When ALL cost center groups for an invoice are fully approved, the system automatically triggers DATEV export (if the tenant has DATEV integration configured). Otherwise, it marks the document as "approved."
Decision: 1 business day reminder, 3 business day escalation.
Decision: Auto-detect OOO via Teams/Outlook calendar.
The system checks Microsoft Graph API for the approver's Out-of-Office status. If OOO, the approval is automatically routed to their pre-configured deputy. The deputy has full authority.
Decision: Anyone involved can cancel.
The AP team or any approver in the chain can cancel an in-flight approval. This returns the invoice to a draft/review state. All pending steps are cancelled and tokens deactivated.
Decision: Tenant-level default, Teams Bot for Teams, email with secure links for email.
Decision: Tokens never expire.
Secure approval tokens remain valid until the approval is completed, recalled, or the invoice is deleted. Tokens are 64-character cryptographically random strings.
Decision: Full data snapshot at each step.
Every approval action stores a complete snapshot of the invoice's structured data at that moment, plus who acted, when, and any comments.
Decision: Not enforced.
No restriction preventing the same person from reviewing and approving an invoice.
Decision: Next level approver, or AP team if last level.
Escalation from a timed-out Level 1 approver goes to Level 2 approvers (with a clear "escalation" label). If the last level times out, it falls back to the AP team for manual resolution.
Decision: Per cost center, explicit.
Each cost center explicitly lists its approver chain (email + display name) for each level. No organizational hierarchy inference.
CREATE TYPE approval_request_status AS ENUM (
'pending', -- Created, waiting for approvers
'active', -- At least one approver has been notified
'approved', -- All required approvers approved
'rejected', -- At least one approver rejected
'recalled', -- Cancelled by AP team or participant
'escalated' -- Timed out and escalated
);
CREATE TYPE approval_step_status AS ENUM (
'pending', -- Not yet acted upon
'approved', -- Approver approved
'rejected', -- Approver rejected
'escalated', -- Timed out, escalated to next level
'recalled', -- Parent request was recalled
'delegated' -- Redirected to deputy (OOO)
);
CREATE TYPE approval_action_type AS ENUM (
'approve', 'reject', 'recall', 'edit',
'escalate', 'delegate', 'remind', 'reassign'
);
CREATE TYPE notification_channel AS ENUM ('teams', 'email');
approval_settings - Tenant-level configurationCREATE TABLE approval_settings (
tenant_id UUID PRIMARY KEY REFERENCES tenant(id) ON DELETE CASCADE,
is_enabled BOOLEAN NOT NULL DEFAULT FALSE,
notification_channel notification_channel NOT NULL DEFAULT 'email',
ap_team_email TEXT NOT NULL,
reminder_after_hours INTEGER NOT NULL DEFAULT 24,
escalation_after_hours INTEGER NOT NULL DEFAULT 72,
teams_bot_config JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
approval_matrix - Per cost center approval rulesCREATE TABLE approval_matrix (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenant(id) ON DELETE CASCADE,
cost_center_number TEXT NOT NULL,
is_default BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, cost_center_number)
);
approval_matrix_tier - Amount thresholdsCREATE TABLE approval_matrix_tier (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
approval_matrix_id UUID NOT NULL REFERENCES approval_matrix(id) ON DELETE CASCADE,
min_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
max_amount NUMERIC(15,2), -- NULL = unlimited
required_levels INTEGER NOT NULL, -- 1-5
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (approval_matrix_id, min_amount)
);
approval_matrix_approver - Approvers per levelCREATE TABLE approval_matrix_approver (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
approval_matrix_id UUID NOT NULL REFERENCES approval_matrix(id) ON DELETE CASCADE,
level INTEGER NOT NULL CHECK (level BETWEEN 1 AND 5),
email TEXT NOT NULL,
display_name TEXT,
deputy_email TEXT,
deputy_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (approval_matrix_id, level, email)
);
approval_request - One per cost center group per documentCREATE TABLE approval_request (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenant(id) ON DELETE CASCADE,
document_id UUID NOT NULL REFERENCES document(id) ON DELETE CASCADE,
cost_center_number TEXT, -- NULL = "no cost center" routed to AP team
group_amount NUMERIC(15,2) NOT NULL,
required_levels INTEGER NOT NULL,
status approval_request_status NOT NULL DEFAULT 'pending',
data_snapshot JSONB NOT NULL, -- Full structured_data at creation
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (document_id, cost_center_number)
);
approval_step - One per approver per requestCREATE TABLE approval_step (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
approval_request_id UUID NOT NULL REFERENCES approval_request(id) ON DELETE CASCADE,
level INTEGER NOT NULL CHECK (level BETWEEN 1 AND 5),
approver_email TEXT NOT NULL,
approver_name TEXT,
status approval_step_status NOT NULL DEFAULT 'pending',
is_deputy BOOLEAN NOT NULL DEFAULT FALSE,
original_approver_email TEXT,
comment TEXT,
data_snapshot JSONB, -- Snapshot at time of decision
decided_at TIMESTAMPTZ,
reminder_sent_at TIMESTAMPTZ,
escalated_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
approval_token - Secure links for external approversCREATE TABLE approval_token (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
approval_step_id UUID NOT NULL REFERENCES approval_step(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE, -- 64-char cryptographically random
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (approval_step_id)
);
approval_audit_log - Immutable append-only audit trailCREATE TABLE approval_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenant(id) ON DELETE CASCADE,
approval_request_id UUID NOT NULL REFERENCES approval_request(id) ON DELETE CASCADE,
approval_step_id UUID REFERENCES approval_step(id) ON DELETE SET NULL,
action approval_action_type NOT NULL,
actor_email TEXT NOT NULL,
actor_name TEXT,
comment TEXT,
metadata JSONB,
data_snapshot JSONB, -- Full invoice state at this moment
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_approval_request_document ON approval_request(document_id);
CREATE INDEX idx_approval_request_tenant_status ON approval_request(tenant_id, status);
CREATE INDEX idx_approval_step_request ON approval_step(approval_request_id);
CREATE INDEX idx_approval_step_pending ON approval_step(status) WHERE status = 'pending';
CREATE INDEX idx_approval_token_token ON approval_token(token);
CREATE INDEX idx_approval_audit_request ON approval_audit_log(approval_request_id);
CREATE INDEX idx_approval_matrix_tenant ON approval_matrix(tenant_id);
-- Auto-update updated_at (reuses existing function)
CREATE TRIGGER update_approval_request_updated_at
BEFORE UPDATE ON approval_request
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_approval_step_updated_at
BEFORE UPDATE ON approval_step
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Notify on approval status changes (for SSE real-time updates)
CREATE OR REPLACE FUNCTION notify_approval_event()
RETURNS TRIGGER AS $$
DECLARE payload jsonb;
BEGIN
payload := jsonb_build_object(
'approval_request/id', NEW.id::text,
'approval_request/status', NEW.status::text,
'document/id', NEW.document_id::text,
'cost_center_number', NEW.cost_center_number,
'old-status', CASE WHEN TG_OP = 'INSERT' THEN NULL ELSE OLD.status::text END
);
PERFORM pg_notify('approval_events', payload::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER approval_request_after_update
AFTER UPDATE ON approval_request
FOR EACH ROW
WHEN (OLD.status IS DISTINCT FROM NEW.status)
EXECUTE FUNCTION notify_approval_event();
-- Tenant approval settings
INSERT INTO approval_settings (tenant_id, is_enabled, notification_channel, ap_team_email)
VALUES ('tenant-uuid', TRUE, 'teams', 'ap-team@customer.com');
-- Matrix for Cost Center 10 (Marketing)
INSERT INTO approval_matrix (id, tenant_id, cost_center_number)
VALUES ('matrix-1', 'tenant-uuid', '10');
-- Tiers
INSERT INTO approval_matrix_tier (approval_matrix_id, min_amount, max_amount, required_levels)
VALUES ('matrix-1', 0, 999.99, 1), -- < EUR 1,000: 1 level
('matrix-1', 1000, 9999.99, 2), -- EUR 1K-10K: 2 levels
('matrix-1', 10000, NULL, 3); -- > EUR 10K: 3 levels
-- Approvers
INSERT INTO approval_matrix_approver (approval_matrix_id, level, email, display_name, deputy_email, deputy_name)
VALUES ('matrix-1', 1, 'john@customer.com', 'John Doe', 'jane@customer.com', 'Jane Smith'),
('matrix-1', 2, 'maria@customer.com', 'Maria Weber', NULL, NULL),
('matrix-1', 3, 'cfo@customer.com', 'Thomas CFO', 'coo@customer.com', 'Anna COO');
-- Default catch-all matrix (for unmapped cost centers)
INSERT INTO approval_matrix (id, tenant_id, cost_center_number, is_default)
VALUES ('matrix-default', 'tenant-uuid', '__default__', TRUE);
+----------+
| pending | (created, matrix evaluated)
+----+-----+
|
notifications sent
|
+----v-----+
+---->| active |<----+
| +----+-----+ |
| | |
routing edit | escalation
restarts group |
| +----+-----+
| | |
| all approved any rejection
| | |
| +---v----+ +---v-----+
| |approved| |rejected |
| +--------+ +---------+
|
| +----------+
+-----|recalled | (anyone involved cancels)
+----------+
| From | To | Trigger |
|---|---|---|
| pending | approved | Approver clicks approve |
| pending | rejected | Approver clicks reject |
| pending | escalated | 3 business day timeout, routed to next level |
| pending | delegated | OOO detected, routed to deputy |
| pending | recalled | Parent request was recalled |
| Condition | Result |
|---|---|
| All CC groups approved | Document = "approved", trigger DATEV export |
| Any CC group rejected | Document shows "needs attention", AP team notified |
| Any CC group recalled | Document returns to "review" state |
| Mixed (some approved, some pending) | Document shows "partially approved" |
The approval flow intercepts the existing ingestion pipeline at the point where DATEV auto-export currently happens.
File: orcha/src/com/getorcha/workers/ingestion.clj (line 434)
;; Current:
(trigger-auto-export! context document)
;; New:
(if (approval/requires-approval? db-pool document)
(approval/initiate-approval! context document)
(trigger-auto-export! context document))
requires-approval? checks if the tenant has approval_settings.is_enabled = true and at least one approval matrix entry.
1. Extract structured_data from completed document
2. Group line items by [:cost-center :number]
3. For each cost center group:
a. Sum line item [:amount] values -> group_amount
b. Query approval_matrix for (tenant_id, cost_center_number)
c. Fall back to is_default=true matrix if no exact match
d. If no matrix at all, skip approval for this group
e. Find matching tier: group_amount BETWEEN min_amount AND max_amount
f. Get required_levels from tier
g. INSERT approval_request (snapshot structured_data)
h. For levels 1..required_levels:
- Query approval_matrix_approver for this level
- INSERT approval_step per approver
- Generate secure token per step
4. For line items with no cost center:
a. Create approval_request with cost_center_number = NULL
b. Create single step assigned to AP team email
5. Dispatch notifications via SQS queue
1. Validate token (is_active, step is pending)
2. UPDATE approval_step SET status='approved', decided_at=now()
3. Store data_snapshot on step
4. INSERT approval_audit_log
5. Check: are ALL steps for this approval_request approved?
-> Yes: UPDATE approval_request SET status='approved'
6. Check: are ALL approval_requests for this document approved?
-> Yes: trigger-auto-export! to DATEV
1. Validate token
2. UPDATE approval_step SET status='rejected', decided_at=now(), comment=reason
3. UPDATE approval_request SET status='rejected'
4. Cancel all other pending steps for this request (set 'recalled')
5. Deactivate all tokens for this request
6. INSERT approval_audit_log
7. Send rejection notification to AP team (with reason + approver info)
1. Verify caller is involved (AP team member or approver on this document)
2. UPDATE approval_request SET status='recalled'
3. UPDATE all pending approval_steps SET status='recalled'
4. Deactivate all tokens
5. INSERT approval_audit_log
6. Notify all participants that approval was cancelled
1. Apply edit to document structured_data (GL account, description)
2. INSERT approval_audit_log with diff in metadata
3. No status change - flow continues
1. Apply edit to document structured_data
2. INSERT approval_audit_log with diff
3. Recall the affected CC group's approval_request
4. Re-run initiation for affected CC groups (re-evaluate matrix)
5. New approval_requests created with fresh steps and tokens
A periodic background job (runs every hour):
1. Query approval_steps WHERE status = 'pending'
2. For each step:
a. Calculate business hours since created_at (exclude weekends)
b. If >= reminder_after_hours AND reminder_sent_at IS NULL:
-> Send reminder notification (same channel as original)
-> UPDATE reminder_sent_at = now()
c. If >= escalation_after_hours AND escalated_at IS NULL:
-> If next level exists in the matrix:
- Create new approval_step at the next level
- Mark current step as 'escalated'
- Send notification to next-level approver with "ESCALATION" label
-> If at last level:
- Notify AP team: "Approval timed out, manual action required"
- Mark step as 'escalated'
-> UPDATE escalated_at = now()
-> INSERT approval_audit_log
A periodic background job (runs every 4 hours, plus on-demand at approval creation):
1. Query pending approval_steps
2. For each step, if tenant has Teams/Outlook integration:
a. Call Microsoft Graph API: GET /users/{email}/mailboxSettings
b. Check automaticRepliesSetting.status
c. If OOO and deputy_email is configured in matrix:
-> Create new approval_step for deputy (is_deputy=true)
-> Mark original step as 'delegated'
-> Generate new token for deputy
-> Send notification to deputy
-> INSERT approval_audit_log
d. If OOO and no deputy configured:
-> Do nothing (let timeout/escalation handle it)
Orcha Backend <--> Azure Bot Service <--> Microsoft Teams
(Bot Framework SDK)
https://api.getorcha.com/webhooks/teams/messagesapproval_settings.teams_bot_config (JSONB){
"type": "AdaptiveCard",
"version": "1.4",
"body": [
{
"type": "TextBlock",
"text": "Invoice Approval Required",
"weight": "Bolder",
"size": "Large"
},
{
"type": "ColumnSet",
"columns": [
{
"type": "Column",
"items": [
{ "type": "TextBlock", "text": "Vendor", "weight": "Bolder" },
{ "type": "TextBlock", "text": "${vendor_name}" }
]
},
{
"type": "Column",
"items": [
{ "type": "TextBlock", "text": "Your Cost Center", "weight": "Bolder" },
{ "type": "TextBlock", "text": "${cost_center} - ${cost_center_name}" }
]
}
]
},
{
"type": "FactSet",
"facts": [
{ "title": "Invoice #", "value": "${invoice_number}" },
{ "title": "Amount (your CC)", "value": "${group_amount} ${currency}" },
{ "title": "Total Invoice", "value": "${total_amount} ${currency}" },
{ "title": "Due Date", "value": "${due_date}" }
]
},
{
"type": "TextBlock",
"text": "Line Items",
"weight": "Bolder",
"separator": true
},
{
"type": "Table",
"columns": [
{ "header": "Description" },
{ "header": "Quantity" },
{ "header": "Amount" }
],
"rows": "${line_items_rows}"
},
{
"type": "ActionSet",
"actions": [
{ "type": "Action.OpenUrl", "title": "View Original PDF", "url": "${pdf_url}" }
]
}
],
"actions": [
{
"type": "Action.Execute",
"title": "Approve",
"verb": "approve",
"style": "positive",
"data": { "token": "${token}", "step_id": "${step_id}" }
},
{
"type": "Action.ShowCard",
"title": "Reject",
"card": {
"type": "AdaptiveCard",
"body": [
{
"type": "Input.Text",
"id": "comment",
"placeholder": "Please provide a reason for rejection...",
"isMultiline": true,
"isRequired": true
}
],
"actions": [
{
"type": "Action.Execute",
"title": "Submit Rejection",
"verb": "reject",
"style": "destructive",
"data": { "token": "${token}", "step_id": "${step_id}" }
}
]
}
}
]
}
Same as above but with a prominent banner:
{
"type": "Container",
"style": "attention",
"items": [{
"type": "TextBlock",
"text": "ESCALATION: This approval was escalated because the original approver (${original_approver}) did not respond within ${escalation_hours} hours.",
"weight": "Bolder",
"color": "Attention",
"wrap": true
}]
}
GET https://graph.microsoft.com/v1.0/users/{email}/mailboxSettings
Response:
{
"automaticRepliesSetting": {
"status": "scheduled", // "disabled", "alwaysEnabled", "scheduled"
"scheduledStartDateTime": {...},
"scheduledEndDateTime": {...}
}
}
Reuses existing Outlook OAuth credentials from the tenant's email integration.
POST /webhooks/teams/messages
Handles Bot Framework invoke activities for Adaptive Card Action.Execute responses.
Sent via AWS SES (already in the AWS ecosystem). HTML email contains:
https://app.getorcha.com/approve/{token}?action=approvehttps://app.getorcha.com/approve/{token}?action=rejecthttps://app.getorcha.com/approve/{token}
pgcrypto)A minimal Hiccup-rendered page (matches existing ERP UI style):
Content:
If ?action=approve: Pre-selects approve, shows confirmation dialog.
If ?action=reject: Pre-selects reject, shows comment field.
GET /admin/tenants/{id}/approval-settings
PUT /admin/tenants/{id}/approval-settings
GET /admin/tenants/{id}/approval-matrix
POST /admin/tenants/{id}/approval-matrix
PUT /admin/tenants/{id}/approval-matrix/{matrix-id}
DELETE /admin/tenants/{id}/approval-matrix/{matrix-id}
GET /documents/{id}/approvals -- All approval requests for a document
POST /documents/{id}/approvals/{req-id}/recall -- Recall an approval
POST /approvals/{step-id}/approve -- In-app approve
POST /approvals/{step-id}/reject -- In-app reject
GET /approve/{token} -- External approval landing page
POST /approve/{token}/approve -- External approve action
POST /approve/{token}/reject -- External reject action
POST /webhooks/teams/messages -- Teams Bot Framework callback
| File | Change |
|---|---|
orcha/src/com/getorcha/workers/ingestion.clj |
Line 434: conditional routing to approval or direct DATEV export |
orcha/src/com/getorcha/erp/ingestion.clj |
Add :approval event variant to DocumentEvent multi-dispatch (line 199) |
orcha/src/com/getorcha/erp/http.clj |
Register approval routes (authenticated + unauthenticated) |
orcha/src/com/getorcha/system.clj |
Register new Integrant components |
orcha/resources/com/getorcha/config.edn |
Approval config (SQS queues, Teams bot creds, SES config) |
| File | Purpose |
|---|---|
src/com/getorcha/approval/engine.clj |
Core logic: initiation, response handling, state transitions |
src/com/getorcha/approval/matrix.clj |
Matrix evaluation: CC grouping, tier matching, approver lookup |
src/com/getorcha/approval/notifications.clj |
Teams + email notification dispatch |
src/com/getorcha/approval/teams.clj |
Teams Bot client: Adaptive Cards, Graph API for OOO |
src/com/getorcha/approval/tokens.clj |
Token generation, validation, deactivation |
src/com/getorcha/approval/scheduler.clj |
Reminder + escalation background worker |
src/com/getorcha/approval/ooo.clj |
OOO detection via Microsoft Graph API |
src/com/getorcha/erp/http/approvals.clj |
HTTP routes for approval endpoints (ERP + external) |
src/com/getorcha/admin/http/approval_settings.clj |
Admin routes for matrix/settings CRUD |
src/com/getorcha/schema/approval.clj |
Malli schemas for approval entities |
resources/migrations/002_approval_workflow.sql |
Database migration with all tables, enums, indexes, triggers |
requires-approval?, initiate-approval!