Fees Backend Documentation
Backend architecture, data model, the calculation engine, services, queues, and operational behavior for the fee management module.
Fees Backend Documentation
1. Documentation Evidence
| Area | Files Inspected | Verified Details |
|---|---|---|
| Module wiring | apps/api/src/modules/fees/fees.module.ts and every submodule *.module.ts | Composition, controllers, providers |
| Calculation | apps/api/src/utils/fees/fee-calculation.util.ts, fee-charge-once.util.ts, fee-rounding.util.ts | The full algorithm, read in full |
| Resolver | apps/api/src/modules/fees/shared/fee-resolution.service.ts | Placement, structure/version resolution, charge-once eligibility |
| Schema | packages/db/src/schema/school/fees-catalog.ts, fees-structures.ts, fees-modifiers.ts, fees-obligations.ts, fees-runs.ts, fees-payments.ts, fees-settings.ts, fees-contract.ts | Every table, column, index, and CHECK, read in full |
| Migration | packages/db/src/migrations/0044_fee_management_integrity.sql | The guard triggers, EXCLUDE constraints, and seed data drizzle-kit has no builder for |
| Queue | packages/jobs/src/index.ts (FeeJob, QueueName.FEES), apps/api/src/modules/fees/workers/*.ts | Job contract, processor, scheduler, sweep |
| Permissions | packages/db/src/authorization/permission-catalog.ts | Every fee permission module |
| Receipts | apps/api/src/modules/fees/shared/fee-receipt-resolver.service.ts, accounting-receipt.client.ts, apps/api/src/config/env.validation.ts | The receipt predicate, the outbound fetch and its limits, the three environment variables |
2. Backend Scope and Boundaries
Owns
- The fee vocabulary: categories, components, terms, fee periods (
catalog). - Fee structures — draft editing, activation, versioning, revision (
structures). - Scholarships and charges, their eligibility rules and per-student assignment stints (
modifiers). - Student fee obligations — the calculation result, its lines, its lifecycle (
obligations,shared). - Bulk fee generation and recalculation (
runs). - Payment recording, reversal, correction, and the accounting application's payment sync inbox (
payments). - The re-match sweep for held/unmatched external payments (
sync). - The pupil/guardian portal read surface (
portal). - Dashboards, the change log, CSV export (
reports). - Global calculation/policy settings and the notification matrix (
settings). - The five-minute sweep, the queue processor, and reminders (
workers). - The one calculation engine and one placement/structure/modifier resolver (
shared), used by every writer.
Does Not Own
- Authentication and RBAC — consumes
JwtAuthGuard,RoleGuard,@Permissions()fromcommon/authorization. - The accounting application's API surface, its credentials, and webhook delivery — owned by the
integrationmodule, which reads fee tables and consumes the fee change feed but never writes them. - School/people data — students, guardians, classes, grades, sections, enrolments — owned by
peopleandschool-profile; the fee module reads placement but never writes it. - Notification delivery mechanics (channel dispatch, outbox, delivery rows) — owned by
notification; this module only suppliesfee-templates.tsand calls the shared notifier. - Audit logging — the global
ActivityAuditInterceptorrecords every mutating request automatically from its@Permissions()code.
Source of Truth
| Concern | Source of Truth | Notes |
|---|---|---|
| A fee's amount | fee_obligations / fee_obligation_lines, written once by calculateFee | Never recomputed client-side; a preview calls the same engine |
| Whether a fee is paid | fee_obligations.payment_status, a GENERATED column | Derived from net_paisa/paid_paisa/waiver_paisa/lifecycle — never written directly |
| What the accounting system last reported | fee_external_transactions.latest_* | Compared against on every new event; the LATEST known state wins |
| Placement (grade/section) at a date | student_class_enrollments, read by the resolver | The fee module never writes placement, only snapshots it onto the obligation at generation time |
| Calculation settings in force | fee_settings, snapshotted onto each obligation (calc_settings) and each run (settings_snapshot) | A later settings change never rewrites an already-generated obligation |
3. Module Composition
| Module | Type | Path | Controllers | Providers | Responsibility |
|---|---|---|---|---|---|
FeesModule | Aggregate | apps/api/src/modules/fees/fees.module.ts | None | Imports every leaf | Composes the whole admin surface, listed in ADMIN_MODULES |
FeesSharedModule | Leaf (shared) | fees/shared/fees-shared.module.ts | None | Calculation-adjacent services: calendar, change feed, errors/locks, ledger, notifier, obligation actions/writer, resolution, run launcher, settings | Imported by every other fee leaf |
FeeCatalogModule | Leaf | fees/catalog/fee-catalog.module.ts | Calendar, Categories, Components, Periods, Terms | Category/component/period/term services | Catalogue |
FeeStructuresModule | Leaf | fees/structures/fee-structures.module.ts | Structures, StructureLifecycle | Read, write, lifecycle, conflicts, revisions, validation | Structures |
FeeModifiersModule | Leaf | fees/modifiers/fee-modifiers.module.ts | Scholarships, Charges | CRUD, lifecycle, eligibility, assignments (shared across both kinds) | Modifiers |
FeeStudentFeesModule | Leaf | fees/obligations/fee-student-fees.module.ts | StudentFees, StudentFeeActions, StudentSummary | Read, actions | Obligations |
FeeRunsModule | Leaf | fees/runs/fee-runs.module.ts | Runs | Read, admin, worker | Runs |
FeePaymentsModule | Leaf | fees/payments/fee-payments.module.ts | Payments, Mappings, SyncEvents | Read, actions, mapper services | Payments |
FeeSyncModule | Leaf | fees/sync/fee-sync.module.ts | None (worker-only) | Intake, matcher, processor, rematch, resolution | Accounting payment sync |
FeePortalModule | Leaf, mobile-only | fees/portal/fee-portal.module.ts | StudentPortal, GuardianPortal | Portal services | Imported ONLY by MOBILE_CHILDREN, never ADMIN_MODULES |
FeeReportsModule | Leaf | fees/reports/fee-reports.module.ts | ChangeLog, Dashboard, Exports | Report services | Reports |
FeeSettingsModule | Leaf | fees/settings/fee-settings.module.ts | Settings, NotificationSettings | Admin, notification settings | Settings |
FeeWorkerModule | Leaf, worker-only | fees/workers/fee-worker.module.ts | None | Queue processor, scheduler, sweep, reminders | Composed by the root OUTSIDE the fast runtime profile — importing it starts a BullMQ worker |
4. File and Directory Map
apps/api/src/modules/fees/
fees.module.ts
catalog/ fee-calendar, fee-categories, fee-components, fee-periods, fee-terms (+dto)
structures/ fee-structures, fee-structure-lifecycle (+conflicts, effects, revisions, validation, row/write util) (+dto)
modifiers/ fee-scholarships/fee-charges controllers over one shared set of services (+dto)
obligations/ fee-student-fees (read), fee-student-fee-actions, fee-student-summary (+dto, mapper)
runs/ fee-runs (controller), fee-runs-admin, fee-runs-read, fee-run-worker, fee-run-state
payments/ fee-payments, fee-mappings, fee-sync-events (+read services, mappers)
sync/ fee-sync-intake, fee-sync-matcher, fee-sync-processor, fee-sync-rematch, fee-sync-resolution
portal/ fee-student-portal, fee-guardian-portal (+mapper)
reports/ fee-change-log, fee-dashboard, fee-exports
settings/ fee-settings-admin, fee-notification-settings
shared/ fee-calendar, fee-change-feed, fee-errors, fee-ledger, fee-locks, fee-notifier,
fee-obligation-actions/lines/support/writer, fee-recipients, fee-resolution(+context/types),
fee-run-launcher, fee-settings
workers/ fee-queue.processor, fee-reminders, fee.scheduler, fee-sweep(+ledger)
__tests__/ integration specs, one per capability area
apps/api/src/utils/fees/
fee-calculation.util.ts the engine
fee-charge-once.util.ts the anchor rule
fee-rounding.util.ts BigInt rounding
fee-eligibility.util.ts the modifier rule DSL
fee-money.util.ts, fee-dates.util.ts, fee-csv.util.ts, fee-reminder-slot.util.ts| File | Purpose | Key Exports |
|---|---|---|
utils/fees/fee-calculation.util.ts | The ONE place fee amounts are computed | calculateFee, feePaymentStatus |
utils/fees/fee-charge-once.util.ts | The charge-once anchor rule | decideChargeOnce, componentChargeOnceScope, modifierChargeOnceScope |
utils/fees/fee-rounding.util.ts | BigInt-safe rounding | divideAndRound, percentOfPaisa |
shared/fee-resolution.service.ts | Placement → structure/version → charge-once-filtered component/modifier list | FeeResolutionService.resolve |
shared/fee-obligation-writer.service.ts | Turns a resolution + calculation into a written obligation | — |
shared/fee-ledger.service.ts | The ONLY writer of payment entries and paid_paisa | — |
shared/fee-locks.util.ts | runFeeTransaction (retry on serialization failure/deadlock), lockStructureActivation | — |
shared/fee-errors.util.ts | translateFeeError — maps named Postgres constraints to ErrorCodes | — |
workers/fee-queue.processor.ts | The one processor on QueueName.FEES | FeeQueueProcessor |
workers/fee.scheduler.ts | The five-minute cron trigger | FeeScheduler |
workers/fee-sweep.service.ts | The six sweep passes | FeeSweepService |
5. Data Model
5.1 Schema Source
packages/db/src/schema/school/
fees-contract.ts every enum and the notification event matrix — the closed vocabulary
fees-catalog.ts fee_categories, fee_components, fee_terms, fee_periods
fees-structures.ts fee_structures, fee_structure_targets, fee_structure_lines,
fee_structure_versions, fee_structure_version_lines
fees-modifiers.ts fee_modifiers, fee_modifier_assignments
fees-obligations.ts fee_obligations, fee_obligation_lines, fee_generation_run_items, fee_reminder_log
fees-runs.ts fee_generation_runs
fees-payments.ts fee_external_transactions, fee_payment_sync_events, fee_payment_entries,
fee_external_student_mappings, fee_external_invoice_mappings, fee_obligation_events
fees-settings.ts fee_settings, fee_notification_settings, fee_sweep_ledger
packages/db/src/migrations/0044_fee_management_integrity.sql
guard triggers, EXCLUDE constraints (btree_gist), and the settings/notification-matrix seedMoney is integer paisa (100 paisa = 1 NPR) stored as bigint (mode: "number"), every amount bounded well inside Number.MAX_SAFE_INTEGER by a CHECK so a JSON response stays exact. Percentages are basis points (1 bp = 0.01%, 10000 = 100%).
5.2 Tables and Collections
fee_categories
| Column | Type | Nullable | Default | Index/Constraint | Notes |
|---|---|---|---|---|---|
id | serial | No | — | PK, unique(id, kind) (composite FK target) | Internal id |
public_id | uuid | No | uuidv7() | unique | Wire id |
kind | text | No | — | CHECK IN (fee, scholarship, charge) | One table for all three; a component FK's target is (id, kind='fee') |
name | text | No | — | unique (kind, lower(name)), CHECK trimmed 1–80 | — |
status | text | No | active | CHECK IN (active, inactive, archived) | Never soft-deleted; retired to inactive/archived |
sort_order | integer | No | 0 | CHECK ≥ 0 | — |
version | integer | No | 1 | — | Optimistic concurrency |
created_by_user_id, updated_by_user_id | uuid | Yes | — | FK users, SET NULL | — |
A category is never hard-deleted while referenced — the RESTRICT foreign keys from fee_components/fee_modifiers enforce it.
fee_components
| Column | Type | Nullable | Notes |
|---|---|---|---|
category_id, category_kind | integer, text | No | Composite FK to fee_categories(id, kind='fee') — pins the category's kind |
value_type | text | No | flat | percent |
amount_paisa | bigint | flat only | CHECK: flat ⇒ 1–10,000,000,000 and no percent_bp; percent ⇒ NULL |
percent_bp | integer | percent only | CHECK 1–10000 |
frequency | text | No | monthly | one_time | annual | term |
status | text | No | active | inactive |
name | text | No | unique lower(name), trimmed 1–120 |
A component is the default a structure line starts from; a structure line COPIES the amount, so editing a component never changes an already-drafted or activated structure.
fee_terms
| Column | Notes |
|---|---|
academic_session_id | FK, RESTRICT |
start_date/end_date | CHECK end ≥ start; EXCLUDE fee_terms_no_overlap (btree_gist, migration-only — drizzle-kit has no builder) so terms of one session never overlap |
unique (academic_session_id, lower(name)) | — |
fee_periods
| Column | Notes |
|---|---|
bs_year, bs_month | GLOBAL unique (bs_year, bs_month) — two sessions can never both bill the same Bikram Sambat month |
sequence | 1-based position within its session, unique (academic_session_id, sequence), CHECK 1–14 |
due_date | Derived from fee_settings.due_day_of_month when prepared; editable, cascades to every non-cancelled obligation of the period |
composite unique (id, academic_session_id) | Composite FK target for obligations |
fee_structures / fee_structure_targets / fee_structure_lines / fee_structure_versions / fee_structure_version_lines
| Table | Purpose | Key constraints |
|---|---|---|
fee_structures | The header: session, name, status, effective_from, current_version_no | current_version_no IS NULL ⇔ status = 'draft'; unique (session, lower(name)) |
fee_structure_targets | Where it applies — section_id IS NULL means grade-wide | unique (structure, grade, coalesce(section, 0)) |
fee_structure_lines | The DRAFT working copy of what it bills | unique (structure, component); frozen once activated (trigger) |
fee_structure_versions | An immutable snapshot, one per activation/revision | unique (structure, version_no); resolution index on (structure, effective_from DESC, version_no DESC); change_note required from version 2 |
fee_structure_version_lines | A version's lines, with component/category NAME as they were | unique (version, component) |
Guard triggers (migration, SQLSTATE 23514): fee_structures_guard_header (dates/session fixed once activated, never returns to draft), fee_structure_children_guard (targets/lines change only while draft), versions and version lines are append-only.
fee_modifiers / fee_modifier_assignments
See the schema comments quoted in full below — one table serves both scholarships and charges.
| Column | Notes |
|---|---|
kind | scholarship | charge |
category_id, category_kind | Composite FK, category_kind = kind |
base_scope | gross | category | component — what a percentage is OF and a flat amount is capped against |
recurrence | per_period | once_per_session |
eligibility_mode | rule | manual; eligibility_rules JSONB required exactly when rule |
auto_apply_new_matches | Rule mode only (CHECK) |
stack_group | Scholarships only (CHECK), lower-cased, 1–40 chars |
status | draft → active → ended → archived; ended requires effective_until set |
fee_modifier_assignments: one stint per (modifier, student); EXCLUDE fee_modifier_assignments_no_overlap (btree_gist) so stints of one (modifier, student) never overlap. matched_rule_snapshot (the student attributes that satisfied the rule) is required exactly when source = 'rule', so a decision stays explainable after the student's record changes.
The daily rule_new_matches pass treats a pupil as already assigned — and skips them — if any stint
of the modifier for that pupil is still within its effective window OR has ended_at set, whichever
came from an administrator or the system. An ENDED stint is never reopened automatically: a pupil
whose scholarship an administrator ended still matches the rule's criteria, and without this the
next day's pass would hand it back. Re-assigning that pupil is always available by hand.
fee_obligations
The full field table (every field is load-bearing — see the schema comment quoted at the top of fees-obligations.ts):
| Column | Type | Notes |
|---|---|---|
student_id, academic_session_id, fee_period_id | FK | Composite FK ties period to its session |
class_id, grade_id, section_id, grade_name, section_name, roll_number | — | A PLACEMENT SNAPSHOT at the evaluation date — never re-derived after the fact |
structure_id, structure_version_id | FK (composite) | Which version priced it |
evaluation_date | date | E — the period's start date (or the join date under charge_period) |
due_date | date | Copied from the period, editable per-obligation via due-date propagation |
lifecycle | text | open | finalized | cancelled |
gross_paisa, scholarship_paisa, charge_paisa, adjustment_paisa, waiver_paisa, net_paisa, paid_paisa | bigint | CHECK: net = gross - scholarship + charge + adjustment - waiver; scholarship ≤ gross; all bounded ≤ 1e12 |
balance_paisa | GENERATED | net_paisa - paid_paisa |
payment_status | GENERATED | The CASE expression quoted in full in §7.3 of the feature doc |
rounding_mode, rounding_unit_paisa, calc_settings, calc_version | — | The settings SNAPSHOT used, so a later settings change never rewrites an existing obligation |
generated_by_run_id | FK, SET NULL | — |
reissued_from_obligation_id | FK to itself | — |
finalized_at, cancelled_at, cancelled_by_user_id, cancel_reason | — | Exactly one stamped pair per lifecycle (CHECK) |
last_payment_at, last_payment_source, last_synced_at | — | Denormalised for the dashboard/reminders |
Index rationale:
| Index | Columns | Supports |
|---|---|---|
fee_obligations_live_slot_unique | (student, period) WHERE lifecycle <> 'cancelled' | At most one live obligation per (student, period); a cancelled one frees the slot |
fee_obligations_period_status_idx | (period, payment_status) | Dashboard aggregates |
fee_obligations_due_idx | (due_date, id) WHERE lifecycle <> 'cancelled' | The reminder and finalize sweep passes |
fee_obligations_session_grade_section_idx | (session, grade, section) | List filtering |
fee_obligation_lines
Signed amounts: component/charge ≥ 0, scholarship ≤ 0, waiver < 0, adjustment ≠ 0 (CHECK fee_obligation_lines_sign). The charge-once slot uniqueness is enforced by FOUR partial unique indexes — one_time_slot (student, component), annual_slot (student, session, component), term_slot (student, term, component), once_modifier_slot (student, session, modifier) — each WHERE superseded_at IS NULL AND released_at IS NULL AND amount_paisa <> 0, which is what makes a double-bill a database-level impossibility rather than a service-level promise.
fee_generation_runs / fee_generation_run_items
Run: kind (generate, recalculate_open, modifier_apply, modifier_end, structure_revision, auto, placement_review), scope/settings_snapshot JSONB, counters CHECKed succeeded + skipped + failed ≤ total, lease_generation bumped on every re-dispatch (part of the BullMQ dedupe key), request_key unique (idempotent create). Item: (run, student, period) unique, status pending → succeeded|skipped|failed|cancelled, outcome one of 8 values (created, recalculated, unchanged, already_exists, cancelled_exists, finalized_protected, not_applicable, cancelled_no_placement).
Payment tables
| Table | Purpose | Key invariant |
|---|---|---|
fee_external_transactions | One row per accounting transaction as last understood | applied_amount_paisa proven by a DEFERRED trigger to equal the sum of its entries |
fee_payment_sync_events | The inbox of what the accounting app posted | Unique (client, external_event_id) — the idempotency key |
fee_payment_entries | The append-only ledger of one obligation | paid_paisa proven by a DEFERRED trigger to equal the sum of entries; a reversal must target a MANUAL entry of the same obligation (trigger fee_payment_entries_reversal_target) |
fee_external_student_mappings | A student's accounting customer reference | At most one active mapping per (client, student) and per (client, ref); reassignment is retire + create, never overwrite |
fee_external_invoice_mappings | An obligation's accounting invoice reference | Unique per (client, ref) and per (client, obligation) |
fee_obligation_events | The append-only audit trail of every generation/adjustment/payment/status change | fee_obligation_events_append_only trigger |
5.3 Relationship Diagram
6. The Calculation Engine
apps/api/src/utils/fees/fee-calculation.util.ts — calculateFee(input). Pure: no database, no clock, no I/O. Every writer (generation, recalculation, previews) calls it; nothing else adds up a fee. The fixed order:
- Component lines. Flat = its stored amount, unrounded. Percentage =
round(flatSubtotal × bp / 10000), whereflatSubtotalis the sum of FLAT component lines of THIS obligation only.gross= the sum of all component lines. - Scholarship candidates. Each scholarship's scope is
gross, the sum of one category's component lines, or one component's line. Percentage →round(scope × bp / 10000); flat → its stored amount. - Stacking. Within a
stackGroup, keep only the largest candidate (ties broken by the lower modifier id). Then, ifscholarshipStacking === "highest_only", keep only the single largest remaining candidate across everything. - Caps. Kept candidates are granted in
amount DESC, modifier id ASCorder, eachmin(candidate, remaining of its own scope key, remaining of the shared gross cap), where the gross cap ismin(gross, round(gross × maxScholarshipBp / 10000)). A grant smaller than its candidate iscapped: true— a zero grant is still a recorded line, never silently dropped. - Charges. Same three scopes, measured on the GROSS component lines — never after scholarships are subtracted. Always stack, never capped. A charge whose scope is empty (the pupil has no line in that category/component) is a
0line, kept so the breakdown explains why. - Manual lines (adjustments, waivers) pass through unchanged — signed as stored.
- Totals.
net = gross − scholarship + charge + adjustment − waiver.net < 0throwsFeeCalculationError("FEE_NET_NEGATIVE")before anything is written.
All intermediate arithmetic is BigInt — amount × basisPoints reaches ~1e14 for the largest permitted amount, past the 2^53 boundary where a JS number silently loses paisa. Results are converted back to number only after Number.isSafeInteger is checked (toSafe).
Rounding (fee-rounding.util.ts)
Rounding is to a multiple of unitPaisa (1 = paisa, 100 = whole rupees), applied to the MAGNITUDE so negative values round symmetrically:
| Mode | Rule | Example |
|---|---|---|
half_up | .5 away from zero | 49999.95 → 50000, 62.5 → 63 |
half_even | .5 to the even multiple | 62.5 → 62, 63.5 → 64 |
down | toward zero | 49999.95 → 49999 |
up | away from zero | 49999.05 → 50000 |
Flat amounts never pass through rounding — they are stored exactly as entered.
Worked examples (from fee-calculation.util.spec.ts and fee-calculation.integration.spec.ts)
| Scenario | Setup | Result |
|---|---|---|
| §23 baseline | 20% Tuition scholarship + an 80000 Transport charge | Net 580000; 300000 paid → balance 280000, partially_paid |
| §63 flat mix | A flat Merit scholarship of 100000 + a flat 80000 Transport charge | Net 530000; 300000 paid → balance 230000 |
| Percentage component | A 5% Levy component of a 550000 flat subtotal | 27500, gross 577500 |
| Rounding | 15% of 333333 | 50000 at the rupee unit (rounds up); 49999 rounds down at paisa precision |
| Scope cap | Two 70% Tuition scholarships on a 100000 Tuition line | First grants -70000 (uncapped); second grants -30000 (capped: true — the scope is exhausted); net 50000 |
| Gross cap | A 70% and a 60% scholarship on a 100000 gross with the default 100% cap | -70000 then -30000 (the gross cap, not the scope, binds second); net 0, nothing_due |
maxScholarshipBp cap | An 80% candidate under a 5000 bp (50%) cap | Grants exactly 50000, capped: true |
| Stack group | A 20% "Merit" and a 30% "Need" scholarship sharing one stack group | Only "Need" (30000) survives; "Merit" recorded in droppedScholarships with reason: "stack_group" |
highest_only | A flat 10000 scholarship vs. a 20% (20000) scholarship, stacking set to highest_only | Only the 20000 one survives; the 10000 one is dropped with reason: "highest_only" |
| Charge on an absent component | A charge scoped to a component the pupil has no line for | A 0 line, never omitted |
| Annual component ordering | Generated for periods in the order P3, P1, P2 | Charged on P1 only — the earliest eligible period, regardless of generation order |
| Term component | A term-frequency component | Charged once per term, at the first period inside it; not charged in a period outside every term |
| Mid-period join | A pupil placed mid-period | charge_period: billed from the join date. next_period: not_applicable for that period |
| Structure ambiguity | Two grade-wide structures target the same grade on the same effective date | FEE_STRUCTURE_AMBIGUOUS — never picked arbitrarily |
| Section beats grade-wide | A section-level structure and a NEWER grade-wide one both apply | The section-level one wins — specificity before date |
Placement and resolution (fee-resolution.service.ts)
For one (student, period), in order:
- E = the period's start date (or the join date, under
charge_period, for a mid-period joiner). - Placement, half-open:
enrolled_on ≤ E < ended_on. A soft-deleted student has no placement.record_statusis deliberately NOT consulted — an unaudited record toggle must never become a fee exemption or a silent re-bill (see thefeesCLAUDE.md-equivalent rule and itsrecord_statusnote in the feature doc). - Structure: section-level targets win over grade-wide ones (specificity before date), then the latest
effective_from ≤ E; a tie isFEE_STRUCTURE_AMBIGUOUS. - Version: the version with the greatest
effective_from ≤ E, ties broken by the higherversion_no— so a same-date later version corrects an earlier one. - Charge-once eligibility (
fee-charge-once.util.ts): a slot is taken when a live line already exists elsewhere for the student in that scope; otherwise, if an earlier eligible period of the scope exists, that period anchors the slot and this period does not take it; otherwise this period takes it. Eligibility of an earlier period requires the pupil was placed there, the version contained the component (or the stint applied), and the period can still be (re)calculated (not cancelled-only, not finalized). - Modifier stints apply by DATE only —
effective_from ≤ evaluation_date ≤ effective_until (or unset). Rules are NEVER evaluated during a calculation; only materializedfee_modifier_assignmentsstints count, so a recalculation is reproducible even if the pupil's attributes have since changed.
Every query in FeeResolutionService.resolve runs inside the caller's transaction, and the caller holds a per-student advisory lock, so slot state cannot move under the checks.
7. Runtime Flows
7.1 Generation run — plan and chunk
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | FeeRunLauncherService.launch | Validates scope, session not locked, writes the run + outbox row in one transaction | FEE_SESSION_LOCKED, FEE_RUN_SCOPE_EMPTY |
| 2 | FeeRunWorkerService.plan | Resolves the exact (student, period) target set, batches into fee_generation_run_items | A target that resolves to zero rows still completes the run with totalItems: 0 |
| 3 | FeeRunWorkerService.chunk | Per item: FeeResolutionService.resolve → calculateFee → fee-obligation-writer.service.ts writes the obligation and lines | Any resolution/calculation error is caught per-item and recorded as failed with an exceptionCode, never aborts the whole run |
| 4 | FeeRunWorkerService | Recounts succeeded/skipped/failed from the items table, sets finished_at and a terminal status once planning_completed_at is set and nothing is pending | Guarded by a single UPDATE any of the last chunk, the watchdog, or another chunk racing to finish may run — first one to see the condition wins |
7.2 Adjustment / waiver
Manual payment recording and reversal
FeeLedgerService.recordManualPayment is idempotent on the office user's Idempotency-Key paired
with a hash of the request body: a replay before the row commits waits on the obligation lock and
then answers with the first attempt's entry rather than racing a unique-index error, and a replay
after commit finds the same entry directly — both return { entry, replayed: true } without writing
a second payment.
reverseManualPayment reverses exactly one whole manual payment, once. Before inserting the
reversal it recomputes the obligation's current manual portion and checks that subtracting the
target payment would not take it below zero. That happens when a correction entry already reduced
the manual total below the payment being reversed — reversing on top of the correction would take
the same money off twice and understate what the office actually reports as received. That case is
refused with a 409 and FEE_PAYMENT_MANUAL_PORTION_NEGATIVE; the office corrects the total
instead of reversing the payment.
Payment receipts
FeeReceiptResolverService (provided and exported by FeesSharedModule) decides whether a payment entry has a receipt in the accounting application, and AccountingReceiptClient fetches it. Three routes use them: GET /api/fees/payments/:paymentId/receipt (FeePaymentsController, FeeReceipts_READ), GET /api/mobile/fees/children/:studentId/payments/:paymentId/receipt (FeeGuardianPortalController) and GET /api/mobile/fees/me/payments/:paymentId/receipt (FeeStudentPortalController), the last two under MyFees_READ. All three carry @IpThrottle(RATE_LIMITS.FEE_RECEIPT_DOWNLOAD) — 10 per minute per account (keyStrategy: "user") with a 100 per minute ceiling per address — because each request can start an outbound fetch.
One predicate. resolvableEntryWhere(accountingClientPublicId) is the single definition of "this entry has a receipt". The three routes use it to pick the entry, and receiptAvailable — computed by FeeReceiptResolverService.availableAmong for a page of entries in one query, without calling accounting — uses the same function, so the flag cannot say yes where the route says no. It requires kind = 'payment' and source = 'external'; a transaction in state applied with applied_amount_paisa > 0 whose integration_client_id is the client whose public id is ACCOUNTING_INTEGRATION_CLIENT_ID; an external_ref that is a uuid (the accounting receipt id, and the only form safe to place in the request path); a fee that is not cancelled; and no fee_payment_sync_events row of the transaction whose matched_student_id differs from the fee's student. The student the PDF is requested for is the fee's student_id.
Scoping. The guardian route resolves in ONE query: entry → transaction → obligation → student → guardian link, filtered by the guardian portal's ownChildrenWhere (live guardian and student rows, graduate access not ended), the child named in the path, and the predicate. The pupil route filters by students.user_id = actor.id with deleted_at IS NULL and the predicate. assertPortalAudience runs first (403); every miss after it is the same 404 FEE_RECEIPT_NOT_AVAILABLE. Neither route uses PeopleAccessService.scopeFor. The office route needs only the permission and the predicate.
Activation. The feature is active only when ACCOUNTING_INTEGRATION_ENABLED is true and ACCOUNTING_API_URL, ACCOUNTING_API_TOKEN and ACCOUNTING_INTEGRATION_CLIENT_ID are all set. ACCOUNTING_API_URL must be https:, or http: on a loopback host (localhost, 127.0.0.0/8, ::1) with no user info; ACCOUNTING_API_TOKEN is at least 32 characters and is never logged; the client id must be a uuid. Blank values read as unset. When the feature is inactive every receipt route answers 404 and receiptAvailable is false; there is no boot dependency and no job. If some but not all of the three variables are set, the resolver logs one warning at module initialisation that names the missing ones (never a value).
Outbound request. AccountingReceiptClient.fetchPdf(receiptRef, studentPublicId) sends GET {base}/internal/receipts/{receiptRef}/pdf?studentId={studentPublicId} with Authorization: Bearer <token>, redirect: "manual" and AbortSignal.timeout(15000). The URL is built from the validated base and its origin and pathname are checked before the request is sent. A response is accepted only if its status is 200, its media type (parameters ignored) is exactly application/pdf, its body starts with %PDF- and its size is at most 5 MB — read through a byte cap that cancels the transfer at the first byte beyond it, and refused up front when Content-Length already exceeds it. A 404 whose body carries errorCode: "RECEIPT_NOT_FOUND" becomes 404 FEE_RECEIPT_NOT_AVAILABLE; every other outcome that is not a verified PDF — including a 404 with any other code — becomes 503 ACCOUNTING_RECEIPT_UNAVAILABLE. A process-wide counter allows four fetches in flight; the fifth is refused at once. There is no circuit breaker and no retry: the four-slot limit plus the immediate 503 for the fifth caller bound how many requests an unhealthy accounting application can hold, and the repo has no breaker provider. Refusals are logged at WARN with the status only, never the URL or the token. The route builds its own Content-Type, Content-Length, Cache-Control: no-store, X-Content-Type-Options: nosniff and Content-Disposition (receipt-<paidOn>-<first 8 characters of the entry id>.pdf, taking paid_on as its stored date string) and forwards nothing the upstream sent.
7.3 Payment sync intake
Include: success path (matched, applied), duplicate eventId (idempotent replay, replayed: true), identity disagreement (FEE_SYNC_IDENTITY_DISAGREES, held held_conflict), unmatched (held_unmatched, picked up later by the daily re-match pass), and a final-attempt processing failure. That last case marks the event failed with errorCode: FEE_SYNC_PROCESSING_FAILED — never FEE_SYNC_PAYLOAD_INVALID, which is reserved for a payload the worker rejects before retrying at all — and notifies the office via sync_failed.
A held event (failed/conflict/unmatched) notifies the office once per event per retry: the office notice's request key includes the event's retryCount, so an administrator's retry that lands back in a held state — held again by the same or a different reason — sends a fresh notice rather than being swallowed as a duplicate of the first one.
Once an administrator rejects a transaction through FeeSyncResolutionService.resolve, the transaction is marked rejected and unbound, and every held event on it closes alongside it. That rejection is sticky: a later event for the same external transaction — any event id, any payload version — is held again as conflict with FEE_SYNC_NOT_RESOLVABLE, and the daily re-match pass never applies it automatically. Only a person resolving it again moves it forward.
8. Caching
The fee module does not use Redis for its own read paths — obligations, structures, and modifiers are read directly from PostgreSQL on every request (indexed for it; see §5.2). The one Redis touchpoint is indirect: the accounting integration's feed-poll marker (integration:feed-poll:*), owned by the integration module, not by fees.
9. BullMQ, Schedulers, and Async Work
| Queue | Job | Producer | Processor | Payload | Idempotency |
|---|---|---|---|---|---|
QueueName.FEES ("fees") | fees.generation_plan | FeeRunLauncherService, via outbox | FeeQueueProcessor | {v, requestId, runPublicId, leaseGeneration} | leaseGeneration — a stale re-dispatch is dropped |
QueueName.FEES | fees.generation_chunk | FeeRunWorkerService.plan | FeeQueueProcessor | {..., firstItemId, lastItemId} | Item status transitions are idempotent (re-running a succeeded item is a no-op) |
QueueName.FEES | fees.payment_sync | FeeSyncIntakeService, via outbox | FeeQueueProcessor | {v, requestId, syncEventPublicId, retryCount} | The sync event's own status field |
QueueName.FEES | fees.change_fanout | FeeChangeFeedService, via outbox | FeeQueueProcessor | {v, requestId, changeEventPublicId} | Fan-out is naturally idempotent per (event, endpoint) — see the integration module |
QueueName.FEES | fees.sweep | FeeScheduler, cron | FeeQueueProcessor | {v, requestId} | buildJobId(["fees-sweep", bucket]) per 5-minute bucket — two API processes ticking at once add one job |
FeeQueueProcessor declares Record<FeeJob, handler> — a FeeJob member added without a handler is a COMPILE error, so a job that would otherwise complete silently having done nothing cannot ship. On the FINAL failed attempt of any job, a job_failures row is recorded (a reference, never the payload); for PAYMENT_SYNC specifically the event is additionally marked failed and the office notified — otherwise it would sit pending forever with nothing telling anyone.
The five-minute sweep
FeeSweepService.tick(), six fenced passes (isolated — one failing pass never stops the rest):
| Pass | Cadence | What it does |
|---|---|---|
watchdog | Every tick | Re-emits stalled runs, finalizes orphaned ones |
sync_rematch | Every tick | Held unmatched/conflict payments whose identity now resolves are applied |
finalize_started_periods | Every tick, batched (500/batch, 10 batches/tick) | An open obligation whose period has started is finalized |
auto_generate | Once per Nepal day (checkpointed) | An auto run over (student, period) pairs with no fee at all, live or cancelled — fills gaps and back-fills periods already started — plus a placement_review run |
reminders | Every tick | See FeeRemindersService — sends before/due/after/repeat:<k> slots, de-duplicated by fee_reminder_log |
rule_new_matches | Once per Nepal day, ≤50 modifiers/tick, resumable cursor | Every active rule-mode scholarship/charge with auto_apply_new_matches applies itself to newly matching pupils; one modifier per transaction, a failing modifier is logged and skipped |
Every daily pass is checkpointed in fee_sweep_ledger (one completed run per Nepal date, a resumable cursor for a pass that runs out of its tick budget). Locked sessions are skipped by construction — runs and the writer both refuse them.
finalize_started_periods selects its batch FOR UPDATE SKIP LOCKED, so a row another transaction
holds at that moment is left out rather than waited on. Because a row this batch finalizes reads as
finalized by the time the pass checks for gaps, any row still open inside the scanned id range was
one SKIP LOCKED left out — the cursor resumes just before the first such row rather than after the
last one scanned, so a row skipped this tick is finalized on a later tick the same day instead of
being missed until the next.
placement_review runs recalculate with placementReview: true. Under
section_change_policy = 'keep' it never re-prices an open obligation — a section move or a fee
settings change reaches only fees generated afterwards. The one thing it still does under keep is
cancel a withdrawn pupil's unpaid open fee, and only when withdrawal_policy = cancel_unpaid_open.
Under section_change_policy = 'recalculate_open' it re-prices every open obligation the review
covers.
The watchdog resumes a processing run whose planning stopped part-way through (page one of
planning sets the run to processing before later pages run) once its in-flight chunks have
drained and nothing else is moving it: it bumps the run onto a new lease and re-enqueues planning.
Item inserts are ON CONFLICT DO NOTHING, so replanning never duplicates an item already recorded.
11. Security, Auth, and Abuse Controls
- Every admin route:
JwtAuthGuard+RoleGuard+ an exact@Permissions("Fee*_ACTION")code — see §12.3 of the API doc for the full list. - No fee permission is part of the automatic
admingrant except the read/operate-day-to-day set; money-moving and disclosure codes (FeePaymentCorrections,FeeSyncResolution,FeeMappings,FeeFinancialSettings,FeeExports,FeeIntegrations) areSUPERADMIN_ONLY_MODULESand must be granted to a named role deliberately. - Portal routes carry BOTH
MyFees_READandassertPortalAudience(actor, "student"|"guardian")— belt and braces, unlike the older mobile pattern that relied on audience assertion alone with no permission code. - The accounting application never authenticates as a user — it authenticates via
IntegrationApiKeyGuardon/api/integration/fees/**, documented in theintegrationmodule. - Receipt routes add
FeeReceipts_READ(office) orMyFees_READplus the audience assertion (portal), a per-account download limit, an https-or-loopback-only accounting URL, and a bearer token that is never logged or forwarded; see "Payment receipts" in §7.2. - Manual money-moving writes (
payments,payment-correction) require an actor and are captured by the globalActivityAuditInterceptorautomatically from their@Permissions()code. - Fail-closed: a calculation that would produce a negative net throws before any write; a session under an exclusive activation/revision lock refuses concurrent generation runs rather than racing them.
13. Error Handling
The fee module registers its error codes under apps/api/src/common/types/error-codes.ts (FEE_*), plus ACCOUNTING_RECEIPT_UNAVAILABLE for the receipt routes. The full, exhaustive list — condition, HTTP status, thrown by — is in api.mdx §7 (Enum Reference) and §13.1 (Route Matrix). Representative rows:
| Error Code | HTTP Status | Thrown By | Condition |
|---|---|---|---|
FEE_NET_NEGATIVE | 409 | calculateFee | An adjustment/waiver would take net below zero |
FEE_VERSION_CONFLICT | 409 | Every versioned write | The supplied version no longer matches the row |
FEE_STRUCTURE_AMBIGUOUS | 409/item failure | fee-resolution.service.ts | Two structures (or two placements) tie on specificity and date |
FEE_SESSION_LOCKED | 409 | fee-locks.util.ts | An activation/revision holds the exclusive session lock |
FEE_OBLIGATION_HAS_PAYMENTS | 409 | fee-student-fee-actions.service.ts | Cancelling an obligation that has any payment entry |
FEE_END_DATE_BEFORE_FINALIZED | 409 | Modifier lifecycle | A modifier's end date is before a finalized obligation's live line |
FEE_SYNC_IDENTITY_DISAGREES | held (not thrown to caller) | fee-sync-matcher.ts | Two payment events on one externalRef disagree on identity |
FEE_SYNC_PROCESSING_FAILED | stored on the sync event (status: "failed") | fee-sync-processor.service.ts | A payment sync event exhausted every retry |
FEE_PAYMENT_MANUAL_PORTION_NEGATIVE | 409 | fee-ledger.service.ts | Reversing a manual payment would take the office's manual total, already reduced by a correction, below zero |
FEE_RECEIPT_NOT_AVAILABLE | 404 | fee-receipt-resolver.service.ts, accounting-receipt.client.ts | Receipt download is off, the entry has no receipt, is not the caller's, or accounting has no such receipt |
ACCOUNTING_RECEIPT_UNAVAILABLE | 503 | accounting-receipt.client.ts | Accounting did not return a verified PDF, or four downloads are already in flight |
FEE_PLACEMENT_MISSING/FEE_PLACEMENT_AMBIGUOUS | item failure | fee-resolution.service.ts | No placement, or an ambiguous one, at the evaluation date |
translateFeeError (shared/fee-errors.util.ts) maps NAMED Postgres constraint violations — walked through Drizzle's .cause chain, never error.code directly — to these codes, so a schema-level guard (a trigger, an EXCLUDE constraint, a partial unique index) and a service-level check produce the same error surface.
14. Observability
| Signal | Location | Purpose |
|---|---|---|
| Log | FeeQueueProcessor.onFailed, FeeScheduler.enqueueSweep | The only channel for a swallowed enqueue failure or a job's final failure |
| Audit | fee_obligation_events | Every generation, recalculation, adjustment, waiver, cancellation, payment, and sync resolution, with before/after amounts |
| Audit (generic) | Global ActivityAuditInterceptor | Every mutating fee request, module/action derived from @Permissions() |
| Run counters | fee_generation_runs.{total,succeeded,skipped,failed}Items | Bulk operation health, visible on the dashboard and via GET /api/fees/generation-runs/:id |
| Sweep ledger | fee_sweep_ledger | Whether a daily pass completed, and its resume cursor |
15. Testing and Validation
| Test Type | Files | Coverage |
|---|---|---|
| Unit | apps/api/src/utils/fees/*.spec.ts | The calculation engine, rounding, charge-once, eligibility DSL — pure logic, every branch |
| Integration | apps/api/src/modules/fees/__tests__/*.integration.spec.ts | One file per capability area (catalog, settings, structures, modifiers, obligations, payments, runs, reports, portal, sweep, ledger-sync), against the real test database, no mocks |
| Integration (receipts) | apps/api/src/modules/fees/__tests__/fee-receipts.integration.spec.ts | The predicate on every surface, scoping, each accounting failure mode against a local HTTP listener standing in for accounting, the in-flight limit, route metadata |
| Structure | apps/api/test/structure/*.spec.ts | Route/permission fingerprint, Swagger coverage |
cd apps/api && pnpm exec jest --testRegex 'fee-calculation.util'
cd apps/api && pnpm exec jest --runInBand --testRegex 'fees/__tests__'See Also
- Features and flows doc:
/docs/developer/fees/feature - API doc:
/docs/developer/fees/api