Approval Workflow Feature Specification

Overview

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.


1. Requirements & Decisions

1.1 Approval Unit

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.

1.2 Trigger

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.

1.3 Approver Identity

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.

1.4 Configuration

Decision: Admin-managed initially.

The Orcha admin team configures approval matrices per tenant. Tenant self-service UI is a future enhancement.

1.5 Decision Matrix Structure

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

1.6 Parallel vs Serial

Decision: Always parallel.

All required approvers across all required levels are notified simultaneously. All must approve. There is no sequential ordering between levels.

1.7 Rejection Handling

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.

1.8 Edits During Approval

Decision: Allowed, with routing-aware restart logic.

1.9 Missing Cost Center

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.

1.10 Post-Approval Action

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

1.11 Timeouts & Escalation

Decision: 1 business day reminder, 3 business day escalation.

1.12 Delegation

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.

1.13 Recall

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.

1.14 Notification Channels

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.

1.16 Audit Trail

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.

1.17 Four-Eyes Principle

Decision: Not enforced.

No restriction preventing the same person from reviewing and approving an invoice.

1.18 Escalation Target

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.

1.19 Approver Mapping

Decision: Per cost center, explicit.

Each cost center explicitly lists its approver chain (email + display name) for each level. No organizational hierarchy inference.


2. Database Schema

2.1 New Enum Types

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

2.2 Tables

approval_settings - Tenant-level configuration

CREATE 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 rules

CREATE 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 thresholds

CREATE 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 level

CREATE 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 document

CREATE 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 request

CREATE 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()
);
CREATE 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 trail

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

2.3 Indexes

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

2.4 Triggers

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

2.5 Example Data

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

3. State Machine

3.1 Approval Request States

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

3.2 Approval Step States

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

3.3 Document-Level Aggregation

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"

4. Flow Engine

4.1 Trigger Point

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.

4.2 Approval Initiation

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

4.3 Response Handling

On Approve

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

On Reject

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)

On Recall

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

On Edit (Non-Routing)

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

On Edit (Routing - CC or Amount Change)

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

4.4 Timeout & Reminder Worker

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

4.5 OOO Detection Worker

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)

5. Teams Bot Integration

5.1 Architecture

Orcha Backend  <-->  Azure Bot Service  <-->  Microsoft Teams
                     (Bot Framework SDK)

5.2 Setup Requirements

  1. Register Azure AD application
  2. Create Bot Channel Registration in Azure
  3. Configure messaging endpoint: https://api.getorcha.com/webhooks/teams/messages
  4. Publish bot to customer Teams tenant (admin consent per tenant)
  5. Store bot credentials in approval_settings.teams_bot_config (JSONB)

5.3 Adaptive Card Design

{
  "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}" }
          }
        ]
      }
    }
  ]
}

5.4 Escalation Card Variant

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

5.5 OOO Detection via Graph API

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.

5.6 Webhook Endpoint

POST /webhooks/teams/messages

Handles Bot Framework invoke activities for Adaptive Card Action.Execute responses.


6. Email Approval Integration

6.1 Outbound Email

Sent via AWS SES (already in the AWS ecosystem). HTML email contains:

6.2 Secure Approval URL

https://app.getorcha.com/approve/{token}

6.3 External Approval Landing Page

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.


7. API Design

7.1 Admin Endpoints

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}

7.2 ERP Endpoints (Authenticated)

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

7.3 Unauthenticated Endpoints

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

8. Integration Points

8.1 Files to Modify

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)

8.2 New Files to Create

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

9. Implementation Phases

Phase 1 - MVP: Core Approval Flow via Email

  1. Database migration (all tables, enums, indexes, triggers)
  2. Malli schemas for approval entities
  3. Approval matrix data model + admin CRUD endpoints
  4. Core engine: requires-approval?, initiate-approval!
  5. CC grouping logic + tier evaluation
  6. Approval step creation + token generation
  7. Email notifications via AWS SES (summary + PDF)
  8. External approval landing page (Hiccup)
  9. Response handling: approve, reject, recall
  10. Status aggregation: all groups approved -> DATEV export
  11. Approval status display on document detail page
  12. Audit log writing (full snapshots)
  13. SSE integration for real-time approval status updates
  14. Integration with ingestion pipeline (conditional routing)

Phase 2 - Reminders & Escalation

  1. Reminder scheduler background worker
  2. Escalation logic (next level or AP team fallback)
  3. Business day calculation (weekend exclusion)
  4. Escalation notification templates

Phase 3 - Teams Bot

  1. Azure Bot registration + authentication
  2. Adaptive Card templates (approval, escalation, reminder)
  3. Teams webhook handler for card action responses
  4. Teams notification dispatch
  5. Tenant channel configuration switch

Phase 4 - OOO & Delegation

  1. Microsoft Graph API integration for mailbox settings
  2. Deputy routing logic
  3. OOO polling worker (periodic + on-demand)
  4. Delegation audit trail

Phase 5 - Advanced Features

  1. Approve-with-edits: non-routing edits (GL, description)
  2. Approve-with-edits: routing edits (CC, amount) with chain restart
  3. Document detail UI showing full approval timeline
  4. Admin dashboard: approval analytics (avg time, bottlenecks)
  5. (Future) Tenant self-service configuration UI

10. Testing Strategy

Unit Tests

Integration Tests (Testcontainers)

Manual E2E Testing

  1. Configure approval matrix for test tenant via admin endpoints
  2. Upload invoice with multiple cost centers via email or manual upload
  3. Verify approval requests created after extraction completes
  4. Open approval email, verify summary + PDF + buttons
  5. Click approve link, verify landing page, approve
  6. Approve all steps across all CC groups, verify DATEV export triggers
  7. Test rejection: upload another invoice, reject one step, verify AP team receives notification
  8. Test recall: start approval, recall it, verify all steps cancelled and tokens deactivated
  9. Test escalation: let an approval sit for 3+ business days (or adjust timer), verify escalation

Teams Testing