diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..13566b8
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/SimpleBank.iml b/.idea/SimpleBank.iml
new file mode 100644
index 0000000..5e764c4
--- /dev/null
+++ b/.idea/SimpleBank.iml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..fa865b5
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/api/account.go b/api/account.go
index 0869f93..df48396 100644
--- a/api/account.go
+++ b/api/account.go
@@ -2,6 +2,7 @@ package api
import (
"database/sql"
+ "errors"
"net/http"
db "github.com/cukhoaimon/SimpleBank/db/sqlc"
@@ -10,7 +11,7 @@ import (
type createAccountRequest struct {
Owner string `json:"owner" binding:"required"`
- Currency string `json:"currency" binding:"required,oneof=USD EUR VND"`
+ Currency string `json:"currency" binding:"required,currency"`
}
func (server *Server) createAccount(ctx *gin.Context) {
@@ -50,7 +51,7 @@ func (server *Server) getAccount(ctx *gin.Context) {
account, err := server.store.GetAccount(ctx, req.ID)
if err != nil {
- if err == sql.ErrNoRows {
+ if errors.Is(err, sql.ErrNoRows) {
ctx.JSON(http.StatusNotFound, errorResponse(err))
return
}
diff --git a/api/server.go b/api/server.go
index 85e542e..1fe27a3 100644
--- a/api/server.go
+++ b/api/server.go
@@ -1,8 +1,11 @@
package api
import (
+ "fmt"
db "github.com/cukhoaimon/SimpleBank/db/sqlc"
"github.com/gin-gonic/gin"
+ "github.com/gin-gonic/gin/binding"
+ "github.com/go-playground/validator/v10"
)
type Server struct {
@@ -15,11 +18,21 @@ func NewServer(store db.Store) *Server {
server := &Server{store: store}
router := gin.Default()
+ if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
+ err := v.RegisterValidation("currency", validCurrency)
+ if err != nil {
+ fmt.Printf("err %v", err.Error())
+ return nil
+ }
+ }
+
// routing here
router.GET("/api/v1/account", server.listAccount)
router.POST("/api/v1/account", server.createAccount)
router.GET("/api/v1/account/:id", server.getAccount)
+ router.POST("/api/v1/transfer", server.createTransfer)
+
server.router = router
return server
}
diff --git a/api/transfer.go b/api/transfer.go
new file mode 100644
index 0000000..20616da
--- /dev/null
+++ b/api/transfer.go
@@ -0,0 +1,69 @@
+package api
+
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+ db "github.com/cukhoaimon/SimpleBank/db/sqlc"
+ "github.com/gin-gonic/gin"
+ "net/http"
+)
+
+type transferRequest struct {
+ FromAccountID int64 `json:"from_account_id" binding:"required,min=1"`
+ ToAccountID int64 `json:"to_account_id" binding:"required,min=1"`
+ Amount int64 `json:"amount" binding:"required,min=1"`
+ Currency string `json:"currency" binding:"required,currency"`
+}
+
+func (server *Server) createTransfer(ctx *gin.Context) {
+ var req transferRequest
+
+ if err := ctx.ShouldBindJSON(&req); err != nil {
+ ctx.JSON(http.StatusBadRequest, errorResponse(err))
+ return
+ }
+
+ if !server.validAccount(ctx, req.FromAccountID, req.Currency) {
+ return
+ }
+
+ if !server.validAccount(ctx, req.ToAccountID, req.Currency) {
+ return
+ }
+
+ arg := db.TransferTxParams{
+ FromAccountID: req.FromAccountID,
+ ToAccountID: req.ToAccountID,
+ Amount: req.Amount,
+ }
+
+ account, err := server.store.TransferTxAccount(ctx, arg)
+ if err != nil {
+ ctx.JSON(http.StatusInternalServerError, errorResponse(err))
+ return
+ }
+
+ ctx.JSON(http.StatusCreated, account)
+}
+
+func (server *Server) validAccount(ctx *gin.Context, accountID int64, currency string) bool {
+ account, err := server.store.GetAccount(ctx, accountID)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ ctx.JSON(http.StatusNotFound, errorResponse(err))
+ return false
+ }
+
+ ctx.JSON(http.StatusInternalServerError, errorResponse(err))
+ return false
+ }
+
+ if account.Currency != currency {
+ err := fmt.Errorf("account [%d] mismatch: %s vs %s ", accountID, account.Currency, currency)
+ ctx.JSON(http.StatusBadRequest, errorResponse(err))
+ return false
+ }
+
+ return true
+}
diff --git a/api/transfer_test.go b/api/transfer_test.go
new file mode 100644
index 0000000..076f580
--- /dev/null
+++ b/api/transfer_test.go
@@ -0,0 +1,289 @@
+package api
+
+import (
+ "bytes"
+ "database/sql"
+ "encoding/json"
+ mockdb "github.com/cukhoaimon/SimpleBank/db/mock"
+ db "github.com/cukhoaimon/SimpleBank/db/sqlc"
+ "github.com/cukhoaimon/SimpleBank/utils"
+ "github.com/gin-gonic/gin"
+ "github.com/golang/mock/gomock"
+ "github.com/stretchr/testify/require"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestServer_createTransfer(t *testing.T) {
+ fromAccount := db.Account{
+ ID: utils.RandomInt(0, 100),
+ Owner: utils.RandomOwner(),
+ Balance: utils.RandomInt(100, 500),
+
+ Currency: utils.VND,
+ }
+
+ toAccount := db.Account{
+ ID: utils.RandomInt(0, 100),
+ Owner: utils.RandomOwner(),
+ Balance: utils.RandomMoney(),
+ Currency: utils.VND,
+ }
+
+ thirdAccount := db.Account{
+ ID: utils.RandomInt(0, 100),
+ Owner: utils.RandomOwner(),
+ Balance: utils.RandomMoney(),
+ Currency: utils.EUR,
+ }
+
+ currency := utils.VND
+ amount := int64(10)
+
+ transferResult := db.TransferTxResult{
+ Transfer: db.Transfer{
+ ID: utils.RandomInt(0, 100),
+ FromAccountID: fromAccount.ID,
+ ToAccountID: toAccount.ID,
+ Amount: amount,
+ },
+ FromAccount: fromAccount,
+ ToAccount: toAccount,
+ FromEntry: db.Entry{
+ ID: utils.RandomInt(0, 100),
+ AccountID: fromAccount.ID,
+ Amount: -amount,
+ },
+ ToEntry: db.Entry{
+ ID: utils.RandomInt(0, 100),
+ AccountID: toAccount.ID,
+ Amount: amount,
+ },
+ }
+
+ tests := []struct {
+ name string
+ body gin.H
+ buildStubs func(store *mockdb.MockStore)
+ checkResponse func(t *testing.T, recorder *httptest.ResponseRecorder)
+ }{
+ {
+ name: "201 created",
+ body: gin.H{
+ "from_account_id": fromAccount.ID,
+ "to_account_id": toAccount.ID,
+ "amount": amount,
+ "currency": currency,
+ },
+ buildStubs: func(store *mockdb.MockStore) {
+ store.EXPECT().
+ GetAccount(gomock.Any(), gomock.Eq(fromAccount.ID)).
+ Times(1).
+ Return(fromAccount, nil)
+
+ store.EXPECT().
+ GetAccount(gomock.Any(), gomock.Eq(toAccount.ID)).
+ Times(1).
+ Return(toAccount, nil)
+
+ store.EXPECT().
+ TransferTxAccount(gomock.Any(), gomock.Eq(db.TransferTxParams{
+ FromAccountID: fromAccount.ID,
+ ToAccountID: toAccount.ID,
+ Amount: amount,
+ })).Times(1).
+ Return(transferResult, nil)
+ },
+ checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
+ require.Equal(t, http.StatusCreated, recorder.Code)
+ var haveResultTx db.TransferTxResult
+
+ data, err := io.ReadAll(recorder.Body)
+ require.Nil(t, err)
+
+ err = json.Unmarshal(data, &haveResultTx)
+
+ require.Equal(t, transferResult, haveResultTx)
+ },
+ },
+ {
+ name: "400 Bad request - binding json error",
+ body: gin.H{
+ "tu_tai_khoan_id": fromAccount.ID,
+ "den_tai_khoan_id": toAccount.ID,
+ "so_luong": amount,
+ "don_vi": currency,
+ },
+ buildStubs: func(store *mockdb.MockStore) {
+ store.EXPECT().
+ TransferTxAccount(gomock.Any(), gomock.Any()).
+ Times(0)
+ },
+ checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
+ require.Equal(t, http.StatusBadRequest, recorder.Code)
+ },
+ },
+ {
+ name: "404 Not found - Missing record from the result set",
+ body: gin.H{
+ "from_account_id": 5000,
+ "to_account_id": toAccount.ID,
+ "amount": amount,
+ "currency": currency,
+ },
+ buildStubs: func(store *mockdb.MockStore) {
+ store.EXPECT().
+ GetAccount(gomock.Any(), gomock.Any()).
+ Times(1).
+ Return(db.Account{}, sql.ErrNoRows)
+
+ store.EXPECT().
+ TransferTxAccount(gomock.Any(), gomock.Any()).
+ Times(0)
+ },
+ checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
+ require.Equal(t, http.StatusNotFound, recorder.Code)
+ },
+ },
+ {
+ name: "400 Bad request - Mismatch from the first account currency",
+ body: gin.H{
+ "from_account_id": fromAccount.ID, // currency is VND
+ "to_account_id": toAccount.ID,
+ "amount": amount,
+ "currency": utils.EUR,
+ },
+ buildStubs: func(store *mockdb.MockStore) {
+ store.EXPECT().
+ GetAccount(gomock.Any(), gomock.Eq(fromAccount.ID)).
+ Times(1).
+ Return(fromAccount, nil)
+
+ store.EXPECT().
+ TransferTxAccount(gomock.Any(), gomock.Any()).
+ Times(0)
+ },
+ checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
+ require.Equal(t, http.StatusBadRequest, recorder.Code)
+ },
+ },
+ {
+ name: "400 Bad request - Mismatch from the second account currency",
+ body: gin.H{
+ "from_account_id": fromAccount.ID, // currency is VND
+ "to_account_id": thirdAccount.ID, // currency is EUR
+ "amount": amount,
+ "currency": utils.VND,
+ },
+ buildStubs: func(store *mockdb.MockStore) {
+ store.EXPECT().
+ GetAccount(gomock.Any(), gomock.Eq(fromAccount.ID)).
+ Times(1).
+ Return(fromAccount, nil)
+
+ store.EXPECT().
+ GetAccount(gomock.Any(), gomock.Eq(thirdAccount.ID)).
+ Times(1).
+ Return(thirdAccount, nil)
+
+ store.EXPECT().
+ TransferTxAccount(gomock.Any(), gomock.Any()).
+ Times(0)
+ },
+ checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
+ require.Equal(t, http.StatusBadRequest, recorder.Code)
+ },
+ },
+ {
+ name: "500 Internal server error from GetAccount",
+ body: gin.H{
+ "from_account_id": fromAccount.ID,
+ "to_account_id": toAccount.ID,
+ "amount": amount,
+ "currency": currency,
+ },
+ buildStubs: func(store *mockdb.MockStore) {
+ store.EXPECT().
+ GetAccount(gomock.Any(), gomock.Eq(fromAccount.ID)).
+ Times(1).
+ Return(db.Account{}, sql.ErrConnDone)
+
+ store.EXPECT().
+ GetAccount(gomock.Any(), gomock.Eq(toAccount.ID)).
+ Times(0)
+
+ store.EXPECT().
+ TransferTxAccount(gomock.Any(), gomock.Eq(db.TransferTxParams{
+ FromAccountID: fromAccount.ID,
+ ToAccountID: toAccount.ID,
+ Amount: amount,
+ })).Times(0)
+ },
+ checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
+ require.Equal(t, http.StatusInternalServerError, recorder.Code)
+ },
+ },
+ {
+ name: "500 Internal server error from TransferTxAccount",
+ body: gin.H{
+ "from_account_id": fromAccount.ID,
+ "to_account_id": toAccount.ID,
+ "amount": amount,
+ "currency": currency,
+ },
+ buildStubs: func(store *mockdb.MockStore) {
+ store.EXPECT().
+ GetAccount(gomock.Any(), gomock.Eq(fromAccount.ID)).
+ Times(1).
+ Return(fromAccount, nil)
+
+ store.EXPECT().
+ GetAccount(gomock.Any(), gomock.Eq(toAccount.ID)).
+ Times(1).
+ Return(toAccount, nil)
+
+ store.EXPECT().
+ TransferTxAccount(gomock.Any(), gomock.Eq(db.TransferTxParams{
+ FromAccountID: fromAccount.ID,
+ ToAccountID: toAccount.ID,
+ Amount: amount,
+ })).Times(1).
+ Return(db.TransferTxResult{}, sql.ErrConnDone)
+ },
+ checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
+ require.Equal(t, http.StatusInternalServerError, recorder.Code)
+ },
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
+ store := mockdb.NewMockStore(ctrl)
+
+ // build stubs
+ tc.buildStubs(store)
+
+ // start test server and send request
+ server := NewServer(store)
+ recorder := httptest.NewRecorder()
+
+ url := "/api/v1/transfer"
+
+ data, err := json.Marshal(tc.body)
+ require.Nil(t, err)
+
+ // send request
+ request, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(data))
+ require.Nil(t, err)
+
+ server.router.ServeHTTP(recorder, request)
+
+ // check response
+ tc.checkResponse(t, recorder)
+ })
+ }
+}
diff --git a/api/validator.go b/api/validator.go
new file mode 100644
index 0000000..737c676
--- /dev/null
+++ b/api/validator.go
@@ -0,0 +1,14 @@
+package api
+
+import (
+ "github.com/cukhoaimon/SimpleBank/utils"
+ "github.com/go-playground/validator/v10"
+)
+
+var validCurrency validator.Func = func(fieldLevel validator.FieldLevel) bool {
+ if currency, ok := fieldLevel.Field().Interface().(string); ok {
+ // check if currency is supported
+ return utils.IsSupportedCurrency(currency)
+ }
+ return false
+}
diff --git a/db/sqlc/store.go b/db/sqlc/store.go
index 6201510..683ef88 100644
--- a/db/sqlc/store.go
+++ b/db/sqlc/store.go
@@ -24,7 +24,7 @@ func NewStore(db *sql.DB) *SQLStore {
}
func (store *SQLStore) execTx(ctx context.Context, fn func(*Queries) error) error {
tx, err := store.db.BeginTx(ctx, &sql.TxOptions{
- Isolation: sql.LevelReadCommitted, // defaul
+ Isolation: sql.LevelReadCommitted, // default
})
if err != nil {
@@ -47,7 +47,7 @@ func (store *SQLStore) execTx(ctx context.Context, fn func(*Queries) error) erro
type TransferTxParams struct {
FromAccountID int64 `json:"from_account_id"`
- ToAccountId int64 `json:"to_account_id"`
+ ToAccountID int64 `json:"to_account_id"`
Amount int64 `json:"amount"`
}
@@ -59,7 +59,7 @@ type TransferTxResult struct {
ToEntry Entry `json:"to_entry"`
}
-// Transaction perform an transfer between two account
+// TransferTxAccount perform a transfer between two account
// It create transfer record, add account entries, update account balance
func (store *SQLStore) TransferTxAccount(ctx context.Context, arg TransferTxParams) (TransferTxResult, error) {
result := TransferTxResult{}
@@ -69,7 +69,7 @@ func (store *SQLStore) TransferTxAccount(ctx context.Context, arg TransferTxPara
result.Transfer, err = q.CreateTransfer(ctx, CreateTransferParams{
FromAccountID: arg.FromAccountID,
- ToAccountID: arg.ToAccountId,
+ ToAccountID: arg.ToAccountID,
Amount: arg.Amount,
})
if err != nil {
@@ -85,18 +85,18 @@ func (store *SQLStore) TransferTxAccount(ctx context.Context, arg TransferTxPara
}
result.ToEntry, err = q.CreateEntry(ctx, CreateEntryParams{
- AccountID: arg.ToAccountId,
+ AccountID: arg.ToAccountID,
Amount: arg.Amount,
})
if err != nil {
return err
}
- if arg.FromAccountID < arg.ToAccountId {
- result.FromAccount, result.ToAccount, err = transferMoney(ctx, q, arg.FromAccountID, -arg.Amount, arg.ToAccountId, arg.Amount)
+ if arg.FromAccountID < arg.ToAccountID {
+ result.FromAccount, result.ToAccount, err = transferMoney(ctx, q, arg.FromAccountID, -arg.Amount, arg.ToAccountID, arg.Amount)
} else {
- result.ToAccount, result.FromAccount, err = transferMoney(ctx, q, arg.ToAccountId, arg.Amount, arg.FromAccountID, -arg.Amount)
+ result.ToAccount, result.FromAccount, err = transferMoney(ctx, q, arg.ToAccountID, arg.Amount, arg.FromAccountID, -arg.Amount)
}
if err != nil {
diff --git a/utils/currency.go b/utils/currency.go
new file mode 100644
index 0000000..97d5bfc
--- /dev/null
+++ b/utils/currency.go
@@ -0,0 +1,18 @@
+package utils
+
+const (
+ USD = "USD"
+ CAD = "CAD"
+ EUR = "EUR"
+ VND = "VND"
+)
+
+// IsSupportedCurrency return true if the currency is support
+// otherwise is false
+func IsSupportedCurrency(currency string) bool {
+ switch currency {
+ case USD, CAD, EUR, VND:
+ return true
+ }
+ return false
+}