Reimbursement Routing & Approval Sync for Auditable Payout Pipelines

Once an expense record clears validation, the hardest engineering problem is not deciding whether to pay it — it is moving that cleared record through an approval chain, into the general ledger, and out to the employee’s bank account exactly once, with a defensible trail at every hop. Reimbursement Routing & Approval Sync is the settlement stage of the expense-audit stack: it consumes the cleared decisions emitted by Automated Policy Validation & Anomaly Flagging, drives each record through a deterministic approval state machine, posts it to the ERP or general ledger, batches the payout, and reconciles the resulting transactions against bank settlement files. For AP managers, corporate travel teams, and Python automation builders, this is where money actually leaves the company, so the tolerance for double-payment, lost approvals, and unreconciled batches is effectively zero. The engineering objective is exactly-once settlement: every cleared record advances through a documented sequence of states, every transition is retry-safe, and every posting is reconcilable back to a signed approval and forward to a bank line item.

Manual routing loses reports in email threads, pays some twice when a batch is re-run, and reconciles bank statements by hand days after settlement. Deterministic routing treats the reimbursement lifecycle as an explicit state machine with an append-only history, keys every side effect to an idempotency token so retries are safe, and posts to downstream systems through an outbox so a crashed worker never drops or duplicates a payment. This page defines the data model, the ordered lifecycle, the core routing engine, and the auditability and failure-handling patterns that make exactly-once payout hold under enterprise load. It draws its category and cost-center contracts from Core Policy Architecture & Taxonomy Design and its source documents from Receipt Ingestion & OCR Data Extraction, and it delegates its four specialist concerns to dedicated guides: Approval Chain Routing, ERP Export Synchronization, Payment Batch Reconciliation, and Approval SLA Monitoring.

Reimbursement routing and approval-sync stage overview A validated expense record enters a deterministic approval state machine that advances it through SUBMITTED, PENDING_APPROVAL, APPROVED, EXPORTED and PAID, with REJECTED and ON_HOLD branches. Each transition writes to an append-only, SHA-256-chained ledger and enqueues an outbox row. The outbox drives an idempotent ERP export and a payment batch, and a reconciliation loop matches bank settlement back to the ledger. cleared record outbox row Cleared Record PASS decision idempotency key Approval State Machine deterministic · retry-safe transitions SUBMITTED PENDING_APPROVAL APPROVED EXPORTED PAID REJECTED terminal ON_HOLD resumable Append-Only Ledger SHA-256 chained transition history Transactional Outbox one row per side effect drained exactly once ERP / GL Post journal entry Batch + Reconcile bank settlement match confirm → PAID
Routing stage overview: a cleared record advances through a deterministic approval state machine, every transition chains into a tamper-evident ledger and an outbox, and the outbox drives idempotent ERP posting, batched payout, and bank reconciliation that confirms the terminal PAID state.

Foundational Architecture & Data Modeling

The routing stage inherits a validated decision and must never re-litigate it; its job is to attach lifecycle state and settlement metadata to that decision and move it forward without loss or duplication. The data model therefore separates three concerns cleanly: the immutable payout instruction (who is owed how much, against which cost center), the mutable lifecycle position (which state the record currently occupies), and the append-only history of how it got there. Modelling these as distinct types keeps the state machine pure — it maps a current state plus an event to a next state — while the payout instruction stays frozen and hashable so it can serve as a stable idempotency anchor across retries.

Money is stored as an integer count of minor units, exactly as upstream stages store it, so that batch totals and bank-settlement comparisons never accumulate floating-point drift. A single mis-rounded cent in a thousand-line batch is enough to make a reconciliation fail to balance, so the Decimal conversion exists only for display. The idempotency key is derived deterministically from the cleared decision — the expense id joined with the policy manifest hash that approved it — so that the same cleared record always produces the same key, and a duplicate delivery from an at-least-once queue collapses onto the existing lifecycle rather than starting a second one.

from __future__ import annotations

import hashlib
import logging
from datetime import date, datetime, timezone
from decimal import Decimal
from enum import Enum
from typing import Optional

from pydantic import BaseModel, ConfigDict, Field, field_validator

logger = logging.getLogger("reimbursement.model")


class ReimbursementState(str, Enum):
    """Lifecycle positions for a cleared expense moving toward payout."""

    SUBMITTED = "submitted"
    PENDING_APPROVAL = "pending_approval"
    APPROVED = "approved"
    EXPORTED = "exported"
    PAID = "paid"
    REJECTED = "rejected"
    ON_HOLD = "on_hold"


class PayoutInstruction(BaseModel):
    """Immutable settlement instruction derived from a cleared decision.

    Frozen so it is hashable and safe to reuse as the stable anchor for an
    idempotency key. Money stays in minor units; the Decimal amount is derived
    only for human-facing display and reconciliation reports.
    """

    model_config = ConfigDict(frozen=True, extra="forbid")

    expense_id: str
    employee_id: str
    cost_center: str
    gl_account: str = Field(..., pattern=r"^\d{4,10}$")
    amount_minor: int = Field(..., ge=0, description="Payout amount in cents")
    currency: str = Field(..., min_length=3, max_length=3)
    cleared_on: date
    policy_manifest_hash: str = Field(..., min_length=64, max_length=64)

    @field_validator("currency")
    @classmethod
    def _upper(cls, value: str) -> str:
        return value.upper()

    @property
    def amount(self) -> Decimal:
        return Decimal(self.amount_minor) / 100

    def idempotency_key(self) -> str:
        """Deterministic key: same cleared decision -> same key, always."""
        seed = f"{self.expense_id}:{self.policy_manifest_hash}"
        return hashlib.sha256(seed.encode("utf-8")).hexdigest()


class ReimbursementRecord(BaseModel):
    """Mutable lifecycle envelope wrapping an immutable instruction."""

    model_config = ConfigDict(extra="forbid")

    instruction: PayoutInstruction
    state: ReimbursementState = ReimbursementState.SUBMITTED
    assigned_approver: Optional[str] = None
    updated_at: datetime = Field(
        default_factory=lambda: datetime.now(timezone.utc)
    )


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO, format="%(levelname)s | %(message)s")
    instr = PayoutInstruction(
        expense_id="EXP-9001",
        employee_id="EMP-204",
        cost_center="CC-620",
        gl_account="600120",
        amount_minor=48250,
        currency="usd",
        cleared_on=date(2026, 7, 10),
        policy_manifest_hash="a" * 64,
    )
    logger.info("payout %s key=%s", instr.expense_id, instr.idempotency_key()[:12])

Records that arrive without a matching cleared decision — a mismatched manifest hash, an unknown cost center, or a stale policy version — never enter the state machine. They are rejected at the boundary and returned to the validation stage, so the routing engine only ever operates on decisions it can trust. This boundary contract is what lets the rest of the stage assume its input is already policy-correct and focus entirely on movement and settlement.

Pipeline Stage Map

Routing is a strictly ordered lifecycle, and each stage owns one class of settlement failure. Enforcing the order in code prevents the most damaging production defect in payout systems: exporting or paying a record whose approval has not actually been recorded, or paying one that was already paid in a previous run. Every transition is guarded, every side effect is deferred to an outbox, and the terminal PAID state is only reached when a bank settlement line confirms the money landed.

Stage Responsibility Owns failure mode On failure
1. Intake Wrap a cleared decision in a ReimbursementRecord Invalid or replayed input Reject at boundary, return to validation
2. Route Resolve the approval chain and assign the next approver Mis-routed or orphaned approvals Escalate per the routing guide, log gap
3. Decide Record APPROVED, REJECTED, or ON_HOLD from an approver Lost or ambiguous decisions Guarded transition; illegal moves rejected
4. Export Post the approved record to the ERP / general ledger Duplicate or dropped postings Idempotent outbox drain, retry-safe
5. Pay Assemble the payout batch and submit to the bank Double payment, split batches Batch keyed by run id; one row per instruction
6. Reconcile Match settlement lines back to ledger entries Silent unreconciled drift Fail closed; hold batch until matched
Six-stage reimbursement lifecycle with per-stage failure ownership Six ordered stages — Intake, Route, Decide, Export, Pay and Reconcile — connected left to right. Each stage card names the settlement failure mode it owns and the fallback action it takes on failure, so a failing stage holds the record rather than paying a half-settled or duplicate record downstream. Ordered reimbursement lifecycle 1 Intake OWNS Replayed input ON FAIL → return to validation 2 Route OWNS Orphaned approvals ON FAIL → escalate + log gap 3 Decide OWNS Lost decisions ON FAIL → reject illegal transition 4 Export OWNS Dropped postings ON FAIL → retry-safe outbox drain 5 Pay OWNS Double payment ON FAIL → one row per instruction 6 Reconcile OWNS Unreconciled drift ON FAIL → fail closed — hold batch Each stage owns one settlement failure mode and holds the record on failure — the lifecycle never pays a half-settled or duplicate record downstream.
Lifecycle stage map: the six ordered stages, each labelled with the settlement failure it owns and the fallback lane it holds the record in on failure.

A subtle ordering constraint runs through the whole lifecycle: the outbox drainer must be at-least-once and idempotent, but it must never run ahead of the ledger commit that authorized it. In practice that means the drainer reads only rows whose backing transition is already durable, dispatches them, and marks them complete after the downstream acknowledgement — never before. If the drainer crashes after dispatch but before marking, the redelivered row carries the same idempotency key, so the ERP or bank absorbs it as a duplicate rather than posting twice. The stage map’s left-to-right order is therefore not merely didactic; it is the exact order in which durability is established, and inverting any two stages — paying before the export is durable, or reconciling before the batch is sealed — reintroduces precisely the double-payment and lost-approval defects the design exists to prevent.

The approval routing itself — how a record’s chain of approvers is resolved from cost-center hierarchy, delegation rules, and out-of-office fallbacks — is deep enough to warrant its own treatment in Approval Chain Routing. This stage assumes the chain resolver returns the next approver for a given record and focuses on the guarantee that every decision that resolver produces is recorded exactly once and advances the state machine legally.

Core Engine

The heart of the stage is a deterministic approval state machine paired with an idempotent transition function. The state machine defines exactly which moves are legal — you cannot pay a record that was never approved, and you cannot approve one that was already rejected — so illegal events are refused rather than silently applied. Every accepted transition does three things atomically: it advances the record’s state, it appends an immutable entry to the audit ledger, and it enqueues an outbox row for any external side effect the new state requires. Because the transition is keyed by an idempotency token, replaying the same event is a no-op that returns the already-recorded outcome instead of double-applying it.

The legal-transition table is the contract. SUBMITTED may move to PENDING_APPROVAL; PENDING_APPROVAL may move to APPROVED, REJECTED, or ON_HOLD; APPROVED moves to EXPORTED; EXPORTED moves to PAID; and ON_HOLD resumes to PENDING_APPROVAL. PAID and REJECTED are terminal. Encoding this as data — not as scattered if branches — means the same table drives both the runtime guard and the documentation an auditor reads, and adding a state is a single-line change reviewed like any other configuration.

from __future__ import annotations

import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from typing import Dict, Optional, Set, Tuple

logger = logging.getLogger("reimbursement.engine")


class ReimbursementState(str, Enum):
    SUBMITTED = "submitted"
    PENDING_APPROVAL = "pending_approval"
    APPROVED = "approved"
    EXPORTED = "exported"
    PAID = "paid"
    REJECTED = "rejected"
    ON_HOLD = "on_hold"


class TransitionEvent(str, Enum):
    ROUTE = "route"
    APPROVE = "approve"
    REJECT = "reject"
    HOLD = "hold"
    RESUME = "resume"
    EXPORT = "export"
    CONFIRM_PAID = "confirm_paid"


# (from_state, event) -> to_state. Absence of a key means the move is illegal.
LEGAL_TRANSITIONS: Dict[Tuple[ReimbursementState, TransitionEvent], ReimbursementState] = {
    (ReimbursementState.SUBMITTED, TransitionEvent.ROUTE): ReimbursementState.PENDING_APPROVAL,
    (ReimbursementState.PENDING_APPROVAL, TransitionEvent.APPROVE): ReimbursementState.APPROVED,
    (ReimbursementState.PENDING_APPROVAL, TransitionEvent.REJECT): ReimbursementState.REJECTED,
    (ReimbursementState.PENDING_APPROVAL, TransitionEvent.HOLD): ReimbursementState.ON_HOLD,
    (ReimbursementState.ON_HOLD, TransitionEvent.RESUME): ReimbursementState.PENDING_APPROVAL,
    (ReimbursementState.APPROVED, TransitionEvent.EXPORT): ReimbursementState.EXPORTED,
    (ReimbursementState.EXPORTED, TransitionEvent.CONFIRM_PAID): ReimbursementState.PAID,
}

TERMINAL_STATES: Set[ReimbursementState] = {
    ReimbursementState.PAID,
    ReimbursementState.REJECTED,
}


class IllegalTransition(Exception):
    """Raised when an event is not permitted from the current state."""


@dataclass(frozen=True)
class TransitionOutcome:
    """Result of applying an event: the resulting state and audit metadata."""

    expense_id: str
    from_state: ReimbursementState
    to_state: ReimbursementState
    event: TransitionEvent
    actor: str
    idempotency_key: str
    replayed: bool
    ledger_head: str
    timestamp: str


class ApprovalStateMachine:
    """Deterministic, idempotent driver for the reimbursement lifecycle.

    Each transition is keyed by an idempotency token so that a retried event
    returns the previously recorded outcome instead of applying it twice. Every
    accepted move appends a ledger entry and enqueues an outbox row, keeping the
    state change, the audit trail, and the external side effect in lockstep.
    """

    def __init__(self, ledger: "AppendOnlyLedger", outbox: "Outbox") -> None:
        self._ledger = ledger
        self._outbox = outbox
        # Committed idempotency key -> the outcome it produced.
        self._applied: Dict[str, TransitionOutcome] = {}

    def next_state(
        self, current: ReimbursementState, event: TransitionEvent
    ) -> Optional[ReimbursementState]:
        return LEGAL_TRANSITIONS.get((current, event))

    def apply(
        self,
        record: "ReimbursementRecord",
        event: TransitionEvent,
        actor: str,
        idempotency_key: str,
    ) -> TransitionOutcome:
        # Exactly-once guard: a replayed event returns the stored outcome.
        prior = self._applied.get(idempotency_key)
        if prior is not None:
            logger.info(
                "replay ignored: %s event=%s key=%s",
                record.instruction.expense_id, event.value, idempotency_key[:12],
            )
            return prior

        current = record.state
        if current in TERMINAL_STATES:
            raise IllegalTransition(
                f"{record.instruction.expense_id} is terminal in {current.value}"
            )

        target = self.next_state(current, event)
        if target is None:
            raise IllegalTransition(
                f"{event.value} not permitted from {current.value} "
                f"for {record.instruction.expense_id}"
            )

        now = datetime.now(timezone.utc).isoformat()
        head = self._ledger.append(
            expense_id=record.instruction.expense_id,
            from_state=current.value,
            to_state=target.value,
            event=event.value,
            actor=actor,
            idempotency_key=idempotency_key,
        )

        # Side effects that must happen exactly once are deferred to the outbox,
        # committed in the same logical unit as the state change and ledger row.
        if target is ReimbursementState.EXPORTED:
            self._outbox.enqueue(
                kind="erp_export",
                expense_id=record.instruction.expense_id,
                idempotency_key=idempotency_key,
            )
        elif target is ReimbursementState.APPROVED:
            self._outbox.enqueue(
                kind="notify_payable",
                expense_id=record.instruction.expense_id,
                idempotency_key=idempotency_key,
            )

        record.state = target
        record.updated_at = datetime.now(timezone.utc)

        outcome = TransitionOutcome(
            expense_id=record.instruction.expense_id,
            from_state=current,
            to_state=target,
            event=event,
            actor=actor,
            idempotency_key=idempotency_key,
            replayed=False,
            ledger_head=head,
            timestamp=now,
        )
        self._applied[idempotency_key] = outcome
        logger.info(
            "%s: %s -> %s by %s",
            record.instruction.expense_id, current.value, target.value, actor,
        )
        return outcome

The engine never mutates state before the ledger write succeeds, and it never enqueues a side effect outside the same guarded path, so a crash between the ledger append and the state update leaves the record recoverable: on restart the idempotency key is either present (the transition committed) or absent (it did not), with no partial in-between. This is the property that makes the whole stage retry-safe. A worker can die mid-batch, the queue can redeliver, and the outcome is identical to a clean single run.

Deriving the per-event idempotency key correctly is what makes exactly-once real rather than aspirational. For an approver decision, the key binds the record’s instruction key to the specific decision event and the approver, so the same approver clicking “approve” twice is one transition, but a genuine later event — a resume after hold — is a distinct key. The EXPORT and CONFIRM_PAID events reuse the instruction’s own idempotency key so a redelivered settlement confirmation can never move a record to PAID twice.

from __future__ import annotations

import hashlib
from typing import List


def event_idempotency_key(
    instruction_key: str, event: str, actor: str, sequence: int
) -> str:
    """Bind an instruction to a specific decision event so retries collapse."""
    seed = f"{instruction_key}|{event}|{actor}|{sequence}"
    return hashlib.sha256(seed.encode("utf-8")).hexdigest()


def drive_to_export(
    machine: "ApprovalStateMachine",
    record: "ReimbursementRecord",
    approver: str,
) -> List[str]:
    """Route, approve, then export a record; return the ledger heads produced."""
    from_engine = record.instruction.idempotency_key()
    heads: List[str] = []
    steps = [
        (TransitionEvent.ROUTE, "system", 0),
        (TransitionEvent.APPROVE, approver, 1),
        (TransitionEvent.EXPORT, "system", 2),
    ]
    for event, actor, seq in steps:
        key = event_idempotency_key(from_engine, event.value, actor, seq)
        outcome = machine.apply(record, event, actor, key)
        heads.append(outcome.ledger_head)
    return heads

Because the transition table is the single source of truth, the SLA-monitoring concern — detecting a record that has been stuck in PENDING_APPROVAL or ON_HOLD past its allowed window — reads directly off the same ledger history rather than a parallel tracking table, which is why that timing logic is factored out into Approval SLA Monitoring rather than tangled into the engine.

Auditability, Versioning & Compliance

SOX-grade controls require that every movement of money be traceable to a specific approver, a specific timestamp, and the specific policy manifest under which the record cleared. The stage achieves this with an append-only ledger whose entries are chained by SHA-256: each entry embeds the digest of its predecessor, so any retroactive edit to an approval history breaks the chain and is caught on verification. Because the ledger records every transition — not just the final state — an auditor can replay precisely who approved a reimbursement, when it was exported, and when settlement confirmed it, with no gaps to explain.

The same chained structure underpins the outbox. A transactional outbox row is written in the same unit of work as the ledger entry and the state change, and a separate drainer marks each row dispatched only after the downstream system acknowledges. This is the classic pattern that gives exactly-once semantics on top of at-least-once transports: the ledger proves the transition happened, and the outbox proves the side effect was dispatched once and only once.

from __future__ import annotations

import hashlib
import json
import logging
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Dict, List

logger = logging.getLogger("reimbursement.ledger")

GENESIS_HASH = "0" * 64


@dataclass(frozen=True)
class LedgerEntry:
    expense_id: str
    from_state: str
    to_state: str
    event: str
    actor: str
    idempotency_key: str
    prev_hash: str
    timestamp: str = field(
        default_factory=lambda: datetime.now(timezone.utc).isoformat()
    )

    def digest(self) -> str:
        payload = json.dumps(asdict(self), sort_keys=True, default=str)
        return hashlib.sha256(payload.encode("utf-8")).hexdigest()


class AppendOnlyLedger:
    """Tamper-evident transition history; each entry chains to the prior digest."""

    def __init__(self) -> None:
        self._entries: List[LedgerEntry] = []
        self._head = GENESIS_HASH

    def append(
        self,
        expense_id: str,
        from_state: str,
        to_state: str,
        event: str,
        actor: str,
        idempotency_key: str,
    ) -> str:
        entry = LedgerEntry(
            expense_id=expense_id,
            from_state=from_state,
            to_state=to_state,
            event=event,
            actor=actor,
            idempotency_key=idempotency_key,
            prev_hash=self._head,
        )
        self._head = entry.digest()
        self._entries.append(entry)
        return self._head

    def verify(self) -> bool:
        prev = GENESIS_HASH
        for entry in self._entries:
            if entry.prev_hash != prev:
                logger.error("chain break at %s", entry.expense_id)
                return False
            prev = entry.digest()
        return True


@dataclass
class OutboxRow:
    kind: str
    expense_id: str
    idempotency_key: str
    dispatched: bool = False


class Outbox:
    """At-least-once transport made exactly-once by the idempotency key."""

    def __init__(self) -> None:
        self._rows: Dict[str, OutboxRow] = {}

    def enqueue(self, kind: str, expense_id: str, idempotency_key: str) -> None:
        composite = f"{kind}:{idempotency_key}"
        if composite in self._rows:
            logger.info("outbox dedupe: %s", composite[:24])
            return  # duplicate side effect suppressed before dispatch
        self._rows[composite] = OutboxRow(kind, expense_id, idempotency_key)

    def drain(self) -> List[OutboxRow]:
        pending = [r for r in self._rows.values() if not r.dispatched]
        for row in pending:
            row.dispatched = True  # mark only after downstream ack in production
        return pending


def batch_manifest(instruction_keys: List[str]) -> str:
    """Point-in-time hash binding an approval batch to its exact membership."""
    canonical = json.dumps(sorted(instruction_keys), sort_keys=True)
    return hashlib.sha256(canonical.encode("utf-8")).hexdigest()

Storing the batch manifest hash alongside the ledger head at run close gives external auditors a single pair of hashes that proves both the exact set of reimbursements in a payout run and the unaltered transition history behind them. The handling of the sensitive banking and employee data these records carry — retention windows, field-level redaction, access boundaries — is governed cross-cutting by Security & Compliance Boundaries, and the discipline for changing the routing rules themselves without disrupting reimbursements already in flight follows Policy Versioning & Rollout.

Integration & Operational Readiness

The state machine is designed to run as a stateless worker: the ledger, outbox, and record store are injected, never held in module globals, so any invocation on any host produces the same result and the fleet scales horizontally. Downstream systems — SAP Concur, Workday, Oracle NetSuite — are reached only through the outbox, never by a direct call inside a transition, so a slow or unavailable ERP can never wedge the state machine or cause a partial transition. The drainer retries dispatch with backoff, and because each side effect carries the record’s idempotency key, a duplicate dispatch after a timeout is absorbed by the target rather than posting a second journal entry.

Operationally, the drainer wraps every downstream call in a circuit breaker and retries with exponential backoff and jitter, so a NetSuite rate limit or a transient Workday timeout degrades to a delayed dispatch rather than a wedged run. Because the state machine and the drainer are decoupled, a downstream outage stalls only the outbox, not the approval flow: reviewers keep approving, records keep reaching APPROVED and EXPORTED, and the queued side effects drain automatically once the ERP recovers. This separation is what lets a payout run survive the exact conditions — month-end ERP contention, banking-window cutoffs — under which a synchronous, call-inline design would either block every approver or spray partial postings across a half-open connection.

Mapping a cleared record onto a specific ERP’s data model — the account codes, tax lines, and vendor references a NetSuite journal entry or a Workday spend record requires — is its own detailed concern handled in ERP Export Synchronization. The reciprocal concern, matching the resulting bank settlement lines back against the batch and handling partial or failed payments, is the subject of Payment Batch Reconciliation. The block below wires the model, engine, ledger, and outbox into one end-to-end run suitable for a queue consumer, then confirms settlement to reach the terminal state.

from __future__ import annotations

import json
import logging
from datetime import date
from typing import Dict, List, Sequence

logger = logging.getLogger("reimbursement.pipeline")


def run_reimbursement_run(
    instructions: Sequence["PayoutInstruction"],
    approver: str,
) -> List[Dict]:
    """Drive cleared records to EXPORTED, then confirm settlement to PAID."""
    ledger = AppendOnlyLedger()
    outbox = Outbox()
    machine = ApprovalStateMachine(ledger, outbox)
    report: List[Dict] = []

    records: Dict[str, "ReimbursementRecord"] = {}
    for instr in instructions:
        rec = ReimbursementRecord(instruction=instr)
        records[instr.expense_id] = rec
        drive_to_export(machine, rec, approver)

    # Simulate the bank settlement callback confirming each exported payout.
    batch = batch_manifest([i.idempotency_key() for i in instructions])
    for expense_id, rec in records.items():
        settle_key = event_idempotency_key(
            rec.instruction.idempotency_key(), "confirm_paid", "bank", 3
        )
        outcome = machine.apply(
            rec, TransitionEvent.CONFIRM_PAID, "bank", settle_key
        )
        report.append(
            {
                "expense_id": expense_id,
                "final_state": outcome.to_state.value,
                "ledger_head": outcome.ledger_head,
                "batch_manifest": batch,
            }
        )

    assert ledger.verify(), "reimbursement ledger integrity check failed"
    pending = outbox.drain()
    logger.info("dispatched %d outbox side effects", len(pending))
    return report


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO, format="%(levelname)s | %(message)s")
    fixtures = [
        PayoutInstruction(
            expense_id="EXP-9001", employee_id="EMP-204", cost_center="CC-620",
            gl_account="600120", amount_minor=48250, currency="USD",
            cleared_on=date(2026, 7, 10), policy_manifest_hash="a" * 64,
        ),
        PayoutInstruction(
            expense_id="EXP-9002", employee_id="EMP-208", cost_center="CC-620",
            gl_account="600120", amount_minor=131900, currency="USD",
            cleared_on=date(2026, 7, 11), policy_manifest_hash="a" * 64,
        ),
    ]
    print(json.dumps(run_reimbursement_run(fixtures, approver="MGR-77"), indent=2))

Policy definitions for the routing layer — approval thresholds, delegation maps, SLA windows — are treated as configuration-as-code: they live in version control, carry a semantic version, and require peer review before promotion, exactly like the taxonomy artifacts in Core Policy Architecture & Taxonomy Design. Because those definitions and the input records both originate from stages already under source control — including the documents surfaced by Receipt Ingestion & OCR Data Extraction — the entire path from receipt to bank settlement is reproducible from versioned inputs, which is exactly what an external reviewer needs to re-derive any payout.

Failure Modes & Edge Cases

Payout pipelines fail in predictable ways, and each mode has a named root cause and a specific mitigation. The table below is the operational checklist reviewers reach for when a reimbursement run behaves unexpectedly.

Failure mode Root cause Symptom Mitigation
Double payment Batch re-run after partial crash Employee paid twice Idempotency key per instruction; outbox dedupe
Illegal transition Event applied from wrong state Unapproved record exported Legal-transition table refuses the move
Lost approval Worker crash between steps Record stuck pre-export Ledger append before state change; recover on key
Orphaned approval Approver left, no delegate Record idles in PENDING Escalation chain in approval routing guide
Split batch Config change mid-run Half a run pays, half holds Pin one batch manifest at run start
Unreconciled line Bank amount differs from ledger Settlement will not balance Fail closed; hold batch until matched
Duplicate export ERP redelivery after timeout Two journal entries Idempotency key echoed to ERP; target dedupes
Chain tamper / loss Retroactive edit to history verify() returns False Fail closed; block payout until chain intact

The recurring principle is exactly-once over best-effort: when the stage cannot make a defensible, reproducible move, it holds the record and records the degradation event rather than paying by default or replaying blindly. That is what preserves settlement integrity during ERP outages, month-end crunch, and queue redelivery storms — the three moments when a naive routing pipeline is most likely to pay someone twice or not at all.

Conclusion

Reimbursement Routing & Approval Sync turns the final leg of expense processing from an error-prone manual handoff into a deterministic, auditable settlement pipeline. Anchoring the stage in an explicit approval state machine, keying every transition and side effect to an idempotency token, deferring external calls to a transactional outbox, and committing the full transition history to a tamper-evident ledger delivers exactly-once payout that survives retries, crashes, and downstream outages. Builders who treat the lifecycle as data, reconcile every batch against bank settlement, and version their routing rules as code will ship systems that move money at enterprise scale while keeping every payout traceable to a signed approval and a matched bank line.