Envelope Budgeting: Month Close, Splits and Overspending
EasyAI-assisted coding · AI-Assisted Debugging
Envelope Budgeting: Month Close, Splits and Overspending
You've joined the team behind a personal-budgeting app that uses envelope (zero-based) budgeting. Income lands in one pool called *ready to assign*; the user assigns it to envelopes such as groceries, rent and fun; purchases draw an envelope down; and at month end whatever is left rolls into the next month. Support has two open tickets about wrong month-end numbers, and product has two changes queued behind them. All amounts are integer cents.
Codebase
| File | What it does |
|---|---|
budget/api.py | run(operations) entry point and the Dispatcher that routes each operation and moves the month clock |
budget/budget.py | Budget: ready-to-assign, the operations, closing months, reports |
budget/envelopes.py | EnvelopeBook: the envelopes' figures for the open month, snapshots, report rows |
budget/ledger.py | TransactionLog: every purchase (and, from Part 2, refund) by transaction id |
budget/months.py | Month parsing and next_month |
budget/rules.py | Argument validation shared by all operations |
budget/models.py | EnvelopeState, Transaction, MonthRecord |
budget/errors.py | BudgetError and every error code |
budget/config.py | Constants |
tests/ | Existing unit and end-to-end tests (one of them currently fails) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh budget and returns one result per operation. Every operation is [name, month, ...args]. month is the "as of" month, a zero-padded "YYYY-MM" string. Errors are returned as strings, never raised.
The month clock. The first well-formed operation sets the open month. After that, every operation's month must be >= the open month, otherwise it returns "error: month in the past" and does nothing. If month is later than the open month, every month in between is closed first (one at a time, including months in which nothing happened), then the operation runs in month. This happens even if the operation itself then fails.
Figures per envelope per month. assigned is money moved in this month, activity is purchases minus refunds (so spending is negative), and available is last month's available + assigned + activity. When a month closes its figures, and the ready-to-assign it ended with, are recorded and never change again. A new month starts with assigned = 0 and activity = 0 for every envelope.
| Operation | Arguments after month | Returns | Errors (checked in this order) |
|---|---|---|---|
create_envelope | name | true | envelope exists |
income | amount | ready-to-assign after | invalid amount |
assign | name, amount | envelope's available after | invalid amount, envelope not found, insufficient ready to assign |
spend | txn_id, name, amount | envelope's available after | invalid amount, envelope not found, duplicate transaction |
report | target_month | report object (below) | month not found |
assignmoves money from ready-to-assign to the envelope; it needsamount <= ready, so the whole of ready-to-assign can be assigned.spendmay take an envelope below zero ("overspent"); that is allowed.- A report is
{"month", "ready", "envelopes"}whereenvelopesis a list sorted by name of{"name", "assigned", "activity", "available"}. For the open month it is live; for a closed month it is the recorded figures (only envelopes that existed then). A month that is not the open month and was never closed (before the first operation, or in the future) returnsmonth not found. - Envelope names and transaction ids are non-empty, case-sensitive strings; otherwise
invalid arguments. A valid amount is an integer> 0(true/falseare not amounts). - Unknown operation names return
"error: unknown operation". A wrong number of arguments, or amonth(orreporttarget) that is not a well-formed"YYYY-MM", returns"error: invalid arguments"and does not start or move the clock. - Each error is returned as
"error: <code>". - Positive balances roll over unchanged. What happens to an *overspent* (negative) balance at month end is deliberately left open until Part 3; the starter carries it unchanged and nothing in Part 1 depends on it.
Worked example
[["create_envelope", "2025-01", "groceries"],
["income", "2025-01", 100000],
["assign", "2025-01", "groceries", 40000],
["spend", "2025-01", "t1", "groceries", 12500],
["report", "2025-01", "2025-01"]]returns
[true, 100000, 40000, 27500,
{"month": "2025-01", "ready": 60000,
"envelopes": [{"name": "groceries", "assigned": 40000, "activity": -12500, "available": 27500}]}]Part 1 — Fix the month-end numbers (BUD-311, BUD-327)
Goal: make rollover, month history and assigning follow the rules above.
Reports from users:
- "After I got through November the app showed a nonsense month and my December and January numbers were gone." Rolling over a year-end seems fine in the unit tests, so look at every month.
- "I opened last month's report after spending this month and the figures had changed."
- "I have 10.00 into one envelope."
Rules
- A closed month's report is exactly what the month looked like when it closed, forever.
- Closing months must work for every calendar month, including across year ends and multi-month jumps.
- Assigning exactly the remaining ready-to-assign succeeds; one cent more fails with
insufficient ready to assignand changes nothing.
tests/test_budget.py::test_all_of_ready_can_be_assigned currently fails; it is not the only defect.
Example (rolling over a year end)
[["create_envelope", "2025-11", "fun"],
["income", "2025-11", 30000],
["assign", "2025-11", "fun", 30000],
["spend", "2025-11", "t1", "fun", 4000],
["spend", "2025-12", "t2", "fun", 1000],
["report", "2026-01", "2025-11"],
["report", "2026-01", "2026-01"]]returns true, 30000, 30000, 26000, 25000, then November's frozen report {"month": "2025-11", "ready": 0, "envelopes": [{"name": "fun", "assigned": 30000, "activity": -4000, "available": 26000}]} and January's live one {"month": "2026-01", "ready": 0, "envelopes": [{"name": "fun", "assigned": 0, "activity": 0, "available": 25000}]}.
Part 2 — Split purchases and refunds (BUD-340)
Goal: one receipt can draw from several envelopes, and a refund puts money back where it came from, even months later.
| Operation | Arguments after month | Returns | Errors (checked in this order) |
|---|---|---|---|
split | txn_id, parts | total spent (sum of parts) | invalid split, invalid amount, envelope not found, duplicate transaction |
refund | refund_id, txn_id, name, amount | envelope's available after | invalid amount, duplicate transaction, transaction not found, envelope not in transaction, refund exceeds purchase |
Rules
partsis a list of at least two[envelope, amount]pairs with distinct envelope names; anything else (wrong shape, one part, a repeated envelope) isinvalid split. Then amounts are checked in list order (invalid amount), then envelopes in list order (envelope not found), then the id. A rejected split changes nothing and does not consumetxn_id.- A successful split lowers
activityandavailableof each part's envelope by its amount, exactly like onespendper part. refundrefers to a purchase (aspendor asplit) bytxn_idand to one envelopenamethat took part in it. Refunded amounts accumulate per (purchase, envelope): the total refunded for an envelope may reach, but not exceed, what that envelope paid in the purchase.- A refund raises
activityandavailableof that envelope in the open month (the month of therefundoperation). Closed months, including the purchase's month, are not touched, and ready-to-assign is not touched. - Purchases and refunds share one id namespace:
refund_idmust not equal any earliertxn_idorrefund_id, and a newspendorsplitid must not equal an earlier refund id (allduplicate transaction). A refund id is not a purchase, so refunding it istransaction not found. - Everything from Part 1 keeps working.
Example
[["create_envelope", "2025-01", "groceries"],
["create_envelope", "2025-01", "household"],
["income", "2025-01", 100000],
["assign", "2025-01", "groceries", 50000],
["assign", "2025-01", "household", 20000],
["split", "2025-01", "t1", [["groceries", 8000], ["household", 3000]]],
["refund", "2025-02", "r1", "t1", "household", 3000],
["report", "2025-02", "2025-01"],
["report", "2025-02", "2025-02"]]returns true, true, 100000, 50000, 20000, 11000, 20000, then January (unchanged by the refund): ready 30000, groceries 50000 / -8000 / 42000, household 20000 / -3000 / 17000 (assigned / activity / available), and February: ready 30000, groceries 0 / 0 / 42000, household 0 / 3000 / 20000.
Part 3 — Overspending is settled at month end; move money (BUD-352)
Goal: finance has decided that debt must not hide inside envelopes. When a month closes, any overspent envelope is reset to zero and the shortfall is paid out of ready-to-assign. This replaces the "carried unchanged" starter behaviour for negative balances; also add move.
| Operation | Arguments after month | Returns | Errors (checked in this order) |
|---|---|---|---|
move | source, target, amount | target's available after | invalid amount, same envelope, envelope not found, insufficient envelope funds |
Rules
- When a month closes, first record it (its report still shows the negative
availableand the ready-to-assign it had before this step). Then, for every envelope whoseavailableis below zero, add that (negative) amount to ready-to-assign and set the envelope'savailableto 0. - This happens once, at the close that ends the overspent month. If several months are closed in one jump, later months see envelopes already at zero.
- Ready-to-assign can therefore become negative; this is the only way it can. While it is negative,
assignfails withinsufficient ready to assign(it needsamount <= ready), andincomesimply adds to it. movetakesamountfromsourcetotargetin the open month:assignedandavailableof the source decrease and of the target increase byamount(soassignedcan be negative), and ready-to-assign is not touched. The source needsavailable >= amountright now (insufficient envelope fundsotherwise, so an overspent source can move nothing). Envelopes are checked source first, then target. A failed move changes nothing.- Refunds still land in the open month (Part 2), so a refund before month end shrinks the shortfall and a refund after it lands in an envelope that was reset to zero.
- Everything from Parts 1 and 2 keeps working.
Example
[["create_envelope", "2025-01", "fun"],
["income", "2025-01", 10000],
["assign", "2025-01", "fun", 10000],
["spend", "2025-01", "t1", "fun", 12500],
["report", "2025-02", "2025-01"],
["report", "2025-02", "2025-02"],
["assign", "2025-02", "fun", 1],
["income", "2025-02", 4000],
["assign", "2025-02", "fun", 1500]]returns true, 10000, 10000, -2500, then January: ready 0, fun 10000 / -12500 / -2500; then February: ready -2500, fun 0 / 0 / 0; then "error: insufficient ready to assign", 1500, 1500.
Example 1
- Input:
- [["create_envelope","2025-01","groceries"],["income","2025-01",100000],["assign","2025-01","groceries",40000],["spend","2025-01","t1","groceries",12500],["report","2025-01","2025-01"]]
- Output:
- [true,100000,40000,27500,{"month":"2025-01","ready":60000,"envelopes":[{"name":"groceries","assigned":40000,"activity":-12500,"available":27500}]}]
- Explanation
- $1,000.00 of income is ready to assign; $400.00 goes to groceries and a $125.00 purchase draws it down to $275.00. Ready-to-assign is now $600.00.
Example 2
- Input:
- [...fun envelope, income 30000, assign 30000..., ["spend","2025-11","t1","fun",4000],["spend","2025-12","t2","fun",1000],["report","2026-01","2025-11"],["report","2026-01","2026-01"]]
- Output:
- [..., 26000, 25000, {"month":"2025-11","ready":0,"envelopes":[{"name":"fun","assigned":30000,"activity":-4000,"available":26000}]}, {"month":"2026-01","ready":0,"envelopes":[{"name":"fun","assigned":0,"activity":0,"available":25000}]}]
- Explanation
- The balance rolls Nov to Dec to Jan. November's report is frozen at the figures it ended with.
Guidelines
- Python 3 standard library only
- Amounts are integer cents; there is no floating-point money anywhere
- Months are zero-padded "YYYY-MM" strings and never decrease from one operation to the next
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised