eHub — Business Logic¶
Non-trivial rules, calculations, and workflows. All facts derived from source code. Sections marked ⚠ flag code that disagrees with itself or with documented behaviour.
1. Authentication & Session¶
1.1 Employer OTP flow¶
Source: apps/core/src/auth/otp/otp.service.ts, auth/auth.service.ts
- OTP = 6 numeric digits, stored hashed in MongoDB
otpscollection. - Plaintext sent via SendGrid. Returned in API response in non-production (
process.env.NODE_ENV !== 'production'). - TTL:
OTP_EXPIRY_MINUTESenv var. ⚠ Declared twice in.env.example(5 then 15) — last one wins. - No TTL index on the
otpscollection — expired records accumulate. - First login: OTP sent to user's email. After first successful verify,
User.isFirstLoginis cleared and subsequent OTPs go to the authorized contact.
1.2 Admin OTP flow¶
Source: apps/core/src/admin/services/admin-auth.service.ts
- OTP stored as
AdminOtpSessionrow in Postgres (not Mongo) withotpHash,expiresAt,attemptCount=0. - Lockout: after
MAX_OTP_ATTEMPTS(default 3) failed attempts →isLocked=true,lockedUntil=now+LOCKOUT_DURATION_MINUTES. - Resend window skip: if
User.lastLoginAtis withinADMIN_OTP_RESEND_WINDOW_HOURS, OTP is skipped and JWT returned directly. - Replay protection:
isUsed=trueafter first successful verify.
1.3 Admin magic links (onboarding / password reset)¶
- Token =
randomBytes(32).hex(64-char hex). - UserId encrypted AES-256-CBC, key =
scrypt(JWT_SECRET, 'salt', 32), random per-call IV. - Stored in Redis as
admin_magic_link:<token>forMAGIC_LINK_TTL_SECONDS(default 300 seconds = 5 minutes).
1.4 Admin session lifecycle¶
Source: libs/shared/src/strategies/jwt.strategy.ts, apps/core/src/admin/services/session-cleanup.service.ts
AdminSessionrow created on successful OTP verify:expiresAt = now + SESSION_TIMEOUT_MINUTES(default 15).- Per-request check when
userType=admin: reject if session is missing,isActive=false, or expired. UpdatelastActivityAt=now. - ⚠ Code computes
newExpiresAtbut never writes it back — sliding expiry is not actually implemented. - Cleanup cron: every 10 minutes, set
isActive=falseon rows whereexpiresAt < now AND isActive=true.
1.5 Employer onboarding state machine¶
NOT_STARTED
↓ registerEmployerAuth() / registerEmployerWithCode()
OTP_VERIFIED
↓ verifyRegistration()
AUTHORIZED_CONTACT_PENDING
↓ verifyAuthorizedContact()
COMPLETED + Employer.registrationStatus = 'approved'
- Path B (existing PENCOM employer code) can jump directly to
COMPLETED + approvedif Oracle confirms the employer. - Discrepancy gate:
registerEmployerAuthreturns 400 if an OPENDiscrepancyrow exists for the employer code.
1.6 Pilot allowlist¶
Source: apps/api-gateway/src/auth/auth.controller.ts
AUTH_PILOT_ALLOWLISTenv var is a JSON array[{code?: string, email?: string[]}].- Every
POST /authis checked: requests for an employer code or email not in the list are rejected with "invitation-only" message. - ⚠ Not documented in
.env.example. If missing/empty, all logins may be blocked.
2. Certificate Service Fee Calculation¶
Source: apps/core/src/services/employee.service.ts
CERTIFICATE_SERVICE_FEES env var (JSON):
{
"ranges": [
{ "minEmployees": 3, "maxEmployees": 50, "fee": 100000 },
{ "minEmployees": 51, "maxEmployees": 100, "fee": 150000 },
{ "minEmployees": 101, "maxEmployees": null, "fee": 250000 }
],
"taxPercent": 7.5
}
Algorithm:
- Load
Employer.numberOfEmployees. - Find the range where
min ≤ count ≤ max(treatingnullas +∞). - Return
{fee, taxPercent}.
⚠ Edge cases:
max === undefineddoes NOT match — must be explicitnull. Misconfiguration → 500.- Employer with
< 3employees → 500 (no fallback range). numberOfEmployeesis updated after employee migration, but not on ADDITION requests until they're approved.
3. Penalty Calculation¶
The most algorithmically complex feature.
Source: apps/compliance/src/services/penalty.service.ts
3.1 Configuration¶
| Env var | Default | Notes |
|---|---|---|
PENALTY_MONTHLY_DEFAULTING_RATE | 0.02 (2%/month) | Active in live path |
PENALTY_TRANSACTION_ISOLATION | SERIALIZABLE | Can downgrade to READ_COMMITTED |
PENALTY_GRACE_DAYS | 11 | ⚠ Only honoured in backfill. Live penalty service hard-codes 11. |
PENALTY_DAILY_RATE | — | ⚠ Does NOT exist in code. Daily rate is always derived from monthly rate. |
REMITTANCE_DUE_DAY | 28 | Clamped 1–28 |
Daily rate derivation: dailyRate = monthlyRate × 12 / 365. With defaults: ≈ 0.000657534/day.
3.2 Penalty formula (per employee row, COM contributions only)¶
days = max(0, value_date − grace_end_date) (calendar days)
penaltyBase = employee_contribution + employer_contribution (AVC excluded)
share = penaltyBase × (monthlyRate × 12 / 365) × days
total = SUM(share) over all employees for that employer-month
due_date= last calendar day of month.grace_end_date=due_date + 11.- First chargeable day =
due_date + 12. - AVC fields (
employee_avc,employer_avc) are excluded frompenaltyBase.
3.3 Penalty state machine¶
GAP_DETECTED → no COM row yet for this month
↓ handleComUpload
PENALTY_COMPUTED → penalty_amount and days_in_default set
↓ handlePenUpload (partial PEN payment)
PARTIALLY_PAID
↓ handlePenUpload (PEN total ≥ penalty_amount)
PAID_IN_FULL
Once PARTIALLY_PAID or PAID_IN_FULL, recomputation is skipped — penalties freeze after payment starts.
3.4 Why SERIALIZABLE transactions?¶
Two PFCs can independently send COM or PEN rows for the same (employer, month) simultaneously. Combined with row-level pessimistic write lock on the penalty row, SERIALIZABLE prevents:
- Lost updates to
paid_amountwhen two PEN uploads finalise the row. - Inconsistent COM history during
penalty_amountrecompute.
3.5 Cron triggers¶
| Trigger | Frequency |
|---|---|
CSV upload completion (EmployeeJobProcessor.runPostStreamPenaltyLogic) | Per upload |
EmployerRebuildService.rebuildEmployer | Manual via POST /admin/cleanup/rebuild/:code |
BackfillCronService.runSweep | Manual via POST /penalty/recalculate-all (⚠ no guard) |
GracePeriodNotificationCronService | Daily at 1am — sends notifications only, does NOT recompute |
4. Contribution Deduplication¶
Source: apps/compliance/src/migrations/1780180264632-UpdateContributionDuplicateKey.ts
Natural key for employee_monthly_contributions¶
Enforced at:
- DB layer: partial unique index
UQ_employee_contributions_monthly(WHERE all four NOT NULL). - Runtime:
employeeContributionNaturalKey()+orIgnore()on bulk insert.
Bulk insert chunked at floor(65535 / 17) = 3855 rows to stay under Postgres bound-parameter limit.
Crash-safe resume: (jobId, sourceRowNumber) protected by a separate partial unique index. CSV processor checkpoints every 10,000 rows.
5. Compliance Rule Engine¶
Source: apps/compliance/src/config/compliance-rules.config.ts
Current rule set (version 2.0, last updated 2025-11-07):
FULL_01 — "Fully Compliant"
conditions:
- contri_status === true
- gli_status === 'ACTIVE'
- penalty_cleared === true ⚠ currently commented out / stubbed
aggregation: all_must_pass
⚠ penalty_cleared check is commented out in compliance-evaluation.service.ts — an employer can be evaluated as "Fully Compliant" even with outstanding penalties.
6. PCC Certificate Workflow¶
Source: apps/compliance/src/admin/services/pcc-approval.service.ts
State machine¶
PENDING
↓ Admin first review
REVIEWED_FIRST
↓ Admin second review
REVIEWED_SECOND
↓ Admin approve → Certificate issued
APPROVED
↓ Hourly cron (expiry_date < now)
EXPIRED
REVIEWED_SECOND
↓ Admin query
QUERIED ↔ EMPLOYER_RESPONDED
↓
REJECTED
Each transition writes a PccActivity row with actor_id, actor_role, previous_status, new_status.
Certificate issuance (on APPROVE)¶
CertificateGeneratorService(pdfkit) renders a PDF.FileUploadService.saveFile(buffer, 'CERTIFICATES')→ MinIO.- Creates
Certificaterow: pcc_number = PCC/${YYYY}/${zero-padded-sequence}(unique).status = Active.expiry_date = today + N months(typically 12).certificate_path= MinIO URL.verification_url=${FRONTEND_URL}/verify-pcc/${token}.digital_signature= hash of certificate contents.- Sends
SEND_PCC_STATUS_UPDATE_EMAIL.
7. Payment Lifecycle (Remita)¶
Source: apps/payments/src/payments.service.ts
State machine¶
PENDING → SUCCESSFUL (via webhook OR validate)
PENDING → FAILED (via validate returning terminal Remita error)
CANCELLED — declared but never reached ⚠
Charge composition (CERTIFICATE_GENERATION)¶
| Layer | Amount |
|---|---|
| Service fee | From CERTIFICATE_SERVICE_FEES (e.g. ₦100,000) |
| VAT | 7.5% of service fee |
| Convenience Fee | ₦161.25 hardcoded — no env override ⚠ |
Payment.amountstoresserviceFee + appliedChargesTotal(the Remita-billed total).- Response
amountfield showsserviceFee + chargesTotal(display total including non-applied charges). - These two numbers are different. ⚠
Idempotency¶
- On create: if a PENDING
Paymentrow exists for(identifier, paymentType), return its existing RRR — no new Remita call. - On webhook: lookup by
orderIdORrrr. Re-receipt overwrites to SUCCESSFUL — idempotent. - ⚠ No DB unique constraint on
(identifier, paymentType)— concurrentPOST /createcalls can create duplicate PENDING rows.
Remita signing¶
- Init hash:
sha512(merchantId + serviceTypeId + orderId + remitaAmount + apiKey). - Status hash:
sha512(rrr + apiKey + merchantId). - Response is JSONP — parsed via regex
/jsonp\s*\((.*)\)/. ⚠ Brittle — fails on whitespace or alternative callback names. - Webhook: no signature verification.
8. Scheduled Jobs Reference¶
All crons in compliance except SessionCleanupService in core.
| Job | Schedule | Purpose |
|---|---|---|
SessionCleanupService | */10 * * * * | Invalidate expired admin sessions |
CertificateExpiryCronService | 0 * * * * | Expire certificates; demote evaluations. ⚠ Not recorded in cron_job_runs |
RevaluateAllEmployersCronService | 0 0 * * * | Re-run compliance evaluation for all approved employers |
ApprovedEvaluationStatusCronService | 0 * * * * | Restore approved+NON_COMPLIANT evaluations to FULLY_COMPLIANT |
GracePeriodNotificationCronService | 0 1 * * * | Send SEND_PENALTY_INCURRED emails. maxPerRun=500. |
GliExpiryService | 0 0 * * * | Mark expired GLI records |
Manually triggered (no @Cron):
BackfillCronService.runSweep()—POST /penalty/recalculate-all. ⚠ No guard — any internal network caller can trigger a full sweep.MissingContributionsRepairService.run()—POST /admin/cleanup/repair-missing-contributions.
9. Key Edge Cases¶
Auth / sessions¶
User.emailnormalised to lowercase on insert/update — case differences collide on the unique index.- Refresh token cookie path is
/auth— frontend must POST to a/auth/*URL to trigger refresh. - Admin sliding-expiry is computed but never persisted — sessions expire at their original
expiresAtregardless of activity.
Payments¶
orderId = "ORD_" + Date.now()— not collision-safe under burst load; no unique constraint.- Remita webhook accepts both
channelANDchannnel(documented typo in Remita's API). CANCELLEDpayment status declared but never set by any code path.
Compliance¶
PENALTY_GRACE_DAYSenv var only takes effect in backfill sweep. Live penalty computation hard-codes 11 days.POST /penalty/recalculate-allhas no guard — any internal caller can trigger a full penalty sweep.POST /compliance/run-daily-accrualis JWT-only with no admin role — any employer can trigger it.