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) readsPayrollSettings.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 coversInvoice/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.