wanis.dev
← All work
PaymentsFlagship

Ceyna

A payments orchestrator that moves money from Canada and Europe to African corridors. It takes a transfer from pay-in to payout across many independent payment partners.

Role
Team lead
Where
Trybe
Status
Ongoing, in production
Stack
Laravel · NestJS · Temporal · Kafka · PostgreSQL

Client work. Diagrams are simplified and contain no client data.

Context

Each transfer touches several outside parties. Card and pay-in acquirers collect funds on one side, and bank and mobile-money providers pay out on the other. Every partner has its own API, its own fields, and its own failure modes: some call back by webhook, some have to be polled, some need funds in advance and some settle afterwards. Between those two ends, the platform has to price the transfer with live FX, pick a route, record every movement of money correctly, run compliance checks, and keep the sender informed. Any step can fail or stall while waiting on a partner, and none of it can lose or double-count money.

My role

Team lead. I architected the platform with the team from the start and began with the core: transfers, routing, FX, quotes, and the ledger. Since then I’ve built across almost all of it: the Temporal workflow activities, the Kafka messaging layer, the partner services, the client API (auth, referrals and promotions, the API for partner consumers), the ops dashboard, and CI.

Architecture

Partner services (one per partner)Core API (NestJS) — financial engineClientswhich adapter?callbackscallbacks

Web app

Mobile app

Ops dashboard

Client API
Laravel
users · KYC · beneficiaries

Transfers & quotes

Routing & FX

Double-entry ledger

Temporal workflows

Service registry
DB-driven, cached

Kafka

Audit service

Webhook proxy

Pay-in adapters
hosted payment pages

Payout adapters
bank · mobile money

Fig. 1 — Ceyna, simplified
  1. Users and ops staff go through the client API (Laravel), which owns users, KYC, and beneficiaries. Money logic lives in a separate core API (NestJS): transfers, quotes, routing, FX, and the ledger.
  2. A transfer becomes a Temporal workflow. Routing scores the possible pay-in → payout partner chains, gets an FX quote, and the workflow posts each money movement to the double-entry ledger.
  3. Pay-in and payout go through partner services, one per partner, behind a common contract. The workflow doesn’t know partners by name: it asks the service registry which service handles this route. The registry also records how each partner reports back (webhook or polling) and how it’s funded (prefund or postfund).
  4. Card details never reach Ceyna. Pay-ins use the partners’ hosted payment pages, and results come back through a webhook proxy into the workflow.
  5. Transfer events go onto Kafka. The client API, the audit service, and notifications consume them without the core calling them directly.

Design decisions & trade-offs

  • Durable workflows for money movement (Temporal). A transfer is a long-running process with waits, retries, and partner callbacks. Modelling it as a workflow keeps that state explicit and recoverable, instead of spread across queues and cron jobs. Trade-off: workflow code has to stay deterministic, and changes to a workflow with transfers in flight have to be patched in so old runs still replay.
  • Split the client API from the financial engine. User and KYC concerns change often; the ledger and routing should not. Keeping them in separate services, in separate databases, keeps the money path small. Trade-off: two codebases and two runtimes, and anything that spans both goes over an API or an event.
  • One service per partner. Every partner’s quirks stay inside its own service, so the core speaks one contract. Shared partner code keeps them consistent, and each has its own verification check. Trade-off: more services to deploy and monitor, and to keep in sync with partner API changes.
  • Database-driven service discovery. Which partner serves which route is data, resolved at runtime and cached for 30 seconds. Changing routing is a configuration change, not a deploy. Trade-off: a bad configuration fails at runtime rather than at build time, and a change takes up to 30 seconds to apply.
  • Immutable double-entry ledger. Every movement is a balanced set of debits and credits. Entries are never edited: mistakes are corrected with reversing entries, and amounts are stored as exact decimals, never floats. Trade-off: stricter modelling up front and more writes per transfer.
  • Idempotency everywhere. Every write requires an idempotency key, and Kafka consumers go through an idempotency guard, so retries and redelivered events can’t move money twice. Trade-off: every client and consumer has to carry and respect the key.
  • Approvals for manual money operations. Refunds and manual journal entries made in the ops dashboard don’t apply until someone approves them. Trade-off: slower operations in exchange for no single-person changes to balances.

Stack

Laravel · NestJS · TypeScript · Temporal · Kafka · PostgreSQL · Prisma · Redis · React · AWS · Docker

What’s next

The platform does what it was built to do: orchestrate payments across partners. Two areas I’d push further:

  • Partner-specific field validation. Each partner needs different fields depending on the route. Today that isn’t abstracted enough. I’d like validation rules to come from the selected route and partner, the same way the partner service itself is resolved.
  • Audit visibility. Audit logs are stored, but there’s no way to browse them yet. An audit dashboard with proper user activity tracking would make investigations and compliance reviews much faster.