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

Well, it is the last month of the year and I should have finished this a while back but I got distracted with work and learning. Anyway, this will be a good way to round up the year.

This is a little reminder of what we were building: a simple salary disbursement application that allows Employers to send money to their workers via yellow card payment API.

In our previous episode, we outlined a couple of endpoints that need to be implemented to have a working application. So without further Ado, let’s get to it.

Authentication Group

The first on the list is the authentication group endpoint, which helps register users and log them into the system. Let’s take a look at the registered user’s code and then provide a detailed explanation.

var (
    hasher = utils.NewHasher(bcrypt.DefaultCost)
)

type CreateUserRequest struct {
    FirstName          string `json:"firstName"`
    LastName           string `json:"lastName"`
    Email              string `json:"email"`
    Password           string `json:"password"`
    BVN                string `json:"bvn,omitempty"`
    DOB                string `json:"dob,omitempty"`
    Address            string `json:"address,omitempty"`
    Phone              string `json:"phone,omitempty"`
    Country            string `json:"country,omitempty"`
    IDNumber           string `json:"idNumber,omitempty"`
    IDType             string `json:"idType,omitempty"`
    AdditionalIDType   string `json:"additionalIdType,omitempty"`
    AdditionalIdNumber string `json:"additionalIdNumber,omitempty"`
}

func RegisterUser(c *gin.Context) {
    repo := common.ReposFromCtx(c)
    logger := common.LoggerFromCtx(c)

    var createUserRequest CreateUserRequest

    if err := c.ShouldBindJSON(&createUserRequest); err != nil {
        logger.Infof("bind request to createUserRequest failed : %v", err)
        c.JSON(http.StatusInternalServerError, utils.ErrorResponse(err))
        return
    }

    ctx, cancelFunc := context.WithTimeout(c, 5*time.Second)
    defer cancelFunc()

    if _, err := repo.User.FindOne(ctx, bson.D{{Key: "email", Value: createUserRequest.Email}}); !errors.Is(err, mongo.ErrNoDocuments) {
        logger.Infof("an error occurred : %v", err)
        c.JSON(http.StatusInternalServerError, utils.ErrorResponse(errors.New("user with the provided email exist")))
        return
    }

    hashedPassword, err := hasher.HashPassword(createUserRequest.Password)
    if err != nil {
        c.JSON(http.StatusInternalServerError, utils.ErrorResponse(err))
        return
    }

    dobTIme, err := time.Parse("2006-01-02", createUserRequest.DOB)
    if err != nil {
        c.JSON(http.StatusBadRequest, utils.ErrorResponse(errors.New("invalid date of birth")))
        return
    }
    dobTimeStr := dobTIme.Format("04/03/2016")

    user := models.User{
        FirstName:          strings.TrimSpace(createUserRequest.FirstName),
        LastName:           strings.TrimSpace(createUserRequest.LastName),
        Email:              strings.ToLower(createUserRequest.Email),
        Password:           hashedPassword,
        DOB:                dobTimeStr,
        IdType:             createUserRequest.IDType,
        IdNumber:           createUserRequest.IDNumber,
        Phone:              createUserRequest.Phone,
        AdditionalIdType:   createUserRequest.AdditionalIDType,
        AdditionalIdNumber: createUserRequest.AdditionalIdNumber,
        Address:            createUserRequest.Address,
        Country:            createUserRequest.Country,
    }

    id, err := repo.User.Create(ctx, user)

    if err != nil {
        c.JSON(http.StatusInternalServerError, utils.ErrorResponse(err))
        return
    }

    userId, ok := id.(primitive.ObjectID)
    if !ok {
        err := fmt.Errorf("error occurred while creating user")
        c.JSON(http.StatusInternalServerError, utils.ErrorResponse(err))
        return
    }

    user.ID = userId
    user, err = user.Omit()
    if err != nil {
        c.JSON(http.StatusInternalServerError, utils.ErrorResponse(err))
        return
    }
    c.JSON(http.StatusOK, utils.SuccessResponse("user created successfully", user))
}

To register an Employer, a CreateUserRequest struct can be marshalled from a JSON string. Then we checked if such an Employer existed previously and if this check was passed, we proceeded to hash the password and save the user in our database which we injected via the context.

type LoginRequest struct {
    Email    string `json:"email"`
    Password string `json:"password"`
}

type LoginResponse struct {
    AccessToken string `json:"accessToken"`
}

func LoginUser(c *gin.Context) {
    cfg := common.ConfigFromCtx(c)
    repo := common.ReposFromCtx(c)
    logger := common.LoggerFromCtx(c)

    var loginRequest LoginRequest
    if err := c.ShouldBindJSON(&loginRequest); err != nil {
        logger.Infof("bind request to createUserRequest failed : %v", err)
        c.JSON(http.StatusInternalServerError, utils.ErrorResponse(err))
        return
    }

    ctx, cancelFunc := context.WithTimeout(c, 5*time.Second)
    defer cancelFunc()

    user, err := repo.User.FindOne(ctx, primitive.D{{Key: "email", Value: loginRequest.Email}})
    if err != nil {
        logger.Infof("error during login : %v", err)
        c.JSON(http.StatusInternalServerError, utils.ErrorResponse(errors.New("user with this email does not exist")))
        return
    }

    err = hasher.CheckPassword(loginRequest.Password, user.Password)
    if err != nil {
        logger.Infof("error during login : %v", err)
        c.JSON(http.StatusBadRequest, utils.ErrorResponse(errors.New("password does not match")))
        return
    }

    token, err := utils.Sign(utils.SigningPayload{
        Algorithm: jose.HS256,
        Payload:   user.ID,
        Issuer:    cfg.JWTCredentials.AccessTokenClaim.Issuer,
        Audience:  cfg.JWTCredentials.AccessTokenClaim.Audience,
        Subject:   user.FirstName + ":" + user.LastName,
        Expiry:    cfg.JWTCredentials.AccessTokenTTL,
        Secret:    cfg.JWTCredentials.AccessTokenSecret,
    })

    if err != nil {
        logger.Infof("error during login : %v", err)
        c.JSON(http.StatusInternalServerError, utils.ErrorResponse(errors.New("error occurred")))
        return
    }

    var loginResponse LoginResponse
    loginResponse.AccessToken = token

    c.JSON(http.StatusOK, utils.SuccessResponse("login successfully", loginResponse))
}

To log in as an Employer, a LoginRequest struct can be marshalled from a JSON string as shown above. Then we checked if a User with the provided email exists in the database, and if this check was passed, we verified the password using a password checker. Upon successful password verification, we generated a JWT access token with the user's details and returned it in the login response.

Link to actual code: here

Management Group

In modern enterprise applications, managing employee data efficiently is crucial. The application begins by defining two critical structures: CreateEmployeeRequest and UpdateEmployeeRequest. These structs serve as data transfer objects (DTOs) that encapsulate the information required when creating or updating an employee.

type CreateEmployeeRequest struct {
    FirstName        string  `json:"firstName,omitempty" validate:"required"`
    LastName         string  `json:"lastName,omitempty" validate:"required"`
    MiddleName       string  `json:"middleName,omitempty"`
    Email            string  `json:"email,omitempty" validate:"required,email"`
    BVN              string  `json:"bvn,omitempty"`
    DOB              string  `json:"dob,omitempty"`
    Address          string  `json:"address,omitempty"`
    Phone            string  `json:"phone,omitempty"`
    Country          string  `json:"country,omitempty"`
    IDNumber         string  `json:"idNumber,omitempty"`
    IDType           string  `json:"idType,omitempty"`
    AdditionalIDType string  `json:"additionalIdType,omitempty"`
    Salary           float64 `json:"salary,omitempty" validate:"required"`
    AccountName      string  `json:"account_name,omitempty" validate:"required"`
    BankName         string  `json:"bank_name,omitempty" validate:"required"`
    AccountType      string  `json:"account_type,omitempty" validate:"required"`
}

type UpdateEmployeeRequest struct {
    FirstName        string  `json:"firstName,omitempty" validate:"required"`
    LastName         string  `json:"lastName,omitempty" validate:"required"`
    MiddleName       string  `json:"middleName,omitempty"`
    Address          string  `json:"address,omitempty"`
    Phone            string  `json:"phone,omitempty"`
    Country          string  `json:"country,omitempty"`
    IDNumber         string  `json:"idNumber,omitempty"`
    IDType           string  `json:"idType,omitempty"`
    AdditionalIDType string  `json:"additionalIdType,omitempty"`
    Salary           float64 `json:"salary,omitempty" validate:"required"`
    AccountName      string  `json:"account_name,omitempty" validate:"required"`
    BankName         string  `json:"bank_name,omitempty" validate:"required"`
    AccountType      string  `json:"account_type,omitempty" validate:"required"`
    Bvn              string  `json:"bvn,omitempty" validate:"required"`
}

Since we are not going to be sending response data back to the employee, since we don’t need to. Anyway, we will define some CRUD functions for employers to add one employee with the expected salary to be paid. Like so below:

func AddEmployee(ctx *gin.Context) {
    logger := common.LoggerFromCtx(ctx)
    repo := common.ReposFromCtx(ctx)

    user, ok := ctx.MustGet(common.UserKey).(*models.User)
    if !ok {
        ctx.JSON(http.StatusInternalServerError, utils.ErrorResponse(errors.New("internal server error")))
        return
    }

    var employeeRequest CreateEmployeeRequest
    if err := ctx.ShouldBindJSON(&employeeRequest); err != nil {
        logger.Errorf("bind request to CreateEmployeeRequest failed: %v", err)
        ctx.JSON(http.StatusBadRequest, utils.ErrorResponse(err))
        return
    }

    logger.Infof("Received employee request: %+v", employeeRequest)

    timeNow := time.Now()
    dobTIme, err := time.Parse("2006-01-02", employeeRequest.DOB)
    if err != nil {
        ctx.JSON(http.StatusBadRequest, utils.ErrorResponse(errors.New("invalid date of birth")))
        return
    }
    dobTimeStr := dobTIme.Format("04/03/2016")

    employee := models.Employee{
        Email:            employeeRequest.Email,
        FirstName:        employeeRequest.FirstName,
        LastName:         employeeRequest.LastName,
        BVN:              employeeRequest.BVN,
        UpdatedAt:        &timeNow,
        CreatedAt:        &timeNow,
        DOB:              dobTimeStr,
        IDType:           employeeRequest.IDType,
        IDNumber:         employeeRequest.IDNumber,
        Salary:           employeeRequest.Salary,
        Phone:            employeeRequest.Phone,
        AdditionalIDType: employeeRequest.AdditionalIDType,
        Address:          employeeRequest.Address,
        BankName:         employeeRequest.BankName,
        Country:          employeeRequest.Country,
        AccountName:      employeeRequest.AccountName,
        AccountType:      employeeRequest.AccountType,
        UserID:           user.ID,
    }

    ctxWithTimeout, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()

    logger.Infof("Checking if employee with email %s already exists", employeeRequest.Email)
    _, err = repo.Employee.FindOne(ctxWithTimeout, bson.D{{Key: "email", Value: employeeRequest.Email}})
    if !errors.Is(err, mongo.ErrNoDocuments) {
        logger.Errorf("Employee with the provided email exists already: %v", err)
        ctx.JSON(http.StatusNotFound, utils.ErrorResponse(errors.New("employee with the provided email exists already")))
        return
    }

    id, err := repo.Employee.Create(ctxWithTimeout, employee)
    if err != nil {
        logger.Errorf("Error occurred while creating employee: %v", err)
        ctx.JSON(http.StatusInternalServerError, utils.ErrorResponse(err))
        return
    }

    employeeId, ok := id.(primitive.ObjectID)
    if !ok {
        logger.Errorf("Invalid type assertion for employee ID: %v", id)
        ctx.JSON(http.StatusInternalServerError, utils.ErrorResponse(errors.New("internal server error")))
        return
    }

    employee.ID = employeeId
    ctx.JSON(http.StatusOK, utils.SuccessResponse("employee created successfully", employee))
}

func DeleteEmployee(ctx *gin.Context) {
    repo := common.ReposFromCtx(ctx)

    user, ok := ctx.MustGet(common.UserKey).(*models.User)
    if !ok {
        ctx.JSON(http.StatusInternalServerError, utils.ErrorResponse(errors.New("internal server error")))
        return
    }

    employeeId, err := primitive.ObjectIDFromHex(ctx.Param("employeeId"))
    if err != nil {
        ctx.JSON(http.StatusInternalServerError, utils.ErrorResponse(err))
        return
    }

    query := primitive.D{
        {Key: "user_id", Value: user.ID},
        {Key: "_id", Value: employeeId},
    }

    // Delete many is not proper here:, but it make ease witht the model definition
    if err := repo.Employee.DeleteMany(ctx, query); err != nil {
        ctx.JSON(http.StatusInternalServerError,
            utils.ErrorResponse(fmt.Errorf("could not delete employee with id [%v]", employeeId.String())))
        return
    }

    ctx.JSON(http.StatusOK, utils.SuccessResponse("", nil))
}

func UpdateEmployee(ctx *gin.Context) {
    repo := common.ReposFromCtx(ctx)
    logger := common.LoggerFromCtx(ctx)

    employeeId, err := primitive.ObjectIDFromHex(ctx.Param("employeeId"))
    if err != nil {
        ctx.JSON(http.StatusInternalServerError, utils.ErrorResponse(err))
        return
    }

    var employeeeRequest UpdateEmployeeRequest

    if err := ctx.ShouldBindJSON(&employeeeRequest); err != nil {
        logger.Infof("bind request to createUserRequest failed : %v", err)
        ctx.JSON(http.StatusInternalServerError, utils.ErrorResponse(err))
        return
    }

    updatedAt := time.Now()
    employee := models.Employee{
        FirstName:        employeeeRequest.FirstName,
        LastName:         employeeeRequest.LastName,
        UpdatedAt:        &updatedAt,
        IDType:           employeeeRequest.IDType,
        IDNumber:         employeeeRequest.IDNumber,
        Salary:           employeeeRequest.Salary,
        Phone:            employeeeRequest.Phone,
        AdditionalIDType: employeeeRequest.AdditionalIDType,
        Address:          employeeeRequest.Address,
        AccountName:      employeeeRequest.AccountName,
        AccountType:      employeeeRequest.AccountType,
        BankName:         employeeeRequest.BankName,
        BVN:              employeeeRequest.Bvn,
    }

    if err := repo.Employee.UpdateOneById(ctx, employeeId, employee); err != nil {
        ctx.JSON(http.StatusInternalServerError,
            utils.ErrorResponse(fmt.Errorf("could not update employee with id [%v]", employeeId.String())))
        return
    }

    ctx.JSON(http.StatusOK, utils.SuccessResponse("", nil))
}

Link to actual code: here

Fund Disbursement

To disburse a salary to an Employee, we initially retrieve the authenticated user and the specific employee from the database using their IDs. We then use a Yellow Card payment client to initiate a payment transfer, constructing a detailed payload with the sender (authenticated user) and destination (employee) information.

After making the payment request to the Yellow Card API, we process the response by parsing the payment details and creating a new Disbursement record in the database. The disbursement tracks the payment status, includes the payment details, and links the sender (authenticated user) and receiver (employee) involved in the transaction.

func MakeDisbursmentToEmployee(ctx *gin.Context) {
    repo := common.ReposFromCtx(ctx)
    logger := common.LoggerFromCtx(ctx)
    cfg := common.ConfigFromCtx(ctx)

    user, ok := ctx.MustGet(common.UserKey).(*models.User)
    if !ok {
        err := fmt.Errorf("error occurred while creating user")
        ctx.JSON(http.StatusInternalServerError, utils.ErrorResponse(err))
        return
    }

    employeeId, err := primitive.ObjectIDFromHex(ctx.Param("employeeId"))
    if err != nil {
        ctx.JSON(http.StatusInternalServerError, utils.ErrorResponse(err))
        return
    }

    query := primitive.D{{Key: "_id", Value: employeeId}}

    employee, err := repo.Employee.FindOne(ctx, query)
    if err != nil {
        ctx.JSON(http.StatusInternalServerError, utils.ErrorResponse(err))
        return
    }

    client := pkg.NewYellowClient(
        cfg.YellowCardCredentials.BaseUrl,
        cfg.YellowCardCredentials.ApiKey,
        cfg.YellowCardCredentials.SecretKey)

    paymentDetails := map[string]interface{}{
        "channelId":   "fe8f4989-3bf6-41ca-9621-ffe2bc127569",
        "sequenceId":  uuid.New().String(),
        "localAmount": employee.Salary,
        "reason":      "other",
        "sender": map[string]interface{}{
            "name":               user.FirstName + " " + user.LastName,
            "phone":              user.Phone,
            "country":            user.Country,
            "address":            user.Address,
            "dob":                user.DOB,
            "email":              user.Email,
            "idNumber":           user.IdNumber,
            "idType":             user.IdType,
            "businessId":         "B1234567",
            "businessName":       "Example Inc.",
            "additionalIdType":   user.AdditionalIdType,
            "additionalIdNumber": user.AdditionalIdNumber,
        },
        "destination": map[string]interface{}{
            "accountNumber": employee.AccountName,
            "accountType":   "bank",
            "networkId":     "31cfcc77-8904-4f86-879c-a0d18b4b9365",
            "accountBank":   employee.BankName,
            "networkName":   "Guaranty Trust Bank",
            "country":       employee.Country,
            "accountName":   employee.FirstName + " " + employee.LastName,
            "phoneNumber":   employee.Phone,
        },
        "forceAccept":  true,
        "customerType": "retail",
    }

    var payment models.Payment
    resp, err := client.MakeRequest(http.MethodPost, "/business/payments", paymentDetails)
    if err != nil {
        ctx.JSON(http.StatusInternalServerError, utils.ErrorResponse(err))
        return
    }
    defer resp.Body.Close()
    body, err := io.ReadAll(resp.Body)
    if err != nil {
        ctx.JSON(http.StatusInternalServerError, utils.ErrorResponse(err))
        return
    }
    err = json.Unmarshal(body, &payment)

    if err != nil {
        ctx.JSON(http.StatusInternalServerError, utils.ErrorResponse(err))
        return
    }

    logger.Infof("Payment = %+v", payment)
    timeNow := time.Now()
    disbursment := models.Disbursement{
        ReceiverID:   employee.ID,
        SenderID:     user.ID,
        CreatedAt:    &timeNow,
        SalaryAmount: employee.Salary,
        Status:       "processing",
        Payment:      payment,
    }

    _, err = repo.Disbursement.Create(ctx, disbursment)
    if err != nil {
        ctx.JSON(http.StatusInternalServerError, utils.ErrorResponse(err))
        return
    }

    ctx.JSON(http.StatusOK, utils.SuccessResponse("disbursement submitted successfully", disbursment))
}

Link to actual code: here

Webhook Automation

To handle a Yellow Card webhook, we first validate the incoming webhook request's signature to ensure its authenticity using an HMAC-SHA256 verification process. After successfully binding the webhook payload to a Webhook struct, we locate the corresponding disbursement in our database using the unique sequence ID.

The webhook handler processes different payment events (Pending, Processing, Completed, Failed) by updating the disbursement status in the database to reflect the current state of the payment. This allows for real-time tracking of payment transactions received from the Yellow Card payment system.

var (
    ProcessingEvent string = "PAYMENT.PROCESSING"
    PendingEvent    string = "PAYMENT.PENDING"
    FailedEvent     string = "PAYMENT.FAILED"
    CompletedEvent  string = "PAYMENT.COMPLETE"
)

type Webhook struct {
    ID         string `json:"id"`
    SequenceID string `json:"sequenceId"`
    Status     string `json:"status"`
    ApiKey     string `json:"apiKey"`
    Event      string `json:"event"`
    ExecutedAt int64  `json:"executedAt"`
}

func YellowCardWebHook(ctx *gin.Context) {
    repo := common.ReposFromCtx(ctx)
    logger := common.LoggerFromCtx(ctx)

    if validateSignature(ctx) {
        ctx.JSON(http.StatusBadRequest,
            utils.ErrorResponse(errors.New("validating request to webhook payload failed")))
        return
    }

    var hook Webhook
    if err := ctx.ShouldBindJSON(&hook); err != nil {
        logger.Errorf("bind request to webhook failed: %v", err)
        ctx.JSON(http.StatusBadRequest, utils.ErrorResponse(err))
        return
    }

    disbursement, err := repo.Disbursement.FindOne(ctx, primitive.D{{Key: "payment.sequenceid", Value: hook.SequenceID}})
    if err != nil {
        ctx.JSON(http.StatusInternalServerError, utils.ErrorResponse(err))
        return
    }

    switch hook.Event {
    case PendingEvent,
        ProcessingEvent,
        CompletedEvent,
        FailedEvent:
        err = repo.Disbursement.UpdateOneById(ctx, disbursement.ID, models.Disbursement{Status: hook.Status})
        if err != nil {
            ctx.JSON(http.StatusInternalServerError, utils.ErrorResponse(err))
            return
        }
    default:
    }

}

func validateSignature(ctx *gin.Context) bool {
    cfg := common.ConfigFromCtx(ctx)
    receivedSignature := ctx.GetHeader("X-YC-Signature")
    if receivedSignature == "" {
        return false
    }

    body, err := io.ReadAll(ctx.Request.Body)
    if err != nil {
        return false
    }

    ctx.Request.Body = io.NopCloser(bytes.NewBuffer(body))
    h := hmac.New(sha256.New, []byte(cfg.YellowCardCredentials.SecretKey))
    h.Write(body)
    computedHash := h.Sum(nil)
    computedSignature := base64.StdEncoding.EncodeToString(computedHash)
    return hmac.Equal([]byte(receivedSignature), []byte(computedSignature))
}

Link to actual code: here

With this whole setup, we have created a basic disbursement REST application using Yellowcard API with golang. This is very simple but if improved upon can be used to develop a more complex application.

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

0
Subscribe to my newsletter

Read articles from Adewole Caleb Erioluwa directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Adewole Caleb Erioluwa
Adewole Caleb Erioluwa

I'm all about building scalable and robust applications, bringing over 5+ years of experience. As a Backend Software Engineer, I've driven efficiency improvements of up to 60%, thanks to my expertise in Go and JavaScript. In my current role at Cudium, as a Senior Software Engineer, I've integrated backend systems with third-party applications, boosting our understanding of user engagement by 85%. Beyond coding, I enjoy mentoring, documentation, and spearheading security features for a safer application. During my internship at Nomba, I honed skills in Node JS, MongoDB, and Java, improving data persistence for vendor services and reducing fraudulent transactions by over 60%. At Kudi.ai, I implemented transactional features, optimized legacy code, and crafted an account balance slack notification service, boosting stakeholder efficiency by 75%. At Airgateway, I was focused on enhancing support for agencies in their airline bookings. When not in the tech world, you'll find me strumming a guitar, hitting the dance floor, or belting out a tune. Let's connect over coffee and chat about tech, career journeys, or the latest music trends! I'm always looking to expand my network. Feel free to reach out at caleberioluwa@gmail.com