Salary Features and Flows
Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for the salary management module.
Salary Features and Flows
1. Documentation Evidence
| Source Type | Files or Docs | What Was Extracted |
|---|---|---|
| Vocabulary | packages/db/src/schema/school/salary-contract.ts | Every closed enum, the payment transition table, the notification matrix |
| Schema | packages/db/src/schema/school/salary-{catalog,structures,assignments,periods,payslips,settings}.ts | Every table, column, and CHECK constraint |
| Calculation engine | apps/api/src/utils/salary/salary-calculation.util.ts | The full 11-step calculation order, read in full |
| Effective dates | apps/api/src/modules/salary/assignments/salary-effective-date.util.ts | The six resolution modes, read in full |
| Approval | apps/api/src/modules/salary/periods/salary-period-approval.service.ts | Inline vs. background, stale-hash refusal, rollback |
| Sweep | apps/api/src/modules/salary/workers/salary-sweep.service.ts | The four idempotent passes |
| Controllers | apps/api/src/modules/salary/**/*.controller.ts (17 files) | Every admin and portal route, its permission, and its purpose |
| Permission catalog | packages/db/src/authorization/permission-catalog.ts (639-667, 848-860) | The 13 salary permission modules, all SUPERADMIN_ONLY except MySalary |
| People integration | apps/api/src/modules/people/staff/staff-salary.service.ts, apps/api/src/modules/people/shared/people-deletion.service.ts | The staff mirror and the deletion refusal |
2. Feature Summary
| Field | Value |
|---|---|
| Module | salary |
| Submodules | catalog, structures, assignments, employees, one-time-items, bulk, periods, payslips, payments, runs, portal, reports, settings, notify, workers, shared |
| Primary user value | The school pays every staff member the correct amount every month, computed from a versioned salary structure and each staff member's own assignment history, with income tax, SSF and leave adjustments applied automatically, and lets a linked accounting application read the same figures and report payment state back without re-entering them. |
| Actors | Office/payroll staff (holding Salary* codes, all SUPERADMIN_ONLY by default), every staff member (their own salary and payslips), the accounting application (via integration), the daily sweep and BullMQ workers |
| Main entry points | /api/salary/** admin routes, /api/mobile/staff/{salary,payslips,assets} portal routes, /api/integration/** (documented in integration), the daily 00:10 sweep, BullMQ jobs on QueueName.SALARY |
| Main outputs | Salary structures and their versions, staff salary assignments, monthly payslips and their lines, payment records, the salary 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 |
|---|---|---|---|---|
Payroll/office (granted Salary* codes) | Manage the catalogue, structures and designation defaults, assign and change staff salary, create and process periods, approve/reopen/send, correct payslips, record payments, resolve accounting sync, export | Anything a code they were not granted covers | JWT + RoleGuard + the specific Salary*_ACTION code | Every Salary* module except MySalary is on SUPERADMIN_ONLY_MODULES; permissions-catalog.spec.ts asserts it |
Any staff member (MySalary_READ) | Read their own current salary assignment, their own released payslips, the assets they hold | See another staff member's pay, change anything, self-approve or self-change their own salary | JWT + RoleGuard, no @Permissions() — SalaryPortalService/SalaryPayslipsPortalService.resolveOwnStaffId is the control | SalaryAccessService additionally refuses a payroll admin from acting on their own salary row (SALARY_SELF_CHANGE_FORBIDDEN) |
Accounting application (via an integration client holding payroll:* scopes) | Read the salary change feed at the scope's grain (payroll:read/payroll:identity:read/payroll:bank:read), report payment state (payroll:write) | Change a structure, an assignment, or approve a period | API-key auth on integration's own guard | Granting/revoking any payroll:* scope needs SalarySync_UPDATE (and StaffSalary_READ for the identity/bank scopes) on the granting admin |
4. Catalogue, Structures and Designation Defaults
Components (salary_components, grouped by salary_component_categories) each carry a SalaryComponentKind (earning/deduction/employer_contribution) and a SalaryComponentRole. basic is the one component every paying assignment carries exactly once; the statutory and leave roles (ssf_employee, ssf_employer, tds, leave_refund, leave_recovery) are computed by the payroll engine and never appear on a structure or assignment line — only basic, regular, and cit (SALARY_LINE_ROLES) do. A component's value is flat or a percent of basic or gross_earnings (an earning may only be a percentage of basic — a percentage of gross would be circular).
A structure (salary_structures) starts draft, is edited freely, then activated into version 1. Each subsequent revise creates a new version effective on a future date, without disturbing staff already on the prior version until their assignment resolves to it. A structure can be deactivated, reactivated, or archived (terminal — a structure name is unique only among non-archived structures). Designation defaults (salary_designation_defaults) map a designation to a default structure, so assigning a new staff member can start from "whatever their designation normally gets."
5. Assignments, Changes and Effective Dates
A staff member's pay is a sequence of salary_assignments rows, each an SALARY_ASSIGNMENT_STATUSES value (scheduled, effective, superseded, cancelled) and a SalaryChangeType recording why the row exists (initial, profile_import, structure_applied, revision, increment, decrement, promotion, demotion, correction, stop, resume). Only the SALARY_CLIENT_CHANGE_TYPES subset may be named by a request body; profile_import and structure_applied are server-written only. Every change type except initial, profile_import, and structure_applied (SALARY_REASONLESS_CHANGE_TYPES) requires a reason.
Effective-date resolution (resolveAssignmentEffectiveDate) accepts one of six modes and always resolves on the server's clock, never the client's, so a preview and its later commit agree given the same "today":
| Mode | Resolves to |
|---|---|
immediate | Today |
ad_date | The given Gregorian date, calendar-validated |
bs_date | The given Bikram Sambat YYYY-MM-DD, converted to Gregorian |
bs_month | The 1st of the given BS year/month |
plus_days | Today plus 1-366 days |
next_period | The 1st of the BS month after today's |
A scheduled change dated on or before the school's "today" is activated by the daily sweep, never by the request that created it — so a change effective "next month" appears on payslips only once that month begins. SALARY_ASSIGNMENT_DATE_IN_LOCKED_PERIOD refuses a date that would need to touch a period already approving/approved/sent/completed.
One-time items (salary_one_time_items) are separate from the recurring assignment line: a bonus or ad-hoc deduction attached to one period, flat or a percentage of the basic in force, never prorated or leave-deducted, and cancellable up until the owning period is locked.
Bulk operations (salary/bulk) apply a structure, adjust salaries, add a one-time item, import a profile, or reschedule, across a staff scope — previewed first, then run as a salary_run (see §9).
6. Periods
A salary_period is exactly one Bikram Sambat month (bs_year, bs_month), unique among non-cancelled periods for that month. Its lifecycle:
SALARY_PERIOD_NOT_CONTIGUOUS refuses preparing a period out of month order while an earlier one remains unapproved (SALARY_PERIOD_EARLIER_UNAPPROVED) or a later one is mid-run (SALARY_PERIOD_LATER_IN_PROGRESS). Preparing snapshots the current settings onto the period (settings_snapshot) so a later global settings change never silently reshapes an already-prepared month. A period locked at approving/approved/sent/completed (SALARY_LOCKED_PERIOD_STATUSES) refuses any assignment, one-time item, or override date change that would touch it.
7. Preparation and the Payslip Calculation
Preparing a period computes one payslip per staff member with a paying assignment that month, in this fixed order (salary-calculation.util.ts):
- Employment — the period dates covered by a paying assignment segment on/after the joining date.
- Recurring earnings — each employed date takes the line value in force that day; prorated lines are
Σ round(value × days_at_value / calendar_days), non-prorated lines take the value in force on the last employed day, whole. - Leave — unpaid dates from the leave module are deducted from leave-deductible earnings under one of three divisors: the period's staff working days, its calendar days, or a fixed 30. The divisor is always the whole period's count, so a staff member who joined or left mid-month pays the same per unpaid day as a colleague employed all month. Each unpaid day is priced at the value the line is paid at: a prorated line at the value in force that day, a non-prorated line at its last-employed-day value. Under the fixed 30 divisor at most thirty days are deducted, and in a 31- or 32-day month where thirty or more days are unpaid, every day actually worked still earns its thirtieth. A leave-deductible earning with every employed working day unpaid loses its whole gross (full absence).
- One-time items — flat, or a percentage of the last-employed-day basic; neither prorated nor leave-deducted.
- Leave refunds — an earlier month's over-deduction returned, as taxable one-time earnings.
- Deductions — on the lines in force on the last employed day; CIT is flat, taken after every other recurring deduction and capped at what they leave. One-time deductions, SSF and TDS come after it and can still take the net below zero, which blocks approval.
- SSF — employee and employer shares of the earned basic, when enabled and the assignment is SSF-enrolled.
- Overrides — an admin's manual override of a deduction or employer-contribution line replaces the computed amount, applied before tax (except a TDS override, applied last).
- TDS — by the cumulative year-to-date projection (
computeTds), against the fiscal year's slabs for the employee's assessment (single/couple). In the month pay ends — a stop segment covering the period's last day, or a stop dated the day after it — the year is trued up on what was actually paid instead of projected over months not worked. - Leave recoveries — an earlier month's under-deduction, capped so net never goes negative; any uncovered remainder re-proposes for next month. The amount recovered comes off this month's taxable income, mirroring a refund, and the payslip stores that reduced taxable income, so every later month's year-to-date projection keeps the relief. The relief never exceeds this month's taxable income, which is never stored below zero. Refunds and recoveries are taxed in full, whatever mix of taxable and non-taxable components the original deduction came from.
- Net = earnings − leave deductions − deductions. A negative net sets
SALARY_PAYSLIP_NEGATIVE_NET, which blocks approval.
How TDS is calculated
TDS is not a fixed rate. Each month the payslip withholds its share of the tax the employee will owe for the whole fiscal year, computed from the progressive slabs configured for that year and the employee's assessment (single or couple) in Salary settings › Tax slabs. The fiscal year starts in Shrawan (fiscal month 1) and ends in Asar (month 12). The logic is computeTds and annualTax in apps/api/src/utils/salary/salary-tax.util.ts; all amounts are integer paisa.
The months left. remaining = 13 − fiscal month (Shrawan 12, Bhadra 11, … Asar 1). In the month pay ends — the last month of the fiscal year, a stop covering the period's last day, or a stop dated the day after it — remaining is 1, so the year is trued up on what was actually paid instead of being projected over months that will not be worked.
Step by step, for one month:
- Project regular income.
projected regular = year-to-date taxable + this month's recurring taxable × remaining. Year-to-date figures come from the approved payslips of earlier months of the same fiscal year, at each payslip's current revision; excluded payslips and cancelled periods contribute nothing. Recurring taxable is the month's earned taxable earnings after leave, plus the employer's SSF share when employer SSF is taxable is on. - Add one-time income.
projected all = projected regular + this month's one-time taxable— bonuses and other taxable one-time earnings, and leave refunds. A leave recovery enters here as a negative amount. - Deduct retirement contributions, separately for both projections. The deduction allowed is the smallest of: the projected contributions (
year-to-date + this month's × remaining, where a month's contributions are employee SSF, CIT, and the employer's SSF when it is taxable), the fixed retirement cap (NPR 5,00,000 by default), and the income multiplied by the configured retirement cap fraction (one third by default). Taxable income never goes below zero. - Tax each projection on the slabs. Each slice of income is taxed at its own slab's rate, the products are summed exactly and rounded once with the configured rounding unit. When the employee is enrolled in SSF, the first slab's rate is not charged if that slab is marked waived for SSF (the social security tax an SSF contributor does not pay).
- This month's regular TDS =
(annual tax on the regular projection − TDS already withheld this year) ÷ remaining, and zero if that is negative. - This month's one-time TDS =
annual tax on the full projection − annual tax on the regular projection, the extra tax the one-time income causes, withheld entirely in the month it is paid. It is negative for a leave recovery, which lowers this month's TDS. - TDS on the payslip = regular + one-time, never below zero.
Because every month starts again from what has actually been paid and withheld, a raise, a bonus, unpaid leave or a mid-year join is absorbed into the months that remain, and the year ends at the tax the slabs give on the year's real income.
Worked example. Slabs for single: 1% up to NPR 5,00,000, then 10%. A staff member not in SSF, with no CIT, earns NPR 48,000 a month from Shrawan.
- Shrawan: remaining 12; projected income 48,000 × 12 = 5,76,000; annual tax = 1% × 5,00,000 + 10% × 76,000 = 5,000 + 7,600 = 12,600; TDS = 12,600 ÷ 12 = NPR 1,050.
- If a NPR 10,000 bonus is paid in Bhadra: remaining 11; year-to-date taxable 48,000 and TDS 1,050; regular projection 48,000 + 48,000 × 11 = 5,76,000 → tax 12,600, regular TDS (12,600 − 1,050) ÷ 11 = 1,050; full projection 5,86,000 → tax 13,600; one-time TDS 1,000; Bhadra TDS NPR 2,050.
What is stored. Each payslip records the month's taxable_income_paisa, projected_annual_taxable_paisa, annual_tax_paisa, and the ytd_taxable_paisa and ytd_tds_paisa it read, so a later month's projection and an auditor can both reconstruct the figure. The stored taxable income already has any leave recovery taken off, so the relief carries into every later month's projection.
Controls and refusals.
- TDS enabled in financial settings switches the calculation off for the school; payslips then carry no TDS.
- An administrator may override the TDS line on a single payslip with a reason; the override is applied last and is recorded on the payslip's revision.
- If any assessment used by an eligible employee has no slabs for the fiscal year, preparing the period is refused with
SALARY_TAX_SLABS_MISSING, naming what is missing — a month is never computed with zero tax by accident. - The slabs screen refuses a ladder that does not start at zero, has a gap or overlap, or lacks an open top slab. Slabs can be copied to the next fiscal year and then edited.
- Leave refunds and recoveries are taxed, or relieved, in full, whatever mix of taxable and non-taxable components the original deduction came from.
Leave approved after a month is paid
A leave approved, changed or cancelled after its month was paid is corrected forward, never by reopening that month. When a later period is prepared, every earlier approved payslip whose attributed unpaid dates no longer match what was deducted is re-priced:
- The earlier month is recomputed with the divisor it was paid on (its stored
divisor_days), not the calendar as it stands today, so a day later reclassified as a holiday does not change the price of a day already paid. - If the calendar for that month no longer exists, the month is left as it was paid. It is never treated as having no working days, which would refund the whole original deduction.
- The difference, minus what earlier periods already refunded or recovered for that month, becomes a
leave_refundearning or aleave_recoverydeduction on the new payslip, referencing the source payslip. - Correcting a sent payslip recomputes its own adjustments too; what it already carries is not counted as settled, so a correction never drops them.
A payslip carries SALARY_FLAGS for anything that needs a human's attention before approval (missing bank details, missing PAN/citizenship/SSF number, a designation mismatch, employment inactive while still paid, a negative net, a bank change since a prior approval, legacy leave basis, a pending leave adjustment, a paid-amount or reported-figures variance from the accounting application). employment_inactive_with_pay and negative_net are SALARY_BLOCKING_FLAGS — approval refuses outright; only employment_inactive_with_pay may be acknowledged away first.
8. Approval
Approval hashes each payslip's inputs (payslipInputsHash) before approving it; if the hash has changed since the payslip was last prepared, approval refuses with SALARY_PAYSLIP_STALE rather than approving stale numbers. Approving INLINE_APPROVAL_LIMIT (200) payslips or fewer runs inline inside the request (sized to finish inside the panel's 20-second timeout); a larger population runs as a background salary_run of kind approve_period, moving the period to approving until the run completes. SALARY_PERIOD_POPULATION_CHANGED refuses approval if the eligible staff population has moved since preparation. SALARY_PERIOD_DISTINCT_APPROVER_REQUIRED refuses letting the same user who prepared a period also approve it, where the school's financial settings require a distinct approver. A period stuck approving for more than 15 minutes with no approval run still processing is rolled back to prepared by the sweep (APPROVAL_STALL_MS).
9. Exclusions and Reopen
A staff member can be deliberately excluded from one period (salary_period_exclusions) — unresolvable data, paid outside payroll, or left before the month — so their preparation is skipped without a payslip; revoking the exclusion re-includes them. approved/sent periods can be reopened back to prepared/approved respectively, incrementing reopened_count, to correct a mistake before payment settles.
10. Send, Bank Refresh, Corrections
send releases an approved period's payslips to staff (visible on the portal) and moves it to sent. refresh-bank re-pulls a staff member's current bank details onto an already-approved payslip and flags bank_changed_since_approval if they differ from what was approved. correct amends a sent payslip in place (SALARY_PAYSLIP_NOT_CORRECTABLE otherwise), recording a corrected event rather than silently overwriting history.
11. Payments
A payslip's SalaryPaymentStatus follows a fixed transition table (SALARY_PAYMENT_TRANSITIONS): pending → {processing, paid, partially_paid, failed, cancelled}, processing → {paid, partially_paid, failed, cancelled}, partially_paid → {processing, paid, partially_paid, failed, reversed}, paid → {paid, reversed}, failed/reversed → {processing, paid, partially_paid, cancelled} (reversed also), and cancelled → pending (re-issue). Payment authority is either manual (the school records payment itself) or external (the accounting application reports it); SALARY_PAYMENT_AUTHORITY_MANUAL/_EXTERNAL/_NEEDS_CLIENT refuse a mismatch between who is allowed to write payment state and who is trying to. A period is completed once every payslip's payment settles (paid, or cancelled for a zero-net payslip) — re-evaluated by the sweep for every sent/completed period so a payment recorded out of band still closes the period.
12. Bulk Runs
salary_runs back every asynchronous population-wide operation: apply_structure, adjust_salary, one_time_item, profile_import, reschedule, prepare_period, approve_period. A run holds SALARY_RUN_STATUSES (queued, processing, completed, completed_with_exceptions, failed, cancelled) and processes salary_run_items in chunks (SalaryJob.RUN_CHUNK) under a lease the sweep's watchdog re-issues if a chunk stalls. A run may be retried (failed/exception items only) or cancelled while still queued/processing.
13. The Sweep
The daily sweep (00:10 Nepal time, and at boot) runs four independent, idempotent passes, each caught on its own so one failure never blocks the others: activate due scheduled assignments, re-lease stalled runs, roll back approvals stalled more than 15 minutes, and re-evaluate sent/completed periods for newly-settled payments.
14. Notifications
SalaryNotifierService maps 23 event keys to an employee or admin audience with default channels (see the backend doc's full matrix). No salary notification variable ever carries an amount, an account number, or an identity number — it names what happened and links to the screen that shows it to someone allowed to see it.
15. Self-Service, Dashboard, History, Export
Every staff member reads their own current assignment (GET /api/mobile/staff/salary) and their own released payslips (GET /api/mobile/staff/payslips, .../:id) with no @Permissions() — the audience assertion is the caller's own staffId, resolved server-side. Payroll admins get a cross-staff dashboard (GET /api/salary/dashboard), a change history log (GET /api/salary/history), and a CSV export of payslips (GET /api/salary/exports/payslips).
16. Audit Visibility
Salary activity records to the same global audit interceptor as every other module, tagged module Salary* — but SalaryAudit is its own permission module, separate from every other Salary* code, so a school can grant "see the salary audit trail" independently of "change salary."
17. Cross-Module Effects
- People: an effective salary assignment mirrors
basic_salary/allowances/total_salaryonto the legacystaffrow (StaffSalaryService.mirrorFromAssignment) the moment a staff member has any assignment row, andPeopleDeletionServicerefuses to delete a staff member with an effective/scheduled assignment or an unsettled payslip (STAFF_HAS_ACTIVE_PAY_OR_ASSETS). - Leave:
LeaveAttributionService.unpaidDatesInRangeis the one read payroll makes of leave — the request's lastunpaid_dayscounted dates, so leave and payroll always agree on which days were unpaid. - Integration:
payroll:read/payroll:identity:read/payroll:bank:read/payroll:writescopes gate the accounting application's view of the salary change feed and its payment-write path.
18. Edge Cases
| Case | Behavior |
|---|---|
| A staff member with no effective or scheduled assignment | SalaryAssignmentWriter accepts only initial (or profile_import) for them; every other client change type needs a current assignment (SALARY_ASSIGNMENT_NO_CURRENT) |
| Two schedule changes target the same future date | SALARY_ASSIGNMENT_SCHEDULED_EXISTS refuses a second scheduled row while one is already pending |
| A payroll admin tries to change or approve their own salary | SALARY_SELF_CHANGE_FORBIDDEN |
| Approving with a stale prepared hash | SALARY_PAYSLIP_STALE — recalculate before approving |
| A period's eligible population moved after preparation | SALARY_PERIOD_POPULATION_CHANGED |
| An inbound accounting payment event repeats an id | SALARY_INBOUND_EVENT_ID_REUSED |
| An inbound accounting event carries an older timestamp than recorded | Rejected as stale, not stored as a failure |
Fees API Reference
Complete API contracts for the fee management module, including routes, auth, DTOs, responses, errors, examples, and integration notes.
Salary Backend Documentation
Backend architecture, data model, the calculation engine, services, queues, and operational behavior for the salary management module.