Async bulk salary disbursement engine built to global FinTech production standards.
PCI DSS · NDPR · SOC 2 · CBN — compliance by design, not by retrofit.
go-payroll-engine is a backend system for processing bulk payroll disbursements through Monnify. It handles the full lifecycle — from payroll batch creation through background processing, real-time webhook reconciliation, and compliance evidence generation.
The codebase is designed as a reference implementation of what production FinTech infrastructure looks like: every security control, compliance requirement, and observability concern addressed at the architecture level rather than patched in later.
Client Request
│
▼
┌─────────────────────────────────────────────────────┐
│ API Layer │
│ │
│ Global: SecurityHeaders → BodyLimit → Logger │
│ → Metrics → RateLimit → Recovery │
│ Per group: JWTAuth → TenantScope → DataResidency │
└────────────────────────┬────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ Service Layer │
│ │
│ CreatePayroll → DB Transaction → Redis Enqueue │
└────────────────────────┬────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ Worker Layer (Asynq) │
│ │
│ FSM Transition → Employee Hash Map → Monnify API │
└────────────────────────┬────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ Webhook Handler │
│ │
│ HMAC Verify → Bloom Filter → FSM → Atomic Counter │
│ → Audit Log → Reconcile Parent Payroll │
└─────────────────────────────────────────────────────┘
| Authentication | Encryption | Transport | Abuse Prevention |
|---|---|---|---|
|
JWT with org-scoped claims RBAC role gates bcrypt cost-12 Timing-safe key comparison |
AES-256-GCM on all PII fields Transparent GORM serializer Envelope encryption (KEK/DEK) KMS-ready interface |
OWASP security headers 1 MB body size limit Non-root Docker image Production startup guards |
Token bucket rate limiting Idempotency keys (Redis, 24h) Bloom filter deduplication HMAC-SHA512 webhook verification |
| Pattern | File | Guarantee |
|---|---|---|
| Finite State Machine | models/models.go |
Illegal status transitions rejected before any DB write |
| Atomic Counter | models/models.go |
Webhook reconciliation fires exactly once per batch |
| Hash Map | workers/payroll_worker.go |
O(1) employee lookup — eliminates N+1 query pattern |
| Bloom Filter | middleware/bloom.go |
~99% of duplicate webhooks caught before DB read |
| Append-Only Log | audit_events table |
Immutable record of every state change — no UPDATE, no DELETE |
| Idempotency Map | middleware/idempotency.go |
Network retry cannot create duplicate payroll batch |
| Weighted Sliding Window | services/analytics_service.go |
Cash flow forecast biased toward recent data |
| Envelope Encryption | models/encryption.go |
PII encrypted at rest, decrypted transparently on read; HMAC blind index for searchable email |
| Postgres Row Level Security | db/migrations/000008–000011 |
Every tenant-scoped table enforces organization_id = app.org_id at the DB layer — a forgotten WHERE clause returns zero rows, not another tenant's data |
| Money as Kobo (BIGINT) | pkg/money/money.go |
All monetary values stored as int64 minor units — no float64 rounding, no silent precision loss |
| FSM CAS Counter Init | workers/payroll_worker.go |
Worker sets status=processing and pending_count=N in one atomic UPDATE — closes the race where webhooks arriving during the Monnify call would decrement from 0 |
| CBN | NDPR | SOC 2 | Data Residency |
|---|---|---|---|
|
BVN verification via Dojah at employee creation. KYC recorded before any payroll data is stored. Response hash stored — not the BVN itself. |
Append-only consent records per employee per type. Withdrawal creates a new row — history is never mutated. Consent expiry enforced at query time. |
Daily evidence snapshots to JSON. One-click compliance report endpoint. Migration version, audit counts, security checks all captured. |
Geo-fencing middleware rejects cross-region requests. Default region |
/metrics ──→ Prometheus (scrape interval: 15s)
│
▼
Grafana Dashboard
│
┌──────────────┼──────────────┐
│ │ │
HTTP Layer Payment Layer Security Layer
│ │ │
request rate Monnify p95 auth failures
error rate % success rate rate limit hits
latency p95 batch duration webhook dupes
Start the full observability stack:
docker-compose up| Service | URL |
|---|---|
| API | http://localhost:28080 |
| Grafana | http://localhost:3000 |
| Prometheus | http://localhost:9090 |
| Metrics | http://localhost:28080/metrics |
Prerequisites: Go 1.25+, Docker, PostgreSQL 15, Redis 7
# Clone
git clone https://github.com/ObeeJ/gopayrollengine.git
cd go-payroll-engine
# Configure
cp .env.example .env
# Edit .env — set JWT_SECRET, ENCRYPTION_KEK (base64 32 bytes),
# ENCRYPTION_HMAC_KEY (base64 32 bytes), MONNIFY credentials.
# Generate keys: openssl rand -base64 32
# Start infrastructure (Postgres on :5432, Redis on :6379)
docker-compose up db redis -d
# Apply migrations — runs all up.sql files in order
make migrate-up # or: APP_MODE=api go run cmd/api/main.go (migrates on startup)
# Seed database (creates ORG-DEMO-0001 with 3 employees + 3 months history)
APP_MODE=seed go run cmd/api/main.go
# Start API server
APP_MODE=api go run cmd/api/main.go
# Start background worker (separate terminal)
APP_MODE=worker go run cmd/api/main.goAuthentication
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/auth/login |
Issue JWT |
POST |
/api/v1/auth/refresh |
Refresh token |
Employees
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/employees/ |
Create employee — BVN verified, consent recorded, PII encrypted |
GET |
/api/v1/employees/ |
List employees (paginated) |
Payroll
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/payrolls/ |
Initiate batch — idempotent, queued async |
GET |
/api/v1/payrolls/:id |
Batch status and all disbursement items |
Analytics & Compliance
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/analytics/predictive |
Cash flow forecast with risk level |
POST |
/api/v1/consent/ |
Record NDPR consent |
GET |
/api/v1/consent/:employee_id |
Full consent history |
GET |
/api/v1/compliance/report |
30-day SOC 2 / CBN evidence bundle — requires role=compliance |
Infrastructure
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/webhooks/monnify |
Disbursement reconciliation — HMAC verified |
GET |
/healthz |
Liveness probe |
GET |
/readyz |
Readiness — checks DB, Redis, encryption key |
GET |
/metrics |
Prometheus scrape endpoint |
| Layer | Technology |
|---|---|
| Language | Go 1.25+ |
| Web framework | Gin |
| ORM | GORM |
| Background jobs | Asynq |
| Migrations | golang-migrate |
| Auth | golang-jwt/jwt/v5 |
| Metrics | Prometheus + Grafana |
| Payment gateway | Monnify |
| KYC | Dojah |
go-payroll-engine/
├── cmd/api/ # Entrypoint — api | worker | seed | collect-evidence
├── config/ # Prometheus config, Grafana dashboard
├── internal/
│ ├── api/
│ │ ├── handlers/ # auth, employees, payrolls, webhooks, compliance
│ │ ├── middleware/ # jwt, ratelimit, idempotency, bloom, residency, logger
│ │ └── routes.go
│ ├── db/
│ │ └── migrations/ # 000001 → 000012 versioned SQL (RLS, Kobo, encryption, indexes)
│ ├── integrations/
│ │ └── monnify/ # bulk transfer + wallet balance (mock-aware)
│ ├── models/ # GORM models, FSM, encryption, audit log
│ ├── observability/ # Prometheus metric definitions
│ ├── services/ # payroll, analytics, BVN, SOC 2 collector
│ └── workers/ # Asynq handlers, Redis client, Asynq client
└── pkg/
└── money/ # Kobo type — integer arithmetic, banker's rounding
The Makefile wraps every step. Integration tests need a Postgres instance with all migrations applied; the targets below handle both.
# Boot the test database (port 5433) and apply migrations
make bootstrap
# Unit tests only
make test
# Unit + integration (against the bootstrapped database)
make test-integration
# With race detector
make test-race
# Aggregate coverage
make coverIntegration tests are gated behind the integration build tag and live alongside the code they exercise (*_integration_test.go). They prove the RLS policy blocks cross-tenant reads/writes, the webhook handler reconciles exactly once under concurrent fire, and the worker initializes its counter before the Monnify call.