This is school collaboration project for one of our course Elements of Software Construction, with Ascenda to build a loyalty point system that offer their customers with the abilities to convert their own loyalty currency to a variety of other third party loyalty currencies.
Intro
This is a very backend focused project to build an robust API to handle the loyalty program and the exchanging of the customer’s credit points.
I work on this project together with my teammate Varshini, Hayrul, Nicholas and Kang Ming!
Tech Stack Chosen
Golang
with gin frame work for API building
Postgres
is our choice of database sincerelational database
have a stricter rules in terms of how each fields are defined.
Next.js
to build a simple UI to carry out the basic interaction with the backend API
Cypress
for frontend testing
Golang
’s testing package for backend unit and integration testing
Features
Sample Architecture
Here’s a proposed architecture for how you could build the apps to mimic the flows we have here in Ascenda.
TransferConnect (where the core logic of points processing exists)
Bank App: Customer facing frontend where a user can submit their points redemption by supplying a loyalty program membership. A simple demo using the different APIs from the TransferConnect App would suffice.
TransferConnect App: Backend only app which handles the API requests of points transfers from the banks and collates them for processing with the loyalty programs.
Loyalty Program: Fulfills the points to be redeemed in their system by ingesting files you generate and upload to the provisioned SFTP folder; You don’t need to build this; You can mock the process by uploading the handback file you generate and have your TransferConnect app ingest it.
My thoughts
- The
validation of membership
can be done in thebackend
when the user try to submit a credit request and reject when it is not correct, there is no need for a dedicated endpoint for it
- The
approval
of thecredit requests
can be built using anadmin system
to allow the third party loyal program tointeract with the API
toupdate the database
accordingly Realtime update
, whenever the third party approves, customer will getnotify immediately
instead of waiting for the server to fetch the hand back file and processReduce
the potentialhuman error
when the third party programs create the hand back files- It definitely
more work
on both frontend and backend
- A better way to
notify user
will be just throughemail
, instead ofwebhook
since this is a webapp and not a phone app, most people do not turn on the notification feature on webapp (at least the people i know)
Planning
Some of our diagrams during the planning phase before we start on the project.
Use Case diagram
The diagram below illustrates the
interactions
between all the parties
and the potential threats
to the webapp and preventions
that can be takenSoftware Development Process
Iterative and Incremental Model
Since the general expected requirements have been outlined by the client, which is Ascenda in this case, we believe an
iterative
and incremental
model is the ideal strategy
for this project.We believe this is the right approach because incremental development allows us to
make adjustments
early
in the process rather than waiting until the end, and iterative development allows us to make continuous improvements
. Furthermore, since a working prototype
may be produced early
on in the project using this development process, it is possible to isolate flaws
in functions or designs as it is being reviewed
and discussed
.Some of the advantages of using this software development process is that progress can be
easily measured
, most problems can be detected
during iteration and higher risk
may be handled
with as an early priority
, and functional prototypes
can be built early in the project life cycle. However, this process also comes with a drawback that the need for more intensive project management
and strong design
of the system architecture
may be required. If there are any errors found in the later iterations, then all the code released at the end of the other iterations also has to be rectified as well since there may be little overlap between each iteration.Database Design
Since the database of our choice is Postgres, which is a
relational database
, the relationship
between each table
should be well established
before we start on the project, and we create our diagram using dbdiagram.io which allows us to visualise
the database relationships base on the sql code
that we write and export to the relevant sql filesUML Diagram
A very general overview of the implementation the entire app. With relevant fields from the database design and corresponding methods required to pass the
Backend
SQLC
generate golang database CRUD code using pure sql syntax
Usage
- Download the
cli
tool - MAC OS:
brew install sqlc
- Ubuntu:
sudo snap install sqlc
- Go:
go install github.com/kyleconroy/sqlc/cmd/sqlc@latest
- More details here
- Initialise
sqlc init
sqlc.yaml
should be generated- update the content
version: "1" packages: - path: "./pkg/models" name: "models" engine: "postgresql" schema: "./pkg/models/migrations" queries: "./pkg/models/query" emit_json_tags: true
- Database Migration
Download
the sql file from the database design- Use one of the
database migration
library. We use golang-migrate. - copy the content in the sql file that is downloaded to the
000001_init.up.sql
- run
migrate -source file://pkg/models/migrations -database "${PSQL_LINK}?sslmode=disable" up
- The command can be written into a
makefile
and simply be called usingmake migrate
. - Now open any
Graphical User Interface (GUI)
for Postgres and connect to the database, you should see the tables well defined and ready to use.
Before we start on any coding, the database structure has to be defined in the Postgres database.
- Write sql queries in the
queries
directory - use
loyalty program
as an example below database schema
must be defined first in the schema directoryschema
andquery
are to be defined in different file in the directory specified in thesqlc.yaml
file
//SCHEMA inside the ./pkg/models/migrations folder CREATE TABLE "loyalty_program" ( "id" bigserial PRIMARY KEY, "name" varchar NOT NULL, "currency_name" varchar NOT NULL, "processing_time" varchar NOT NULL, "description" varchar, "enrollment_link" varchar NOT NULL, "terms_condition_link" varchar NOT NULL, "format_regex" varchar NOT NULL, "partner_code" varchar NOT NULL, "initial_earn_rate" float NOT NULL );
//query file loyalty_program.sql inside ./pkg/models/query -- name: GetLoyaltyByID :one SELECT * FROM loyalty_program WHERE id = $1 LIMIT 1; -- name: GetLoyaltyByName :one SELECT * FROM loyalty_program WHERE name = $1 LIMIT 1; -- name: ListLoyalty :many SELECT * FROM loyalty_program ORDER BY name; -- name: CreateLoyalty :one INSERT INTO loyalty_program( name, currency_name,processing_time,description,enrollment_link, terms_condition_link,format_regex,partner_code,initial_earn_rate ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9 ) RETURNING *; -- name: UpdateLoyalty :exec UPDATE loyalty_program SET name = COALESCE($1,name), currency_name = COALESCE($2,currency_name), processing_time = COALESCE($3,processing_time), description = COALESCE($4,description), enrollment_link = COALESCE($5,enrollment_link), terms_condition_link = COALESCE($6,terms_condition_link), format_regex = COALESCE($7,format_regex), partner_code = COALESCE($8,partner_code), initial_earn_rate = COALESCE($9,initial_earn_rate) WHERE id = $10; -- name: DeleteLoyalty :exec DELETE FROM loyalty_program WHERE id = $1;
- Generate CRUD
sqlc generate
db.go
contains the database transaction and instantiationmodels.go
contains the struct of the data modelloyalty_program.go
contains the go functions for the relevant operations matching to the sql query- Sample result
const createLoyalty = `-- name: CreateLoyalty :one INSERT INTO loyalty_program( name, currency_name,processing_time,description,enrollment_link, terms_condition_link,format_regex,partner_code,initial_earn_rate ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9 ) RETURNING id, name, currency_name, processing_time, description, enrollment_link, terms_condition_link, format_regex, partner_code, initial_earn_rate ` type CreateLoyaltyParams struct { Name string `json:"name"` CurrencyName string `json:"currency_name"` ProcessingTime string `json:"processing_time"` Description string `json:"description"` EnrollmentLink string `json:"enrollment_link"` TermsConditionLink string `json:"terms_condition_link"` FormatRegex string `json:"format_regex"` PartnerCode string `json:"partner_code"` InitialEarnRate float64 `json:"initial_earn_rate"` } func (q *Queries) CreateLoyalty(ctx context.Context, arg CreateLoyaltyParams) (LoyaltyProgram, error) { row := q.db.QueryRowContext(ctx, createLoyalty, arg.Name, arg.CurrencyName, arg.ProcessingTime, arg.Description, arg.EnrollmentLink, arg.TermsConditionLink, arg.FormatRegex, arg.PartnerCode, arg.InitialEarnRate, ) var i LoyaltyProgram err := row.Scan( &i.ID, &i.Name, &i.CurrencyName, &i.ProcessingTime, &i.Description, &i.EnrollmentLink, &i.TermsConditionLink, &i.FormatRegex, &i.PartnerCode, &i.InitialEarnRate, ) return i, err }
Problems
sql.Null<type>
is used for defining of fields that can containNULL
- It has the following struct
- this struct is very problematic when is used by the
gin framework
tomarshal
andunmarshal
the json data come of the api request body. - Some of the solutions that is found online are to define a wrapper class for this type and define a
MarshalJSON
function to define themarshal
behaviour. Then put the wrapper class in the struct that needs theseNull Fields
- The problem is, the struct that uses these Null Fields is
auto generated
usingsqlc
using thesql queries
defined. - It can be changed to the new wrapper class, but every there is a need to
update the queries
andsqlc generate
is run to generate the code, the new code will overwrite the changes that you have made. - The work around is to define null fields as
NOT NULL
but with aDEFAULT
to an empty string.
type NullString struct { Stringstring Validbool // Valid is true if String is not NULL }
Reward Calculation
This is one of the core function of the app. There is a bonus requirement from Ascenda to make the reward varies base on promotions given by third party. Hence the logic have to be well thought out and carefully implemented.
Promotion
table is defined with the following fieldsCREATE TABLE "promotion" ( "id" bigserial PRIMARY KEY, "program" int NOT NULL, "promo_type" promo_type_enum NOT NULL, "start_date" date NOT NULL, "end_date" date NOT NULL, "earn_rate_type" earn_rate_type_enum NOT NULL, "constant" float NOT NULL, "card_tier" int, "loyalty_membership" int );
start
andend dates
(to specify theperiod
of promotion)
program
which is aforeign key
points to whichloyalty program
that this promotion is related with
promot_type
whether it isonetime
(each customer can only claim once) orongoing
(valid for as long as the current date of making credit transfer falls inside ) promotion
earn_rate_type
whether the additional reward is granted usingaddition
ormultiplication
constant
theamount
to multiply or add
card_tier
aforeign key
points to a predefined card tier by the bank. Eg. Gold, Platinum… This field isoptional
, if provided, only user with that particular card tier will be able to enjoy the promotion
loyalty_membership
aforeign key
points to a predefined card tier by the third party loyalty program. Eg. Gold, Platinum… This field isoptional
, if provided, only user with that particular membership will be able to enjoy the promotion. We later found that this field isnot practical
since the membership details is only available to the third party and we do not have access
Different Scenarios
The rewards varies under different conditions. Test cases are written for each of the condition.
Some of the variables used
creditToTransfer
— the amount of credit that the user plan to transfer. initalEarnRate
the earn rate defined by the third party loyalty program- Reward without promotion
- reward = creditToTransfer * initalEarnRate
- Reward with on onging promotion
- reward = creditToTransfer * initalEarnRate (+/*) constant
- Reward with on onetime promotion
- Check the past credit to see if this user has any active credit transfer use the current promo
- if no
- reward = creditToTransfer * initalEarnRate (+/*) constant
- if yes
- reward = creditToTransfer * initalEarnRate
- Reward with a card tier specified
- Check the user has the matching card tier
- if yes
- reward = creditToTransfer * initalEarnRate (+/*) constant
- if no
- reward = creditToTransfer * initalEarnRate
- Reward with multiple promotions that is valid
- calculate all the reward that the user might get and return only the highest one
Code
The main function for the above requirements.
func CalculateReward(c context.Context, query *models.Queries, body models.TransferParams) (result float64, promoUsed sql.NullInt32, err error) { if body.CreditToTransfer < 0 { // return 0,error } program, err := query.GetLoyaltyByID(c, int64(body.ProgramId)) if err != nil { return 0, sql.NullInt32{Valid: false}, nil } getPromoParam := models.GetPromotionByDateRangeParams{ Column1: time.Now().Format("2006-01-02"), Program: int32(program.ID), } promotions, err := query.GetPromotionByDateRange(c, getPromoParam) if err != nil { return 0, sql.NullInt32{Valid: false}, nil } user, err := query.GetUserByID(c, int64(body.UserId)) if err != nil { return 0, sql.NullInt32{Valid: false}, nil } var base float64 = program.InitialEarnRate * body.CreditToTransfer var promoIdUsed int32 = 0 var max float64 = base for _, promotion := range promotions { var tempReward float64 = 0 if promotion.PromoType == "onetime" { args := models.GetCreditRequestByPromoParams{ Program: int32(program.ID), PromoUsed: sql.NullInt32{Valid: true, Int32: int32(promotion.ID)}, } _, err = query.GetCreditRequestByPromo(c, args) //skip the loop if there is result found // if err.Error()!="sql: no rows in result set"{ if err != sql.ErrNoRows { fmt.Println("no pass request made") continue } } if promotion.CardTier.Valid && user.CardTier.Valid { if promotion.CardTier.Int32 == user.CardTier.Int32 { tempReward = processReward(promotion, base) } } else if promotion.CardTier.Valid { continue } else { tempReward = processReward(promotion, base) } if tempReward != 0 { if tempReward > max { max = tempReward promoIdUsed = int32(promotion.ID) } } } if promoIdUsed != 0 { return max, sql.NullInt32{Int32: promoIdUsed, Valid: true}, nil } else { return max, sql.NullInt32{Valid: false}, nil } }
Database Transaction
To make sure that the
right amount
is deducted or credited to the user, a series
of database operation
has to be carried out. If any
of the operation failed
. The entire series operation operation should be abort
and revert to the previous state.An example: bank transfer
- Alice want to transfer 1000 dollar to Bob
- Right flow
- Deduct 1000 dollar from Alice’s account
- Credit 1000 dollar to Bob’s account
- if the database
fails
to deduct 1000 dollar from Alice’s account. It shouldabort
entire flow andnot credit
the amount to bob’s account
Here comes the idea of
Transaction
Code to implement transaction
The deduct of credit from the user’s credit balance should only be carried out if a new credit request is created successfully. if the deduction is not successful, it should revert the process
func (store *Store) execTx(ctx context.Context,fn func(*Queries) error) error{ tx,err := store.db.BeginTx(ctx,nil) if err!=nil{ return err } q := New(tx) err = fn(q) if err!=nil{ if rbErr:=tx.Rollback(); rbErr!=nil{ return fmt.Errorf("tx err: %v, rb err: %v",err,rbErr) } return err } return tx.Commit() } func (store *Store) CreditTransferOut(ctx context.Context,arg TransferParams,promo sql.NullInt32) (CreditRequest,error){ var result CreditRequest err:= store.execTx(ctx, func (q *Queries)error{ var err error request:=CreateCreditRequestParams{ UserID:arg.UserId, Program:arg.ProgramId, MemberID:arg.MembershipId, CreditUsed:arg.CreditToTransfer, RewardShouldReceive:arg.RewardShouldReceive, PromoUsed:promo, TransactionTime:sql.NullTime{Time:time.Now(),Valid:true}, TransactionStatus:TransactionStatusEnum("created"), } result,err =q.CreateCreditRequest( ctx,request, ) if err!=nil{ return err } balanceParam := DecreBalanceParams { CreditBalance:arg.CreditToTransfer, ID: int64(arg.UserId), } //TODO prevent deadlock err = q.DecreBalance(ctx,balanceParam) if err!=nil{ return err } return nil }) return result,err }
Database Dead Lock Prevention
Dead lock happens when there is not resources to be allocated to current ongoing processes to let them finish and release the resources that they are holding.
Testing
Extensive testing is done with a plenty of test cases written for unit and integration testing on the backend and frontend testing is done using cypress
Code Snippets
Some of the test cases that we wrote
Unit testing for database operations
func createLoyaltyObject() CreateLoyaltyParams { arg := CreateLoyaltyParams{ Name: utils.RandomString(6), CurrencyName: utils.RandomString(6), ProcessingTime: utils.RandomString(4), Description: sql.NullString{String: utils.RandomString(20), Valid: true}, EnrollmentLink: utils.RandomString(20), TermsConditionLink: utils.RandomString(20), FormatRegex: utils.RandomString(10), PartnerCode: utils.RandomString(5), InitialEarnRate: utils.RandomFloat(10), } return arg } func TestCreateLoyalty(t *testing.T) { obj := createLoyaltyObject() program, err := testQueries.CreateLoyalty(context.Background(), obj) require.NoError(t, err) require.NotEmpty(t, program) require.Equal(t, program.Name, obj.Name) require.Equal(t, program.CurrencyName, obj.CurrencyName) require.Equal(t, program.ProcessingTime, obj.ProcessingTime) require.Equal(t, program.Description.String, obj.Description.String) require.Equal(t, program.EnrollmentLink, obj.EnrollmentLink) require.Equal(t, program.TermsConditionLink, obj.TermsConditionLink) require.Equal(t, program.FormatRegex, obj.FormatRegex) require.Equal(t, program.PartnerCode, obj.PartnerCode) require.Equal(t, program.InitialEarnRate, obj.InitialEarnRate) require.NotZero(t, program.ID) } func TestDeleteLoyalty(t *testing.T) { obj := createLoyaltyObject() program, err := testQueries.CreateLoyalty(context.Background(), obj) require.NoError(t, err) require.NotEmpty(t, program) deleteErr := testQueries.DeleteLoyalty(context.Background(), program.ID) require.NoError(t, deleteErr) _, getErr := testQueries.GetLoyaltyByID(context.Background(), program.ID) require.EqualError(t, getErr, "sql: no rows in result set") }
Integration testing on reward calculation
// when promo ask for cartier but use no cardtier/ user's cardtier is below what is requested func TestRewardCalPromoOutOfRange(t *testing.T) { cardTierArgs := models.CreateCardTierParams{ Name: utils.RandomString(7), Tier: 2, } cardTier, err := testQueries.CreateCardTier(context.Background(), cardTierArgs) createLoyaltyArgs := createLoyaltyObject() createUserArgs := createUserObject(sql.NullInt32{Valid: false}) var creditToTransfer float64 = 100 program, err := testQueries.CreateLoyalty(context.Background(), createLoyaltyArgs) require.NoError(t, err) user, err := testQueries.CreateUser(context.Background(), createUserArgs) require.NoError(t, err) args := models.TransferParams{ UserId: int32(user.ID), ProgramId: int32(program.ID), CreditToTransfer: float64(creditToTransfer), MembershipId: utils.RandomString(6), } startDate, err := time.Parse("2006-01-02", "2022-07-01") require.NoError(t, err) endDate, err := time.Parse("2006-01-02", "2022-07-15") require.NoError(t, err) var constant float64 = 1000 createPromoArgs := models.CreatePromotionParams{ Program: int32(program.ID), PromoType: models.PromoTypeEnum("ongoing"), StartDate: startDate, EndDate: endDate, EarnRateType: models.EarnRateTypeEnum("add"), Constant: float64(constant), CardTier: sql.NullInt32{Valid: true, Int32: int32(cardTier.ID)}, } _, err = testQueries.CreatePromotion(context.Background(), createPromoArgs) require.NoError(t, err) result, _, err := CalculateReward(context.Background(), testQueries, args) require.NoError(t, err) expected := creditToTransfer * (program.InitialEarnRate) require.Equal(t, expected, result) }
Makefile
shorten some of the commands
Simply run
make <command>
for the shortened command that is specified belowinclude .env export docker-postgres: docker run --name postgres-db -p 55001:5432 -e POSTGRES_PASSWORD=postgrespw -d postgres migrations: @read -p "Enter the name of the migration: " migration_name;\ migrate create -ext sql -dir pkg/models/migrations -seq $$migration_name migrate: migrate -source file://pkg/models/migrations -database "${PSQL_LINK}" up migrate-down: migrate -source file://pkg/models/migrations -database "${PSQL_LINK}" down test-model: go test esc/ascendaRoyaltyPoint/pkg/models -v -cover test-controllers: go test esc/ascendaRoyaltyPoint/pkg/controllers -v -cover sqlc: sqlc generate
Conclusion
This project is currently at its final stage before submission and I think that there are still many more features and use case scenarios that can be implemented but could not due to the limited amount of time.
Nevertheless, I have horned my skills in making database design and interaction with relational database. I also picked up the basic syntax and concepts of golang which has been one of my biggest skill that i want to learn on my ‘wish list’.
One of the most important lesson or skill that I took away is how to construct promoter test cases to make sure that the code runs with less error.
Even though there are curses like:
Testing can only find the presence of errors, not their absence — Edsger W. Dijkstra
But we should still do extensive testing on our program, because any bugs may cause significant decrease in user experience or bring detrimental impact to the business. Even Microsoft spent 75% of the time testing
Every new project, all new experience. With different tools that I get to explore for different feature implementation, makes the world of programming full of possibility!