From-Scratch Build · Python · FastAPI
A working expense system whose substance is the approval-routing engine: give it an expense and an org policy and it computes the ordered chain of approvers, advances a small state machine as each one approves or rejects, and records every step in an audit trail. SQLite persistence and a FastAPI surface sit on top.
The core
The interesting part of an expense system isn't the form — it's deciding who has to sign off, in what order, and why. That logic lives in one pure function: given an expense and a policy, it returns an ordered, reasoned list of approvers. Same inputs, same chain, no side effects — which is exactly what makes it testable.
The policy is data, not code. Thresholds, the finance-review category list and the auto-approve limit all come from data/policy.json, so the rules change without touching the engine.
| Rule | Default | Effect |
|---|---|---|
auto_approve_under | 25 | Below this, auto-approves on submit (empty chain). |
manager_limit | 500 | Above this, a director is appended after the manager. |
finance_categories | travel, equipment | Always add a final finance review. |
finance_amount_floor | 2000 | Any amount at/above this adds finance review. |
The state machine
Submitting computes the chain and either auto-approves (empty chain) or parks the expense PENDING on its first approver. Each approval advances to the next step; the final approval finalises it. Any rejection halts the chain immediately — later approvers never get a turn. Only the current step's approver may decide; an out-of-turn or out-of-state decision is refused.
# real output — python demo.py, expense #5 (5000.00 other) 1. Priya Anand (manager) manager approval 2. Dana Reyes (director) director approval (amount over 500) 3. Marco Lindt (finance) finance review (amount >= 2000) # and when the submitter reports to an absent manager, it escalates: 1. Dana Reyes (director) manager approval (escalated past absent approver)
The audit trail
The store keeps an append-only audit trail. Each submit, route, approval, rejection and finalisation is a timestamped row tied to the expense and the actor — so you can always reconstruct why a claim ended where it did.
# real audit trail for the 5000 expense, approved end-to-end
[submitted ] Lena Ortiz other expense for 5000
[routed ] system awaiting manager (employee 3): manager approval
[approved ] Priya Anand looks good
[routed ] Priya Anand awaiting director (employee 1): director approval (amount over 500)
[approved ] Dana Reyes looks good
[routed ] Dana Reyes awaiting finance (employee 2): finance review (amount >= 2000)
[approved ] Marco Lindt looks good
[finalized ] Marco Lindt all approvals complete
The API
The acting employee is passed via an X-Employee-Id header. Run it with uvicorn expenses.api:app --reload and the interactive docs are at /docs.
File an expense; the response carries the computed approval chain.
Everything I've filed, with current status.
Expenses waiting on me right now — only my turn shows up.
Advance the step assigned to me to the next approver.
Halt the chain; the expense is final.
The full timestamped history of one expense.
Tested
The routing engine is tested directly and the API through FastAPI's TestClient — 24 tests, all passing.
Scope
The original framing for this project was a cloud deployment — serverless functions, a managed database, object storage for receipts, hosted auth. That deployment is out of scope here and not claimed.
What this repo actually contains is the real application underneath it: the routing engine, the state machine, the SQLite store and the FastAPI surface — all runnable and tested locally. Receipts are referenced by path rather than uploaded to a blob store, and the X-Employee-Id header stands in for a managed identity service. This page describes the code that exists, not a deployment that doesn't.