@duro/* packages they share, and a Postgres/Redis/R2 data plane. No Docker in the hot path — services run under PM2 on a VPS.
The whole system
Three services on one VPS, isolated test and live schemas, queue-driven work.
How a payment settles
Capture, verify, reconcile, and book every naira exactly once. Nomba’s webhook is at-least-once, so Duro re-queries the order as the source of truth and settles behind an idempotency lease — a closed tab never loses a completed payment, and a replayed webhook never double-credits.One payment, from checkout to a double-entry ledger row.
The recovery loop
A failed charge is the start of recovery, not the moment of churn: payday-aware retries scheduled around Nigerian salary cycles, a wallet sweep, and rail-switching from card to mandate to pay-link — with access held (grace) for the whole window.The recovery state machine: recover, or churn only after every attempt.
Why three services, not one
A clean separation of trust boundaries and runtime shapes:
The dashboard and the public API are different audiences with different threat models, so they’re different processes. The worker has a completely different runtime shape — long-running jobs, no request/response — so it’s isolated where a slow charge can never block a checkout.
The packages do the real work
Controllers are thin. The intelligence lives in framework-agnostic packages so it’s testable in isolation and reusable across services:@duro/billing
Pure logic, zero IO. The subscription state machine, proration math, the dunning decision engine, payday windows, retry schedules, billing-cycle date math: all pure functions over enums, unit-tested in isolation. This is the brain.
@duro/db
Two generated Prisma clients (core + data), a
RepositoryContext that hands a controller exactly the tenant-scoped repositories it needs, version-bump cache invalidation, cursor pagination.@duro/http-kit
BaseController, responders, the PermissionGuard, tenant-scope and API-key middleware, idempotency, pagination parsing. Write a controller in twenty lines.@duro/payments / nomba-client / queue / whatsapp / email
Abstractions over the outside world: a
ChargeGateway interface with a NombaChargeGateway implementation (real Nomba charges, mandates, refunds, transfers, virtual accounts, VAS), BullMQ factories, the WhatsApp Cloud client, the SMTP mailer. Swap the implementation, keep the callers.A request’s worth of moving parts
A merchantPOST /v1/subscriptions touches a precise, short chain:
The key move: by the time the controller runs, req.repos is already a RepositoryContext bound to the right tenant and the right schema. The controller cannot accidentally read another tenant’s data — it has no client that can. See multi-tenancy →
What runs on a timer
The worker is the only thing in the system that acts without a caller. Seven repeatable scanners, each a cross-tenant sweep that enqueues per-item jobs: Each scanner runs across both test and live schemas — a background process has no “current mode,” so it must process everyone. Request-scoped code never does this; only the worker. See queues & workers →Conventions, enforced
These aren’t style preferences — they’re checked in CI and they shape how the code reads.- OOP everywhere. Zero free
export functions in business logic. Behaviour lives in classes with static or instance methods. Presenters, services, gateways, guards — all classes. - No
any, ever.strictTypeScript withexactOptionalPropertyTypes,noUncheckedIndexedAccess,verbatimModuleSyntax. - No comments. The code is named to be read. The explanation lives here, in the docs, not in the source.
- Exact version pins. No
^/~. Lockfile is law. - Every mutation is verified live. The integration suite runs the real money loop against Postgres on every push.