Building A Custom Payroll System: MongoDB, Go, and Yellowcard API Series - Part 3

Building A Custom Payroll System: MongoDB, Go, and Yellowcard API Series - Part 3

Complete Integration of Routes, Payment Client, Webhooks, and Helpers Explained

Prerequisite - check out the second article here

Listening to "Strangers" by Lewis Capaldi ... I guess it provides a bit of inspiration ๐Ÿซ .

As mentioned in the second article of this series, we will connect the dots with helpers, routes, payment provider clients, and controllers. So, without further ado, let's get started.

Helpers ๐Ÿ”ง

One funny thing about helpers or utility functions is that they are often reusable for other applications. Just a simple copy and paste will get the job done ๐Ÿซฃ.

In our case, we need a function to encrypt the user's plain password. Bcrypt is a well-known algorithm we can use. So, create a utils/password.go file and add this code.

package utils

import (
    "fmt"

    "golang.org/x/crypto/bcrypt"
)

type Hasher struct {
    Cost int
}

func NewHasher(cost int) Hasher {
    return Hasher{
        Cost: cost,
    }
}

func (h Hasher) HashPassword(password string) (string, error) {
    hashed, err := bcrypt.GenerateFromPassword([]byte(password), h.Cost)
    if err != nil {
        return "", fmt.Errorf("failed to successfully hash password : [%w] ", err)
    }
    return string(hashed), nil
}

func (h Hasher) CheckPassword(password, hashedPassword string) error {
    return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
}

When we provide our plain text password to the function above, it returns a hashed password and allows us to verify it against the plain text password.

Another important helper we need is for managing our JWT token. This token will be used to sign our payload and verify it during user authorization. We provide the necessary signer configuration, pass in our payload, and a JWT token is generated for us. Make sure to run go get github.com/square/go-jose/v3 to install the JWT library we will be using.

package utils

import (
    "time"
    "<project-name>/internals"

    "github.com/square/go-jose/v3"
    "github.com/square/go-jose/v3/jwt"
)

var logger internals.Logger

func init() {
    logger = internals.GetLogger()
}

type SigningPayload struct {
    Payload                   any
    Secret                    string
    Algorithm                 jose.SignatureAlgorithm
    Issuer, Subject, Audience string
    Expiry                    time.Duration
}

type VerificationPayload struct {
    Token            string
    Secret           string
    Issuer, Audience string
}

func Sign(payload SigningPayload) (string, error) {
    signer, err := jose.NewSigner(jose.SigningKey{Algorithm: payload.Algorithm, Key: []byte(payload.Secret)}, nil)
    if err != nil {
        logger.Errorf("Failed to create signer: %v\n", err)
        return "", err
    }

    claims := jwt.Claims{
        Issuer:   payload.Issuer,
        Subject:  payload.Subject,
        Audience: jwt.Audience{payload.Audience},
        Expiry:   jwt.NewNumericDate(time.Now().Add(payload.Expiry)),
        IssuedAt: jwt.NewNumericDate(time.Now()),
    }

    data := struct {
        jwt.Claims
        Payload any `json:"payload"`
    }{
        Claims:  claims,
        Payload: payload.Payload,
    }

    rawJwt, err := jwt.Signed(signer).Claims(data).CompactSerialize()
    if err != nil {
        logger.Errorf("Failed to create JWT: %v\n", err)
        return "", err
    }

    return rawJwt, nil
}

func Verify(payload VerificationPayload) (any, error) {
    parsedToken, err := jwt.ParseSigned(payload.Token)

    if err != nil {
        logger.Errorf("Failed to parse JWT: %v\n", err)
        return nil, err
    }

    claims := struct {
        jwt.Claims
        Payload any `json:"payload"`
    }{}

    err = parsedToken.Claims([]byte(payload.Secret), &claims)
    if err != nil {
        logger.Errorf("Failed to verify JWT: %v\n", err)
        return nil, err
    }

    err = claims.Validate(jwt.Expected{
        Issuer:   payload.Issuer,
        Audience: jwt.Audience{payload.Audience},
        Time:     time.Now(),
    })
    if err != nil {
        logger.Errorf("JWT claims validation failed: %v\n", err)
        return nil, err
    }

    return claims.Payload, nil
}

You can check out the other utility functions here. I won't bore you with the details.

Payment Facilitator ๐ŸŸจ

A payment facilitator is an organization that helps merchants or users accept payments, usually by credit card, debit card, or other electronic methods. We will use Yellow Card, a company that focuses on Pan-African cryptocurrency exchange with popular, affordable, and localized payment methods, including Mobile Money and bank transfers.

Integrating a payment provider can be as simple as creating an HTTP client with all the necessary configurations, such as the authorization token, API key, and secret key.

Create a pkg/yc-client.go file. Here, we will build a client that allows us to communicate over HTTP with the Yellow Card payment server so we can use it to make transfers to employees.

package pkg

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/base64"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "strings"
    "time"
    "<project-name>/utils"

    "github.com/samber/lo"
)


type YellowClient struct {
    client                     *http.Client
    baseUrl, apiKey, apiSecret string
}

// NewYellowClient constructor
func NewYellowClient(baseUrl, apiKey, apiSecret string) *YellowClient {
    return &YellowClient{
        client:    &http.Client{},
        baseUrl:   baseUrl,
        apiKey:    apiKey,
        apiSecret: apiSecret,
    }
}

// httpAuth method to generate authorization headers
func (yc *YellowClient) httpAuth(path, method string, body map[string]interface{}) (map[string]string, error) {
    yc.client.Timeout = time.Second * 10
    yc.client.Transport = &http.Transport{
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 20,
    }
    yc.client.Jar = nil
    yc.client.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }

    date := time.Now().UTC().Format(time.RFC3339)
    h := hmac.New(sha256.New, []byte(yc.apiSecret))
    h.Write([]byte(date))
    h.Write([]byte(path))
    h.Write([]byte(method))

    if body != nil && lo.Contains([]string{http.MethodPost, http.MethodPut}, method) {
        bodyBytes, err := json.Marshal(body)
        if err != nil {
            return nil, err
        }
        bodyHmac := sha256.Sum256(bodyBytes)
        bodyB64 := base64.StdEncoding.EncodeToString(bodyHmac[:])
        h.Write([]byte(bodyB64))
    }

    signature := base64.StdEncoding.EncodeToString(h.Sum(nil))

    return map[string]string{
        "X-YC-Timestamp": date,
        "Authorization":  fmt.Sprintf("YcHmacV1 %s:%s", yc.apiKey, signature),
        "Content-Type":   "application/json",
    }, nil
}

// MakeRequest method to make an authorized request
func (yc *YellowClient) MakeRequest(method string, path string, body map[string]interface{}) (*http.Response, error) {
    headers, err := yc.httpAuth(path, method, body)
    if err != nil {
        return nil, err
    }

    log.Printf("header = %v", headers)

    url := yc.baseUrl + path

    var bodyBytes []byte
    if body != nil && lo.Contains([]string{http.MethodPost, http.MethodPut}, method) {
        bodyBytes, err = json.Marshal(body)
        if err != nil {
            return nil, err
        }
    }
    http.DefaultClient = yc.client
    req, err := http.NewRequest(method, url, strings.NewReader(string(bodyBytes)))
    if err != nil {
        return nil, err
    }

    utils.LoopOverMap(headers, func(k, v string) { req.Header.Set(k, v) })

    resp, err := yc.client.Do(req)
    if err != nil {
        return nil, err
    }

    if !lo.Contains([]int{http.StatusCreated, http.StatusOK, http.StatusNoContent}, resp.StatusCode) {
        body, err := io.ReadAll(resp.Body)
        if err != nil {
            return resp, err
        }
        return resp, fmt.Errorf("failed to submit payment = %v , code = %v", string(body), resp.Status)
    }

    return resp, nil
}

Building this client allows us to add extra methods to communicate with specific endpoints provided by Yellow Card.

We used some utility function here you can check them out in the utils folder of the attached code repository.

For more details on the client, check out https://github.com/caleberi/EDB/blob/main/pkg/yc-client.go.

Routes

Routes are pathways to controllers that handle requests and return responses. Based on our previous code, we need to create the following routes to support requests made to the application.

Create a engine/routes.go file and add the following code:

package engine

import (
    "<project-name>/common"
    "<project-name>/controllers"
)

func (srv *Application) RegisterRoute() *Application {
    r := srv.mux
    r.RemoveExtraSlash = true
    r.RedirectFixedPath = false
    r.RedirectTrailingSlash = false

    r.POST("/webhook/yellow-card", controllers.YellowCardWebHook)

    authorizedRouter := r.Group("/auth")
    {
        authorizedRouter.POST("/register", controllers.RegisterUser)
        authorizedRouter.POST("/login", controllers.LoginUser)

        authorizedRouter.Use(common.AuthorizeUser()).
            POST("/logout", controllers.LogoutUser)
    }

    managementRouter := r.Group("/employee")
    managementRouter.Use(common.AuthorizeUser())
    {
        managementRouter.POST("/", (controllers.AddEmployee))
        managementRouter.PUT("/:employeeId", (controllers.UpdateEmployee))
        managementRouter.DELETE("/:employeeId", (controllers.DeleteEmployee))
    }

    // include admin route check here
    disbursementRouter := r.Group("/disbursements")
    disbursementRouter.Use(common.AuthorizeUser())
    {
        disbursementRouter.POST("/:employeeId", (controllers.MakeDisbursmentToEmployee))
    }

    return srv
}

The routes are now attached to our application. In our main.go file, we can set up our database and call the function to register our routes as shown below:

func main() {
    config, err := common.LoadConfiguration(common.ConfEnvSetting{YamlFilePath: []string{"./dev.yml"}}) //"./dev.example.yml"
    if err != nil {
        log.Fatal(err)
    }
    logger := internals.GetLogger()
    serverCtx, serverStopCtx := context.WithCancel(context.Background())
    defer serverStopCtx()
    if config.LogLevel == "debug" {
        logger.Infof("[Gin-Debug] SET gin.forceConsoleLog")
        gin.ForceConsoleColor()
    }

    clientOpts := options.Client().
        ApplyURI(config.MongoDB.DBUri).
        SetMaxPoolSize(20).
        SetMinPoolSize(5)
    client := setupDatabase(clientOpts)
    app := &engine.Application{
        DB:      client,
        Config:  config,
        Logger:  logger,
        Context: serverCtx,
    }

    app.Setup().
        RegisterRoute().
        GracefulShutdown()

    if err := app.ListenAndServe(); err != nil {
        log.Panic(err)
    }
}

We have configured our application to use the MongoDB client and created an instance of the Application struct, which is our server. By calling the register route function, we can now implement our controllers. If you run the application now, it will fail to build because we haven't implemented our controllers yet. I feel this quite long enough. Long articles can be boring sometimes ๐Ÿค”.

Therefore, I will extend the final puzzle piece to the next article to avoid making this one too long. A short break can give you time to reflect on what we've covered so far.

I am Caleb and you can reach me on Linkedin or follow me on Twitter. @Soundboax