Wallet Ledger: Transfers, Retries and Scheduled Payments
MediumAI-assisted coding · AI-Assisted Feature Work
Wallet Ledger: Transfers, Retries and Scheduled Payments
You've joined the payments team that owns the wallet ledger: the service that holds customer balances and moves money between wallets. Support has escalated two money-movement bugs, and the roadmap has three features queued behind them. All amounts are integer cents.
Codebase
| File | What it does |
|---|---|
ledger/api.py | run(operations) entry point and the Dispatcher that routes each operation |
ledger/service.py | LedgerService: deposits, withdrawals, transfers, balances, history |
ledger/rules.py | Shared validation rules (account active, sufficient funds, distinct accounts) |
ledger/accounts.py | AccountRegistry: open, look up and close accounts |
ledger/journal.py | Append-only Journal of postings, indexed by account; allocates transfer ids |
ledger/statements.py | Renders postings as statement lines for history |
ledger/models.py | Account and Posting dataclasses, status and posting-kind constants |
ledger/money.py | Amount validation and format_cents |
ledger/errors.py | LedgerError and every error code |
ledger/config.py | Constants (transfer id prefix, currency symbol, request-id retention) |
ledger/idempotency.py | Placeholder for Part 2 |
ledger/scheduler.py | PaymentScheduler stub for Part 3 (routes exist, answer "not supported") |
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 ledger and returns one result per operation. Every operation is [name, ts, ...args]; ts is an integer number of seconds and never decreases. Errors are returned as strings, never raised.
| Operation | Arguments after ts | Returns | Errors (checked in this order) |
|---|---|---|---|
open_account | account_id, owner | true | account exists |
close_account | account_id | true | account not found, account closed, balance not zero |
deposit | account_id, amount | new balance | invalid amount, account not found, account closed |
withdraw | account_id, amount | new balance | invalid amount, account not found, account closed, insufficient funds |
transfer | source_id, target_id, amount | transfer id | invalid amount, same account, account not found, account closed, insufficient funds |
balance | account_id | balance (int) | account not found |
history | account_id | list of statement lines, oldest first | account not found |
- Each error is returned as
"error: <code>", e.g."error: insufficient funds". - An unknown operation name returns
"error: unknown operation". A wrong number of arguments or a non-integertsreturns"error: invalid arguments", as doesopen_accountwith an empty or non-stringaccount_idorowner. - A valid amount is an integer
> 0(true/falseare not amounts). - Account ids are case-sensitive strings. A closed account still answers
balanceandhistory. - For
transfer, "account not found" and "account closed" check the source before the target. - Transfer ids are
"tr-1","tr-2", ... in the order transfers succeed. A rejected transfer does not use up an id. - Statement lines:
deposit +$50.00,withdrawal -$7.00,tr-1 to bob -$12.50,tr-1 from alice +$12.50(thousands are comma-separated:+$1,234.56).
Worked example
[["open_account", 1, "alice", "Alice Ng"],
["open_account", 2, "bob", "Bob Ortiz"],
["deposit", 3, "alice", 5000],
["transfer", 4, "alice", "bob", 1250],
["withdraw", 5, "bob", 2000],
["history", 6, "alice"]]returns
[true, true, 5000, "tr-1", "error: insufficient funds", ["deposit +$50.00", "tr-1 to bob -$12.50"]]Part 1 — Fix two money-movement bugs (SUP-2291, SUP-2304)
Goal: make transfers and withdrawals follow the rules in the table above.
Customer reports:
- "I tried to empty my wallet before closing it, but sending or withdrawing my entire balance is declined with *insufficient funds*. I can only move out one cent less than I have."
- "I sent money to a friend's wallet that had already been closed, and it went through. The money is now stuck in the closed account."
Rules
- A withdrawal or transfer of exactly the available balance succeeds and leaves a balance of 0. Anything above the balance fails with
insufficient funds. - A transfer whose source or target is closed fails with
account closedand changes nothing. - The error order in the table must hold (e.g. a transfer to a closed account from an account without enough money reports
account closed).
tests/test_service.py::test_customer_can_send_entire_balance currently fails.
Part 2 — Idempotent retries with request ids (PAY-1183)
Goal: mobile clients retry requests when a response is lost; a retry must never move money twice.
Rules
deposit,withdrawandtransfer(and, after Part 3,scheduleandcancel_payment) accept one optional extra argument at the end: a request id (non-empty string). Any other operation given an extra argument, or a request id that is not a non-empty string, returns"error: invalid arguments".- The first time a request id is used, the operation runs normally and its result is stored — including error results like
"error: insufficient funds". - A later operation with the same request id, the same operation name and the same arguments (ignoring
ts) returns the stored result and has no effect (no money moves, no new transfer id). - Same request id but a different operation name or different arguments returns
"error: idempotency key reused"and has no effect. That response is not stored. - Request ids are global (not per account). Operations without a request id are never deduplicated.
- A request id is remembered for 86 400 seconds: an operation at
ts < first_ts + 86400is a replay; atts >= first_ts + 86400the id is forgotten and the operation runs as a brand-new request (whose result is then stored with the newfirst_ts).
Example (after opening alice and bob)
[["deposit", 3, "alice", 5000, "req-1"],
["transfer", 4, "alice", "bob", 1000, "req-2"],
["transfer", 9, "alice", "bob", 1000, "req-2"],
["transfer", 10, "alice", "bob", 2500, "req-2"],
["balance", 11, "alice"]]returns [5000, "tr-1", "tr-1", "error: idempotency key reused", 4000].
Part 3 — Scheduled and recurring payments (PAY-1207)
Goal: implement PaymentScheduler so customers can schedule future and recurring transfers.
| Operation | Arguments after ts | Returns | Errors (in order) |
|---|---|---|---|
schedule | payment_id, source_id, target_id, amount, due_ts, interval | true | payment exists, invalid amount, invalid schedule, same account, account not found |
cancel_payment | payment_id | true | payment not found, payment not active |
payment_status | payment_id | {"status", "runs", "next_due"} | payment not found |
Rules
due_tsmust be an integer greater thants, andintervalan integer>= 0; otherwiseinvalid schedule. Apayment_idthat is not a non-empty string returnsinvalid arguments.interval = 0is a one-off payment;interval > 0repeats everyintervalseconds (due_ts,due_ts + interval, ...). At schedule time only the checks in the table run — balances and closed accounts are checked when the payment executes.- Payments run lazily. Before handling *any* operation whose
tsis an integer (reads, replays and operations that end up failing included), every occurrence with due time<= tsruns first, in order of due time, ties broken by the order the payments were created. Recurring payments catch up on every missed occurrence. - Each occurrence is an ordinary transfer (same rules as Part 1, next transfer id, appears in
history). If it succeeds,runsincreases; a one-off payment becomescompleted. - If an occurrence fails for any reason, the payment becomes
failedand never runs again. cancel_paymentworks only on ascheduledpayment (elsepayment not active); because of rule 2, an occurrence due exactly at the cancel'stsruns before the cancel.payment_statusreturnsstatus("scheduled","completed","failed"or"cancelled"),runs(successful occurrences) andnext_due(the next due time, ornullunlessscheduled).scheduleandcancel_paymentsupport request ids exactly as in Part 2.
Example (after opening alice, bob and depositing 5000 to alice)
[["schedule", 10, "rent", "alice", "bob", 1500, 100, 0],
["balance", 99, "bob"],
["balance", 100, "bob"],
["payment_status", 101, "rent"]]returns [true, 0, 1500, {"status": "completed", "runs": 1, "next_due": null}].
Part 4 — Spending leaderboard at scale (stretch)
Goal: add ["top_spenders", ts, k], returning the k accounts that have sent the most money.
Rules
- An account's spend is the total of its successful outgoing transfers, including scheduled ones. Deposits, withdrawals and incoming transfers do not count; replays do not count twice.
- Return
[[account_id, spend], ...], largest spend first, ties byaccount_idascending (plain string order). Accounts with zero spend are omitted; closed accounts are included. Fewer thankqualifying accounts → return all of them. kmust be an integer>= 1, otherwise"error: invalid arguments".- Hidden tests run tens of thousands of scheduled executions and hundreds of
top_spenderscalls in onerun; the whole suite has a few seconds. Rescanning the journal on every call is too slow.
Notes
money.py,statements.py,journal.pyandaccounts.pyare known-good.- You may use the AI assistant freely; you are expected to verify what it produces.
- Later parts must not break earlier ones; hidden tests re-check earlier behaviour.
- Parts 1-3 is a strong result. Part 4 is a stretch.
Example 1
- Input:
- [["open_account",1,"alice","Alice Ng"],["open_account",2,"bob","Bob Ortiz"],["deposit",3,"alice",5000],["transfer",4,"alice","bob",1250],["withdraw",5,"bob",2000],["history",6,"alice"]]
- Output:
- [true,true,5000,"tr-1","error: insufficient funds",["deposit +$50.00","tr-1 to bob -$12.50"]]
- Explanation
- Alice deposits $50.00 and sends $12.50 to Bob as transfer tr-1. Bob only holds $12.50, so withdrawing $20.00 fails. Alice's history lists her postings oldest first.
Example 2
- Input:
- [...open alice and bob..., ["deposit",3,"alice",5000,"req-1"],["transfer",4,"alice","bob",1000,"req-2"],["transfer",9,"alice","bob",1000,"req-2"],["transfer",10,"alice","bob",2500,"req-2"],["balance",11,"alice"]]
- Output:
- [true,true,5000,"tr-1","tr-1","error: idempotency key reused",4000]
- Explanation
- The retry of req-2 at ts 9 replays the stored result tr-1 without moving money again. Reusing req-2 with a different amount is rejected. Only $10.00 left Alice's account.
Example 3
- Input:
- [...open alice and bob..., ["deposit",3,"alice",5000],["schedule",10,"rent","alice","bob",1500,100,0],["balance",99,"bob"],["balance",100,"bob"],["payment_status",101,"rent"]]
- Output:
- [true,true,5000,true,0,1500,{"status":"completed","runs":1,"next_due":null}]
- Explanation
- The one-off payment is due at 100. Nothing happens at ts 99; the balance read at ts 100 first runs the due payment, so Bob already has $15.00.
Guidelines
- Python 3 standard library only
- Amounts are integer cents; there is no floating-point money anywhere
- Timestamps are integer seconds and never decrease from one operation to the next
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- Part 4 inputs contain tens of thousands of scheduled-payment executions