Fintech infrastructure
Loan recovery and idempotent direct-debit at Vida AI
The backend of an embedded lending and BNPL platform. This deep dive is the hardest part: a direct-debit recovery engine built so each repayment settles exactly once, even under retries and duplicate callbacks.
- Role
- Backend Engineer, VeendHQ (Vida AI)
- Period
- Mar 2024 to present
- Stack
- NestJS · TypeScript · PostgreSQL · MongoDB · Redis · BullMQ · AWS
- Live
- Visit ↗
The recovery engine here pulled back ₦69M in overdue loans that manual collections could not, a result BusinessDay covered.
Vida AI is an embedded credit and BNPL platform serving several lending partners. As its primary backend engineer I built it across origination, borrower onboarding, KYC, disbursement, and recovery. This page is about the part that is hardest to get right, recovery and direct debit. When it breaks, a borrower gets charged twice or a repayment goes missing, and you often do not find out until reconciliation.
Impact. A 40% recovery rate on loans more than 90 days overdue: ₦69M pulled back from a ₦172.5M delinquent portfolio, against a manual baseline of about 5%. BusinessDay reported it.
The problem
A lending business only works if the money comes back. Once a loan is more than 90 days overdue, manual collections recover very little. The lever is automated direct debit: standing mandates against the borrower’s account that let the platform pull a due repayment on its own. Two things make that hard to get right.
- Providers are unreliable and asynchronous. A debit request can time out, succeed without telling you, or confirm minutes later through a webhook. Sometimes that webhook fires more than once. And no single provider clears every mandate, so you need several of them, tried in a sensible order.
- Getting it wrong costs real money, both ways. Charge a borrower twice and you owe them a refund and an apology. Miss a success and you go chasing money you already collected. So the system has to move each repayment exactly once, and it has to hold that guarantee under retries, concurrent runs, and duplicate callbacks.
Architecture
A recovery run picks up the mandates whose repayments are due. For each one, a configurable, lender-scoped provider-ordering policy decides which provider to try first and where to fall through next. Every debit attempt is wrapped in an idempotency guard. The key is written before the charge goes out, so when the provider’s callback comes back later, possibly twice, it maps to that one attempt and settles a single time. If an attempt fails, it drops to the next provider or onto a retry schedule. The ledger is the record that each due amount resolved exactly once.
Decisions and tradeoffs
Idempotency keys on every money operation
The provider callback is where the risk is. It can arrive twice, out of order, or
at the same time as a manual retry. So I made idempotent payment operations
a hard rule. An operation claims a unique key before it charges. Two callbacks
that race each other collide on that key, and only one wins. The obvious
alternative, an if (!alreadyPaid) charge() check, has a gap between the read and
the write, and under concurrency that gap is a double charge. Pushing the decision
down to a unique constraint in the database closes it, because that is the one
place the race actually gets settled.
Rejected: in-memory locks, or check-then-charge. They fall apart the moment there is more than one worker or a webhook gets redelivered. In production there always is.
Configurable provider ordering instead of one hard-coded PSP
Recovery rates depend on which provider holds the mandate and how well each one performs for a given lender. Hard-code a single provider and you cap recovery at that provider’s reach. So the provider order is configurable and scoped per lender. A debit falls through a prioritized list instead of giving up on the first miss. More attempts against live mandates means more money actually recovered.
Tradeoff: a fall-through chain multiplies the callbacks and the dedupe surface. That is why the idempotency guard had to be built first. It could not be bolted on afterward.
Per-partner feature toggles over per-partner forks
Vida serves several lending partners, each with its own rules. Instead of branching the codebase per partner, most features toggle on and off per partner without a code change. That includes the credit-decisioning products, which are scoped per lender and gated behind maker-checker approval, so no single person can push a change that moves money on their own.
Rejected: partner-specific forks. Every bug fix becomes N deploys, and the partners quietly drift out of sync.
Mandate lifecycle as explicit state, not booleans
A direct-debit mandate moves through setup, active, failed, and cancelled, and it does that across several providers. I modeled the lifecycle as explicit state instead of a scatter of boolean flags. That keeps one question easy to answer: can we debit this borrower right now? A stuck mandate surfaces as its own state instead of lurking inside a boolean nobody checks.
What it also covers
Recovery was the deep end, but the platform is broad: I built loan origination and borrower onboarding, KYC and AML, a configurable credit-decisioning rules engine, and disbursement. An AI-driven credit-risk service, also mine, reads credit-bureau and bank-statement data and turns it into plain explanations for borrowers and risk assessments for lenders. Elsewhere at Veend I built payment-rail integrations (bank transfer and card) and a maker-checker approval control on the bill-payments platform, and earlier worked on Veend’s multi-tenant lending platform: mandate setup and tracking, loan closure and eligibility, and a finance and sales analytics dashboard.
The through-line
The same patterns run through my Job Hunt CRM: idempotent operations, explicit state machines, exactly-once settlement. That one is open source, so you can read it end to end.