A simple Payments Engine that just adds/subtracts balances! — Part 2

Akarsh Agarwal
6 min readOct 2, 2022

--

What if I told you that writing your First Payments Engine was as simple as doing that math problem we learned in 4th grade?

Yes, we’ll learn to write our first Payments Engine in this blog. From scratch! We’ll target a simple engine and then increase the details as we understand more about the Payments domain over the following blogs.

To reference our learnings from the previous blog, we’ll focus on the Direct Charge mechanism for now.

Just FYI, I’ll be using Go to write some code below. It’s very similar to C and has been my first language since graduation, so I prefer that.

So, why keep waiting?

Our 4th grade Engine

Let’s start with understanding and developing what we’ve learned before.

What do we need for a transaction? A sender, a receiver, and two accounts, one to deduct from and another to deposit into. A transaction is adding the amount into the receiver’s account and subtracting the amount from the sender’s account.

Before we even start writing any code, let’s make 2 significant assumptions about the complexity of the problem at hand:

  1. Bob has the required balance to pay Alice for the transaction, no matter how considerable an amount.
  2. Our system runs on a single machine and doesn’t go down. It’s highly unlikely or impossible in today’s world, but as we progress further, we’ll work on this.

With this in mind, we can write a simple function to transact as follows:

func processTx(receiver, sender AccountDetails, amount int) {
receiver.Balance = receiver.Balance + amount
sender.Balance = sender.Balance - amount
save(receiver)
save(sender)
}

Well, that’s it? Yes, that’s our very first payment processing engine. It represents our knowledge from the 4th grade, where we would add/subtract balances to complete a transaction.

Now, let’s process an example for the same:

Bob needs to pay Alice $10 for OrangesLet's say:
- Bob has $20
- Alice has $0
processTx function:
-> Alice.Balance = Alice.Balance + $10
-> Bob.Balance = Bob.Balance - $10
After the transaction is complete:
-> Bob has $10
-> Alice has $10

Well, that’s it! Bob has paid Alice!

If I’m candid, this engine wasn’t that exciting to build or develop. We made significant assumptions that usually don’t hold in any payment engine.

Let’s tackle those assumptions one by one now.

Is Bob that rich to be able to pay every time?

Before developing our engine, one of the assumptions we made was that Bob had all the money in the world to pay Alice, no matter the transaction amount. However, we know that’ll seldom be true.

So, let’s take a scenario, where Bob doesn’t have the amount to pay. In this scenario, Bob’s account balance would go negative in the above function. The above engine doesn’t perform the necessary checks and hence, it’s a loss to our company. So, let’s add a balance check.

func processTx(receiver, sender AccountDetails, amount int) {
// check Bob's balance
if sender.Balance - amount < 0 {
return
}
...
}

Okay, now it looks a little better. We added a balance check for our sender to be 100% sure that they can pay. Uhmm, interesting!

What if I told you that we’re not out of the woods yet? There’s a problem with the above check.

The problem is an underflow. If the Balance attribute is always defined as an unsigned integer, deducting an amount larger than the Balance would underflow the minimum 0.

Hence, the check above doesn’t help!

Similarly, in our original function processTx, deducting an amount greater than Balance would ideally increase the Balance of Bob. Woah! All in all, our engine was paying Bob money to pay Alice. How cool is that?

Jokes aside, how do we fix that? Well, we could do the following too:

func processTx(receiver, sender AccountDetails, amount int) {
// check Bob's balance
if sender.Balance < amount {
return
}
...
}

A classic 5th-grade problem of solving inequalities by moving the variables around. I’m sure every one of us learned this too.

So, we’re down to 1 last assumption.

Don’t worry! Our engine never blows off!

Really? Well, I guess then everyone would use our engine. Right?

Sadly, that’s one of the biggest lies anyone can assume for their software. Hence, the second assumption. Our systems are intelligent and fast but prone to failure too.

Let’s consider a scenario of failure:

func processTx(receiver, sender AccountDetails, amount int) {
...
save(receiver)
// Boom! Our server blows off!
save(sender)
}

What happens if our server blows off after saving the receiver’s balance increase? Technically, we paid Alice and didn’t deduct any money from Bob. However, if this was the case, I’m sure Bob would advertise our engine to the world!

Okay, so the easiest solution is: Save the sender’s balance first and then the receiver’s. So:

func processTx(receiver, sender AccountDetails, amount int) {
...
save(sender)
// Boom! Our server blows off!
save(receiver)
}

The problem now is that our engine would have deducted the sender’s account. But our receiver got no money? So now, Bob will be discrediting our engine, and no one will use it further.

So, there’s no solution for it? Because at least one should come before the other. Right?

Welcome, SMs!

What if we could divide the transaction into two separate steps? One that deducts the sender and the other that deposits the receiver.

Can we do that? How would that work? Do we call the second function from inside the first one? Wouldn’t that cause a problem?

Welcome, State Machines! (SMs)

To put it without any technical jargon, a State Machine defines the steps of your process and tracks its status. In our case, the transaction. Hence, a state machine would follow which phase the transaction is in, in the depositing or deducting step.

One thing to note here is that we’ve introduced the transaction concept. Earlier, we only had a receiver and a sender, adding/subtracting balances, as we’ve always learned.

Here, it looks like a Transaction needs to exist independently and not depend on receiver/sender details and their contexts. In the previous version, the receiver and sender contained the transaction details internally. (I skipped the details stored inside the AccountDetails object that tracks the transactions for brevity.)

However, we now create a transaction as a separate entity in our database. Therefore, our functions could process a transaction based on the states defined. How does a transaction look now?

type Transaction struct {
Receiver AccountDetails
Sender AccountDetails
Amount int
State string
}

Let’s define our states:

"deposited"
"deducted"

So, any new transaction that comes in would first have no state. Post that, once the depositing to the receiver is complete, it moves to the deposited state. And after that, once the amount has been deducted from the sender, it moves to the deducted state. The deducted state marks our transaction as complete.

So, let’s see how our function would change now:

func processTx(tx Transaction) {
// balance check

tx.Receiver.Balance += amount
tx.State = "deposited"
save(tx)
tx.Sender.Balance -= amount
tx.State = "deducted"
save(tx)
}

Now we know if a transaction was in the deposited state when our server blew off, the receiver has been paid. Hence, we only need to re-process the deduction step. But, again, I'm only mentioning the details necessary at the moment. A lot goes on behind that save function, and we'll see those details too.

Yay! We have Engine! Eureka!

Are we done? Looks good, right?

Well, we can process the transaction and track its progress. Right? As a payments engine, it would work okay or in scenarios where nothing fails. So, what did we miss?

Let’s track all of it down, and we’ll tackle it in the following article, as this one’s getting a little longer. So, some of the questions to ask with the above approach are:

  1. We deduct the sender AFTER we’ve deposited the receiver. It means we’re paying the receiver out of our pocket before we even take the money from the sender. Alright! Let’s swap the steps. Is that okay? Are we done? Would that work?
  2. What happens when the server dies in the middle of the SM? Do we restart or process the transaction from where it was last processed? What happens if a transaction dies before the first state is deposited? Should we ask the sender to retry?
  3. Can this mechanism support multiple payment methods like VISA, MasterCard, and more?

And a few more.

This article introduces you to State Machines, a crucial part of our transactions worldwide. SMs are a Computer Science concept used extensively in multi-step processes where each step could exist individually.

In the following article, we’ll try to tackle a few of the above questions and also understand what goes on behind the scenes before the processTx function executes. Also, we'll look into the race condition of balances and how it affects our sender's account, allowing them to deduct once but pay twice.

--

--

Akarsh Agarwal
Akarsh Agarwal

Written by Akarsh Agarwal

All about Distributed Systems and Stakeholder Management. #golang #distributedsystems #management

No responses yet