Skip to content

[Feature]: Cafeteria meal plans & prepaid canteen wallet with allergen-safe, idempotent charging #282

Description

@MOHITKOURAV01

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

  • New page
  • New component
  • Other: new Mongoose models, controller and route module

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions