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

Finishing touches for the simple custom payroll system.

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