A backend for an Employer Contribution Management System. Built with NestJS, TypeORM, and PostgreSQL.
With Docker (recommended):
git clone https://github.com/degide/Contribution-Management.git
cd Contribution-Management
docker compose down # (additionally remove volumes if not first time setup)
docker compose up --buildAPI: http://localhost:3000/api
Swagger: http://localhost:3000/api/docs
Without Docker:
cp .env.example .env # set DB credentials
npm install
npm run migration:run
npm run seed
npm run start:dev- Node.js 20+
- PostgreSQL 16+
cp .env.example .envEdit .env with your database credentials. The defaults match the Docker Compose setup.
Key variables:
| Variable | Default | Description |
|---|---|---|
DB_HOST |
localhost |
Postgres host |
DB_PORT |
5432 |
Postgres port |
DB_USERNAME |
rssb_user |
Database user |
DB_PASSWORD |
rssb_password |
Database password |
DB_NAME |
rssb_db |
Database name |
DB_POOL_SIZE |
10 |
Connection pool size |
JWT_SECRET |
— | Required. Set a strong secret. |
JWT_EXPIRES_IN |
24h |
Token lifetime |
npm install# Run all migrations (creates tables, indexes, enums)
npm run migration:run
# Seed demo data
npm run seednpm run start:dev # development (hot reload)
npm run start:prod # production buildVisit: http://localhost:3000/api/docs
Seed credentials:
| Role | Password | |
|---|---|---|
| admin | admin@rssb.rw |
Admin1234! |
| employer | employer@kigalitea.rw |
Employer1234! |
| employer | employer@rwconstruct.rw |
Employer2nd! |
npm run test # unit tests
npm run test:e2e # end-to-end tests (requires a running DB)
npm run test:cov # coverage reportAll endpoints are prefixed with /api. JWT Bearer token required on all endpoints except POST /auth/login.
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /auth/login |
public | Login -> JWT |
| POST | /auth/register-admin |
admin | Create admin account |
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /employers |
admin | Onboard employer + create login atomically |
| GET | /employers |
any | List (admin sees all; employer sees own) |
| GET | /employers/:id |
any | Get employer |
| PATCH | /employers/:id |
any | Update business details |
| DELETE | /employers/:id |
admin | Soft-delete (cascades to linked user + employees) |
| PATCH | /employers/:id/suspend |
admin | Suspend employer |
| GET | /employers/:id/contribution-summary |
any | Monthly breakdown with optional ?from=YYYY-MM&to=YYYY-MM |
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /employees |
any | Register employee |
| GET | /employees |
any | List (scoped to employer) |
| GET | /employees/:id |
any | Get employee |
| PATCH | /employees/:id |
any | Update (includes enrollment defaults) |
| DELETE | /employees/:id |
any | Soft-delete (row retained for audit) |
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /declarations |
any | Create draft declaration |
| GET | /declarations |
any | List (scoped by role) |
| GET | /declarations/:id |
any | Get declaration with all contribution lines |
| PATCH | /declarations/:id/lines |
employer | Replace lines (draft only) |
| PATCH | /declarations/:id/submit |
employer | Submit for admin review |
| PATCH | /declarations/:id/validate |
admin | Approve |
| PATCH | /declarations/:id/reject |
admin | Reject with reason |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /audit-logs |
admin | Paginated audit trail with before/after snapshots |
Supports filters: ?action=EMPLOYER_UPDATE, ?targetType=Employer, ?targetId=<uuid>, ?userId=<uuid>, ?from=<ISO-8601>&to=<ISO-8601>
All list endpoints accept ?limit=10&offset=0.
Each domain (auth, employers, employees, declarations, audit) is a self-contained NestJS module with its own controller, service, DTOs, and entities. This makes each domain testable in isolation and easy to locate when something needs changing.
POST /employers creates both the employer record and its linked user account inside a single DataSource.transaction(). If either insert fails, duplicate TIN, duplicate email, the whole operation rolls back cleanly. This means there is no state where an employer record exists without a login, or vice versa.
A single declaration write touches three things: the declaration row, all contribution lines, and the cached totals. All three are wrapped in one transaction so it is impossible to have a declaration with missing lines or stale totals due to a partial failure.
The declarations table stores pre-computed total_pension, total_medical, total_maternity, and grand_total columns, recalculated whenever lines change. The alternative, summing lines at query time, is wasteful when the contribution summary endpoint aggregates across many declarations at once.
DRAFT -> SUBMITTED -> VALIDATED -> REJECTED
Transitions are enforced in the service layer. Employers can only submit from DRAFT; admins can only validate or reject from SUBMITTED. Once submitted, lines cannot be edited. A rejected declaration is terminal, the employer creates a fresh one.
No row is ever hard-deleted. Employers, employees, and users all get a status = 'deleted' flag and a deleted_at timestamp instead. This preserves foreign key integrity (declared contribution lines still reference their employees), keeps the full history intact, and means deleted records are excluded from all list and lookup queries at the service layer, not the database layer, so the data is always recoverable.
Deleting an employer cascades in a single transaction: the employer, its linked user account, and all active employees are soft-deleted together. A deleted user's JWT is rejected immediately on the next request, the strategy checks status === 'active' on every token validation rather than waiting for the token to expire.
Each employee carries two enrollment flags, enrolledMedical and enrolledMaternity, that represent their standing default across all future declarations. These are set once (e.g. enrolledMedical: false for an employee on a private insurance scheme) and automatically applied when declaration lines are built.
A per-line override (overrideMedical, overrideMaternity) can flip the default for a single declaration period without changing the employee record. The resolution rule is simple: the override wins when present; the employee default applies otherwise.
Once a declaration line is created, its includeMedical and includeMaternity flags are frozen. Later changes to the employee's defaults do not retroactively affect past declarations.
Every mutating request (POST, PATCH, PUT, DELETE) is audited automatically by a single APP_INTERCEPTOR registered in AppModule. No controller or service needs to know auditing exists.
The interceptor derives a human-readable action name from the HTTP method and the matched Express route pattern:
POST /employers -> EMPLOYER_CREATE
PATCH /employers/:id/suspend -> EMPLOYER_SUSPEND
PATCH /declarations/:id/lines -> DECLARATION_UPDATE_LINES
PATCH /declarations/:id/validate -> DECLARATION_VALIDATE
Before the handler runs, it fetches the current DB row (before_state). After the handler responds, it captures the response body (after_state). Both are stored as top-level JSONB columns on the audit log, not nested inside a generic metadata blob, so they can be queried directly in Postgres:
SELECT * FROM audit_logs
WHERE before_state->>'status' = 'draft'
AND after_state->>'status' = 'submitted';Passwords and password_hash values are stripped from every snapshot recursively before storage. The audit write is fire-and-forget, a failure writing to audit_logs never affects the response.
The TypeORM connection pool size is configurable via DB_POOL_SIZE (default 10). Worth noting: the audit interceptor performs a SELECT against the DB before every mutating request (to capture before_state), so at peak load each audit-eligible request momentarily holds two connections from the pool, one for the snapshot, one for the handler. Keep this in mind when sizing the pool under concurrent load; 20 is a reasonable starting point for production.
Role enforcement happens in the service layer, not only at the guard level. An employer user is silently filtered to their own resources regardless of which ID they pass in the URL. This means a misconfigured guard cannot accidentally expose another employer's data.
The GET /employers/:id/contribution-summary endpoint is implemented as a second controller inside DeclarationsModule rather than adding declaration logic to EmployersController. NestJS supports multiple controllers per module cleanly, and this keeps each module's controller focused on one resource type.
users : login credentials + role + employer FK
employers : business registration, status, sector
employees : person data, salary, enrollment flags, employer FK
declarations : period, status workflow, cached totals, employer FK
contribution_lines : per-employee amounts for a declaration, enrollment snapshot
audit_logs : immutable record of every mutating request
-- Employer TIN is globally unique (Rwanda Revenue Authority format)
CREATE UNIQUE INDEX "UQ_employers_tin" ON employers (tin);
-- National ID uniquely identifies a person across all employers
CREATE UNIQUE INDEX "UQ_employees_national_id" ON employees (national_id);
-- Most employee queries filter by employer
CREATE INDEX "IDX_employees_employer_id" ON employees (employer_id);
-- Core business rule: one declaration per employer per period
CREATE UNIQUE INDEX "UQ_declaration_employer_period" ON declarations (employer_id, period);
-- Contribution summary queries always filter on (employer_id, period)
CREATE INDEX "IDX_declarations_employer_period" ON declarations (employer_id, period);
-- Admin dashboard filters by status
CREATE INDEX "IDX_declarations_status" ON declarations (status);
-- Line lookups always start with declaration_id
CREATE INDEX "IDX_contribution_lines_declaration" ON contribution_lines (declaration_id);
-- Prevents an employee appearing twice in the same declaration
CREATE UNIQUE INDEX "UQ_contribution_line_declaration_employee"
ON contribution_lines (declaration_id, employee_id);
-- Audit lookups: by actor, action type, target resource, or time window
CREATE INDEX "IDX_audit_user_id" ON audit_logs (user_id);
CREATE INDEX "IDX_audit_action" ON audit_logs (action);
CREATE INDEX "IDX_audit_target" ON audit_logs (target_type, target_id);
CREATE INDEX "IDX_audit_created_at" ON audit_logs (created_at DESC);
-- Partial indexes to filter out soft-deleted records efficiently
CREATE INDEX "IDX_users_status" ON users (status) WHERE status = 'active';
CREATE INDEX "IDX_employees_status" ON employees (status) WHERE status = 'active';
CREATE INDEX "IDX_employers_status" ON employers (status) WHERE status != 'deleted';Pension = gross_salary × 6.0% (always applied)
Medical = gross_salary × 7.5% (zero if employee opted out)
Maternity = gross_salary × 0.3% (zero if employee not eligible)
Example for a salary of 500,000 RWF with full enrollment:
| Type | Rate | Amount (RWF) |
|---|---|---|
| Pension | 6% | 30,000 |
| Medical Insurance | 7.5% | 37,500 |
| Maternity Leave | 0.3% | 1,500 |
| Total | 13.8% | 69,000 |
An employee on private insurance (enrolledMedical: false) with the same salary contributes only 31,500 RWF (pension + maternity only). The rates are defined as named constants in contribution-line.entity.ts so a future rate change is a single-file edit.
| Rule | Enforcement |
|---|---|
| Unique TIN per employer | DB unique index + service check |
| Unique national ID per employee | DB unique index + service check |
| National ID remains reserved after soft-delete | No hard delete; unique index always holds |
| One declaration per employer per period | DB composite unique index + service check |
| Unique payment number | DB unique index + @BeforeInsert generator |
| Draft-only line edits | Service layer state check |
| Cannot submit an empty declaration | Service layer line count check |
| Employer can only access own data | Service layer ownership check |
| Suspended employer cannot create declarations | Service layer status check |
| Employee must belong to the declaring employer | Service layer FK check |
| Deleted user's JWT rejected immediately | JWT strategy checks status === 'active' |
| Pension is always applied, no opt-out | No enrolledPension flag; always calculated |
| Contribution line enrollment flags are immutable | Set at creation; never updated in place |
JWT-based via passport-jwt.
Token payload:
{
"sub": "<userId>",
"email": "...",
"role": "employer|admin",
"employerId": "<uuid>|null"
}How it works:
JwtAuthGuardvalidates the token and attachesreq.userRolesGuard+@Roles(...)enforces role requirements at the handler levelJwtStrategy.validate()checksuser.status === 'active'on every request, deleted accounts are locked out without waiting for token expiry- Service methods perform an additional ownership check to scope employer users to their own data
Rate limits:
- Login: 10 requests/minute
- Register admin: 5 requests/minute
- All other endpoints: 60 requests/minute (global
ThrottlerGuard)
Every mutating request produces one row in audit_logs:
{
"id": "uuid",
"userId": "uuid",
"userEmail": "admin@rssb.rw",
"userRole": "admin",
"action": "DECLARATION_VALIDATE",
"targetType": "Declaration",
"targetId": "uuid",
"before": {
"id": "uuid",
"status": "submitted",
"period": "2025-01",
"grand_total": "69000.00"
},
"after": {
"id": "uuid",
"status": "validated",
"period": "2025-01",
"grand_total": "69000.00",
"validated_at": "2025-03-09T10:00:00Z"
},
"ipAddress": "192.168.1.1",
"createdAt": "2025-03-09T10:00:00Z"
}beforeisnullfor creates (no prior row exists)afterisnullfor deletes (handler returns 204 with no body)- Passwords are stripped recursively from both snapshots
-
One user account per employer. The system links a single login to each employer record. Adding multiple users per employer (e.g. HR + Finance roles) would require an
employer_usersjoin table and is not in scope. -
Gross salary can be overridden per declaration period. When building declaration lines, callers can pass a
grossSalarythat overrides the employee's base salary for that period. This handles bonus months and mid-period salary changes without updating the employee record. -
Payment number is for reference only. It is auto-generated, guaranteed unique, and intended to match bank transfers to declarations. It is not validated against any external payment gateway.
-
Period validation is format-only. The system validates
YYYY-MMbut does not block future or very old periods. A production constraint like "no declarations more than 3 months in arrears" would be a one-line addition to the service. -
TIN is always 9 digits. Based on Rwanda Revenue Authority format. Relax the regex in
employer.dto.tsfor multi-country deployment. -
Rejection is terminal. A rejected declaration cannot be re-submitted. The employer creates a new declaration for the same period. This is the simpler model; a
REJECTED -> DRAFTre-open transition could be added if needed. -
Rates represent the employer's contribution only. The 6% / 7.5% / 0.3% figures are the employer-side share. Employee-side deductions are out of scope.
-
Soft-deleted national IDs stay reserved. Re-registering an employee after soft-deletion is not supported via a new record. The existing row must be reactivated manually (status reset to
active). This prevents accidental duplicate identities for the same person. -
Audit logs are append-only. There is no delete or update endpoint for
audit_logs. Cleanup, if ever needed, is a direct DB operation with appropriate access controls outside the API.
- Refresh tokens. Current JWTs are single-token with no rotation. A
refresh_tokenstable with short-lived access tokens and longer-lived refresh tokens would harden session management. - Idempotency keys. Declaration submission should accept an
Idempotency-Keyheader to safely retry on network failure without creating duplicates.
- Redis caching on the contribution summary endpoint. Validated declarations do not change, so the monthly aggregation is a good candidate for a short TTL cache.
- Background job queue (Bull/BullMQ) for large declarations with hundreds of employees, rather than blocking the HTTP request for the full computation.
- Bulk employee import. CSV upload to onboard many employees at once, which is the typical case for a new employer joining the system.
- Email notifications. Alert the employer when their declaration is validated or rejected.
- Payment gateway webhook. Expose a webhook endpoint to receive payment confirmation and mark a validated declaration as paid.
- Declaration amendments. Allow correcting a validated declaration by creating an amendment that references the original, preserving the full audit trail.
- Export endpoints. Download a declaration as a PDF or CSV for physical record-keeping.
- Unit tests for calculation logic. The e2e suite covers the happy path end-to-end, but isolated unit tests for
ContributionLine.calculate(), the enrollment resolution logic, and thederiveAction()audit helper would be faster to run and easier to pin regressions to. - OpenAPI client generation. The Swagger spec is already complete. Auto-generating a TypeScript client SDK from it would make integration straightforward for frontend consumers.