Fees Features and Flows
Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for the fee management module.
Fees Features and Flows
1. Documentation Evidence
| Source Type | Files or Docs | What Was Extracted |
|---|---|---|
| Schema | packages/db/src/schema/school/fees-*.ts, packages/db/src/migrations/0044_fee_management_integrity.sql | Every table, enum, constraint, and the guard triggers behind each lifecycle |
| Calculation engine | apps/api/src/utils/fees/fee-calculation.util.ts, fee-charge-once.util.ts, fee-rounding.util.ts | The fixed calculation order, rounding, caps, charge-once anchor rule |
| Resolver | apps/api/src/modules/fees/shared/fee-resolution.service.ts | Placement, structure/version resolution, charge-once eligibility |
| Controllers | apps/api/src/modules/fees/**/*.controller.ts (22 files) | Every admin and portal route, its permission, and its purpose |
| Permission catalog | packages/db/src/authorization/permission-catalog.ts | The 25 fee permission modules and their default grants |
| Notification matrix | packages/db/src/schema/school/fees-contract.ts, apps/api/src/modules/notification/templates/fee-templates.ts | The 26 fee notification events, their audiences, channels, and templates |
| Queue and scheduler | packages/jobs/src/index.ts, apps/api/src/modules/fees/workers/*.ts | FeeJob, the five-minute sweep, and its six passes |
| Worked examples | apps/api/src/utils/fees/fee-calculation.util.spec.ts, apps/api/src/modules/fees/__tests__/fee-calculation.integration.spec.ts | Exact numbers for every calculation rule below |
| Sibling docs | This module's backend.mdx, api.mdx | Cross-referenced throughout |
2. Feature Summary
| Field | Value |
|---|---|
| Module | fees |
| Submodules | catalog, structures, modifiers, obligations, runs, payments, sync, portal, reports, settings, workers, shared |
| Primary user value | A school bills every pupil the correct amount for every month, term, and one-time charge, tracks what is paid, and lets a linked accounting application read and reconcile the same figures without re-entering them. |
| Actors | Admin/office staff (with fee permissions), pupils, guardians, the accounting application (via the integration module), the daily sweep and BullMQ workers |
| Main entry points | /api/fees/** admin routes, /api/mobile/fees/** portal routes, /api/integration/fees/** (documented in the integration module), the five-minute cron sweep, BullMQ jobs on QueueName.FEES |
| Main outputs | Student fee obligations and their line breakdowns, payment records, generation runs, the fee change feed (consumed by integration), in-app/push/email/SMS notifications |
| Related docs | Backend, API |
3. Actor Matrix
| Actor | Can Do | Cannot Do | Auth Requirement | Notes |
|---|---|---|---|---|
Office / accountant (granted Fee* codes) | Manage the catalogue, structures, scholarships/charges, generate and recalculate fees, record and correct payments, resolve accounting sync exceptions | Anything a code they were not granted covers — every fee code is withheld from staff/teacher by default | JWT + RoleGuard + the specific Fee*_ACTION code | No fee code is part of the automatic admin grant except FeeDashboard/FeeCategories/FeeComponents/FeeStructures/FeeScholarships/FeeCharges/StudentFees/FeePayments/FeeSync/FeeSettings-family read/write; money-moving and disclosure codes (FeePaymentCorrections, FeeSyncResolution, FeeMappings, FeeFinancialSettings, FeeExports, FeeIntegrations) are superadmin-only and must be granted to a named role |
Pupil (student role) | Read their own fees list and one fee's detail | See any other pupil's fee, make a payment, see internal reasons/notes | JWT + RoleGuard + MyFees_READ + the route's own assertPortalAudience(actor, "student") check | /api/mobile/fees/me, /api/mobile/fees/me/:obligationId |
Guardian (guardian role) | Read each linked child's fees list and detail | Read a child not linked to them, make a payment | JWT + RoleGuard + MyFees_READ + assertPortalAudience(actor, "guardian") + the child resolved against the guardian's own links before anything is read | /api/mobile/fees/children, /api/mobile/fees/children/:studentId, /api/mobile/fees/children/:studentId/:obligationId |
| Accounting application | Read the catalogue, structures, scholarships/charges, students, obligations; report payments; manage student/invoice mappings; poll the change feed; receive webhooks | Write to anything but payments and mappings; read another accounting client's private mapping events | Authorization: Bearer ssk_… (API key) + scope (fees:read, students:read, payments:write, mappings:write) — see the integration module's docs | Every route is @Public() to the staff JwtAuthGuard; the API-key guard is its own authentication |
| Sweep / BullMQ worker | Finalize started periods, auto-generate and re-price fees, send reminders, apply new rule matches, plan and run generation runs, process payment sync events, fan out change events | Anything requiring a human decision (a manual adjustment, a cancellation reason) | Internal — no HTTP surface | FeeQueueProcessor on QueueName.FEES, concurrency: 2 |
4. Capability Matrix
| Capability | Surface | Actor | Route/Trigger | State Read | State Written | Linked API Section |
|---|---|---|---|---|---|---|
| Manage categories/components | Admin | Office | /api/fees/categories, /api/fees/components | fee_categories, fee_components | Same | Catalogue |
| Prepare/edit fee periods | Admin | Office | POST /api/fees/periods/prepare, PATCH /api/fees/periods/:id | academic_sessions, fee_settings | fee_periods, cascades due-date edits to every non-cancelled obligation of the period | Catalogue |
| Manage terms | Admin | Office | /api/fees/terms | academic_sessions | fee_terms (no-overlap EXCLUDE constraint) | Catalogue |
| Draft/edit/activate a fee structure | Admin | Office | /api/fees/structures/**, /api/fees/structures/:id/activate | fee_categories, fee_components | fee_structures, fee_structure_targets, fee_structure_lines (draft) → fee_structure_versions, fee_structure_version_lines (on activate) | Structures |
| Revise an active structure | Admin | Office | POST /api/fees/structures/:id/revisions (preview then commit) | Current version | A new fee_structure_versions row, a structure_revision generation run to re-price open obligations | Structures |
| Create/activate/end a scholarship or charge | Admin | Office | /api/fees/scholarships/**, /api/fees/charges/** | fee_categories | fee_modifiers | Modifiers |
| Preview/apply rule-mode eligibility | Admin | Office | POST /api/fees/{scholarships,charges}/:id/eligibility-preview, /apply | Live placements against eligibilityRules | fee_modifier_assignments, a modifier_apply run | Modifiers |
| Assign/end a manual stint | Admin | Office | POST /api/fees/{scholarships,charges}/:id/assignments, .../end | — | fee_modifier_assignments, a modifier_end run on ending | Modifiers |
| Generate fees for a scope | Admin | Office | POST /api/fees/generation-runs | Placements, structure versions, modifier stints | fee_generation_runs, items, fee_obligations, fee_obligation_lines | Runs |
| Retry/cancel a run | Admin | Office | POST /api/fees/generation-runs/:id/{retry,cancel} | Run/item state | Same | Runs |
| Adjust/waive/reverse | Admin | Office | POST /api/fees/student-fees/:id/{adjustments,waivers}, .../reverse | Obligation | fee_obligation_lines, fee_obligation_events | Obligations |
| Finalize/cancel/reissue | Admin | Office | POST /api/fees/student-fees/:id/{finalize,cancel,reissue} | Obligation | fee_obligations, releases charge-once lines, may create a reissued obligation | Obligations |
| Record/reverse/correct a payment | Admin | Office (corrections: superadmin-granted) | /api/fees/student-fees/:id/payments, .../reverse, .../payment-correction | Obligation | fee_payment_entries, fee_obligations.paid_paisa (via FeeLedgerService) | Payments |
| Read/resolve sync exceptions | Admin | Office (resolution: superadmin-granted) | /api/fees/sync-events/** | fee_payment_sync_events | Same, plus applies or rejects onto an obligation | Payments |
| Manage student/invoice mappings | Admin | Office (superadmin-granted) | /api/fees/student-mappings, /api/fees/invoice-mappings | — | fee_external_student_mappings, fee_external_invoice_mappings | Payments |
| Configure calendar/policies/notifications | Admin | Office | /api/fees/settings, /api/fees/settings/financial, /api/fees/notification-settings | — | fee_settings, fee_notification_settings | Settings |
| Export student fees to CSV | Admin | Office (superadmin-granted) | GET /api/fees/exports/student-fees | Filtered obligations | — (streams a file) | Reports |
| View dashboard/change log | Admin | Office | /api/fees/dashboard, /api/fees/change-log | Aggregates over obligations/events | — | Reports |
| Read own fees | Portal | Pupil/guardian | /api/mobile/fees/** | fee_obligations scoped to the pupil | — | Portal |
| Auto-generate and reprice | Worker | System | Five-minute sweep, auto_generate pass, once per Nepal day | Enabled sessions/periods | auto and placement_review runs | Runs |
| Finalize started periods | Worker | System | Sweep, finalize_started_periods pass, every tick | Open obligations whose period has started | fee_obligations.lifecycle = 'finalized' | Obligations |
| Send reminders | Worker | System | Sweep, reminders pass | Open/unpaid obligations by due date | fee_reminder_log, notification sends | — |
| Apply new rule matches | Worker | System | Sweep, rule_new_matches pass, once per Nepal day | Active rule-mode modifiers with auto_apply_new_matches | fee_modifier_assignments, modifier_apply runs | Modifiers |
| Re-match held payments | Worker | System | Sweep, sync_rematch pass | fee_external_transactions in held_unmatched/held_conflict | Applies or reclassifies | Payments |
| Process a payment sync event | Worker | System | FeeJob.PAYMENT_SYNC | fee_payment_sync_events | fee_external_transactions, fee_payment_entries, fee_obligations.paid_paisa | Payments |
| Fan a change event out | Worker | System | FeeJob.CHANGE_FANOUT | integration_change_events | integration_webhook_deliveries (see integration module) | — |
5. User-Facing Flows
5.1 A pupil reads their own fees
Summary
A pupil or a guardian opens the fees screen and sees every fee owed for the current or a chosen academic session, then drills into one fee's full breakdown.
Preconditions
- A valid JWT with an active role of
studentorguardian. - The role holds
MyFees_READ(seeded to both roles by default). - For a guardian, the pupil must be one of their linked children.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Pupil/guardian | GET /api/mobile/fees/me or /children/:studentId | Route resolves the pupil (self, or the guardian's link) | fee-student-portal.controller.ts, fee-guardian-portal.controller.ts |
| 2 | Backend | assertPortalAudience(actor, "student"|"guardian") | Confirms the caller's role scope kind matches; a mismatch is 403 | portal-audience.ts |
| 3 | Backend | Reads fee_obligations filtered to the pupil, optionally the session | A list of fee summaries | fee-student-portal.service.ts, fee-guardian-portal.service.ts |
| 4 | Backend | Maps to FeePortalFeeDto/FeePortalFeeListDto | No internal reason, note, or actor identity | fee-portal.mapper.ts |
| 5 | Client | Renders the list; the pupil taps one fee | GET /api/mobile/fees/me/:obligationId | — |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Wrong audience | A staff/teacher token calls the route | Refused before any row is read | 403, audience mismatch |
| Guardian, unlinked child | studentId is not one of the guardian's children | Row never resolved | 404 — never discloses that the child exists |
| No fees yet | The pupil has no obligation for the session | Empty list | 200 with items: [] |
| Cancelled fee | An obligation was cancelled | Still returned, paymentStatus: "cancelled" | Visible, not hidden |
| Detail of another pupil's fee | A pupil requests an obligationId that is not theirs | Row-scoped query returns nothing | 404 |
5.2 The office generates fees for a period
Summary
An accountant asks the system to bill a period (or a filtered scope of students) against the active fee structure; a background run does the actual per-student work so the request returns immediately.
Preconditions
- The period exists (
fee_periods, prepared from the academic session). - An active fee structure targets every grade/section the run should cover.
- The academic session is not locked (
lockStructureActivation).
Every screen that narrows work to a grade or a section — the generation-run
scope, the student-fee filters, a structure's targets and a scholarship or
charge's eligibility rules — offers the classes the chosen session ACTUALLY
runs, read from the classes module (GET /api/classes/options, keyed by the
session's integer id). The grade and section reference tables hold every grade
from Nursery to 12 and every section letter the school might ever use; offering
those would let an operator scope a run, a target or a rule to a class that
does not exist. A caller without Classes_READ sees no grade or section
filter rather than an empty one.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Office | POST /api/fees/generation-runs with a scope (periods, optional grade/section/student filter) | A fee_generation_runs row, status: "queued", idempotent on requestKey | fee-runs-admin.service.ts, fee-run-launcher.service.ts |
| 2 | Backend | Enqueues FeeJob.GENERATION_PLAN in the same transaction (outbox) | The plan job resolves target (student, period) pairs into fee_generation_run_items, batched | fee-run-worker.service.ts |
| 3 | Worker | FeeJob.GENERATION_CHUNK per batch | For each item: resolve placement/structure/modifiers, calculateFee, write the obligation | fee-run-worker.service.ts, fee-obligation-writer.service.ts |
| 4 | Backend | Recounts the run's counters from its items; finalizes when planning is complete and nothing is pending | status: "completed" or "completed_with_exceptions" | fee-run-worker.service.ts |
| 5 | Backend | Change-feed events, notifications | integration_change_events, fees.bulk_completed/.admin | fee-change-feed.service.ts, fee-notifier.service.ts |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Empty scope | No student matches the filter | Run completes with totalItems: 0 | FEE_RUN_SCOPE_EMPTY refused at creation if the scope resolves to nothing up front |
| No placement | A targeted student has no placement at the evaluation date | Item outcome not_applicable | Counted, not an item failure |
| Ambiguous structure | Two grade-wide (or two section-level) structures both target the pupil on the same date | Item fails with FEE_STRUCTURE_AMBIGUOUS | Item failed, run completed_with_exceptions |
| Slot already taken | An annual/term/one-time component's charge-once slot is already billed elsewhere | Line outcome slot_unbilled — the item still succeeds, just without that line | Documented in the obligation's calc detail |
| Already exists | A live obligation already exists for the (student, period) | Outcome already_exists, no duplicate write | Idempotent |
| Cancelled exists | The (student, period) slot holds a cancelled obligation | Outcome cancelled_exists — the slot stays free unless explicitly reissued | No new obligation |
| Session locked | lockStructureActivation is held exclusively (an activation/revision in progress) | The run is refused or waits, never races the structure change | FEE_SESSION_LOCKED |
| Run retried | POST .../retry on a failed/completed_with_exceptions run | Only failed items re-run, under a bumped leaseGeneration | New chunk jobs, same run row |
| Run cancelled | POST .../cancel on a non-terminal run | Pending items stop being claimed | status: "cancelled" |
5.3 The office corrects a mistake
Summary
Adjustments, waivers, reversals, cancellations and reissues are the manual escape hatches over the otherwise system-computed fee — used for a scholarship applied late, a billing error, or a withdrawal.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Office | POST /api/fees/student-fees/:id/adjustments (or /waivers) with an amount, label, reason, and the version last read | A manual fee_obligation_lines row, origin: "manual" | fee-student-fee-actions.service.ts |
| 2 | Backend | Recomputes totals from the LIVE line set, checked at commit by the deferred constraint trigger | fee_obligations totals updated; net < 0 is refused | fee_obligations_guard, FEE_NET_NEGATIVE |
| 3 | Backend | Writes a fee_obligation_events row with before/after amounts | Audit trail | recordObligationEvent |
| 4 | Backend | Notifies the family and/or office per the notification matrix | fees.fee_adjusted, .admin | fee-notifier.service.ts |
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Stale version | The version in the request no longer matches | Refused before any write | FEE_VERSION_CONFLICT |
| Finalized obligation | The obligation already finalized | Adjustments and waivers are still allowed (finalization freezes the CALCULATION, not manual lines) — see backend doc | — |
| Zero amount | amountPaisa === 0 | Refused | FEE_ADJUSTMENT_INVALID |
| Reversal target already reversed | .../adjustments/:lineId/reverse on an already-reversed line | Refused, one reversal per line (unique index) | FEE_ADJUSTMENT_ALREADY_REVERSED |
| Cancel with payments | .../cancel on an obligation with any payment entry | Refused — a cancellation only ever applies to nothing-paid obligations | FEE_OBLIGATION_HAS_PAYMENTS |
| Cancel without payments | Cancel on an unpaid obligation | Lifecycle → cancelled, its charge-once lines released, and the released once-only charge is re-offered — see below | fee_obligations_cancel_complete requires a 3–1000 character reason |
| Reissue | .../reissue on a cancelled obligation | A brand-new obligation for the same (student, period), reissuedFromObligationId set | Slot re-taken |
Cancelling a fee that held a charge-once (annual/term/one-time) line releases that line, then looks
for somewhere the released charge can land. If the student has another OPEN fee in the same
academic session, that fee is re-priced so it picks the charge up. If not, and a later period of
the session still has no live fee for the student, nothing is recorded — the anchor rule means that
later period takes the charge when it is generated, and recording slot_unbilled here would invite
an office user to bill the family a second time. Only when neither of those is true — no open fee
can take it, and no later period is still pending — does the cancellation record a slot_unbilled
event for an administrator to resolve by hand.
6. Admin Flows
6.1 Activating a fee structure
Route: POST /api/fees/structures/:id/activate. Permission: FeeStructureActivation_UPDATE.
Side effects: an integration_change_events row (fee_structure.activated), an admin notice (fee_structure_changed), and the structure's dates and session become fixed by the header guard trigger — nothing returns to draft.
6.2 Revising an active structure
Route: POST /api/fees/structures/:id/revisions/preview then POST /api/fees/structures/:id/revisions. Permission: FeeStructureActivation_UPDATE.
The preview shows what would change without writing anything; the commit creates a new immutable version (versionNo + 1, its own effectiveFrom) and launches a structure_revision generation run that re-prices every OPEN obligation the new version now governs. Already-finalized obligations are untouched — this is why finalization exists.
6.3 Ending a scholarship or charge
Route: POST /api/fees/{scholarships,charges}/:id/end. Permission: FeeScholarships_UPDATE/FeeCharges_UPDATE.
Applicability is by date, not by status — a period whose evaluation date is on or before effective_until still receives the modifier even after it is ended.
6.4 Recording a manual payment
Route: POST /api/fees/student-fees/:id/payments. Permission: FeePayments_CREATE.
Idempotent on an Idempotency-Key header (stored as idempotency_key + request_hash); a retried request with the same key and the same body returns the original result rather than double-recording. FeeLedgerService is the only writer of fee_payment_entries and of fee_obligations.paid_paisa, and a deferred trigger proves the two agree at commit.
6.5 Downloading a payment receipt
Routes: GET /api/fees/payments/:paymentId/receipt (office, FeeReceipts_READ), GET /api/mobile/fees/children/:studentId/payments/:paymentId/receipt (guardian) and GET /api/mobile/fees/me/payments/:paymentId/receipt (pupil), the last two under MyFees_READ.
When the accounting application reports a payment, it issues a receipt. A payment entry whose receiptAvailable is true can be downloaded by the office, and by the pupil or guardian it belongs to, as a PDF the school fetches from accounting on request and relays; the school stores no copy. A client shows the download action only when receiptAvailable is true. An entry the office recorded by hand, a correction, a reversal, a cancelled fee, or any entry while receipt download is switched off has no receipt. A guardian can download only their own children's receipts; a pupil only their own; anything else answers 404, indistinguishable from "no receipt". If accounting is down, slow or answers something other than a PDF, the caller gets a 503 and can try again later.
7. Lifecycle and State Transitions
7.1 Fee structure
| From | Event/Action | To | Guard Condition | Side Effects |
|---|---|---|---|---|
draft | Activate | active | ≥1 target, ≥1 line | Version 1 snapshot |
active | Deactivate | inactive | — | No new obligations resolve against it (existing ones unaffected) |
inactive | Reactivate | active | — | — |
active/inactive | Archive | archived | Not referenced by an open obligation's CURRENT structure (existing obligations keep their version reference regardless) | Terminal |
active | Revise | active (new version) | — | New fee_structure_versions row, structure_revision run |
7.2 Scholarship / charge (modifier)
| From | Event/Action | To | Guard Condition | Side Effects |
|---|---|---|---|---|
draft | Activate | active | Value shape, base scope, eligibility rules valid | — |
active | End | ended | No finalized line dated after the chosen end date | effective_until set, modifier_end run |
ended | Archive | archived | No open obligation still carries a live line for it | Terminal |
draft | Delete | (removed) | Draft only | Hard delete |
7.3 Student fee obligation
| From | Event/Action | To | Guard Condition | Side Effects |
|---|---|---|---|---|
open | Period starts (sweep) | finalized | finalized_at stamped | Calculation freezes; payments/adjustments/cancellation still allowed |
open | Cancel | cancelled | No payment entries, reason 3–1000 chars | Charge-once lines released, slot freed |
finalized | Cancel | cancelled | Same | Same |
cancelled | Reissue | a NEW open obligation | — | reissued_from_obligation_id links them |
payment_status is a GENERATED column, never written directly: cancelled (lifecycle) → waived (net 0, paid 0, a waiver exists) → nothing_due (net 0, paid 0, no waiver) → unpaid → partially_paid → paid → overpaid.
7.4 Generation run
A run's counters are always RECOUNTED from its items, never incremented blindly, and it finalizes only when planning_completed_at is set and no item is pending — whichever of the last plan batch, the last chunk, or the watchdog observes that state last.
9. Data and Side Effects by Flow
| Flow | DB Writes | Cache Effects | Jobs | Realtime | Notifications |
|---|---|---|---|---|---|
| Generate fees | fee_generation_runs, items, fee_obligations, fee_obligation_lines, fee_obligation_events | — | GENERATION_PLAN, GENERATION_CHUNK | — | fee_generated (family), bulk_completed/bulk_exception (admin) |
| Activate/revise structure | fee_structures, fee_structure_versions(_lines) | — | structure_revision run on a revision | — | fee_structure_changed (admin) |
| Apply a scholarship/charge | fee_modifiers, fee_modifier_assignments | — | modifier_apply/modifier_end run | — | scholarship_applied/charge_applied (family), scholarship_review_required (admin, not-evaluated pupils) |
| Record a payment | fee_payment_entries, fee_obligations.paid_paisa | — | CHANGE_FANOUT (change feed) | — | payment_received/payment_partial/payment_full (family), payment_status_changed (admin) |
| Adjust/waive | fee_obligation_lines, fee_obligation_events | — | CHANGE_FANOUT | — | fee_adjusted (family) |
| Cancel | fee_obligations, releases lines | — | CHANGE_FANOUT | — | fee_cancelled (family) |
| Payment sync intake | fee_payment_sync_events, fee_external_transactions, fee_payment_entries | — | PAYMENT_SYNC | — | sync_failed/payment_unmatched/sync_conflict (admin) |
| Reminders (sweep) | fee_reminder_log | — | — | — | reminder_before_due/reminder_due/reminder_overdue (family) |
10. Error and Recovery Flows
| Scenario | Trigger | User/System Experience | Recovery | Source |
|---|---|---|---|---|
| Ambiguous placement | Two structures or two placements tie on specificity/date | Item fails, run completed_with_exceptions | Office fixes the structure targeting, retries the run | fee-resolution.service.ts |
| Session locked | An activation/revision holds the exclusive session lock while a run tries to start | FEE_SESSION_LOCKED | Retry after the lock releases | fee-locks.util.ts |
| Payment sync failure | The final attempt of FeeJob.PAYMENT_SYNC throws | Event marked failed, office notified (sync_failed) — never left pending forever | Office resolves manually via /api/fees/sync-events/:id/resolve or retries | FeeQueueProcessor.onFailed |
| Held unmatched payment | externalRef matches no mapping | fee_external_transactions.state = held_unmatched | The daily sync_rematch pass, or a mapping created later, resolves it automatically | fee-sync-rematch.service.ts |
| Receipt unavailable | A receipt download while accounting is unreachable, slow, or returns something other than a PDF | 503 ACCOUNTING_RECEIPT_UNAVAILABLE; receiptAvailable is unchanged because it reflects the school's records, not accounting's liveness | Try again later | accounting-receipt.client.ts |
| No receipt for a payment | A receipt request for an office-recorded entry, a correction, a cancelled fee, another family's payment, or while receipt download is off | 404 FEE_RECEIPT_NOT_AVAILABLE | None; do not request a receipt when receiptAvailable is false | fee-receipt-resolver.service.ts |
| Net would go negative | An adjustment/waiver would take netPaisa below 0 | Refused before any write | FEE_NET_NEGATIVE — the office enters a smaller amount | fee-calculation.util.ts |
12. Mandatory Feature and Flow Deep-Dive Pack
12.1 Feature Inventory With Minor Behaviors
| Feature | Minor Behavior | Actor | Trigger | User/System Result | Backend Side Effect | Source |
|---|---|---|---|---|---|---|
| Fee period preparation | Global uniqueness of (bsYear, bsMonth) across every session | Office | POST /api/fees/periods/prepare | A period can never be billed by two sessions | Composite unique constraint | fee-periods.ts |
| Due date edit | Editing a period's due date cascades | Office | PATCH /api/fees/periods/:id | Every non-cancelled obligation of the period gets the new due date | FeeObligationActionsService.propagateDueDate, in the same transaction | fee-periods.service.ts |
| Term overlap | Two terms of one session can never overlap | Office | POST /api/fees/terms | A second overlapping term is refused | fee_terms_no_overlap EXCLUDE (btree_gist) | 0044_fee_management_integrity.sql |
| Percentage component base | A percentage component is a percentage of the FLAT subtotal only, never of another percentage | System | Every calculation | Prevents compounding | fee-calculation.util.ts | |
| Scholarship on an absent component | A scholarship scoped to a component the pupil does not have | System | Calculation | A zero, capped: true line — explainable, not silently missing | fee-calculation.util.ts | |
| Charge on an absent component | Same, for a charge | System | Calculation | A zero line, capped: false (charges are never capped) | fee-calculation.util.ts | |
| Charge-once anchor | An annual/term/one-time component bills on the EARLIEST eligible period, regardless of generation order | System | Resolution | A period generated out of order does not double-bill or skip | fee-charge-once.util.ts | |
| Mid-period join | A pupil placed mid-period | System | Resolution | charge_period: billed from the join date. next_period: nothing owed this period | fee_settings.mid_period_join_policy | |
| Withdrawal | A pupil's placement ends | Worker (sweep, placement_review) | Recalculation | cancel_unpaid_open: unpaid open obligations for periods after withdrawal are cancelled. flag: left for manual review | fee_settings.withdrawal_policy | |
| Section change | A pupil moves sections mid-session | Worker | Recalculation | keep: existing obligations keep their old placement snapshot. recalculate_open: open obligations are re-priced against the new section's structure | fee_settings.section_change_policy | |
| Rounding | Every computed (percentage) amount rounds to the configured unit and mode | System | Calculation | Flat amounts never round — entered exactly | fee-rounding.util.ts | |
| Scholarship stacking | stack: every non-grouped scholarship applies. highest_only: only the single largest scholarship across ALL candidates applies | System | Calculation | Changes which scholarships a pupil actually receives | fee_settings.scholarship_stacking | |
| Stack group | Scholarships sharing a stackGroup (scholarships only) | System | Calculation | Only the largest candidate in the group survives; the rest are recorded as dropped_scholarships with reason stack_group | fee-calculation.util.ts | |
| Gross cap | maxScholarshipBp bounds total scholarships as a percentage of gross | System | Calculation | Kept candidates are granted amount-DESC, id-ASC, each capped by its own remaining scope AND the shared remaining gross cap | fee-calculation.util.ts | |
| Idempotent payment | An Idempotency-Key header on a manual payment | Office | POST .../payments | A retried identical request returns the original result, never double-records | fee_payment_entries_idempotency_unique | |
| Idempotent sync event | An accounting client's own eventId | Accounting app | POST /api/integration/fees/payments | A replayed event returns replayed: true, no second write | fee_payment_sync_events_client_event_unique | |
| Reminder de-duplication | One reminder slot (before/due/after/repeat:<k>) per obligation, ever | Worker | Sweep reminders pass | A re-run sweep can never send the same slot twice | fee_reminder_log primary key | |
| Consolidated notifications | fee_settings.consolidate_notifications | System | Every family notice | A guardian with several children gets one message per event, not one per child, when enabled | fee-notifier.service.ts | |
| Public CSV export cap | FEE_EXPORT_TOO_LARGE | Office | GET /api/fees/exports/student-fees | A filter matching too many rows is refused rather than streaming an unbounded file | fee-exports.service.ts |
12.3 Business Rules and Policy Traceability
| Rule | Business Reason | Actor Impact | Enforced In | API Impact | Backend Impact | Tests |
|---|---|---|---|---|---|---|
| A student fee obligation's net can never be negative | A school never owes a family credit through this system | An adjustment/waiver that would do so is refused | calculateFee | FEE_NET_NEGATIVE | No write occurs | fee-calculation.util.spec.ts |
| A percentage component is of the flat subtotal only | Prevents compounding percentages into an unpredictable total | The fee is easy to explain to a parent | fee-calculation.util.ts step 1 | basePaisa on the response line shows what it was taken of | — | §"percentage component" tests |
| Scholarships are measured before charges, charges never after scholarships are subtracted | Charges are punitive/administrative and should not shrink because a scholarship was granted | A late fee is the same whether or not a pupil has a scholarship | fee-calculation.util.ts step 5 comment | — | — | "charges are measured on gross" test |
| Charge-once components bill on the earliest eligible period | A component marked annual/term/one_time must bill exactly once, however runs are ordered | The family is billed once, predictably | fee-charge-once.util.ts, fee-resolution.service.ts | Manifests as slot_unbilled on a later period | Unique partial indexes make a double-bill a DB-level impossibility | "generated in order P3, P1, P2 charged on P1 only" |
| A cancellation is only allowed with zero payments | Cancelling a paid fee would leave the ledger inconsistent (money received for nothing owed) | Money already paid must go through a payment reversal first, not a cancellation | fee-student-fee-actions.service.ts | FEE_OBLIGATION_HAS_PAYMENTS | — | — |
| A finalized obligation's calculation never recalculates | Once a period has started, the bill is the bill — a later structure edit should not silently change a bill already sent | Manual adjustments/waivers still work; nothing else recomputes | fee_obligations_guard trigger | — | 23514 on any attempt | — |
record_status is never a fee eligibility signal | It is a record-keeping toggle on students/student_enrollments, not the enrolment lifecycle | An administrative toggle can never silently exempt or re-bill a pupil | fee-resolution.service.ts placement query | — | — | — |
12.5 Flow Edge-Case Matrix
| Flow | Edge Case | Trigger | Expected Behavior | User/System Feedback | Source |
|---|---|---|---|---|---|
| Generate | Same run requested twice | Same requestKey | Second request returns the first run, no duplicate | 201 with the same run | fee-run-launcher.service.ts |
| Generate | Structure has no line for the pupil's grade | No matching target | Item not_applicable | Item counted, not a failure | fee-resolution.service.ts |
| Adjust | Concurrent adjustment from two tabs | Two requests read the same version | Second write refused | FEE_VERSION_CONFLICT | fee-locks.util.ts |
| Payment | Overpayment | paidPaisa > netPaisa | Allowed, payment_status: "overpaid" | Shown to the family as an overpayment, not an error | fee-calculation.util.ts mirror |
| Reminder | Obligation already fully paid before the due-date reminder fires | Sweep reads balance at send time | No reminder sent (nothing owed) | Silent skip | fee-reminders.service.ts |
| Sync | Two payment events name the same externalRef with conflicting amounts | Second event's identity disagrees with the first's resolution | held_conflict, admin notified | sync_conflict notice, requires manual resolution | fee-sync-matcher.ts |
| Portal | A guardian's child is later unlinked | Guardian re-requests the child's fee | Row-scope query returns nothing | 404 | fee-guardian-portal.service.ts |
13. Completion Checklist
- Every catalogue, structure, modifier, obligation, run, payment, sync, portal, settings, and reporting capability is listed.
- Every actor (office, pupil, guardian, accounting application, worker) has allowed and forbidden behavior stated.
- Major user, admin, and worker flows include steps, branches, and diagrams.
- Every lifecycle (structure, modifier, obligation, run) has a transition table and state diagram.
- Flows link to the API and backend docs.
- The calculation engine's business rules are traced to enforcing code and tests.
See Also
- Backend doc:
/docs/developer/fees/backend - API doc:
/docs/developer/fees/api
Complaints API Reference
Complete API contracts for Complaints, including routes, auth, DTOs, responses, errors, examples, and integration notes.
Fees Backend Documentation
Backend architecture, data model, the calculation engine, services, queues, and operational behavior for the fee management module.