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
- 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.
- 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.
- 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).
- Card details never reach Ceyna. Pay-ins use the partners’ hosted payment pages, and results come back through a webhook proxy into the workflow.
- 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.