Payments Engine Code 101

Akarsh Agarwal
5 min readNov 27, 2022
Photo by Mohammad Rahmani on Unsplash

Understanding the payments engine is one of the critical steps toward developing one. In the previous blog post, we wrote the payment engine's sudo code. This blog will formulate the sudo code into a working Go code.

For the sake of simplicity, I’ve developed a straightforward state machine Go package as I couldn’t find one I wanted to use. (Please ignore the missing functionalities in the package. It should serve the purpose of this blog and our idea.)

The functionalities we intend to implement are:

  1. Initialize a new State Machine with two steps: Deduct + Deposit
  2. Write a driver code that would execute some test cases to execute the transaction and see if it performs as per the state machine.

Moreover, I’ve added the above code in my GitHub repo: https://github.com/mychewcents/go-payments-engine. It should help you take a detailed look into the code. I promise that it’s straightforward.

So, why keep waiting? Let’s get started!

Create our State Machine

When I first introduced the state machines, we started with two states: deducted and deposited. Once our system records a transaction, we deduct the payer and deposit the exact amount to the payee. With that in mind, let's start with initializing our SM as follows:

stateMachine := sm.New()

stateMachine.CreateRoute(0, deductSender, 1)
stateMachine.CreateRoute(1, depositReceiver, 2)
// Here, the mapping of the state numbers is as follows:
// 0 -> Denotes the INIT state. Every transaction starts in this `initial` state.
// 1 -> Denotes the "deducted" state. Once the sender has been deducted, the state updates to 1.
// 2 -> Denotes the "deposited" state. Once the receiver has been credited, the state updates to 2.
// It is the final state of the transaction. Any transaction not in state = 2 means that something
// went wrong while executing / is still executing.

And that’s it! Yes!

We’ve defined our state machine and reference this every time we create a new transaction. Then, all we need to do is write the driver code.

Driver Code to run the Tx

For our application to use the SM we defined above, we need to call the Run function.

currTxState := &CurrentState{
State: 0, // Our INIT state
Entity: tx,
}

stateMachine.Run(currTxState) // One call to the `Run` function only makes one hop. Hence, here, we'll need
// to call the RUN twice if no error occurred to deposit the amount to the receiver.
stateMachine.Run(currTxState)

The above code block only calls the required functions without error handling and definitions. I’ve added those nuances in the GitHub repo link mentioned at the start of this article.

One thing to note here is that I’m calling stateMachine.Run twice. The SM module only handles 1 hop when it's called. We can add a loop/recursion to the Run function to make it traverse as far as possible in the SM. Here, it's not added for the sake of simplicity.

Now that we have all the pieces required, let’s stitch them through with a test.go file and see if we have a transaction successfully executed or not.

Bring together all the pieces

To run our transaction tests, we’ll write a test file that would call the above functions and execute a transaction. Primarily, we’ll execute two use cases, probably the easiest ones:

  1. Happy Case → Bob can pay Alice.
  2. Low Balance Case → Bob doesn’t have the amount to pay Alice.

Our SM should be able to handle these scenarios and print us the final states of the balances at the end of the transaction.

We had defined the transaction as an entity to execute and exist independently in our system. So we’ll be leveraging that here.

So, the test file would look something like this:

func TestEx(t *testing.T) {
initializeSM()
type testCases struct {
name string
tx *Tx
expectedErr error
}

scenarios := []testCases{
{
name: "happy path",
tx: &Tx{
amount: 10,
senderName: "Bob",
senderBal: 20,
receiverName: "Alice",
receiverBal: 10,
},
expectedErr: nil,
},
{ // Low Balance error
name: "low balance error case",
tx: &Tx{
amount: 100,
senderName: "Bob",
senderBal: 20,
receiverName: "Alice",
receiverBal: 10,
},
expectedErr: errLowBalance,
},
}

for _, scenario := range scenarios {
fmt.Printf("Scenario: %s\\n", scenario.name)
err := execute(scenario.tx)
fmt.Printf("tx state after calling the execute: %+v\\n\\n", scenario.tx)
if scenario.expectedErr != nil {
assert.Contains(t, err.Error(), scenario.expectedErr.Error(), "wrong error was thrown")
} else {
assert.Nil(t, err)
}
}
}

Here, I’ve added more Printf statements than required to help us differentiate between the scenarios we're executing. Now, it's time to take this code for the ride.

Running the first SM-powered Tx

If you clone the repo mentioned at the start or even just write something similar on your own, you could run the above test file as:

go mod tidy # this is to install all the required packages
go test # executes the test function TestTx that we created above

The results should look something like the below:

Scenario: happy path
tx state after calling the execute: {amount:10 senderName:Bob senderBal:10 receiverName:Alice receiverBal:20} , err=<nil>

Scenario: low balance error case
tx state after calling the execute: {amount:100 senderName:Bob senderBal:20 receiverName:Alice receiverBal:10} , err=sm returned an error; err=low balance
PASS
ok github.com/mychewcents/go-payments-engine 0.453s

Summary

Well, that’s the end to the start of code 101 for a payments engine. To summarise, we learned how to define an SM transition, write the functions to execute the transaction, and write a couple of tests to check if the transaction would return the required errors.

The following article will focus on adding more detail to this naive payments engine. A few things to add are:

  1. Move a transaction to a non-retry-able FAILED state when an error occurs for a transaction.
  2. Move a transaction to a retryable state when an error occurs, eventually succeeding the transaction.
  3. Make our SM multi-hop so that we don’t need to call the Run function every time we add a new step.
  4. Introduce Auth/Capture flow to deduct the sender and deposit the receiver.

So, that’s all for this one! Thanks for taking the time to read! See you at the next one!

--

--

Akarsh Agarwal

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