Skoolsewa - School Management System Docs
Developer ResourcesSalary

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

AreaFiles InspectedVerified Details
Vocabularypackages/db/src/schema/school/salary-contract.tsEvery enum, the payment transition table, the notification matrix, read in full
Schemapackages/db/src/schema/school/salary-{catalog,structures,assignments,periods,payslips,settings}.tsEvery table, column, index, and CHECK, read in full
Calculationapps/api/src/utils/salary/salary-calculation.util.tsThe full algorithm's header comment and types, read
Effective datesapps/api/src/modules/salary/assignments/salary-effective-date.util.tsThe six modes, read in full
Approvalapps/api/src/modules/salary/periods/salary-period-approval.service.tsInline/background split, stale-hash refusal
Sweepapps/api/src/modules/salary/workers/salary-sweep.service.tsThe four passes, read in full
Queuepackages/jobs/src/index.ts (SalaryJob, QueueName.SALARY)Job contract
Permissionspackages/db/src/authorization/permission-catalog.ts (639-667, 848-860)13 salary permission modules
Locksapps/api/src/modules/salary/shared/salary-locks.util.tsThe global lock order
People integrationapps/api/src/modules/people/staff/staff-salary.service.ts, people-deletion.service.tsThe 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 — people owns that table; salary writes to it only through StaffSalaryService.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, SalarySharedModule

4. 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

ColumnTypeNullDefaultMeaning
nametextno—Display name
kindtextno—SalaryComponentKind
descriptiontextyes—≤500 chars
statustextnoactiveSalaryCatalogStatus
sort_orderintno0≥0
is_systemboolnofalseSeeded, not user-created
versionintno1Optimistic 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

ColumnTypeNullDefaultMeaning
codetextno—^[A-Z][A-Z0-9_]{1,29}$
nametextno—Display name
category_idintno—FK, composite with kind
kindtextno—SalaryComponentKind
roletextnoregularSalaryComponentRole
frequencytextnomonthlySalaryFrequency
default_value_typetextyes—SalaryValueType, null for a computed role
default_amount_paisabigintyes—Set iff default_value_type = 'flat'
default_percent_bpintyes—Set iff default_value_type = 'percent', 0-10000
default_percent_basetextyes—SalaryPercentBase, set iff percent
taxableboolnotrueIncluded in taxable income
proratedboolnofalseEarnings only
leave_deductibleboolnofalseEarnings, basic/regular role only
descriptiontextyes—≤500 chars
statustextnoactiveSalaryCatalogStatus
is_systemboolnofalseSeeded
sort_orderintno0≥0
versionintno1Optimistic 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

ColumnTypeNullDefaultMeaning
nametextno—1-150 chars, trimmed
descriptiontextyes—≤2000
statustextnodraftSalaryStructureStatus
currencytextnoNPRFixed — CHECK = 'NPR'
effective_fromdateno—Fixed once activated
effective_from_bs_{year,month,day}smallintno—BS mirror, 2000-2090 / 1-12 / 1-32
current_version_nointyes—NULL iff draft
activated_at/activated_by_user_idtimestamptz/uuidyes—Set on activation
deactivated_attimestamptzyes—Set on deactivate
archived_attimestamptzyes—(status='archived') = (archived_at IS NOT NULL)
versionintno1Optimistic 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).

ColumnTypeNullMeaning
staff_iduuidnoFK staff.id, RESTRICT
statustextnoSalaryAssignmentStatus
change_typetextnoSalaryChangeType
sourcetextnomanual/bulk/system
effective_from + BS mirrordate/smallintnoServer-resolved (§5 of the feature doc)
previous_assignment_idintyesFK (id, staff_id) self-referential, RESTRICT — the row this one supersedes
department_id, designation_idintyesFK RESTRICT; composite FK (department_id, designation_id) → designations(department_id, id)
previous_department_id, previous_designation_id, previous_designation_nameint/int/textyesSnapshot of the pre-change post, for a promotion/demotion audit trail
structure_id, structure_version_idintyesFK RESTRICT; paired — both null or both set; null on a stop
deviates_from_structureboolno, default falseIndividual lines differ from the pinned version
tax_assessmenttextno, default singleSalaryTaxAssessment
ssf_enrolledboolno, default false—
reasontextyesRequired (3-2000 chars) unless change_type is server-only
run_idintyesFK salary_runs.id, RESTRICT; set for a bulk-originated row
created_xact_idxid8no, default pg_current_xact_id()The transaction that created the header — what the line-insert guard checks
applied_at, superseded_attimestamptzyesSet exactly when the status reaches effective/superseded
cancelled_at, cancelled_by_user_id, cancel_reason—yesAll-or-nothing with status = 'cancelled'
activation_error, activation_failed_attext/timestamptzyesPaired; only while still scheduled
versionintnoOptimistic 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

ColumnTypeNullMeaning
staff_iduuidnoFK RESTRICT
component_id, frequency, kind, role—noFK RESTRICT into the component's key; frequency fixed one_time, role fixed regular
period_bs_year, period_bs_monthsmallintno2000-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%)
reasontextno3-2000 chars, mandatory (never server-only)
statustextno, default activeSalaryItemStatus
run_idintyesFK RESTRICT; set for a bulk-originated row
idempotency_keyuuidyesPaired with created_by_user_id in a partial unique index
cancelled_at, cancelled_by_user_id, cancel_reason—yesAll-or-nothing
versionintnoOptimistic 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

ColumnTypeNullMeaning
bs_year, bs_monthsmallintno2000-2090 / 1-12
start_date, end_datedatenoGregorian bounds
calendar_dayssmallintno29-32, must equal end_date - start_date + 1
fiscal_year_bssmallintgeneratedbs_year - (bs_month < 4) — Nepali FY starts BS month 4
fiscal_monthsmallintgenerated((bs_month + 8) % 12) + 1
statustextno, default openSalaryPeriodStatus
settings_snapshotjsonbyesFrozen 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—yesEach pair set if and only if that lifecycle status is reached
reopened_countsmallintno, default 0≥0, incremented on every reopen
notestextyes≤2000
versionintnoOptimistic 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):

ColumnTypeNullMeaning
statustextno, default draftSalaryPayslipStatus
revisionsmallintno, default 1≥1; bumped only by a correction
revision_xact_idxid8noStamped with the transaction that bumped revision; what admits new lines
assignment_idintyesFK (id, staff_id), RESTRICT
structure_id, structure_version_idintyesThe pinned version this revision priced against
tax_assessment, ssf_enrolled—noFrozen from the assignment
period_days, employed_days, divisor_dayssmallintno29-32 / 0-period_days / 0-32
leave_days, leave_paid_days, leave_unpaid_days, leave_deduction_dayssmallintno, default 0leave_paid_days + leave_unpaid_days = leave_days; leave_deduction_days ≤ leave_unpaid_days
gross_earnings_paisa, leave_deduction_paisa, total_deductions_paisa, employer_contributions_paisabigintno, default 0All ≥0
net_pay_paisabigintno, default 0Generated 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_paisabigintno, default 0All ≥0 — the TDS cumulative-projection inputs and result
flags, acknowledged_flagstext[]no, default {}Subsets of SALARY_FLAGS; acknowledged_flags is always a subset of what could be acknowledged
inputs_hashtextno^[0-9a-f]{64}$ — what approval compares to detect staleness
bank_name, bank_account_number, bank_branch, bank_account_holder, bank_snapshot_at—yesSnapshotted at approval; mandatory once status='approved'
payment_statustextno, default pendingSalaryPaymentStatus
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—yesThe accounting application's own reported figures, for the variance flag
approved_at, approved_by_user_id—yesSet exactly once, at approval, and frozen after
cancelled_at, cancel_reason—yesSet only while still draft
versionintnoOptimistic 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 (FK RESTRICT), 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+kind FK RESTRICT; origin (SalaryLineOrigin); one_time_item_id FK RESTRICT (set iff origin='one_time'); source_payslip_id FK RESTRICT, self-referential (set iff origin='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 an earning, amount_paisa = gross_paisa - leave_deduction_paisa; for anything else, leave_deduction_paisa = 0 and gross_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+kind FK RESTRICT (kind restricted to deduction/employer_contribution — never an earning or basic); amount_paisa (0-9,999,999,999); reason (3-2000); cleared_at/cleared_by_user_id paired; 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 (bounded deduction_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's activateDue.
  • SalaryEmployeeAssignmentsService — preview and commit for one staff member's own change. evaluateChange is the single place every blocking check lives (self-dealing, Staff_UPDATE for 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 echoed basis (the current/scheduled assignment ids and versions preview returned) against what it just re-read, refusing SALARY_VERSION_CONFLICT on any mismatch even when the re-run of evaluateChange alone would have passed.
  • SalaryBulkService — preview and commit for a population-wide change. Preview computes eligibility and, for apply_structure/adjust_salary only, a proposed monthly 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's staffIds is still in a freshly recomputed eligible set before launching the salary_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 pure calculatePayslip and writes the result via SalaryPayslipWriterService.
  • SalaryPayslipInputsService — loads everything calculatePayslip needs: segments, lines, one-time items, leave adjustments, settings, tax context. It also sets payEndsWithPeriod when 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 stored divisor_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 for integration in the same transaction as the change.
  • SalaryNotifierService — notify(tx, input), matrix lookup, notification rows.
  • StaffSalaryService (in people) — the legacy mirror; mirrorFromAssignment is the ONLY path allowed to write staff.basic_salary/allowances once a staff member has any salary_assignments row.

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.