Building a Go microservice for calculating cash-out or admin fees

In this article we will see how to implement a micro-service to calculate administrative fees. We will use the concept of cash out fees for a fictional mobile money service operator, using Go armed with only the standard library for the HTTP server and a utility library for calculating the fees. We will leverage hurl and venom for API testing.

Pre-requisites

You will need to have the following installed to follow along:

What are cash out / admin fees?

Imagine you are offering a service online and want to get commission off of every transaction, such commissions are usually pushed to customers as administrative fees. Typically these fees are calculated relative to the amount the customer/user pays.

To make this more concrete we will use the concept of mobile money cash-out fee wherein a Customer desires to withdraw an amount from their mobile money wallet and receive cash. Mobile networks typically push the fees onto the customer while splitting that with the agent who fulfils that cash out.

We will use real cash out values from this article: https://temovision.com/tnm-mpamba-withdraw-send-and-bank-charges-malawi/

After reading this article you should be able to

  • Build a fairly simple micro-service with Go using standard net/http Server

  • Use the golang-malawi/chigoli particularly the servicefee module for calculating service fees

  • Test API end-points using Hurl and Venom

Step 1: Project setup and calculating fees from the command-line

Create a new Go module

go mod init example.com/fees-api

Add the servicefee dependency from Golang Malawi

go get github.com/golang-malawi/chigoli/servicefee

First let’s create the logic for calculating fees based on the cash out / withdrawal fee bands.

package main

import (
    "fmt"
    "log"

    "github.com/golang-malawi/chigoli/servicefee"
)

func main() {
    serviceFees := servicefee.NewFixedFee(servicefee.FeeExpressions{
        "x >= 100 &&  x <= 500":        20.0,
        "x >= 501 &&  x <= 1000":       40.0,
        "x >= 1001 &&  x <= 2000":      85.0,
        "x >= 2001 &&  x <= 3000":      150.0,
        "x >= 3001 &&  x <= 5000":      200.0,
        "x >= 5001 &&  x <= 10000":     380.0,
        "x >= 10001 &&  x <= 20000":    750.0,
        "x >= 20001 &&  x <= 40000":    1700.0,
        "x >= 40001 &&  x <= 60000":    2500.0,
        "x >= 60001 &&  x <= 100000":   3750.0,
        "x >= 100001 &&  x <= 200000":  6500.0,
        "x >= 200001 &&  x <= 300000":  9000.0,
        "x >= 300001 &&  x <= 400000":  11000.0,
        "x >= 400001 &&  x <= 500000":  12500.0,
        "x >= 500001 &&  x <= 600000":  13500.0,
        "x >= 600001 &&  x <= 750000":  14500.0,
        "x >= 750001 &&  x <= 1500000": 14500.0,
    })

    total, fee, err := serviceFees.CalculateTotalAndFee(6000)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println("Total: ", total, "Fee: ", fee)
}

Let us test this simple program with go run main.go.You should get output similar to the the following:

Total: 6380 Fee: 380

If that worked, we can now go to the next step of implementing our API endpoint.

Step 2: Implementing the API Server

Create a main.go file with the following content

package main

import (
    "encoding/json"
    "errors"
    "io"
    "log"
    "log/slog"
    "net/http"

    "github.com/golang-malawi/chigoli/servicefee"
)

var ServiceFees = servicefee.NewFixedFee(servicefee.FeeExpressions{
    "x >= 100 &&  x <= 500":        20.0,
    "x >= 501 &&  x <= 1000":       40.0,
    "x >= 1001 &&  x <= 2000":      85.0,
    "x >= 2001 &&  x <= 3000":      150.0,
    "x >= 3001 &&  x <= 5000":      200.0,
    "x >= 5001 &&  x <= 10000":     380.0,
    "x >= 10001 &&  x <= 20000":    750.0,
    "x >= 20001 &&  x <= 40000":    1700.0,
    "x >= 40001 &&  x <= 60000":    2500.0,
    "x >= 60001 &&  x <= 100000":   3750.0,
    "x >= 100001 &&  x <= 200000":  6500.0,
    "x >= 200001 &&  x <= 300000":  9000.0,
    "x >= 300001 &&  x <= 400000":  11000.0,
    "x >= 400001 &&  x <= 500000":  12500.0,
    "x >= 500001 &&  x <= 600000":  13500.0,
    "x >= 600001 &&  x <= 750000":  14500.0,
    "x >= 750001 &&  x <= 1500000": 14500.0,
})

type CalculateFeeRequest struct {
    Amount float64 `json:"Amount"`
}

type FeeResponse struct {
    Amount float64 `json:"Amount"`
    Fee    float64 `json:"Fee"`
    Total  float64 `json:"Total"`
}

func CalculateFeesHandler(serviceFees servicefee.Fees) func(w http.ResponseWriter, r *http.Request) {
    return func(w http.ResponseWriter, r *http.Request) {
        request := CalculateFeeRequest{}
        data, err := io.ReadAll(r.Body)
        if err != nil {
            slog.Error("failed to parse request body", "error", err)
            w.WriteHeader(http.StatusBadRequest)
            w.Write([]byte("failed to parse request"))
            return
        }

        err = json.Unmarshal(data, &request)
        if err != nil {
            slog.Error("json.Unmarshal: failed to parse request body", "error", err)
            w.WriteHeader(http.StatusInternalServerError)
            w.Write([]byte("failed to parse request"))
            return
        }

        defer r.Body.Close()

        slog.Info("processing request for amount", "amount", request.Amount)
        total, fee, err := serviceFees.CalculateTotalAndFee(request.Amount)
        if err != nil {
            slog.Error("failed to process request", "error", err)
            if errors.Is(err, servicefee.ErrNoApplicableFee) {
                data, err := json.Marshal(FeeResponse{
                    Amount: request.Amount,
                    Total:  request.Amount,
                    Fee:    0,
                })
                if err != nil {
                    w.WriteHeader(http.StatusInternalServerError)
                    return
                }
                w.WriteHeader(http.StatusOK)
                w.Write(data)
                return
            }
            w.WriteHeader(http.StatusInternalServerError)
            return
        }

        responseData, err := json.Marshal(FeeResponse{
            Amount: request.Amount,
            Total:  total,
            Fee:    fee,
        })
        if err != nil {
            slog.Error("failed to process request got error", "error", err)
            w.WriteHeader(http.StatusInternalServerError)
            return
        }
        w.Header().Add("Content-Type", "application/json")
        w.WriteHeader(http.StatusOK)
        w.Write(responseData)
    }
}

func main() {
    addr := ":4001"

    http.HandleFunc("/calculate-fees", CalculateFeesHandler(ServiceFees))

    slog.Info("Starting server on port", "address", addr)
    err := http.ListenAndServe(addr, nil)
    if err != nil {
        log.Fatalf("failed to run server got %v", err)
    }
}

We can run this with

$ go run main.go

Then in another terminal we can use cURL to verify it works

$ curl -X POST http://localhost:4001/calculate-fees -d '{"Amount":4000}'
# Output should be something like
{"Amount":4000,"Fee":200,"Total":4200}

Step 3: Adding Tests for our Endpoint

In this section we will implement some tests to ensure that our code works as we expect/anticipate and to catch any bugs or regressions. Tests help developers to validate their code and catch bugs, I highly recommend getting used to writing tests even if you aren’t already using a methodology like TDD (Test Driven Development)

Create a file named main_test.go and add the following content

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestServer(t *testing.T) {
    mu := http.NewServeMux()
    mu.HandleFunc("/calculate-fees", CalculateFeesHandler(ServiceFees))
    testServer := httptest.NewServer(mu)

    testCases := []struct {
        Amount        float64
        CalculatedFee float64
    }{
        {Amount: 3000, CalculatedFee: 150},
        {Amount: 4000, CalculatedFee: 200},
        {Amount: 6000, CalculatedFee: 380},
        {Amount: 20_000, CalculatedFee: 750},
    }

    for _, testCase := range testCases {
        // Create the request data
        body := []byte(fmt.Sprintf(`{"Amount": %f}`, testCase.Amount))

        res, err := http.Post(testServer.URL+"/calculate-fees", "application/json", bytes.NewBuffer(body))
        if err != nil {
            t.Errorf("failed to test server: %v", err)
        }
        defer res.Body.Close()

        data, err := io.ReadAll(res.Body)
        if err != nil {
            t.Errorf("failed to test server: %v", err)
        }
        responseStruct := FeeResponse{}
        err = json.Unmarshal(data, &responseStruct)
        if err != nil {
            t.Errorf("failed to test server: %v", err)
        }

        if responseStruct.Fee != testCase.CalculatedFee {
            t.Errorf("incorrect fee calculated. \n\twant: %f\n\thave: %f", testCase.CalculatedFee, responseStruct.Fee)
        }
    }

    t.Cleanup(func() {
        testServer.Close()
    })
}

Run the tests using the following command

$ go test ./...

Step 4: Functional Testing the API using Hurl and venom

Create a new file named test_basic.hurl in the directory with the following content:

# File: test_basic.hurl
POST http://localhost:4001/calculate-fees 
Content-Type: application/json
{
  "Amount": 6000
}

HTTP 200
[Asserts]
jsonpath "$.Fee" == 380

This file defines a Hurl file that instructs hurl to send a POST request to the /calculate-fee endpoint and checks two things 1) checks that the HTTP response status code is 200 OK and 2) Uses JSON Assertions to verify that the fee was calculated correctly.

Use the following command to run hurl to process the file and perform the tests.

$ hurl test_basic.hurl

As you can see the above file only allows us to test a hardcoded amount and fee, a better approach would be to use variables so that we can inject different amounts and fees.

See the example below

# File: test_variables.hurl
POST http://localhost:4001/calculate-fees 
Content-Type: application/json
{
  "Amount": {{amount}}
}

HTTP 200
[Asserts]
jsonpath "$.Fee" == {{fee}}

We can now test the service with variables passed via the command-line. This allows greater flexibility as we can test more cases without cluttering the hurl file with the same configuration for each test case.

$ hurl --variable amount=4000 --variable fee=200 test_variables.hurl
$ hurl --variable amount=7000 --variable fee=380 test_variables.hurl

Testing using ovh/venom

Venom is an integration testing tool. It has support for various protocols and uses YAML for defining and structuring tests. Although for our basic needs, hurl is sufficient for the tests - we will look at how we can leverage venom with its support for HTTP as well support for running binaries like hurl for performing tests.

Create a new file named test_venom.yaml and paste into it the configuration below:

name: Service fee microservice tests 
description: Tests servicefee microservice
testcases:
  - name: Test that we can shell out to hurl
    steps:
    - type: exec
      command: [ hurl,  test_basic.hurl ]
  - name: Test that fee is calculated
    steps:
    - type: http
      method: POST
      url: http://localhost:4001/calculate-fees
      body: '{"Amount": 6000}'
      assertions:
        - result.body ShouldContainSubstring Fee
        - result.bodyjson ShouldContainKey Fee
        - result.bodyjson.Fee ShouldEqual 380
        - result.statuscode ShouldEqual 200

With venom installed, use the following command to run tests:

$ venom run test_venom.yaml

The output will be something like

          [trac] writing venom.19.log
 • Service fee microservice tests (test_venom.yaml)
        • Test-that-we-can-shell-out-to-hurl PASS
        • Test-that-fee-is-calculated PASS
final status: PASS

Step 5: Building a Docker Image for our service

Now that we have build and tested the API, we will build a Docker image that can be used to run containers either through Docker locally, or a container orchestration platform like Kubernetes.

To create a Docker image, add a Dockerfile in the directory. Place the following into the Dockerfile

FROM golang:1.23-alpine as go-builder
RUN apk add --no-cache git
WORKDIR /go/
COPY . .
RUN go generate -x -v
RUN go build -o /bin/servicefee-microservice 
RUN chmod +x /bin/servicefee-microservice 

FROM alpine
ENV PORT 4001
ENV ADDRESS "0.0.0.0"
ENV CACHE_DURATION "3m"
COPY --from=go-builder /bin/servicefee-microservice  /servicefee-microservice 
CMD ["/servicefee-microservice"]

The above Dockerfile defines a multi-stage Docker build that uses the Alpine linux Operating System as a base image for building the service. The base image has Go installed so we can build the code in that environment the same way as on our local machine/laptop. After building the image, we move onto the next stage (starting at FROM alpine) in which we define the contents of the actual Docker image

With the above Dockerfile, run the following command to build a container image locally

$ docker build -t tutorial/servicefee .

Once this completes successfully, you should have a Docker image you can run as a container

$ docker run -p "4001:4001" -e "PORT=8080" tutorial/servicefee

Next Steps

  • Loading Service fee configurations from a File or Remote Service

    The service fees are currently hardcoded in the code, ideally the service would load the definition of the fees from a separate file or a remote configuration service or KMS that can be updated without needing to recompile the code. This will make the service flexible to changing requirements should the fees bands ever need to change.

Conclusion

In this article we have gone through the process of implementing a basic microservice for calculating service fees. Although we used the use-case of cashout fees, the concepts in this article can be applied to different scenarios where you want to calculate a numerical value based on some bands or ranges of values. We have also seen how to use hurl and venom, two useful tools that a modern Software Engineer must have in their toolkit.

I hope you found this somewhat useful.

Please reach out for comments or if you spot some errors or places that need correction. zikani03 <at> gmail [.] com

0
Subscribe to my newsletter

Read articles from Zikani Nyirenda Mwase directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Zikani Nyirenda Mwase
Zikani Nyirenda Mwase