HOA Dues Ledger: Late Fees, Bounced Checks and Payment Plans
EasyAI-assisted coding · AI-Assisted Debugging
HOA Dues Ledger: Late Fees, Bounced Checks and Payment Plans
You've picked up the dues ledger that Maple Ridge HOA's treasurer uses to bill homeowners. The board has had three complaints about wrong balances since the new year, and two features are queued behind them. All amounts are integer cents; all dates are YYYY-MM-DD.
Codebase
| File | What it does |
|---|---|
hoa/api.py | run(operations) entry point and the Dispatcher that parses, orders and routes each operation |
hoa/ledger.py | Ledger: units, charges, payments, statements and the late-fee clock (accrue) |
hoa/allocation.py | The order in which a payment is applied to open charges |
hoa/fees.py | Late-fee arithmetic |
hoa/config.py | Bylaw constants (grace days, fee percentage and cap) |
hoa/models.py | Unit, Charge and Payment records, charge kinds, late-fee check states |
hoa/dates.py, hoa/validate.py, hoa/errors.py | Date parsing, argument checks, error codes (known-good) |
hoa/plans.py | Placeholder for Part 3 |
tests/test_ledger.py | Existing unit tests (some of them currently fail) |
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, date, ...args]. Errors are returned as "error: <code>" strings, never raised.
| Operation | Arguments after date | Returns | Errors (checked in this order) |
|---|---|---|---|
open_unit | unit_id, monthly_cents | true | invalid amount, unit exists |
post_monthly | none | list of the new charge ids, in the order units were opened | month already posted |
special | unit_id, cents | the new charge id | invalid amount, unit not found |
payment | unit_id, cents | payment id ("p-1", "p-2", ...) | invalid amount, unit not found, payment exceeds balance |
statement | unit_id | {"unit", "balance", "open"} (see below) | unit not found |
- A valid amount is an integer
> 0(true/falseare not amounts). Unit ids are case-sensitive, non-empty strings. Operations whose arguments are the wrong count or type return"error: invalid arguments", as does adatethat is not a real calendar date inYYYY-MM-DDform. - Dates never go backwards: an operation dated earlier than the previous accepted operation returns
"error: date out of order". An unknown operation name returns"error: unknown operation". - The late-fee clock runs before every operation that has a valid, in-order date, whatever that operation is (a read, or one that goes on to fail). It posts every late fee that has fallen due on or before the operation's date. Nothing else moves time forward.
post_monthlyposts onemonthlyassessment per unit (itsmonthly_cents) dated that day; the billing period is the month of the date (2025-01). A secondpost_monthlyin the same period is an error. Units opened later are not back-billed.specialposts a one-offspecialassessment, whose period is the month of its date.- Charge ids are
"c-1","c-2", ... in creation order across the whole ledger. Fees take ids too. - A payment may not exceed the unit's total open balance. It is applied to open charges in allocation order (below) and there is no credit balance.
- A
statementlists the unit's open charges in allocation order as[charge_id, kind, period, posted_date, outstanding_cents], plus the totalbalance.kindismonthly,special,late_fee(and laternsf_fee).
The rules the bylaws actually say
- Grace and late fee. An assessment (monthly or special) posted on date
Dis late fromD + 15 days. Posted January 1st, it is late from January 16th (day 16): a payment on January 15th is on time, a payment on January 16th is not. If the assessment still has anything outstanding when that day begins, onelate_feecharge is posted, dated that day, once per assessment, ever. Fees are never charged on fees. - Fee amount. 5% of the assessment's original amount (not what is still outstanding), rounded half up to the cent, at most
5000($50.00). The fee belongs to the period of the assessment that caused it (a January fee is a January charge though it is posted on the 16th). - Allocation order ("oldest first"). Sort open charges by period, oldest first; within a period, fees before assessments; then by creation order. A January fee, then January assessments, then February's fee, then February's assessments, and so on.
Worked example
[["open_unit", "2025-01-01", "A1", 28000],
["open_unit", "2025-01-01", "B2", 28000],
["post_monthly", "2025-01-01"],
["payment", "2025-01-15", "B2", 28000],
["payment", "2025-01-16", "A1", 10000],
["statement", "2025-01-16", "A1"],
["statement", "2025-01-16", "B2"]]returns
[true, true, ["c-1", "c-2"], "p-1", "p-2",
{"unit": "A1", "balance": 19400, "open": [["c-1", "monthly", "2025-01", "2025-01-01", 19400]]},
{"unit": "B2", "balance": 0, "open": []}]On January 16th the clock posted one fee, c-3 (100.00 pays that fee first and then $86.00 of the assessment.
Part 1 — Fix the balance complaints (HOA-212, HOA-219, HOA-231)
Goal: make the ledger follow rules 1-3 above. Complaints from the treasurer's inbox:
- "Unit 14 paid on the 16th and I'm told it was fine. The bylaws say the 16th is late."
- "Unit 7 paid 280 and was charged a 280 assessment."
- "A payment of $100 left a unit's *assessment* reduced while its late fee stayed open. Late fees are supposed to be paid first."
tests/test_ledger.py has a failing test for the first. Hidden tests also cover the $50 cap, half-up rounding (10010 cents gives a 501 fee, 10009 gives 500), several months at once, and special assessments posted mid-month.
Part 2 — Bounced checks (HOA-240)
Goal: add ["reverse", date, payment_id]: a payment bounced. Take back exactly what it paid, and charge the unit an NSF fee.
| Operation | Arguments after date | Returns | Errors (in order) |
|---|---|---|---|
reverse | payment_id | the id of the NSF fee charge | payment not found, payment already reversed |
Rules
- Reversing un-allocates exactly the amounts that payment was allocated to each charge when it was made; nothing else changes. Later payments stay where they were applied. Each charge's outstanding amount goes back up. The payment cannot be reversed again. Reversals do not consume payment ids.
- An assessment that the reversed payment had touched is re-evaluated: if it has anything outstanding after the un-allocation, its fee day (rule 1, posted + 15 days) has already passed (on or before the reversal's date), and no late fee was posted for it (its check had found it paid), a late fee is posted now, on the reversal's date, with the usual amount and the assessment's period. If its fee day has not been reached, nothing extra happens: the normal check will find it unpaid. Assessments that already got a fee are unaffected.
- These retroactive fees are posted first, one per assessment in allocation order. After them the NSF fee is posted: kind
nsf_fee, a flat3500($35.00), dated the reversal's date, belonging to the period of the reversal's date, and treated as a fee in allocation order (rule 3). - The operation returns the NSF fee's charge id (the last id it created).
Example (after the Part 1 example's first five operations)
[["reverse", "2025-01-20", "p-1"],
["statement", "2025-01-20", "B2"],
["reverse", "2025-01-20", "p-1"]]returns
["c-5",
{"unit": "B2", "balance": 32900, "open": [
["c-4", "late_fee", "2025-01", "2025-01-20", 1400],
["c-5", "nsf_fee", "2025-01", "2025-01-20", 3500],
["c-2", "monthly", "2025-01", "2025-01-01", 28000]]},
"error: payment already reversed"]Part 3 — Payment plans (HOA-258)
Goal: the board now lets owners who are behind agree on a payment plan. While a plan is current, the late fees on the charges it covers are held back; if the owner defaults, they all come back. This changes rule 1 (a late fee is no longer always posted) and interacts with Part 2.
| Operation | Arguments after date | Returns | Errors (in order) |
|---|---|---|---|
plan_start | unit_id, count, first_due | [[due_date, cents], ...] | unit not found, invalid plan, plan already active, nothing owed |
plan_status | unit_id | {"status", "total", "paid", "next_due", "frozen_fees"} | unit not found, no plan |
A non-integer count (or boolean) or a first_due that is not a valid date is invalid arguments.
Rules
plan_startcovers every charge the unit has open at that moment (assessments and fees alike); charges posted later are not covered.countmust be 1-12 andfirst_duestrictly after the operation's date, otherwiseinvalid plan. A unit can have one active plan at a time.totalis the unit's balance at the start;nothing owedif it is 0.- Installment amounts:
total // counteach, with the firsttotal % countinstallments one cent larger. Installmenti(0-based) is duefirst_due + 30 * idays. A plan is active until it completes or defaults. - Freezing. While a plan is active, when the late-fee check of a covered assessment finds it unpaid, no fee is posted. The fee amount (rule 2) is held instead ("frozen"). Assessments not covered by the plan are charged fees as usual.
- Paid toward the plan is the total allocated to the covered charges by payments made after the plan started that have not been reversed. Installment
iis judged at the start of daydue + 8(a payment ondue + 7still counts): the plan defaults ifpaidis less than the sum of installments0..i. When the last installment is judged and the plan is still active, it defaults too. - Default. The plan's status becomes
defaulted. Each frozen fee whose assessment still has something outstanding is posted as a normal late fee, dated the day the default happened (the judging day, not the date of the operation that triggered the replay), in the assessment's period, in order of the assessments' creation. Frozen fees of assessments paid by then are dropped (the assessment counts as cleared). A defaulted unit may start a new plan. - Completion. When a payment leaves every covered charge at 0 outstanding, the plan is
completedand all its frozen fees are dropped for good (those assessments count as cleared, so the Part 2 retroactive-fee rule applies if a payment is reversed later). A completed or defaulted plan never becomes active again. - The late-fee clock replays everything due since the last operation in date order: plan judgments and late-fee checks mixed, with a plan judgment first when both fall on the same day. A fee whose day comes after a plan defaulted is therefore posted normally on its own day.
plan_status describes the unit's most recent plan: status (active, completed, defaulted), total, paid, next_due (while active, the due date of the first installment whose cumulative total exceeds paid, or the last installment's date if there is none; null otherwise) and frozen_fees (the sum currently held back, 0 unless active).
Example
[["open_unit", "2025-01-01", "A1", 28000],
["post_monthly", "2025-01-01"],
["plan_start", "2025-01-10", "A1", 2, "2025-01-31"],
["payment", "2025-01-31", "A1", 14000],
["plan_status", "2025-03-09", "A1"],
["plan_status", "2025-03-11", "A1"],
["statement", "2025-03-11", "A1"]]returns
[true, ["c-1"], [["2025-01-31", 14000], ["2025-03-02", 14000]], "p-1",
{"status": "active", "total": 28000, "paid": 14000, "next_due": "2025-03-02", "frozen_fees": 1400},
{"status": "defaulted", "total": 28000, "paid": 14000, "next_due": null, "frozen_fees": 0},
{"unit": "A1", "balance": 15400, "open": [
["c-2", "late_fee", "2025-01", "2025-03-10", 1400],
["c-1", "monthly", "2025-01", "2025-01-01", 14000]]}]The January 16th check froze the 280.00.
Notes
dates.py,validate.pyanderrors.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 and 2 plus a working freeze/default path is a strong result.
Example 1
- Input:
- [["open_unit","2025-01-01","A1",28000],["open_unit","2025-01-01","B2",28000],["post_monthly","2025-01-01"],["payment","2025-01-15","B2",28000],["payment","2025-01-16","A1",10000],["statement","2025-01-16","A1"],["statement","2025-01-16","B2"]]
- Output:
- [true,true,["c-1","c-2"],"p-1","p-2",{"unit":"A1","balance":19400,"open":[["c-1","monthly","2025-01","2025-01-01",19400]]},{"unit":"B2","balance":0,"open":[]}]
- Explanation
- B2 paid on day 15 and owes no fee. A1 was still unpaid on day 16, so a $14.00 late fee was posted first; A1's $100.00 payment pays that fee ($14.00) and then $86.00 of the assessment.
Example 2
- Input:
- [...open A1 and B2, post January..., ["payment","2025-01-15","B2",28000], ["payment","2025-01-16","A1",10000], ["reverse","2025-01-20","p-1"], ["statement","2025-01-20","B2"]]
- Output:
- [..., "c-5", {"unit":"B2","balance":32900,"open":[["c-4","late_fee","2025-01","2025-01-20",1400],["c-5","nsf_fee","2025-01","2025-01-20",3500],["c-2","monthly","2025-01","2025-01-01",28000]]}]
- Explanation
- B2's check bounced. Its January assessment is open again and its fee date (Jan 16) has passed, so the late fee is charged now, followed by the $35.00 NSF fee.
Example 3
- Input:
- [open A1, post January, ["plan_start","2025-01-10","A1",2,"2025-01-31"], ["payment","2025-01-31","A1",14000], ..., ["plan_status","2025-03-11","A1"]]
- Output:
- [..., [["2025-01-31",14000],["2025-03-02",14000]], "p-1", ..., {"status":"defaulted","total":28000,"paid":14000,"next_due":null,"frozen_fees":0}]
- Explanation
- The plan froze the January late fee. The second installment was never paid, so on March 10 the plan defaulted and the $14.00 fee was reinstated.
Guidelines
- Python 3 standard library only
- Amounts are integer cents; there is no floating-point money anywhere
- Dates are YYYY-MM-DD strings and never go backwards from one operation to the next
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- Late fees and plan checks are replayed lazily from the dates of incoming operations; there is no background scheduler