Building A Custom Payroll System: MongoDB, Go, and Yellowcard API Series - Final
Finishing touches for the simple custom payroll system.
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