How We Rebuilt Wealthsimple Pay
By Katie Lim, Meeral Rizvi, and Simon Savage
At Wealthsimple Presents we reintroduced Wealthsimple Pay. In addition to renaming "Pay Friends," we added the ability to reverse a send, increase transfer limits, and redesigned the contacts page. Alongside that, the feature as a whole will be available on the desktop version of Wealthsimple for the first time. But most of the work happened before any of that, when we moved Wealthsimple Pay onto the system the rest of our money movement already runs on.
Origin story
Wealthsimple Pay is one of our older features. It started as its own payment rail, with its own data model and its own way of moving money between Wealthsimple clients. It worked, but it shared nothing with the rails we added later, like Interac e-Transfers® and bill payments.
Since then, we’ve built an architecture called funding intents that now underpins nearly all of our rails. With it, we build safety, security, and convenience features once, and every rail on funding intents gets them.
We wanted to rebuild Wealthsimple Pay because we see a lot of potential in its social power and convenience. But because Pay wasn’t built on funding intents, everything about it cost more to build and to run:
- Every new feature needed custom logic.
- Its monitoring was separate from what we used for every other rail.
- Anyone debugging a stuck transfer had to learn how Pay worked before they could start.
- It fell behind our other rails. Undo, reversals, and contact management would all have had to be built from scratch.
So we migrated Wealthsimple Pay to funding intents.
The funding intent system
More than two dozen transaction types run on funding intents today, from EFT deposits and wires to e-Transfers, bill payments, and currency conversions. The platform moves $17.5B a month, and at the time of writing, that volume is doubling every year.
It’s built from four primitives:
- A funding intent is the record of one request to move money: its type, amount, source, destination, and current state. Every change to it is stored as an event.
- A funding method is whatever sits at the far end of the rail, such as a linked bank account, an e-Transfer profile, a wire recipient, a bill payee, or a Wealthsimple Pay contact. Funding methods own rail-specific logic, like talking to a bank or a payment network.
- A requirement is a single check, like “the amount is within limits” or “the recipient can receive money.” Requirements are grouped into checklists, which run at different points in a transaction’s lifecycle.
- A configuration ties these together for one transaction type: which checklist it uses, how it posts to the ledger, and whether and how it can be cancelled or reversed.
Every rail shares the same lifecycle. A funding intent is created, checked, processed, posted to the ledger, and completed, and it can be cancelled or reversed along the way.
The lifecycle of a funding intent
After the migration, a Wealthsimple Pay send goes through the system like this:
- When you send $20 CAD to your friend $markCarney, we create a funding intent with your account as the source and your friend's contact as the destination.
- Checklist validations confirm the currency is supported, then your friend can receive the money and the amount is within your limits.
- Processing checks reserve the $20 in your balance and screen the transfer for suspicious activity.
- The Wealthsimple Pay payment rail does the rail-specific work. The funding intent then posts to the ledger, and it completes once our balance system confirms the money has moved.
Adding a payment rail mostly means writing a funding method and a configuration, then choosing its checks. Here is a simplified version of Wealthsimple Pay's.
A rail chooses its checks but doesn't implement them, and it never touches state or history. The framework handles all of that, and it's where most of our reliability, observability, and testability comes from.
Funding intent state
Every funding intent moves through one state machine, whatever rail it's on. The framework decides which transitions are allowed, records each one as an event and retries work that fails, so every rail gets the same guarantees:
- State is event-sourced. Each transition is its own event and declares which states it can start from, so a transaction can only move along paths we've defined.
- When a checklist requirement errors, the checklist can retry with backoff. A brief outage in a dependency delays a transaction instead of failing it.
- Every transaction keeps its full event history. When our support team or an engineer asks what happened to a transfer, the answer is in that history.
Funding intent extensions
The shared record and lifecycle also make the system easy to extend. Anything we build against funding intents whether it's a new check, a reaction to a state change, or a tool that reads transactions, works for every rail that opts in:
- Limits, undos, and fraud screening are shareable. Each rail can choose to opt in to these modular extensions as it makes sense.
- Side effects hang off state transitions, so publishing to our event stream and recording how long a transaction spends in each state works the same way on every rail.
- Activity feeds, support tools, risk systems, reporting and dashboards all read the same record, whichever rail produced it.
- Each requirement is a small class with one job. We test it on its own and reuse it across rails.
What Wealthsimple Pay gets now
Wealthsimple Pay now runs on funding intents, which gives us a more reliable foundation to build on. Since the migration, we've:
- Redesigned and renovated contact management
- Added payment undo to allow users to correct mistakes
- Increased spending limits to $100,000, alongside richer safety controls
- Built a web implementation of Wealthsimple Pay
- Removed an abundance of surface area from our codebase, simplifying how we operate this great product.
All of this was made vastly easier thanks to our funding intent migration. Give Wealthsimple Pay a try, and let us know what you think!
Interested in working at Wealthsimple? Check out the open roles on our team today.
