Salary Backend Documentation
Backend architecture, data model, the calculation engine, services, queues, and operational behavior for the salary management module.
Salary Backend Documentation
1. Documentation Evidence
| Area | Files Inspected | Verified Details |
|---|---|---|
| Vocabulary | packages/db/src/schema/school/salary-contract.ts | Every enum, the payment transition table, the notification matrix, read in full |
| Schema | packages/db/src/schema/school/salary-{catalog,structures,assignments,periods,payslips,settings}.ts | Every table, column, index, and CHECK, read in full |
| Calculation | apps/api/src/utils/salary/salary-calculation.util.ts | The full algorithm's header comment and types, read |
| Effective dates | apps/api/src/modules/salary/assignments/salary-effective-date.util.ts | The six modes, read in full |
| Approval | apps/api/src/modules/salary/periods/salary-period-approval.service.ts | Inline/background split, stale-hash refusal |
| Sweep | apps/api/src/modules/salary/workers/salary-sweep.service.ts | The four passes, read in full |
| Queue | packages/jobs/src/index.ts (SalaryJob, QueueName.SALARY) | Job contract |
| Permissions | packages/db/src/authorization/permission-catalog.ts (639-667, 848-860) | 13 salary permission modules |
| Locks | apps/api/src/modules/salary/shared/salary-locks.util.ts | The global lock order |
| People integration | apps/api/src/modules/people/staff/staff-salary.service.ts, people-deletion.service.ts | The mirror and the deletion refusal |
2. Backend Scope and Boundaries
Owns
- The pay vocabulary: components, categories (
catalog). - Salary structures, their lines, versions and revisions, and designation defaults (
structures). - Staff salary assignments, changes, promotions, and their effective-date resolution (
assignments,employees). - One-time items (
one-time-items). - Bulk operations and their background runs (
bulk,runs). - Periods — creation, preparation, approval, reopen, send, cancel (
periods). - Payslips — calculation, overrides, exclusions, corrections, bank refresh (
payslips). - Payment recording and the accounting application's inbound payment events (
payments). - The staff self-service portal (
portal). - Dashboards, change history, CSV export (
reports). - Global settings, financial policy, tax slabs, notification matrix (
settings). - Notifications (
notify). - The daily sweep and the queue processor (
workers). - The one calculation engine, the one effective-date resolver, locks, access control, and the change feed writer (
shared), used by every writer.
Does Not Own
- Authentication and RBAC — consumes
JwtAuthGuard,RoleGuard,@Permissions(). - The accounting application's API surface and webhook delivery — owned by
integration, which reads the salary change feed and writes only payment state and inbound sync fields. - Leave balances and requests — reads
LeaveAttributionService's output but does not compute or store leave itself. - The legacy
staff.basic_salary/allowances/bank_name/etc. columns —peopleowns that table;salarywrites to it only throughStaffSalaryService.mirrorFromAssignment, and only once a staff member has any assignment row.
3. Module Composition
apps/api/src/modules/salary/
catalog/ SalaryCatalogModule — component categories, components
structures/ SalaryStructuresModule — structures, lines, versions, designation defaults
assignments/ SalaryAssignmentsModule — the writer, effective-date resolver, activation
employees/ SalaryEmployeesModule — per-staff assignment read/create/preview/cancel, promotions
one-time-items/ SalaryOneTimeItemsModule — one-time items
bulk/ SalaryBulkModule — bulk preview and launch
periods/ SalaryPeriodsModule — period lifecycle, approval, release, makers
payslips/ SalaryPayslipsModule — calculator, inputs, writer, actions, population, leave adjustments
payments/ SalaryPaymentsModule — payment recording, inbound accounting events
runs/ SalaryRunsModule — run read/retry/cancel, the worker service
portal/ SalaryPortalModule — staff self-service, mounted under /api/mobile
reports/ SalaryReportsModule — dashboard, history, exports
settings/ SalarySettingsModule — settings, notification settings, tax slabs
notify/ SalaryNotifierService
workers/ SalaryQueueProcessor, SalarySweepService, SalaryWorkerModule, scheduler
shared/ SalaryLocksUtil, SalaryAccessService, SalaryErrorsUtil, SalaryChangeFeedService, SalaryDatesUtil, SalarySharedModule4. Data Model
Every table below is read in full from packages/db/src/schema/school/salary-{catalog,structures,assignments,periods,payslips,settings}.ts, cross-checked against the guard-trigger migration packages/db/src/migrations/0046_salary_asset_management_integrity.sql. Money columns are bigint paisa; percentages are integer basis points (0-10000). Every table carries a serial id PK plus a uuid public_id (default uuidv7()) unless noted — the integer id is for internal joins, public_id is what a route ever addresses.
salary_component_categories
| Column | Type | Null | Default | Meaning |
|---|---|---|---|---|
name | text | no | — | Display name |
kind | text | no | — | SalaryComponentKind |
description | text | yes | — | ≤500 chars |
status | text | no | active | SalaryCatalogStatus |
sort_order | int | no | 0 | ≥0 |
is_system | bool | no | false | Seeded, not user-created |
version | int | no | 1 | Optimistic concurrency |
Indexes/constraints: unique(id, kind) (the composite target a component's own FK pins to); uniqueIndex(lower(name)); CHECKs — kind known, status known, name trimmed 1-80, description ≤500, sort_order ≥0. FKs: created_by_user_id/updated_by_user_id → users.id, ON DELETE SET NULL.
salary_components
| Column | Type | Null | Default | Meaning |
|---|---|---|---|---|
code | text | no | — | ^[A-Z][A-Z0-9_]{1,29}$ |
name | text | no | — | Display name |
category_id | int | no | — | FK, composite with kind |
kind | text | no | — | SalaryComponentKind |
role | text | no | regular | SalaryComponentRole |
frequency | text | no | monthly | SalaryFrequency |
default_value_type | text | yes | — | SalaryValueType, null for a computed role |
default_amount_paisa | bigint | yes | — | Set iff default_value_type = 'flat' |
default_percent_bp | int | yes | — | Set iff default_value_type = 'percent', 0-10000 |
default_percent_base | text | yes | — | SalaryPercentBase, set iff percent |
taxable | bool | no | true | Included in taxable income |
prorated | bool | no | false | Earnings only |
leave_deductible | bool | no | false | Earnings, basic/regular role only |
description | text | yes | — | ≤500 chars |
status | text | no | active | SalaryCatalogStatus |
is_system | bool | no | false | Seeded |
sort_order | int | no | 0 | ≥0 |
version | int | no | 1 | Optimistic concurrency |
Indexes/constraints: unique(code), uniqueIndex(lower(name)), unique(id, kind), unique(id, frequency, kind, role) (the four-column target every line table's FK pins to, which is what makes "this line's role/kind cannot drift from its component's" a foreign key rather than a service check), uniqueIndex(role) WHERE role <> 'regular' (one component per non-regular role, so the payroll engine can find "the" TDS/SSF/CIT component unambiguously). CHECKs: code/name format, kind/role/frequency/status known, role IN ('basic','leave_refund') → kind = 'earning', role IN ('ssf_employee','cit','tds','leave_recovery') → kind = 'deduction', (role = 'ssf_employer') = (kind = 'employer_contribution'), basic is monthly and flat-or-unset, a computed role (SALARY_COMPUTED_ROLES) carries no default value at all, cit is monthly, the default value shape is coherent (exactly one of flat/percent fully populated), amounts/bp bounded, an earning percentage is always of basic, leave_deductible only on an earning with role basic/regular, prorated only on an earning. FK: (category_id, kind) → salary_component_categories(id, kind), RESTRICT — a category cannot be deleted or have its kind changed while any component references it, and a component cannot be filed under a category of a different kind.
salary_structures
| Column | Type | Null | Default | Meaning |
|---|---|---|---|---|
name | text | no | — | 1-150 chars, trimmed |
description | text | yes | — | ≤2000 |
status | text | no | draft | SalaryStructureStatus |
currency | text | no | NPR | Fixed — CHECK = 'NPR' |
effective_from | date | no | — | Fixed once activated |
effective_from_bs_{year,month,day} | smallint | no | — | BS mirror, 2000-2090 / 1-12 / 1-32 |
current_version_no | int | yes | — | NULL iff draft |
activated_at/activated_by_user_id | timestamptz/uuid | yes | — | Set on activation |
deactivated_at | timestamptz | yes | — | Set on deactivate |
archived_at | timestamptz | yes | — | (status='archived') = (archived_at IS NOT NULL) |
version | int | no | 1 | Optimistic concurrency |
Indexes: uniqueIndex(lower(name)) WHERE status <> 'archived' (an archived name may be reused); index(status, id). Guard trigger salary_structures_guard_header (0046_salary_asset_management_integrity.sql:79-118): DELETE only from draft; effective_from fixed once past draft; never returns to draft; archived reachable only from inactive; an archived row is otherwise fully read-only; becoming active from any other state requires exactly one role = 'basic' line in salary_structure_lines (salary_structures_basic_line, re-checked on reactivation by salary_structures_activation_guard in 0047), and a draft is activated or deleted, never moved straight to inactive.
salary_structure_lines
Draft-only working lines. | Column | Type | Null | Meaning |
|---|---|---|---|
| structure_id | int | no | FK CASCADE — deleted with a draft |
| component_id | int | no | FK RESTRICT, composite (component_id, frequency, kind, role) |
| frequency | text | no | Fixed monthly |
| kind, role | text | no | Must be in SALARY_LINE_ROLES (basic/regular/cit) |
| value_type, amount_paisa, percent_bp, percent_base | — | — | Same value-shape rules as the component |
| sort_order | int | no | Display order |
unique(structure_id, component_id) — one line per component. Guard trigger salary_structure_lines_draft_only (:122-146): a line's structure_id never changes; every insert/update/delete re-reads the parent's status FOR SHARE and refuses unless it is draft (a NOT FOUND parent — mid-cascade-delete — passes through).
salary_structure_versions / salary_structure_version_lines
Append-only frozen snapshots, one row per activate/revise. salary_structure_versions: structure_id FK RESTRICT, version_no (unique(structure_id, version_no), ≥1), effective_from + BS mirror, change_note (≤2000), created_by_user_id FK RESTRICT + a name snapshot. salary_structure_version_lines: version_id FK RESTRICT, component_id FK RESTRICT (not composite — this table freezes the component's own code/name/category at the time, in component_code/component_name/category_name, so a later catalogue rename never rewrites history), same value-shape CHECKs as the structure line. Both are covered by the shared salary_append_only trigger (:38-73): any UPDATE or DELETE raises — the only way to change history is a new version.
salary_structure_designations / salary_designation_defaults
salary_structure_designations: composite PK (structure_id, designation_id), structure_id FK CASCADE, designation_id FK RESTRICT — which designations a structure declares itself applicable to. salary_designation_defaults: designation_id is the PK (at most one default per designation), structure_id FK RESTRICT, plus a composite FK (structure_id, designation_id) → salary_structure_designations — a structure can only be a designation's default if it already declared that designation applicable. Guard trigger salary_designation_defaults_guard (:148-165): the referenced structure must be active at write time. Deferred constraint trigger salary_structure_defaults_follow_status (:167-183, checked at commit): a structure that is no longer active may not still be referenced by any default row — the service is free to deactivate the structure and remove its defaults in either order within one transaction, but not leave one without the other.
salary_assignments
The append-only pay timeline, one row per change (packages/db/src/schema/school/salary-assignments.ts:75).
| Column | Type | Null | Meaning |
|---|---|---|---|
staff_id | uuid | no | FK staff.id, RESTRICT |
status | text | no | SalaryAssignmentStatus |
change_type | text | no | SalaryChangeType |
source | text | no | manual/bulk/system |
effective_from + BS mirror | date/smallint | no | Server-resolved (§5 of the feature doc) |
previous_assignment_id | int | yes | FK (id, staff_id) self-referential, RESTRICT — the row this one supersedes |
department_id, designation_id | int | yes | FK RESTRICT; composite FK (department_id, designation_id) → designations(department_id, id) |
previous_department_id, previous_designation_id, previous_designation_name | int/int/text | yes | Snapshot of the pre-change post, for a promotion/demotion audit trail |
structure_id, structure_version_id | int | yes | FK RESTRICT; paired — both null or both set; null on a stop |
deviates_from_structure | bool | no, default false | Individual lines differ from the pinned version |
tax_assessment | text | no, default single | SalaryTaxAssessment |
ssf_enrolled | bool | no, default false | — |
reason | text | yes | Required (3-2000 chars) unless change_type is server-only |
run_id | int | yes | FK salary_runs.id, RESTRICT; set for a bulk-originated row |
created_xact_id | xid8 | no, default pg_current_xact_id() | The transaction that created the header — what the line-insert guard checks |
applied_at, superseded_at | timestamptz | yes | Set exactly when the status reaches effective/superseded |
cancelled_at, cancelled_by_user_id, cancel_reason | — | yes | All-or-nothing with status = 'cancelled' |
activation_error, activation_failed_at | text/timestamptz | yes | Paired; only while still scheduled |
version | int | no | Optimistic concurrency |
Indexes: unique(id, staff_id); uniqueIndex(staff_id) WHERE status='effective' and WHERE status='scheduled' — at most one effective and one scheduled row per staff member at a time, enforced by Postgres, not the service; uniqueIndex(run_id, staff_id) WHERE run_id IS NOT NULL; index(staff_id, effective_from, id) (the timeline read order); index(effective_from) WHERE status='scheduled' (what the sweep's activateDue scans). CHECKs: status/change_type/source/tax_assessment known; BS bounds; a stop carries no structure_id; the structure/version pair is coherent; reason required outside SALARY_REASONLESS_CHANGE_TYPES; cancelled/superseded/applied pairs; promotion/demotion requires a designation_id; a designation_id requires a department_id; the activation-error pair, and it may only be set while still scheduled.
Guard trigger salary_assignments_guard (:221-280): no DELETE, ever. Every column except status and the small set of activation-failure columns is frozen for the row's whole life — an UPDATE that touches anything else raises. While status stays scheduled, only the activation-error columns may move. The only legal status transitions are scheduled → effective (refused if a later assignment already exists for that staff member, so activation can never regress the timeline), scheduled → cancelled, and effective → superseded; every other requested transition raises naming the attempted old/new pair. salary_assignments_insert_guard (0047) applies the same ordering rule to a row inserted directly as effective.
salary_assignment_lines
One row per component on an assignment. assignment_id FK RESTRICT; (component_id, frequency, kind, role) FK RESTRICT into the component's own four-column unique key; unique(assignment_id, component_id). source is SalaryLineSource (structure/override/individual); structure_amount_paisa/structure_percent_bp freeze what the pinned structure version said, alongside the line's own (possibly overridden) amount_paisa/percent_bp, so a deviation is visible without re-reading the version. CHECKs mirror the structure line's value-shape rules, plus basic/cit are always flat. Covered by salary_append_only (no UPDATE/DELETE ever). Guard trigger salary_assignment_lines_same_xact (:284-297): a line may only be inserted in the same transaction that created its assignment header (created_xact_id = pg_current_xact_id()) — lines can never be added to an existing assignment later. Deferred constraint trigger salary_assignment_lines_shape_check (:301-338, fires after either the header insert or a line insert): a stop assignment must end with zero lines; every other assignment must end with exactly one role='basic' line.
salary_one_time_items
| Column | Type | Null | Meaning |
|---|---|---|---|
staff_id | uuid | no | FK RESTRICT |
component_id, frequency, kind, role | — | no | FK RESTRICT into the component's key; frequency fixed one_time, role fixed regular |
period_bs_year, period_bs_month | smallint | no | 2000-2090 / 1-12 |
value_type, amount_paisa, percent_bp, percent_base | — | — | Flat (1-9,999,999,999 paisa) or percent-of-basic (1-100,000 bp, i.e. up to 1000%) |
reason | text | no | 3-2000 chars, mandatory (never server-only) |
status | text | no, default active | SalaryItemStatus |
run_id | int | yes | FK RESTRICT; set for a bulk-originated row |
idempotency_key | uuid | yes | Paired with created_by_user_id in a partial unique index |
cancelled_at, cancelled_by_user_id, cancel_reason | — | yes | All-or-nothing |
version | int | no | Optimistic concurrency |
Indexes: unique(id, staff_id); uniqueIndex(run_id, staff_id) WHERE run_id IS NOT NULL; uniqueIndex(created_by_user_id, idempotency_key) WHERE idempotency_key IS NOT NULL — a replayed request with the same key from the same actor is a no-op, not a duplicate item; index(period_bs_year, period_bs_month) WHERE status='active'. Guard trigger salary_one_time_items_guard (:341-379): no DELETE (cancel instead); every column except status is frozen after insert; a cancelled item is fully frozen (status too); on every insert/update the owning month's period (if one exists and is not cancelled, read FOR SHARE) must be open or prepared — not preparing, so a one-time item cannot be slipped in mid-calculation.
salary_periods
| Column | Type | Null | Meaning |
|---|---|---|---|
bs_year, bs_month | smallint | no | 2000-2090 / 1-12 |
start_date, end_date | date | no | Gregorian bounds |
calendar_days | smallint | no | 29-32, must equal end_date - start_date + 1 |
fiscal_year_bs | smallint | generated | bs_year - (bs_month < 4) — Nepali FY starts BS month 4 |
fiscal_month | smallint | generated | ((bs_month + 8) % 12) + 1 |
status | text | no, default open | SalaryPeriodStatus |
settings_snapshot | jsonb | yes | Frozen copy of salary_settings at prepare time; mandatory once past preparing |
prepared_at/prepared_by_user_id, approved_at/approved_by_user_id, sent_at/sent_by_user_id, completed_at, cancelled_at/cancelled_by_user_id/cancel_reason | — | yes | Each pair set if and only if that lifecycle status is reached |
reopened_count | smallint | no, default 0 | ≥0, incremented on every reopen |
notes | text | yes | ≤2000 |
version | int | no | Optimistic concurrency |
Indexes: uniqueIndex(bs_year, bs_month) WHERE status <> 'cancelled' — a cancelled period frees its month for a fresh one; index(start_date, end_date). CHECKs enforce every *_pair above plus BS bounds, the days/date-span match, reopened_count ≥ 0, and text lengths. Guard trigger salary_periods_transition_guard (0047_salary_asset_guards.sql): a status change must be one of open→preparing, open→cancelled, preparing→prepared, prepared→preparing (recalculation), prepared→approving, prepared→cancelled, approving→approved, approving→prepared (rollback), approved→prepared (reopen), approved→sent, sent→completed, completed→sent; anything else raises salary_periods_transition. Every payslip and one-time-item guard trusts the period's status, so this is what keeps them honest. The rest of the lifecycle's legality (contiguity, distinct approver, population match) is enforced by the service under the period and staff locks.
salary_period_exclusions
period_id/staff_id FK RESTRICT, reason (3-2000), excluded_by_user_id FK RESTRICT, revoked_at/revoked_by_user_id (paired). uniqueIndex(period_id, staff_id) WHERE revoked_at IS NULL — at most one active exclusion per staff member per period; revoking and re-excluding is a fresh row.
salary_runs / salary_run_items
salary_runs: kind (SalaryRunKind), status (SalaryRunStatus, default queued), scope/params (jsonb), period_id (FK RESTRICT, required iff kind IN ('prepare_period','approve_period')), total_items/succeeded_items/skipped_items/failed_items (all ≥0, and succeeded+skipped+failed ≤ total), lease_generation (≥0, bumped by the watchdog), request_key (uniqueIndex(requested_by_user_id, request_key) WHERE request_key IS NOT NULL — per-requester idempotency), payload_hash (^[0-9a-f]{64}$), error_code/error_message (≤500). salary_run_items: run_id/staff_id FK RESTRICT, unique(run_id, staff_id), status (SalaryRunItemStatus, default pending), outcome (≤64), attempts (0-10), error_message (≤500). No guard trigger on either — the worker's own lease/generation check is the concurrency control (§5).
salary_payslips
The calculation result, one per (period_id, staff_id) (unique(period_id, staff_id)). Selected columns beyond the identity/name snapshot fields (employee_code, employee_name, designation_id/name, department_id/name, is_teaching — all frozen at preparation, never re-read from the live staff row):
| Column | Type | Null | Meaning |
|---|---|---|---|
status | text | no, default draft | SalaryPayslipStatus |
revision | smallint | no, default 1 | ≥1; bumped only by a correction |
revision_xact_id | xid8 | no | Stamped with the transaction that bumped revision; what admits new lines |
assignment_id | int | yes | FK (id, staff_id), RESTRICT |
structure_id, structure_version_id | int | yes | The pinned version this revision priced against |
tax_assessment, ssf_enrolled | — | no | Frozen from the assignment |
period_days, employed_days, divisor_days | smallint | no | 29-32 / 0-period_days / 0-32 |
leave_days, leave_paid_days, leave_unpaid_days, leave_deduction_days | smallint | no, default 0 | leave_paid_days + leave_unpaid_days = leave_days; leave_deduction_days ≤ leave_unpaid_days |
gross_earnings_paisa, leave_deduction_paisa, total_deductions_paisa, employer_contributions_paisa | bigint | no, default 0 | All ≥0 |
net_pay_paisa | bigint | no, default 0 | Generated by CHECK, not a Postgres GENERATED column: = gross_earnings_paisa - leave_deduction_paisa - total_deductions_paisa; may be negative only while draft/cancelled |
taxable_income_paisa, projected_annual_taxable_paisa, annual_tax_paisa, ytd_taxable_paisa, ytd_tds_paisa | bigint | no, default 0 | All ≥0 — the TDS cumulative-projection inputs and result |
flags, acknowledged_flags | text[] | no, default {} | Subsets of SALARY_FLAGS; acknowledged_flags is always a subset of what could be acknowledged |
inputs_hash | text | no | ^[0-9a-f]{64}$ — what approval compares to detect staleness |
bank_name, bank_account_number, bank_branch, bank_account_holder, bank_snapshot_at | — | yes | Snapshotted at approval; mandatory once status='approved' |
payment_status | text | no, default pending | SalaryPaymentStatus |
paid_amount_paisa, paid_on, payment_reference, accounting_reference, payment_source | — | — | payment_source is manual/external; zeroed while pending/cancelled |
last_external_updated_at, last_external_event_id, reported_net_paisa, reported_deductions_paisa | — | yes | The accounting application's own reported figures, for the variance flag |
approved_at, approved_by_user_id | — | yes | Set exactly once, at approval, and frozen after |
cancelled_at, cancel_reason | — | yes | Set only while still draft |
version | int | no | Optimistic concurrency |
Indexes: index(staff_id, period_id), index(period_id, status), index(payment_status) WHERE status='approved', index(assignment_id, staff_id). CHECKs cover every pairing above plus salary_payslips_net_arithmetic, salary_payslips_net_nonneg_when_live, the payment-transition-adjacent shape rules (_only_approved, _cancelled_unpaid, _unpaid_zeroed, _paid_dated, _partial_bounds, _paid_amount), and amount bounds.
salary_payslips_insert_guard (0047): a payslip is created only as a draft, and only while its period is preparing or prepared. Guard trigger salary_payslips_guard (:382-497) — the single largest guard in the module, encoding the whole lifecycle: no DELETE, ever; period_id/staff_id/public_id/created_at are permanently fixed; a revision bump must move revision by exactly +1 and stamp revision_xact_id with the current transaction in the same write. From cancelled, the only legal move is back to draft while the period is prepared (reinstatement). From draft: a payment field may never change; staying draft is allowed only while the period is preparing/prepared (recalculation); moving to cancelled only while prepared (exclusion); moving to approved only while the period is approving and none of the calculated fields changed in the same write (a pure approval — bank snapshot and flag acknowledgements only). From approved: it may return to draft only while the period is approving/approved, payment is still pending, and no payment field is touched (a reopen, or the rollback of an incomplete approval); it may never move to anything but draft/approved (an approved payslip is never cancelled); a calculation change is legal only as a correction of a sent, unpaid payslip advancing revision by exactly one; a payment-field change is legal only once the period is sent/completed; a bank-detail change is legal only while the period is approved/sent and payment is still pending; approved_at/approved_by_user_id never move again once set.
Deferred constraint trigger salary_payslip_totals_check (:501-537, fires at commit on the payslip or any of its current-revision lines): the header's four totals must equal the sum of the current revision's lines by kind (earning→gross/leave, deduction→deductions, employer_contribution→employer) — a line write and a header write can happen in either order within the transaction, but must agree by the time it commits.
salary_payslip_segments, salary_payslip_lines, salary_payslip_overrides, salary_payslip_leaves
All four are children of one payslip revision, all covered by salary_append_only on segments/lines/leaves (no UPDATE/DELETE) and by the shared salary_payslip_children_guard (:539-565) on insert: a line/segment/leave insert is only accepted for the payslip's current revision, and only in the exact transaction that wrote that revision (revision_xact_id, tightened in 0047) — so revision N is always one transaction's snapshot and no later transaction can append to it. salary_payslip_children_owner_guard (0047) ties each child to its payslip's owner: a line's one-time item must be the same staff member's, for the same BS month, and active; a line's source payslip must be the same staff member's; a segment's assignment must be the same staff member's. Overrides are separately guarded by the same function: an override may be cleared (its clearing columns set) but never have its substantive fields edited, and may change at all only while the payslip is draft.
salary_payslip_segments:payslip_id/revision/assignment_id(FKRESTRICT),from_date/to_date(to_date ≥ from_date),paying(bool),lines(jsonb snapshot of that segment's line values) —unique(payslip_id, revision, assignment_id, from_date).salary_payslip_lines:component_id+kindFKRESTRICT;origin(SalaryLineOrigin);one_time_item_idFKRESTRICT(set ifforigin='one_time');source_payslip_idFKRESTRICT, self-referential (set ifforigin='leave_adjustment'— the earlier month's payslip a refund/recovery traces back to);gross_paisa/leave_deduction_paisa/computed_amount_paisa/amount_paisa(all ≥0); for anearning,amount_paisa = gross_paisa - leave_deduction_paisa; for anything else,leave_deduction_paisa = 0andgross_paisa = amount_paisa;override_reason(3-2000, set only when a manual override replaced the computed value). Three partial unique indexes enforce one row per(payslip, revision, component)for an ordinary line, one per(…, one_time_item_id)for a one-time line, and one per(…, source_payslip_id, component_id)for a leave-adjustment line — so a component can legitimately appear more than once on one payslip (its recurring line and its leave-refund line), each exactly once.salary_payslip_overrides:payslip_id/component_id+kindFKRESTRICT(kind restricted todeduction/employer_contribution— never an earning orbasic);amount_paisa(0-9,999,999,999);reason(3-2000);cleared_at/cleared_by_user_idpaired;uniqueIndex(payslip_id, component_id) WHERE cleared_at IS NULL— at most one live override per component.salary_payslip_leaves:leave_request_id(uuid, no FK — leave requests are soft-deleted, never hard-deleted, and this snapshot must survive a correction there);day_count_basis(school_days/staff_working_days/calendar_days);request_paid_days/request_unpaid_days/days_in_period/unpaid_days_in_period/deduction_days(boundeddeduction_days ≤ unpaid_days_in_period ≤ days_in_period);unpaid_dates(date array,cardinality = unpaid_days_in_period);is_legacy(reconstructed from a pre-migration request with no stored counted dates);unique(payslip_id, revision, leave_request_id).
salary_payslip_events
Append-only (salary_append_only). event_type (SalaryPayslipEventType, 14 values), source (SalaryEventSource: system/manual/external), from_revision/to_revision, from_payment_status/to_payment_status (each independently must be a known SalaryPaymentStatus or null), amount_paisa, note (≤2000), actor_user_id FK RESTRICT, integration_client_id FK RESTRICT, inbound_event_id, request_key/payload_hash (paired; uniqueIndex(actor_user_id, request_key) WHERE request_key IS NOT NULL — the manual-payment endpoint's per-actor idempotency: the same actor replaying the same body gets the stored result, a different body or actor reusing the key is refused).
salary_inbound_events
The accounting application's payment-report log. integration_client_id/payslip_id FK RESTRICT, external_event_id (1-128, unique(integration_client_id, external_event_id)), payload_hash (^[0-9a-f]{64}$), payload (jsonb), state (SalaryInboundState: applied/stale — a refused event is answered and never stored, so the same id can succeed once the condition clears). index(received_at, id) is the read order. Covered by salary_append_only (0047_salary_asset_guards.sql): a stored report is the record of what the accounting application sent and the source of every replay answer, so it is never updated or deleted.
salary_settings
Singleton (uniqueIndex(singleton) WHERE singleton, CHECK singleton = true — never CHECK (id = 1), which is the pattern this repo deliberately avoids). Selected columns: rounding_mode/rounding_unit_paisa (FEE_ROUNDING_MODES/_UNITS, shared with fees), leave_divisor (SalaryLeaveDivisor), payment_authority (SalaryPaymentAuthority; external requires payroll_client_id set — salary_settings_external_needs_client), payroll_client_id (FK integrationClients.id, SET NULL), require_distinct_approver (bool), ssf_enabled/ssf_employee_rate_bp/ssf_employer_rate_bp (0-10000 each), employer_ssf_taxable (bool), tds_enabled (bool), retirement_cap_paisa (0-999,999,999,999) plus retirement_cap_numerator/_denominator (a fraction — 0 < numerator ≤ denominator ≤ 100 — because "one third" has no exact basis-point value), share_citizenship_with_accounting (bool). A period freezes a copy of this whole row into its own settings_snapshot at prepare time — a rate change afterward never re-prices a month already in review.
salary_notification_settings
One row per event key (eventKey is the PK, SalaryNotificationEventKey), is_enabled, audiences/channels (text arrays, each a checked subset of SALARY_NOTIFICATION_AUDIENCES/_CHANNELS), with enabled ⇒ both arrays non-empty (salary_notification_settings_enabled_needs_targets).
salary_tax_slabs
(fiscal_year_bs, assessment, seq) unique. upper_bound_paisa (null = open/top slab), rate_bp (0-10000), waived_for_ssf (bool, only legal on seq = 1). Deferred constraint trigger salary_tax_slabs_check (:185-220, fires at commit on any insert/update/delete for that (fiscal_year_bs, assessment) group): an empty group always passes (a fiscal year may be removed entirely); a non-empty group must have seq contiguous from 1, every bound strictly increasing over the previous slab's, and exactly one open (null) top slab which must be the last one by seq — so a group can never be left with two open slabs or a gap in the middle, though the two writes that fix a bad group (delete the old slabs, insert the new ones) may happen in either statement order within one transaction.
5. The Global Lock Order
salary-locks.util.ts fixes one order for every writer: period → staff advisory lock → payslip → assignment. Every service that touches more than one of these takes them in this order inside its own transaction (tx.execute, never the pooled db.execute — a pooled connection's advisory lock locks nothing). lockPeriodsFrom and lockSalaryStaff are the two entry points; the sweep and every writer route through them.
6. Services
SalaryAssignmentWriter— the one path that creates, activates, cancels and mirrors an assignment; also serves the sweep'sactivateDue.SalaryEmployeeAssignmentsService— preview and commit for one staff member's own change.evaluateChangeis the single place every blocking check lives (self-dealing,Staff_UPDATEfor a promotion/demotion, designation validity, locked-period dates); preview calls it read-only and reports every entry it finds, commit calls it again under the period+staff locks and throws the first entry (throwIfBlocked) — so a race that only preview could have caught is caught again at commit, not silently skipped. Commit additionally compares the request's echoedbasis(the current/scheduled assignment ids and versions preview returned) against what it just re-read, refusingSALARY_VERSION_CONFLICTon any mismatch even when the re-run ofevaluateChangealone would have passed.SalaryBulkService— preview and commit for a population-wide change. Preview computes eligibility and, forapply_structure/adjust_salaryonly, aproposedmonthly total per eligible row on the current page (salary-bulk-proposal.util.ts, shared with the run handler so the previewed number and the number the run writes can never diverge); commit re-checks that every id in the request'sstaffIdsis still in a freshly recomputed eligible set before launching thesalary_run.SalaryPayslipCalculatorService—prepareStaff: locks the staff, activates any now-due assignment first (so a payslip never uses a stale rate), checks exclusion, then calls the purecalculatePayslipand writes the result viaSalaryPayslipWriterService.SalaryPayslipInputsService— loads everythingcalculatePayslipneeds: segments, lines, one-time items, leave adjustments, settings, tax context. It also setspayEndsWithPeriodwhen the staff member has a scheduled or effective stop dated the day after the period ends, so the last month trues the tax year up; the flag enters the inputs hash only when true.SalaryLeaveAdjustmentsService— finds earlier approved payslips whose attributed unpaid dates changed, recomputes each with its storeddivisor_days(a missing calendar leaves the month as paid), subtracts what other periods already settled — excluding the period being computed — and returns signed adjustments for the calculator.SalaryPeriodPreparationService/SalaryPeriodMakersService— period creation and the preparation run.SalaryPeriodApprovalService— hashes and approves inline or via a background run; rolls back a stalled approval.SalaryPeriodReleaseService— send, and the completion re-evaluation the sweep calls per period.SalaryRunLauncherService/SalaryRunWorkerService— launches and chunks a background run; the watchdog re-leases a stalled one.SalaryAccessService— the self-dealing guard (SALARY_SELF_CHANGE_FORBIDDEN) and field-level gates, called independently of the route guard.SalaryChangeFeedService— records salary events forintegrationin the same transaction as the change.SalaryNotifierService—notify(tx, input), matrix lookup, notification rows.StaffSalaryService(inpeople) — the legacy mirror;mirrorFromAssignmentis the ONLY path allowed to writestaff.basic_salary/allowancesonce a staff member has anysalary_assignmentsrow.
7. Runtime Flows
Assign → activate → payslip: POST /api/salary/employees/:staffId/assignments creates a scheduled or immediately effective row (depending on the resolved effective date) → the daily sweep (or, for an immediate change, the writer itself) activates it → SalaryAssignmentWriter.mirrorFromAssignment updates the legacy staff row → the next prepare for that period picks up the new lines.
Prepare → approve → send → complete: POST /api/salary/periods → POST /:id/prepare computes one payslip per eligible staff member, snapshotting settings → POST /:id/approve hashes and approves each payslip (inline ≤200, background run above) → POST /:id/send releases them to the portal → the sweep re-evaluates sent periods against payment state and flips to completed once every payslip settles.
Accounting sync: the accounting application reads the salary change feed at its granted scope, and reports payment state through the inbound events path (GET /api/salary/inbound-events, documented alongside integration); a stale timestamp is rejected without becoming a stored failure (SALARY_INBOUND_STATES).
8. Security
Every Salary* permission module except MySalary is SUPERADMIN_ONLY. The three portal routes (staff/salary, staff/payslips, staff/assets) carry no @Permissions() and are on NO_PERMISSION_ADMIN_HANDLERS in role.guard.ts, mirrored in route-permissions.spec.ts. SalaryAccessService refuses a payroll admin from changing or approving their own salary row regardless of the permissions they hold.
9. Observability
Every mutating salary route is covered by the global ActivityAuditInterceptor, module Salary*, but visibility of that trail is gated by the separate SalaryAudit permission — a school can grant "see the salary log" independently of every write permission.
10. Testing
Integration specs live under apps/api/src/modules/salary/__tests__/, sharing salary-test-support.ts (ensureSalarySeeds, resetSalaryAssetTables). They run against the real test database, never a mocked ORM, Redis, or queue.