Skip to main content
Read this once at altitude, then drill into any box. Duro is a pnpm + Turborepo monorepo. Three deployable Node services, a constellation of @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

Runtime topology: Vercel frontends, VPS services, Postgres, Redis, Nomba

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.
Settlement pipeline: checkout, capture, webhook, reconcile, settle, ledger

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.
Recovery state machine: active, charge, decline, then recover or churn

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 merchant POST /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. strict TypeScript with exactOptionalPropertyTypes, 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.
Next: the data model — how the three schemas are laid out and why.