Expense Policy Engine: Currency, Trip Caps and Manager Exceptions
MediumAI-assisted coding · AI-Assisted Feature Work
Expense Policy Engine: Currency, Trip Caps and Manager Exceptions
You've joined the Finance Platform team that owns the expense policy engine. Employees submit expenses (in any supported currency), the engine converts them to USD and checks them against company policy, and managers can occasionally approve an exception. Finance has reported a pair of wrong-by-a-few-cents bugs, and two policy features are queued behind them. All money is an integer: amounts are in the *minor unit* of their currency (cents, pence, whole yen) and totals are in USD cents.
Codebase
| File | What it does |
|---|---|
expenses/api.py | run(operations) entry point and the Dispatcher that routes each operation |
expenses/service.py | ExpenseService: employees, expense submission, totals, trips, exceptions |
expenses/policy.py | Policy rules (RULES) and evaluate, which collects every violated rule |
expenses/fx.py | Currency conversion to USD cents |
expenses/config.py | Exchange rates, per-category limits, receipt threshold |
expenses/directory.py | Employees and the management chain |
expenses/store.py | ExpenseStore: every submitted expense, in submission order |
expenses/models.py | Employee, Trip, Expense dataclasses and status constants |
expenses/validation.py | Argument-shape helpers (is_text, is_amount, is_date) |
expenses/errors.py | ExpenseError and every error code |
expenses/trips.py | TripBook stub for Part 2 |
expenses/audit.py | AuditLog stub for Part 3 |
tests/ | Existing unit and end-to-end tests (some currently fail) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh engine and returns one result per operation. Every operation is [name, *args]. Errors are returned as strings, never raised.
| Operation | Arguments | Returns | Errors (checked in this order) |
|---|---|---|---|
add_employee | employee_id, name, manager_id (manager_id may be null) | true | invalid arguments, employee exists, employee not found (unknown manager) |
submit_expense | expense_id, employee_id, category, amount, currency, date, trip_id, has_receipt (trip_id may be null) | decision object (below) | invalid arguments, invalid amount, employee not found, unknown category, unknown currency, duplicate expense, trip not found |
employee_total | employee_id | USD cents of the employee's approved expenses | employee not found |
open_trip | trip_id, employee_id, start, end, daily_cap | true | Part 2 |
trip_total | trip_id | USD cents of approved expenses on the trip | Part 2 |
approve_exception | approver_id, expense_id | decision object | Part 3 |
audit | employee_id or null | list of audit entries | Part 3 |
- Each error is returned as
"error: <code>", e.g."error: unknown category". An unknown operation name returns"error: unknown operation"; a wrong number of arguments returns"error: invalid arguments". invalid argumentsmeans a wrong type or shape: ids, category and currency must be non-empty strings,datemust look likeYYYY-MM-DD(it is not checked against the calendar),has_receiptmust be a boolean,trip_idmust benullor a non-empty string.invalid amountmeansamountis not an integer> 0(booleans are not integers here).- Categories:
meals,lodging,transport,supplies,other. Currencies:USD,EUR,GBP,CAD,JPY. A manager must be added before their reports. Ids are case-sensitive. - A decision object is
{"status": "approved" | "rejected", "usd_cents": int, "reasons": [str]}.reasonsis empty for approved expenses. A rejected expense is still stored (it just does not count toward any total). submit_expenseconverts to USD cents, then runs every policy rule and lists all the reasons that apply, in the order rules run.
Conversion. usd_cents = round_half_up(amount * rate / 10000) using integer arithmetic only, where rate is:
| USD | EUR | GBP | CAD | JPY |
|---|---|---|---|---|
| 10000 | 10850 | 12700 | 7400 | 6700 |
So 100 EUR cents is 108.5 USD cents, which becomes 109.
Policy rules (existing).
| Reason | Applies when |
|---|---|
receipt required | usd_cents > 2500 and has_receipt is false |
over category limit | usd_cents is greater than the category limit: meals 7500, lodging 30000, transport 20000, supplies 10000, other 5000 |
An expense exactly at a limit or at the receipt threshold is fine.
Worked example
[["add_employee", "ada", "Ada Okafor", null],
["submit_expense", "e1", "ada", "meals", 4500, "USD", "2024-03-04", null, true],
["submit_expense", "e2", "ada", "supplies", 9000, "EUR", "2024-03-04", null, true],
["submit_expense", "e3", "ada", "meals", 9000, "USD", "2024-03-04", null, true],
["employee_total", "ada"]]returns
[true,
{"status": "approved", "usd_cents": 4500, "reasons": []},
{"status": "approved", "usd_cents": 9765, "reasons": []},
{"status": "rejected", "usd_cents": 9000, "reasons": ["over category limit"]},
14265]Part 1 — Fix the wrong-by-a-few-cents reports (FIN-388, FIN-402)
Goal: make conversion and the limit check follow the rules above.
Reports from Finance:
- "Our EUR expenses come out a cent lower than the bank statement, so month-end totals never reconcile. It's always the small ones that are off."
- "A 75.00."
Rules
- Conversion rounds half up on the exact value (never down, never via floating point).
- An expense is over the category limit only when its converted USD cents are strictly greater than the limit.
tests/test_fx.py::test_fractions_of_a_cent_round_half_up and tests/test_policy.py::test_expense_exactly_at_the_limit_is_allowed currently fail.
Part 2 — Per-trip daily caps (FIN-412)
Goal: employees on a business trip get a daily spending cap, so one lavish day is flagged even when each expense is individually within policy.
New behavior for open_trip and for submit_expense with a trip_id:
Rules
open_tripcreates a trip owned byemployee_id.startandendare dates, both days are part of the trip, anddaily_capis USD cents. Errors in order:invalid arguments(a bad id or date,endbeforestart, or adaily_capthat is not an integer> 0),employee not found,trip exists.submit_expensewith atrip_idfails withtrip not foundif the trip does not exist or belongs to a different employee. An expense withtrip_idofnullis not affected by any trip rule.- New reason
outside trip dates: the expensedateis beforestartor afterend. - New reason
over daily cap: for an expense dated inside the trip, add itsusd_centsto what is already approved on that same trip and day; if the sum is greater thandaily_cap, the expense is over the cap. Only the converted USD value counts, rejected expenses do not count, and each date is capped separately. lodgingis exempt: a lodging expense is neverover daily capand does not count toward the day's total.- Reasons are listed in this order:
receipt required,over category limit,outside trip dates,over daily cap. An expense outside the trip dates is never checked against the daily cap. trip_total(trip_id)returns the USD cents of all approved expenses on the trip (lodging included), orerror: trip not found.
Example
[["add_employee", "ada", "Ada Okafor", null],
["open_trip", "t1", "ada", "2024-03-04", "2024-03-06", 10000],
["submit_expense", "e1", "ada", "meals", 6000, "USD", "2024-03-04", "t1", true],
["submit_expense", "e2", "ada", "transport", 5000, "USD", "2024-03-04", "t1", true],
["submit_expense", "e3", "ada", "lodging", 25000, "USD", "2024-03-04", "t1", true],
["submit_expense", "e4", "ada", "meals", 1000, "USD", "2024-03-07", "t1", true],
["trip_total", "t1"]]returns
[true, true,
{"status": "approved", "usd_cents": 6000, "reasons": []},
{"status": "rejected", "usd_cents": 5000, "reasons": ["over daily cap"]},
{"status": "approved", "usd_cents": 25000, "reasons": []},
{"status": "rejected", "usd_cents": 1000, "reasons": ["outside trip dates"]},
31000]Part 3 — Manager exceptions with an audit trail (FIN-431)
Goal: a manager can approve an expense that policy rejected, as long as the problem is something a manager is allowed to waive. Every exception must be visible to auditors.
Rules
approve_exception(approver_id, expense_id)turns a rejected expense into an approved one. Errors in order:employee not found(unknown approver),expense not found,not authorized,not rejected(the expense is already approved),not overridable.- The approver must be above the submitter in the management chain: the direct manager or any manager higher up. Peers, reports, the submitter themselves and unrelated managers get
not authorized. - Only
over category limitandover daily capcan be waived. If any of the expense's reasons isreceipt requiredoroutside trip dates, the result isnot overridable(the expense stays rejected). - On success the expense becomes approved with its original
usd_cents, and the result is{"status": "approved", "usd_cents": int, "exception_by": approver_id}. It now counts everywhere an approved expense counts:employee_total,trip_total, and the daily-cap total for later expenses on the same trip day. A second exception on the same expense isnot rejected. audit(employee_id)returns the exceptions for that employee (nullmeans everyone), in the order they were approved, as objects{"expense_id", "employee_id", "approver_id", "usd_cents", "overrode": [reasons]}whereoverrodeis the expense's reasons in the order they were reported. A non-null unknownemployee_idiserror: employee not found. Failed attempts are not recorded.- Parts 1 and 2 behavior must not change for expenses that never receive an exception.
Example
[["add_employee", "boss", "Priya Raman", null],
["add_employee", "ada", "Ada Okafor", "boss"],
["submit_expense", "e1", "ada", "meals", 9000, "USD", "2024-03-04", null, true],
["approve_exception", "ada", "e1"],
["approve_exception", "boss", "e1"],
["approve_exception", "boss", "e1"],
["employee_total", "ada"],
["audit", null]]returns
[true, true,
{"status": "rejected", "usd_cents": 9000, "reasons": ["over category limit"]},
"error: not authorized",
{"status": "approved", "usd_cents": 9000, "exception_by": "boss"},
"error: not rejected",
9000,
[{"expense_id": "e1", "employee_id": "ada", "approver_id": "boss", "usd_cents": 9000, "overrode": ["over category limit"]}]]Notes
directory.pyandvalidation.pyare known-good.- You may use the AI assistant freely. Later parts must not break earlier ones; the hidden tests re-check earlier behavior as well.
- Every part is graded by hidden tests. Aim to finish Parts 1 and 2 cleanly and make real progress on Part 3.
Example 1
- Input:
- [["add_employee","ada","Ada Okafor",null],["submit_expense","e1","ada","meals",4500,"USD","2024-03-04",null,true],["submit_expense","e2","ada","supplies",9000,"EUR","2024-03-04",null,true],["submit_expense","e3","ada","meals",9000,"USD","2024-03-04",null,true],["employee_total","ada"]]
- Output:
- [true,{"status":"approved","usd_cents":4500,"reasons":[]},{"status":"approved","usd_cents":9765,"reasons":[]},{"status":"rejected","usd_cents":9000,"reasons":["over category limit"]},14265]
- Explanation
- 90.00 EUR converts to 97.65 USD and is approved. The 90.00 USD meal exceeds the 75.00 limit and is rejected, so only the two approved expenses are totalled.
Example 2
- Input:
- [... ada and trip t1 (2024-03-04..06, cap 10000) ..., ["submit_expense","e1","ada","meals",6000,"USD","2024-03-04","t1",true],["submit_expense","e2","ada","transport",5000,"USD","2024-03-04","t1",true],["submit_expense","e3","ada","lodging",25000,"USD","2024-03-04","t1",true]]
- Output:
- [..., approved 6000, rejected ["over daily cap"], approved 25000]
- Explanation
- Meals plus transport on the same day come to 110.00, above the 100.00 daily cap. Lodging is exempt from the cap.
Example 3
- Input:
- [... boss manages ada ..., ["approve_exception","ada","e1"],["approve_exception","boss","e1"],["audit",null]]
- Output:
- [..., "error: not authorized", {"status":"approved","usd_cents":9000,"exception_by":"boss"}, [{..."overrode":["over category limit"]}]]
- Explanation
- Only someone above the submitter can waive the over-limit rejection, and the waiver is recorded in the audit output.
Guidelines
- Python 3 standard library only
- Amounts are integers in a currency's minor unit; totals are integer USD cents (no floats)
- Dates are YYYY-MM-DD strings and compare correctly as plain strings
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- A rejected expense is stored but never counts toward a total