The most dangerous line of code in any product is the one that moves money.
When I started redesigning billing for Spluur, I decided there should be exactly one place in the system where that can happen. Everything else is allowed to be wrong, as long as it can't charge anybody.
Why billing is a different kind of code
Spluur bills in five currencies, Naira included, and takes payments through Paystack. Most bugs in a product are annoying. A billing bug is a customer charged twice, or a customer who never gets charged, or a balance that quietly stops adding up. Those are the bugs that cost trust, and trust is hard to win back.
The easy first version is one billing module that does everything: work out a price, check what the plan allows, charge the customer, update the balance. It is quick to write because every part can call every other part directly.
That convenience is the problem. In a single module, a bug in the code that displays a price has a direct path to the code that takes the money.
The four jobs
So I split billing into four pieces, each with one job and strict limits on what it can touch:
- Pricing produces quotes. You give it what someone wants and the currency, and it returns a number. It can't charge anyone.
- Limits decides what a plan allows. It can say no. It can't charge anyone either.
- Billing turns quotes into charges. It decides what is owed.
- Wallet is the only piece allowed to move money, and it is where Paystack is connected.
Here is the shape of it as a sketch. This is not Spluur's real code, only the idea:
type Quote = { planId: string; currency: string; amountMinor: number };
interface Pricing {
quote(planId: string, currency: string): Quote;
}
interface Limits {
allows(userId: string, action: string): boolean;
}
interface Billing {
charge(userId: string, quote: Quote): Promise<void>;
}
interface Wallet {
debit(userId: string, amountMinor: number, idempotencyKey: string): Promise<void>;
}Only the wallet has a method that moves money. Everything else produces information or makes decisions.
Two details in there are deliberate:
- Amounts are integers in the smallest unit (kobo, cents). Decimal numbers and money don't mix, because rounding errors appear and accumulate.
- Every debit carries an idempotency key. Payment systems retry, and webhooks can arrive more than once. If the same request shows up twice, the wallet must recognise it and charge once.
What this buys
The point is not tidiness. It is blast radius.
If Pricing has a bug, a customer sees a wrong quote. That is bad, but nobody has lost money yet, and the mistake is visible. If Limits has a bug, someone gets access they shouldn't, or is refused something they should have. That is bad too, and still not a payment error.
The only way to get a real financial mistake is through the wallet, which is small, narrow, and the most heavily tested thing in the system. I can give that piece far more attention than the other three because it is the only one where attention is a matter of money.
It also makes each piece easy to test. Pricing is a plain function: same input, same quote, every time. You can test it without a database or a payment provider anywhere near it.
What it costs
I don't want to pretend this is free.
- More interfaces. Upgrading a plan used to be one function. Now it touches four pieces and the agreements between them.
- More places to get out of sync. A quote, a limit check and a charge are three separate moments. Anything can change between them.
- More to understand. Someone new to the code has to learn the boundaries before they can follow a single flow.
And I'm one person. The boundaries aren't there to coordinate a team. They are there to protect me from myself on a day when I'm tired and in a hurry.
Is it overkill?
Maybe. This is a redesign, and it has not yet been tested by real load, real refunds, or real edge cases. I'm confident about the principle, which is that dangerous capability should live in exactly one place. I'm less sure that four is the perfect number of pieces, or that I've drawn every line in the right place.
I'd rather publish the reasoning now and write the follow-up when I know what it got wrong.