Dentolize · HR Module Walkthrough
On this pageBusiness viewTechnical view

Automation: Expiry Cron, WhatsApp & Resilient Posting

The parts that run without anyone clicking — the safety net under the money, the reminders, and the notifications.

Business view

  • Compliance-expiry reminders. Every day the system scans employee documents —

iqama / national ID, passport, contract, license, and uploaded files — and sends managers a push notification when something is about to expire, at 30 / 14 / 7 / 3 / 1 / 0 days out. One summarized notification per manager device, not a flood.

  • WhatsApp notifications. Leave decisions ping the right person over WhatsApp

(on by default); payslip delivery over WhatsApp is opt-in (off by default). A messaging hiccup never blocks the action that triggered it.

  • Crash-safe money. Every payroll/loan/end-of-service posting goes through a

resilient outbox: if the ledger write fails, it retries; if the app crashes between "money moved" and "journal posted," a background sweep finishes the job. This is why the books stay balanced.

Technical view

Compliance-expiry cron — cronJobs/hr/hrExpiryCron.js

  • Milestones: MILESTONES = [30, 14, 7, 3, 1, 0] days (:8). Dedup is

not state-based — a document is included only when its integer days-until exactly equals a milestone (MILESTONES.includes(days), :53). So each document pushes at most 6 times over its life; on any other day it's skipped.

  • What it scans (:24): EmployeeRecord.nationalIdExpiry ("National ID /

Iqama"), passportExpiry, contractEndDate, licenseExpiry (KIND_LABEL :65), plus File.expiryDate for user-attached files (label = file name). Window = today → today + 31 days; long-expired items are ignored.

  • Company selection (:88): groups companies that have any tracked date or any

user file with an expiry.

  • Who's notified (:112): non-disabled users with push tokens whose group

permissions intersect HR_MANAGER_PERMS = ['DO_ALL','MANAGE_HR'] (:11); no such managers → company skipped.

  • Message (:118): items sorted by days-left; body shows the first 4 lines

("{name} — {label}: {N}d left" / "expires today") plus "(+N more)". Title HR: {n} document(s) expiring soon, data.type = 'HR_EXPIRY', to the last 20 tokens per manager.

  • Registration (cronJobs/cronJobs.js:45): schedule '30 7 * * *' (daily

07:30), wrapped in wrapCronJob('hrExpiryCron', …); a Redis running-lock and per-company Sentry tagging keep it safe and observable.

It's push-only (not WhatsApp), and it's the reason the PR needs the expo client passed into the cron signature.

WhatsApp notifications — utils/hr/hrNotify.js

  • notifyUserWhatsApp (:11) never sends directly — it **queues a

Communication row targeted at a user (mirroring patient messaging); the WhatsApp worker delivers it later. Wrapped in try/catch — it never throws**, so a notify failure can't break (or duplicate) the HR action that triggered it.

  • hrNotificationSettings (:41) reads PayrollSettings.notifyLeaveWhatsApp

(default on) and notifyPayslipWhatsApp (default off).

  • Callers: leave auto-approve/pending/decision (hrMutations.js:283), and

opt-in per-payslip messages after a run (payrollRun.js:193). There are no WhatsApp templates here — the content is free text (unlike the settings-upload welcome template).

Resilient posting / outbox — accounting/resilientPosting.js

  • safePost (:108) runs the post in its own transaction, up to

IMMEDIATE_RETRIES = 2 extra attempts with backoff; never throws — a persistent failure parks a PendingPosting outbox row and logs POSTING_DEFERRED. Success clears any parked row.

  • parkPendingIntent (:85) pre-parks the outbox row **inside the operational

transaction** (with nextAttemptAt = now + 5min so the sweep can't race the inline post). Used by loan disburse/repay, end-of-service payment, and the payroll withholding path — closing the crash window between the operational commit and the inline post.

  • retryPendingPostings (:137) is the cron sweep: per row it calls the

registered handler, deletes on success, backs off exponentially on failure (5min × 2^attempts), and marks FAILED after MAX_ATTEMPTS = 8 (or immediately if no handler is registered — surfaced for a human).

  • Idempotency: postSourceCreated (engine.js:689) no-ops if a JE already

exists; a unique index makes retries safe; entries balance on functional-currency totals.

Honest note: the orphan scanner (scanOrphanedPostings, :219) only covers Invoice/Payment. Payroll source types are not in the orphan scanner — which is exactly why the payroll paths pre-park intents instead, closing the same gap a different way (payrollRun.js:151).

This machinery is why the PR can claim balanced trial balances and Δ0 subledgers across two rich seed runs — the postings are retry-safe and crash-safe by construction.