Problem Statement
The school runs a canteen, but nothing about it exists in the app. Today the whole thing is run off a paper register at the counter:
- Parents hand cash to their child in the morning, so the school has no record of what a student actually spent, and a lost note is simply gone.
- Meal plan subscriptions (the "full week lunch" families pay for up front) are tracked in a spreadsheet, so the counter has no reliable way to tell whether a student in the queue is prepaid or paying today.
- Allergen information lives nowhere. A student with a declared nut allergy can be served a nut dish because the person at the counter has no way to know. This is the part that actually worries me — everything else is an accounting inconvenience, this one is a safety problem.
- At the end of the month the office reconciles the register by hand against the fee ledger, and it never balances.
backend/models/FeeInvoice.js covers tuition invoicing, but a canteen balance is a fundamentally different object: it is a prepaid, decrementing balance spent many times a day in small amounts, not a bill that gets settled once. Trying to model it as an invoice would be wrong.
Proposed Solution
A cafeteria module with two pieces: meal plans (what is on offer) and a prepaid canteen account per student (how it gets paid for).
Meal plans — backend/models/MealPlan.js
A plan the office publishes: name, description, the meals it covers (breakfast / lunch / snack / dinner), which weekdays it runs, a price, a validity window, and a capacity so a plan can be capped at what the kitchen can actually cook.
Critically, each plan declares an allergens array from a fixed vocabulary (nuts, dairy, gluten, egg, soy, shellfish, fish, sesame). This is a closed enum on purpose — free-text allergen labels do not compare reliably, and a comparison that silently fails is worse than no check at all.
Canteen accounts — backend/models/CanteenAccount.js
One prepaid account per student, holding:
balance — the spendable amount
ledger[] — an append-only list of every top-up, charge, refund and reversal, each with a running balanceAfter so the account can be audited without replaying arithmetic
dietaryFlags[] — the student's declared allergens, from the same vocabulary as the plans
subscriptions[] — which meal plans this student is on and until when
dailySpendLimit — a parent-set cap
The three things this has to get right
1. Debits must be atomic. A canteen at lunchtime is the definition of a concurrent workload — the same student's account can be hit by two tills. A read-then-write debit lets both reads see the same balance and both writes succeed, and the account goes negative. The debit needs to be a single conditional update whose filter carries the sufficient-funds test ($expr: { $gte: ['$balance', amount] }), so the losing request matches no document and gets a clean 409 instead of an overdraft.
2. A charge must be idempotent. The counter tablet is on school wifi. A request that times out after the server committed it will be retried by a human pressing the button again, and the student gets charged twice for one sandwich. Each charge should carry a client-supplied idempotencyKey; a replay of a key already in the ledger should return the original entry rather than making a second one.
3. Allergen conflicts must block the sale, not warn about it. If the plan being charged declares an allergen the student has flagged, the request should fail with a 409 naming the allergen. Not a warning banner someone can click past.
API — /api/cafeteria
| Method |
Path |
Who |
| GET |
/plans |
any signed-in user |
| POST |
/plans |
admin / staff |
| PUT |
/plans/:id |
admin / staff |
| DELETE |
/plans/:id |
admin / staff |
| GET |
/account/me |
student — own account and ledger |
| GET |
/accounts |
admin / staff |
| POST |
/accounts/:id/topup |
admin / staff |
| POST |
/accounts/:id/charge |
admin / staff — the atomic debit |
| POST |
/accounts/:id/refund |
admin / staff |
| PATCH |
/accounts/:id/dietary |
student (own) or staff |
| POST |
/accounts/:id/subscribe |
admin / staff |
| GET |
/summary |
admin / staff |
Frontend
A /cafeteria page. Students see their balance, recent ledger entries, declared allergens and active subscriptions. Admin and office staff get a counter panel instead — search a student, see their allergen flags in red before anything else on the card, take a payment, top up.
Alternative Approaches
Fold it into the existing fee module. Rejected above — an invoice is settled once, a canteen balance is decremented dozens of times. Sharing the model would mean either FeeInvoice grows a mode flag that changes what half its fields mean, or canteen spending gets recorded as hundreds of tiny invoices.
Charge at the end of the month instead of prepaying. Simpler to build, but it removes the daily spend cap, which is the feature parents actually ask for, and it means the school extends credit to every family.
A warning rather than a hard block on allergens. A warning is the current situation with extra steps.
Affected Area
Mockups / Additional Context
Follows the module layout already in the repo — models/ + controllers/ + routes/ registered in backend/server.js, page in frontend/src/pages/, staff panel in frontend/src/components/, exactly as the fee module (#250) and meeting module (#272) do.
I'd like to pick this one up.
Problem Statement
The school runs a canteen, but nothing about it exists in the app. Today the whole thing is run off a paper register at the counter:
backend/models/FeeInvoice.jscovers tuition invoicing, but a canteen balance is a fundamentally different object: it is a prepaid, decrementing balance spent many times a day in small amounts, not a bill that gets settled once. Trying to model it as an invoice would be wrong.Proposed Solution
A cafeteria module with two pieces: meal plans (what is on offer) and a prepaid canteen account per student (how it gets paid for).
Meal plans —
backend/models/MealPlan.jsA plan the office publishes: name, description, the meals it covers (
breakfast/lunch/snack/dinner), which weekdays it runs, a price, a validity window, and a capacity so a plan can be capped at what the kitchen can actually cook.Critically, each plan declares an
allergensarray from a fixed vocabulary (nuts,dairy,gluten,egg,soy,shellfish,fish,sesame). This is a closed enum on purpose — free-text allergen labels do not compare reliably, and a comparison that silently fails is worse than no check at all.Canteen accounts —
backend/models/CanteenAccount.jsOne prepaid account per student, holding:
balance— the spendable amountledger[]— an append-only list of every top-up, charge, refund and reversal, each with a runningbalanceAfterso the account can be audited without replaying arithmeticdietaryFlags[]— the student's declared allergens, from the same vocabulary as the planssubscriptions[]— which meal plans this student is on and until whendailySpendLimit— a parent-set capThe three things this has to get right
1. Debits must be atomic. A canteen at lunchtime is the definition of a concurrent workload — the same student's account can be hit by two tills. A read-then-write debit lets both reads see the same balance and both writes succeed, and the account goes negative. The debit needs to be a single conditional update whose filter carries the sufficient-funds test (
$expr: { $gte: ['$balance', amount] }), so the losing request matches no document and gets a clean 409 instead of an overdraft.2. A charge must be idempotent. The counter tablet is on school wifi. A request that times out after the server committed it will be retried by a human pressing the button again, and the student gets charged twice for one sandwich. Each charge should carry a client-supplied
idempotencyKey; a replay of a key already in the ledger should return the original entry rather than making a second one.3. Allergen conflicts must block the sale, not warn about it. If the plan being charged declares an allergen the student has flagged, the request should fail with a 409 naming the allergen. Not a warning banner someone can click past.
API —
/api/cafeteria/plans/plans/plans/:id/plans/:id/account/me/accounts/accounts/:id/topup/accounts/:id/charge/accounts/:id/refund/accounts/:id/dietary/accounts/:id/subscribe/summaryFrontend
A
/cafeteriapage. Students see their balance, recent ledger entries, declared allergens and active subscriptions. Admin and office staff get a counter panel instead — search a student, see their allergen flags in red before anything else on the card, take a payment, top up.Alternative Approaches
Fold it into the existing fee module. Rejected above — an invoice is settled once, a canteen balance is decremented dozens of times. Sharing the model would mean either
FeeInvoicegrows a mode flag that changes what half its fields mean, or canteen spending gets recorded as hundreds of tiny invoices.Charge at the end of the month instead of prepaying. Simpler to build, but it removes the daily spend cap, which is the feature parents actually ask for, and it means the school extends credit to every family.
A warning rather than a hard block on allergens. A warning is the current situation with extra steps.
Affected Area
Mockups / Additional Context
Follows the module layout already in the repo —
models/+controllers/+routes/registered inbackend/server.js, page infrontend/src/pages/, staff panel infrontend/src/components/, exactly as the fee module (#250) and meeting module (#272) do.I'd like to pick this one up.