Skoolsewa - School Management System Docs
Developer ResourcesFees

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

AreaFiles InspectedVerified Details
Module wiringapps/api/src/modules/fees/fees.module.ts and every submodule *.module.tsComposition, controllers, providers
Calculationapps/api/src/utils/fees/fee-calculation.util.ts, fee-charge-once.util.ts, fee-rounding.util.tsThe full algorithm, read in full
Resolverapps/api/src/modules/fees/shared/fee-resolution.service.tsPlacement, structure/version resolution, charge-once eligibility
Schemapackages/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.tsEvery table, column, index, and CHECK, read in full
Migrationpackages/db/src/migrations/0044_fee_management_integrity.sqlThe guard triggers, EXCLUDE constraints, and seed data drizzle-kit has no builder for
Queuepackages/jobs/src/index.ts (FeeJob, QueueName.FEES), apps/api/src/modules/fees/workers/*.tsJob contract, processor, scheduler, sweep
Permissionspackages/db/src/authorization/permission-catalog.tsEvery fee permission module
Receiptsapps/api/src/modules/fees/shared/fee-receipt-resolver.service.ts, accounting-receipt.client.ts, apps/api/src/config/env.validation.tsThe 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() from common/authorization.
  • The accounting application's API surface, its credentials, and webhook delivery — owned by the integration module, 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 people and school-profile; the fee module reads placement but never writes it.
  • Notification delivery mechanics (channel dispatch, outbox, delivery rows) — owned by notification; this module only supplies fee-templates.ts and calls the shared notifier.
  • Audit logging — the global ActivityAuditInterceptor records every mutating request automatically from its @Permissions() code.

Source of Truth

ConcernSource of TruthNotes
A fee's amountfee_obligations / fee_obligation_lines, written once by calculateFeeNever recomputed client-side; a preview calls the same engine
Whether a fee is paidfee_obligations.payment_status, a GENERATED columnDerived from net_paisa/paid_paisa/waiver_paisa/lifecycle — never written directly
What the accounting system last reportedfee_external_transactions.latest_*Compared against on every new event; the LATEST known state wins
Placement (grade/section) at a datestudent_class_enrollments, read by the resolverThe fee module never writes placement, only snapshots it onto the obligation at generation time
Calculation settings in forcefee_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

ModuleTypePathControllersProvidersResponsibility
FeesModuleAggregateapps/api/src/modules/fees/fees.module.tsNoneImports every leafComposes the whole admin surface, listed in ADMIN_MODULES
FeesSharedModuleLeaf (shared)fees/shared/fees-shared.module.tsNoneCalculation-adjacent services: calendar, change feed, errors/locks, ledger, notifier, obligation actions/writer, resolution, run launcher, settingsImported by every other fee leaf
FeeCatalogModuleLeaffees/catalog/fee-catalog.module.tsCalendar, Categories, Components, Periods, TermsCategory/component/period/term servicesCatalogue
FeeStructuresModuleLeaffees/structures/fee-structures.module.tsStructures, StructureLifecycleRead, write, lifecycle, conflicts, revisions, validationStructures
FeeModifiersModuleLeaffees/modifiers/fee-modifiers.module.tsScholarships, ChargesCRUD, lifecycle, eligibility, assignments (shared across both kinds)Modifiers
FeeStudentFeesModuleLeaffees/obligations/fee-student-fees.module.tsStudentFees, StudentFeeActions, StudentSummaryRead, actionsObligations
FeeRunsModuleLeaffees/runs/fee-runs.module.tsRunsRead, admin, workerRuns
FeePaymentsModuleLeaffees/payments/fee-payments.module.tsPayments, Mappings, SyncEventsRead, actions, mapper servicesPayments
FeeSyncModuleLeaffees/sync/fee-sync.module.tsNone (worker-only)Intake, matcher, processor, rematch, resolutionAccounting payment sync
FeePortalModuleLeaf, mobile-onlyfees/portal/fee-portal.module.tsStudentPortal, GuardianPortalPortal servicesImported ONLY by MOBILE_CHILDREN, never ADMIN_MODULES
FeeReportsModuleLeaffees/reports/fee-reports.module.tsChangeLog, Dashboard, ExportsReport servicesReports
FeeSettingsModuleLeaffees/settings/fee-settings.module.tsSettings, NotificationSettingsAdmin, notification settingsSettings
FeeWorkerModuleLeaf, worker-onlyfees/workers/fee-worker.module.tsNoneQueue processor, scheduler, sweep, remindersComposed 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
FilePurposeKey Exports
utils/fees/fee-calculation.util.tsThe ONE place fee amounts are computedcalculateFee, feePaymentStatus
utils/fees/fee-charge-once.util.tsThe charge-once anchor ruledecideChargeOnce, componentChargeOnceScope, modifierChargeOnceScope
utils/fees/fee-rounding.util.tsBigInt-safe roundingdivideAndRound, percentOfPaisa
shared/fee-resolution.service.tsPlacement → structure/version → charge-once-filtered component/modifier listFeeResolutionService.resolve
shared/fee-obligation-writer.service.tsTurns a resolution + calculation into a written obligation—
shared/fee-ledger.service.tsThe ONLY writer of payment entries and paid_paisa—
shared/fee-locks.util.tsrunFeeTransaction (retry on serialization failure/deadlock), lockStructureActivation—
shared/fee-errors.util.tstranslateFeeError — maps named Postgres constraints to ErrorCodes—
workers/fee-queue.processor.tsThe one processor on QueueName.FEESFeeQueueProcessor
workers/fee.scheduler.tsThe five-minute cron triggerFeeScheduler
workers/fee-sweep.service.tsThe six sweep passesFeeSweepService

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 seed

Money 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

ColumnTypeNullableDefaultIndex/ConstraintNotes
idserialNo—PK, unique(id, kind) (composite FK target)Internal id
public_iduuidNouuidv7()uniqueWire id
kindtextNo—CHECK IN (fee, scholarship, charge)One table for all three; a component FK's target is (id, kind='fee')
nametextNo—unique (kind, lower(name)), CHECK trimmed 1–80—
statustextNoactiveCHECK IN (active, inactive, archived)Never soft-deleted; retired to inactive/archived
sort_orderintegerNo0CHECK ≥ 0—
versionintegerNo1—Optimistic concurrency
created_by_user_id, updated_by_user_iduuidYes—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

ColumnTypeNullableNotes
category_id, category_kindinteger, textNoComposite FK to fee_categories(id, kind='fee') — pins the category's kind
value_typetextNoflat | percent
amount_paisabigintflat onlyCHECK: flat ⇒ 1–10,000,000,000 and no percent_bp; percent ⇒ NULL
percent_bpintegerpercent onlyCHECK 1–10000
frequencytextNomonthly | one_time | annual | term
statustextNoactive | inactive
nametextNounique 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

ColumnNotes
academic_session_idFK, RESTRICT
start_date/end_dateCHECK 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

ColumnNotes
bs_year, bs_monthGLOBAL unique (bs_year, bs_month) — two sessions can never both bill the same Bikram Sambat month
sequence1-based position within its session, unique (academic_session_id, sequence), CHECK 1–14
due_dateDerived 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

TablePurposeKey constraints
fee_structuresThe header: session, name, status, effective_from, current_version_nocurrent_version_no IS NULL ⇔ status = 'draft'; unique (session, lower(name))
fee_structure_targetsWhere it applies — section_id IS NULL means grade-wideunique (structure, grade, coalesce(section, 0))
fee_structure_linesThe DRAFT working copy of what it billsunique (structure, component); frozen once activated (trigger)
fee_structure_versionsAn immutable snapshot, one per activation/revisionunique (structure, version_no); resolution index on (structure, effective_from DESC, version_no DESC); change_note required from version 2
fee_structure_version_linesA version's lines, with component/category NAME as they wereunique (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.

ColumnNotes
kindscholarship | charge
category_id, category_kindComposite FK, category_kind = kind
base_scopegross | category | component — what a percentage is OF and a flat amount is capped against
recurrenceper_period | once_per_session
eligibility_moderule | manual; eligibility_rules JSONB required exactly when rule
auto_apply_new_matchesRule mode only (CHECK)
stack_groupScholarships only (CHECK), lower-cased, 1–40 chars
statusdraft → 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):

ColumnTypeNotes
student_id, academic_session_id, fee_period_idFKComposite 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_idFK (composite)Which version priced it
evaluation_datedateE — the period's start date (or the join date under charge_period)
due_datedateCopied from the period, editable per-obligation via due-date propagation
lifecycletextopen | finalized | cancelled
gross_paisa, scholarship_paisa, charge_paisa, adjustment_paisa, waiver_paisa, net_paisa, paid_paisabigintCHECK: net = gross - scholarship + charge + adjustment - waiver; scholarship ≤ gross; all bounded ≤ 1e12
balance_paisaGENERATEDnet_paisa - paid_paisa
payment_statusGENERATEDThe 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_idFK, SET NULL—
reissued_from_obligation_idFK 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:

IndexColumnsSupports
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

TablePurposeKey invariant
fee_external_transactionsOne row per accounting transaction as last understoodapplied_amount_paisa proven by a DEFERRED trigger to equal the sum of its entries
fee_payment_sync_eventsThe inbox of what the accounting app postedUnique (client, external_event_id) — the idempotency key
fee_payment_entriesThe append-only ledger of one obligationpaid_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_mappingsA student's accounting customer referenceAt most one active mapping per (client, student) and per (client, ref); reassignment is retire + create, never overwrite
fee_external_invoice_mappingsAn obligation's accounting invoice referenceUnique per (client, ref) and per (client, obligation)
fee_obligation_eventsThe append-only audit trail of every generation/adjustment/payment/status changefee_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:

  1. Component lines. Flat = its stored amount, unrounded. Percentage = round(flatSubtotal × bp / 10000), where flatSubtotal is the sum of FLAT component lines of THIS obligation only. gross = the sum of all component lines.
  2. 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.
  3. Stacking. Within a stackGroup, keep only the largest candidate (ties broken by the lower modifier id). Then, if scholarshipStacking === "highest_only", keep only the single largest remaining candidate across everything.
  4. Caps. Kept candidates are granted in amount DESC, modifier id ASC order, each min(candidate, remaining of its own scope key, remaining of the shared gross cap), where the gross cap is min(gross, round(gross × maxScholarshipBp / 10000)). A grant smaller than its candidate is capped: true — a zero grant is still a recorded line, never silently dropped.
  5. 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 0 line, kept so the breakdown explains why.
  6. Manual lines (adjustments, waivers) pass through unchanged — signed as stored.
  7. Totals. net = gross − scholarship + charge + adjustment − waiver. net < 0 throws FeeCalculationError("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:

ModeRuleExample
half_up.5 away from zero49999.95 → 50000, 62.5 → 63
half_even.5 to the even multiple62.5 → 62, 63.5 → 64
downtoward zero49999.95 → 49999
upaway from zero49999.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)

ScenarioSetupResult
§23 baseline20% Tuition scholarship + an 80000 Transport chargeNet 580000; 300000 paid → balance 280000, partially_paid
§63 flat mixA flat Merit scholarship of 100000 + a flat 80000 Transport chargeNet 530000; 300000 paid → balance 230000
Percentage componentA 5% Levy component of a 550000 flat subtotal27500, gross 577500
Rounding15% of 33333350000 at the rupee unit (rounds up); 49999 rounds down at paisa precision
Scope capTwo 70% Tuition scholarships on a 100000 Tuition lineFirst grants -70000 (uncapped); second grants -30000 (capped: true — the scope is exhausted); net 50000
Gross capA 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 capAn 80% candidate under a 5000 bp (50%) capGrants exactly 50000, capped: true
Stack groupA 20% "Merit" and a 30% "Need" scholarship sharing one stack groupOnly "Need" (30000) survives; "Merit" recorded in droppedScholarships with reason: "stack_group"
highest_onlyA flat 10000 scholarship vs. a 20% (20000) scholarship, stacking set to highest_onlyOnly the 20000 one survives; the 10000 one is dropped with reason: "highest_only"
Charge on an absent componentA charge scoped to a component the pupil has no line forA 0 line, never omitted
Annual component orderingGenerated for periods in the order P3, P1, P2Charged on P1 only — the earliest eligible period, regardless of generation order
Term componentA term-frequency componentCharged once per term, at the first period inside it; not charged in a period outside every term
Mid-period joinA pupil placed mid-periodcharge_period: billed from the join date. next_period: not_applicable for that period
Structure ambiguityTwo grade-wide structures target the same grade on the same effective dateFEE_STRUCTURE_AMBIGUOUS — never picked arbitrarily
Section beats grade-wideA section-level structure and a NEWER grade-wide one both applyThe section-level one wins — specificity before date

Placement and resolution (fee-resolution.service.ts)

For one (student, period), in order:

  1. E = the period's start date (or the join date, under charge_period, for a mid-period joiner).
  2. Placement, half-open: enrolled_on ≤ E < ended_on. A soft-deleted student has no placement. record_status is deliberately NOT consulted — an unaudited record toggle must never become a fee exemption or a silent re-bill (see the fees CLAUDE.md-equivalent rule and its record_status note in the feature doc).
  3. Structure: section-level targets win over grade-wide ones (specificity before date), then the latest effective_from ≤ E; a tie is FEE_STRUCTURE_AMBIGUOUS.
  4. Version: the version with the greatest effective_from ≤ E, ties broken by the higher version_no — so a same-date later version corrects an earlier one.
  5. 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).
  6. Modifier stints apply by DATE only — effective_from ≤ evaluation_date ≤ effective_until (or unset). Rules are NEVER evaluated during a calculation; only materialized fee_modifier_assignments stints 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

StepCode PathBehaviorFailure Case
1FeeRunLauncherService.launchValidates scope, session not locked, writes the run + outbox row in one transactionFEE_SESSION_LOCKED, FEE_RUN_SCOPE_EMPTY
2FeeRunWorkerService.planResolves the exact (student, period) target set, batches into fee_generation_run_itemsA target that resolves to zero rows still completes the run with totalItems: 0
3FeeRunWorkerService.chunkPer item: FeeResolutionService.resolve → calculateFee → fee-obligation-writer.service.ts writes the obligation and linesAny resolution/calculation error is caught per-item and recorded as failed with an exceptionCode, never aborts the whole run
4FeeRunWorkerServiceRecounts succeeded/skipped/failed from the items table, sets finished_at and a terminal status once planning_completed_at is set and nothing is pendingGuarded 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

QueueJobProducerProcessorPayloadIdempotency
QueueName.FEES ("fees")fees.generation_planFeeRunLauncherService, via outboxFeeQueueProcessor{v, requestId, runPublicId, leaseGeneration}leaseGeneration — a stale re-dispatch is dropped
QueueName.FEESfees.generation_chunkFeeRunWorkerService.planFeeQueueProcessor{..., firstItemId, lastItemId}Item status transitions are idempotent (re-running a succeeded item is a no-op)
QueueName.FEESfees.payment_syncFeeSyncIntakeService, via outboxFeeQueueProcessor{v, requestId, syncEventPublicId, retryCount}The sync event's own status field
QueueName.FEESfees.change_fanoutFeeChangeFeedService, via outboxFeeQueueProcessor{v, requestId, changeEventPublicId}Fan-out is naturally idempotent per (event, endpoint) — see the integration module
QueueName.FEESfees.sweepFeeScheduler, cronFeeQueueProcessor{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):

PassCadenceWhat it does
watchdogEvery tickRe-emits stalled runs, finalizes orphaned ones
sync_rematchEvery tickHeld unmatched/conflict payments whose identity now resolves are applied
finalize_started_periodsEvery tick, batched (500/batch, 10 batches/tick)An open obligation whose period has started is finalized
auto_generateOnce 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
remindersEvery tickSee FeeRemindersService — sends before/due/after/repeat:<k> slots, de-duplicated by fee_reminder_log
rule_new_matchesOnce per Nepal day, ≤50 modifiers/tick, resumable cursorEvery 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 admin grant except the read/operate-day-to-day set; money-moving and disclosure codes (FeePaymentCorrections, FeeSyncResolution, FeeMappings, FeeFinancialSettings, FeeExports, FeeIntegrations) are SUPERADMIN_ONLY_MODULES and must be granted to a named role deliberately.
  • Portal routes carry BOTH MyFees_READ and assertPortalAudience(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 IntegrationApiKeyGuard on /api/integration/fees/**, documented in the integration module.
  • Receipt routes add FeeReceipts_READ (office) or MyFees_READ plus 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 global ActivityAuditInterceptor automatically 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 CodeHTTP StatusThrown ByCondition
FEE_NET_NEGATIVE409calculateFeeAn adjustment/waiver would take net below zero
FEE_VERSION_CONFLICT409Every versioned writeThe supplied version no longer matches the row
FEE_STRUCTURE_AMBIGUOUS409/item failurefee-resolution.service.tsTwo structures (or two placements) tie on specificity and date
FEE_SESSION_LOCKED409fee-locks.util.tsAn activation/revision holds the exclusive session lock
FEE_OBLIGATION_HAS_PAYMENTS409fee-student-fee-actions.service.tsCancelling an obligation that has any payment entry
FEE_END_DATE_BEFORE_FINALIZED409Modifier lifecycleA modifier's end date is before a finalized obligation's live line
FEE_SYNC_IDENTITY_DISAGREESheld (not thrown to caller)fee-sync-matcher.tsTwo payment events on one externalRef disagree on identity
FEE_SYNC_PROCESSING_FAILEDstored on the sync event (status: "failed")fee-sync-processor.service.tsA payment sync event exhausted every retry
FEE_PAYMENT_MANUAL_PORTION_NEGATIVE409fee-ledger.service.tsReversing a manual payment would take the office's manual total, already reduced by a correction, below zero
FEE_RECEIPT_NOT_AVAILABLE404fee-receipt-resolver.service.ts, accounting-receipt.client.tsReceipt download is off, the entry has no receipt, is not the caller's, or accounting has no such receipt
ACCOUNTING_RECEIPT_UNAVAILABLE503accounting-receipt.client.tsAccounting did not return a verified PDF, or four downloads are already in flight
FEE_PLACEMENT_MISSING/FEE_PLACEMENT_AMBIGUOUSitem failurefee-resolution.service.tsNo 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

SignalLocationPurpose
LogFeeQueueProcessor.onFailed, FeeScheduler.enqueueSweepThe only channel for a swallowed enqueue failure or a job's final failure
Auditfee_obligation_eventsEvery generation, recalculation, adjustment, waiver, cancellation, payment, and sync resolution, with before/after amounts
Audit (generic)Global ActivityAuditInterceptorEvery mutating fee request, module/action derived from @Permissions()
Run countersfee_generation_runs.{total,succeeded,skipped,failed}ItemsBulk operation health, visible on the dashboard and via GET /api/fees/generation-runs/:id
Sweep ledgerfee_sweep_ledgerWhether a daily pass completed, and its resume cursor

15. Testing and Validation

Test TypeFilesCoverage
Unitapps/api/src/utils/fees/*.spec.tsThe calculation engine, rounding, charge-once, eligibility DSL — pure logic, every branch
Integrationapps/api/src/modules/fees/__tests__/*.integration.spec.tsOne 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.tsThe predicate on every surface, scoping, each accounting failure mode against a local HTTP listener standing in for accounting, the in-flight limit, route metadata
Structureapps/api/test/structure/*.spec.tsRoute/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