commit 8319200b4a80cf331e3979483ef62a47be07f29d Author: Junv Zhao Date: Tue Dec 17 07:25:03 2019 +1100 code before onsite diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..62c8935 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea/ \ No newline at end of file diff --git a/commands/command.go b/commands/command.go new file mode 100644 index 0000000..88bceeb --- /dev/null +++ b/commands/command.go @@ -0,0 +1,8 @@ +package commands + +import "github.com/wahyd4/cash/domains" + +// ICommand defines the banking app command +type ICommand interface { + Run(bank *domains.Bank) error +} diff --git a/commands/deposit.go b/commands/deposit.go new file mode 100644 index 0000000..12a85eb --- /dev/null +++ b/commands/deposit.go @@ -0,0 +1,18 @@ +package commands + +import "github.com/wahyd4/cash/domains" + +// DepositCommand the command for depositing some money +type DepositCommand struct { + Account string + Money float64 +} + +// Run deposits some money +func (command *DepositCommand) Run(bank *domains.Bank) error { + account, err := bank.AccountFromName(command.Account) + if err != nil { + return err + } + return bank.DepositFor(account, command.Money) +} diff --git a/commands/deposit_test.go b/commands/deposit_test.go new file mode 100644 index 0000000..5958a56 --- /dev/null +++ b/commands/deposit_test.go @@ -0,0 +1,64 @@ +package commands + +import ( + "github.com/wahyd4/cash/domains" + "testing" +) + +func TestDepositCommand_Run(t *testing.T) { + bank := domains.InitBank() + bank.OpenAccount("Tom") + + type fields struct { + Account string + Money float64 + } + type args struct { + bank *domains.Bank + } + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + { + "can deposit some money", + fields{ + Account: "Tom", + Money: 20.10, + }, + args{bank: bank}, + false, + }, + { + "fail to deposit some money due to money is negative", + fields{ + Account: "Tom", + Money: -20.10, + }, + args{bank: bank}, + true, + }, + { + "fail to deposit some money due to the user is not exist", + fields{ + Account: "NotExist", + Money: -20.10, + }, + args{bank: bank}, + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + command := &DepositCommand{ + Account: tt.fields.Account, + Money: tt.fields.Money, + } + if err := command.Run(tt.args.bank); (err != nil) != tt.wantErr { + t.Errorf("Run() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/commands/openaccount.go b/commands/openaccount.go new file mode 100644 index 0000000..5c746b6 --- /dev/null +++ b/commands/openaccount.go @@ -0,0 +1,16 @@ +package commands + +import ( + "github.com/wahyd4/cash/domains" +) + +// OpenAccountCommand the command for creating a bank account +type OpenAccountCommand struct { + Username string +} + +// Run creates a bank account +func (command *OpenAccountCommand) Run(bank *domains.Bank) error { + bank.OpenAccount(command.Username) + return nil +} diff --git a/commands/openaccount_test.go b/commands/openaccount_test.go new file mode 100644 index 0000000..167e195 --- /dev/null +++ b/commands/openaccount_test.go @@ -0,0 +1,19 @@ +package commands + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/wahyd4/cash/domains" +) + +func TestOpenAccountCommand_Run_ShouldCreateABankAccount(t *testing.T) { + command := OpenAccountCommand{ + Username: "Mindy", + } + + bank := domains.InitBank() + assert.Equal(t, len(bank.TotalAccounts()), 0) + command.Run(bank) + assert.Equal(t, len(bank.TotalAccounts()), 1) +} diff --git a/commands/totalbalance.go b/commands/totalbalance.go new file mode 100644 index 0000000..3ae758f --- /dev/null +++ b/commands/totalbalance.go @@ -0,0 +1,17 @@ +package commands + +import ( + "github.com/sirupsen/logrus" + "github.com/wahyd4/cash/domains" +) + +// TotalBalanceCommand for getting the balance +type TotalBalanceCommand struct { +} + +// Run gets the balance for a specific account when account is not nil, otherwise for the bank +func (command *TotalBalanceCommand) Run(bank *domains.Bank) error { + logrus.Infof("the total balance is %.2f", bank.TotalBalance()) + + return nil +} diff --git a/commands/totalbalance_test.go b/commands/totalbalance_test.go new file mode 100644 index 0000000..4e2dacd --- /dev/null +++ b/commands/totalbalance_test.go @@ -0,0 +1,41 @@ +package commands + +import ( + "github.com/wahyd4/cash/domains" + "testing" +) + +func TestTotalBalanceCommand_Run(t *testing.T) { + bank := domains.InitBank() + bank.OpenAccount("Tom") + bank.OpenAccount("Lily") + + account, _ := bank.AccountFromName("Tom") + account2, _ := bank.AccountFromName("Lily") + + bank.DepositFor(account, 500) + bank.DepositFor(account2, 29.10) + + type args struct { + bank *domains.Bank + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + "can print balance for the whole bank", + args{bank: bank}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + command := &TotalBalanceCommand{} + if err := command.Run(tt.args.bank); (err != nil) != tt.wantErr { + t.Errorf("Run() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/commands/userbalance.go b/commands/userbalance.go new file mode 100644 index 0000000..e90f3dc --- /dev/null +++ b/commands/userbalance.go @@ -0,0 +1,23 @@ +package commands + +import ( + "github.com/sirupsen/logrus" + "github.com/wahyd4/cash/domains" +) + +// UserBalanceCommand for getting the balance +type UserBalanceCommand struct { + Account string +} + +// Run gets balance for a specific account when account is not nil, otherwise for the bank +func (command *UserBalanceCommand) Run(bank *domains.Bank) error { + account, err := bank.AccountFromName(command.Account) + if err != nil { + return err + } + + logrus.Infof("the balance of %s is %.2f", account.Name, bank.BalanceFor(account)) + return nil + +} diff --git a/commands/userbalance_test.go b/commands/userbalance_test.go new file mode 100644 index 0000000..94be33e --- /dev/null +++ b/commands/userbalance_test.go @@ -0,0 +1,60 @@ +package commands + +import ( + "github.com/wahyd4/cash/domains" + "testing" +) + +func TestBalanceCommand_Run(t *testing.T) { + bank := domains.InitBank() + bank.OpenAccount("Tom") + bank.OpenAccount("Lily") + + account, _ := bank.AccountFromName("Tom") + account2, _ := bank.AccountFromName("Lily") + + bank.DepositFor(account, 500) + bank.DepositFor(account2, 29.10) + + type fields struct { + Account string + } + type args struct { + bank *domains.Bank + } + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + { + "can print balance for user", + fields{Account: "Tom"}, + args{bank: bank}, + false, + }, + { + "can print balance for user", + fields{Account: "Lily"}, + args{bank: bank}, + false, + }, + { + "cannot print the user du to the user is not exist", + fields{Account: "NotExist"}, + args{bank: bank}, + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + command := &UserBalanceCommand{ + Account: tt.fields.Account, + } + if err := command.Run(tt.args.bank); (err != nil) != tt.wantErr { + t.Errorf("Run() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/commands/withdraw.go b/commands/withdraw.go new file mode 100644 index 0000000..91e848d --- /dev/null +++ b/commands/withdraw.go @@ -0,0 +1,18 @@ +package commands + +import "github.com/wahyd4/cash/domains" + +// WithdrawCommand the command for withdrawing some money +type WithdrawCommand struct { + Account string + Money float64 +} + +// Run withdraws money from the bank, returns error if fails +func (command *WithdrawCommand) Run(bank *domains.Bank) error { + account, err := bank.AccountFromName(command.Account) + if err != nil { + return err + } + return bank.WithdrawFor(account, command.Money) +} diff --git a/commands/withdraw_test.go b/commands/withdraw_test.go new file mode 100644 index 0000000..3cbf7e0 --- /dev/null +++ b/commands/withdraw_test.go @@ -0,0 +1,66 @@ +package commands + +import ( + "github.com/wahyd4/cash/domains" + "testing" +) + +func TestWithdrawCommand_Run(t *testing.T) { + bank := domains.InitBank() + bank.OpenAccount("Tom") + account, _ := bank.AccountFromName("Tom") + bank.DepositFor(account, 30) + + type fields struct { + Account string + Money float64 + } + type args struct { + bank *domains.Bank + } + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + { + "can withdraw some money", + fields{ + Account: "Tom", + Money: 20.10, + }, + args{bank: bank}, + false, + }, + { + "cannot withdraw money due to does not have enough money", + fields{ + Account: "Tom", + Money: 50.0, //we only have 9.9 left + }, + args{bank: bank}, + true, + }, + { + "cannot withdraw due to the user can't found", + fields{ + Account: "NotExist", + Money: 0.00, + }, + args{bank: bank}, + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + command := &WithdrawCommand{ + Account: tt.fields.Account, + Money: tt.fields.Money, + } + if err := command.Run(tt.args.bank); (err != nil) != tt.wantErr { + t.Errorf("Run() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..a371cc3 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,9 @@ +version: "3" + +services: + cash: + image: golang:1.13 + working_dir: /cash + volumes: + - .:/cash + command: ["go", "run", "main.go", "input.txt"] \ No newline at end of file diff --git a/domains/account.go b/domains/account.go new file mode 100644 index 0000000..b5cd3ed --- /dev/null +++ b/domains/account.go @@ -0,0 +1,32 @@ +package domains + +import "fmt" + +const ( + dollarToCents = 100 +) + +// BankAccount represents for a bank customer +type BankAccount struct { + Name string + balance int // in cent +} + +// Balance get balance and return with ($)dollar instead of the unit which is cent +func (account *BankAccount) Balance() float64 { + return float64(account.balance) / float64(dollarToCents) +} + +// Deposit deposits money for the account +func (account *BankAccount) Deposit(moneyInCents int) { + account.balance += moneyInCents +} + +// Withdraw withdraw money for the account +func (account *BankAccount) Withdraw(moneyInCents int) error { + if account.balance < moneyInCents { + return fmt.Errorf("no enough money can be withdrawn") + } + account.balance -= moneyInCents + return nil +} diff --git a/domains/account_test.go b/domains/account_test.go new file mode 100644 index 0000000..9c09a9d --- /dev/null +++ b/domains/account_test.go @@ -0,0 +1,165 @@ +package domains + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestBankAccount_Balance(t *testing.T) { + type fields struct { + Name string + balance int + } + tests := []struct { + name string + fields fields + want float64 + }{ + { + "balance is 0", + fields{ + Name: "Tom", + balance: 0, + }, + 0.00, + }, + { + "balance is not 0 and no extra cents", + fields{ + Name: "Lily", + balance: 178800, + }, + 1788.00, + }, + { + "balance is not 0 and with some extra cents", + fields{ + Name: "Dave", + balance: 8912, + }, + 89.12, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + account := &BankAccount{ + Name: tt.fields.Name, + balance: tt.fields.balance, + } + if got := account.Balance(); got != tt.want { + t.Errorf("Balance() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestBankAccount_Deposit(t *testing.T) { + type fields struct { + Name string + balance int + } + type args struct { + moneyInCents int + } + tests := []struct { + name string + fields fields + args args + }{ + { + name: "deposits some money when balance is 0", + fields: fields{ + Name: "Tom", + balance: 0, + }, + args: args{moneyInCents: 3500}, + }, + { + name: "deposits some money when balance is not 0", + fields: fields{ + Name: "Tom", + balance: 2800, + }, + args: args{moneyInCents: 3500}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + account := &BankAccount{ + Name: tt.fields.Name, + balance: tt.fields.balance, + } + account.Deposit(tt.args.moneyInCents) + assert.Equal(t, account.balance, tt.fields.balance+tt.args.moneyInCents) + }) + } +} + +func TestBankAccount_Withdraw1(t *testing.T) { + type fields struct { + Name string + balance int + } + type args struct { + moneyInCents int + } + tests := []struct { + name string + fields fields + args args + expectedBalance int + wantErr bool + }{ + { + "can withdraw some money when user has enough balance", + fields{ + Name: "Tom", + balance: 10000, + }, + args{moneyInCents: 3500}, + 6500, + false, + }, + { + "can withdraw some money when user withdraw all the money he has", + fields{ + Name: "Tom", + balance: 8765, + }, + args{moneyInCents: 8765}, + 0, + false, + }, + { + "can't withdraw the money more than the user has", + fields{ + Name: "Tom", + balance: 8765, + }, + args{moneyInCents: 9888}, + 8765, + true, + }, + { + "can't withdraw money more than the user has", + fields{ + Name: "Tom", + balance: 8765, + }, + args{moneyInCents: 10000}, + 8765, + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + account := &BankAccount{ + Name: tt.fields.Name, + balance: tt.fields.balance, + } + if err := account.Withdraw(tt.args.moneyInCents); (err != nil) != tt.wantErr { + t.Errorf("Withdraw() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/domains/bank.go b/domains/bank.go new file mode 100644 index 0000000..581a713 --- /dev/null +++ b/domains/bank.go @@ -0,0 +1,95 @@ +package domains + +import ( + "fmt" + + "github.com/sirupsen/logrus" +) + +var ( + ErrActionMoneyAmountInvalid = fmt.Errorf("money can't be negative or 0") +) + +const roundFloat = 0.5 + +// Bank represents for the bank +// can be improved by using sync.Mutex +type Bank struct { + accounts []*BankAccount + totalBalance int +} + +// InitBank inits the bank +func InitBank() *Bank { + return &Bank{} +} + +// OpenAccount opens a account for customer +func (bank *Bank) OpenAccount(customerName string) { + bank.accounts = append(bank.accounts, &BankAccount{Name: customerName}) + + logrus.Infof("opened bank account for: %s", customerName) +} + +// DepositFor deposit for a user +func (bank *Bank) DepositFor(account *BankAccount, money float64) error { + if money <= 0 { + return ErrActionMoneyAmountInvalid + } + moneyInCents := toCents(money) + + account.Deposit(moneyInCents) + bank.totalBalance += moneyInCents + + logrus.Infof("deposited $%.2f for %s", money, account.Name) + + return nil +} + +// WithdrawFor withdraw money for a user +func (bank *Bank) WithdrawFor(account *BankAccount, money float64) error { + if money <= 0 { + return ErrActionMoneyAmountInvalid + } + + moneyInCents := toCents(money) + if err := account.Withdraw(moneyInCents); err != nil { + return fmt.Errorf("failed to withdraw money %.2f due to %v", money, err) + } + bank.totalBalance -= moneyInCents + + logrus.Infof("withdrawn $%.2f for %s", money, account.Name) + + return nil +} + +// BalanceFor gets balance for a user +func (bank *Bank) BalanceFor(account *BankAccount) float64 { + return float64(account.balance) / float64(dollarToCents) +} + +// TotalBalance gets total balance for the bank +func (bank *Bank) TotalBalance() float64 { + return float64(bank.totalBalance) / float64(dollarToCents) +} + +// TotalAccounts returns all the bank accounts for the bank +func (bank *Bank) TotalAccounts() []*BankAccount { + return bank.accounts +} + +// AccountFromName finds account by name, returns error if not found. +func (bank *Bank) AccountFromName(accountName string) (*BankAccount, error) { + for _, account := range bank.accounts { + if account.Name == accountName { + return account, nil + } + } + return nil, fmt.Errorf("no account with name %s", accountName) +} + +// toCents convert dollar to cents and make sure we get accurate result +// e.g. 1.23 to $1.23, 1.345 to $1.35 +func toCents(moneyInDollar float64) int { + return int(moneyInDollar*dollarToCents + roundFloat) +} diff --git a/domains/bank_test.go b/domains/bank_test.go new file mode 100644 index 0000000..7652533 --- /dev/null +++ b/domains/bank_test.go @@ -0,0 +1,408 @@ +package domains + +import ( + "github.com/stretchr/testify/assert" + "reflect" + "testing" +) + +func TestInitBank(t *testing.T) { + tests := []struct { + name string + want *Bank + }{ + { + "init bank with empty account list and 0 balance", + &Bank{ + accounts: []*BankAccount{}, + totalBalance: 0, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := InitBank(); (len(got.accounts) != len(tt.want.accounts)) || (got.totalBalance != tt.want.totalBalance) { + t.Errorf("InitBank() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestBank_OpenAccount(t *testing.T) { + bank := InitBank() + t.Run("should open a account for customer", func(t *testing.T) { + bank.OpenAccount("Nick") + assert.Equal(t, len(bank.accounts), 1) + assert.Equal(t, bank.totalBalance, 0) + }) +} + +func TestBank_DepositFor(t *testing.T) { + type fields struct { + accounts []*BankAccount + totalBalance int + } + type args struct { + account *BankAccount + money float64 + } + tom := &BankAccount{balance: 0, Name: "Tom"} + tests := []struct { + name string + fields fields + args args + expectedBalance int + wantErr bool + }{ + { + "can deposit some money", + fields{ + accounts: []*BankAccount{tom}, + totalBalance: 0, + }, + args{ + account: tom, + money: 12.34, + }, + 1234, + false, + }, + { + "can deposit some money", + fields{ + accounts: []*BankAccount{tom}, + totalBalance: 6700, + }, + args{ + account: tom, + money: 210, + }, + 27700, + false, + }, + { + "cannot deposit when money is negative", + fields{ + accounts: []*BankAccount{tom}, + totalBalance: 6700, + }, + args{ + account: tom, + money: -100, + }, + 6700, + true, + }, + { + "cannot deposit when money is 0", + fields{ + accounts: []*BankAccount{tom}, + totalBalance: 6700, + }, + args{ + account: tom, + money: 0, + }, + 6700, + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bank := &Bank{ + accounts: tt.fields.accounts, + totalBalance: tt.fields.totalBalance, + } + err := bank.DepositFor(tt.args.account, tt.args.money) + if (err != nil) != tt.wantErr { + t.Errorf("DepositFor() error = %v, wantErr %v", err, tt.wantErr) + } + if err == nil { + assert.Equal(t, tt.expectedBalance, bank.totalBalance) + } + }) + } +} + +func TestBank_WithdrawFor(t *testing.T) { + type fields struct { + accounts []*BankAccount + totalBalance int + } + type args struct { + account *BankAccount + money float64 + } + + tests := []struct { + name string + fields fields + args args + expectedBalance int + wantErr bool + }{ + { + "can withdraw some money", + fields{ + accounts: []*BankAccount{{Name: "Tom", balance: 8000}}, + totalBalance: 10000, + }, + args{ + account: &BankAccount{Name: "Tom", balance: 8000}, + money: 12.34, + }, + 8766, + false, + }, + { + "can withdraw all money the user has", + fields{ + accounts: []*BankAccount{{Name: "Tom", balance: 8000}}, + totalBalance: 5678, + }, + args{ + account: &BankAccount{Name: "Tom", balance: 8000}, + money: 56.78, + }, + 0, + false, + }, + { + "cannot withdraw 0", + fields{ + accounts: []*BankAccount{{Name: "Tom", balance: 8000}}, + totalBalance: 10000, + }, + args{ + account: &BankAccount{Name: "Tom", balance: 8000}, + money: 0, + }, + 10000, + true, + }, + { + "cannot withdraw negative", + fields{ + accounts: []*BankAccount{{Name: "Tom", balance: 8000}}, + totalBalance: 10000, + }, + args{ + account: &BankAccount{Name: "Tom", balance: 8000}, + money: -23, + }, + 10000, + true, + }, + { + "cannot withdraw the money more than the user has", + fields{ + accounts: []*BankAccount{{Name: "Tom", balance: 800}}, + totalBalance: 800, + }, + args{ + account: &BankAccount{Name: "Tom", balance: 800}, + money: 20.88, + }, + 800, + true, + }, + + { + "cannot withdraw the money more than the user has but bank has enough money", + fields{ + accounts: []*BankAccount{{Name: "Tom", balance: 800}}, + totalBalance: 1800, + }, + args{ + account: &BankAccount{Name: "Tom", balance: 800}, + money: 20.88, + }, + 800, + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bank := &Bank{ + accounts: tt.fields.accounts, + totalBalance: tt.fields.totalBalance, + } + err := bank.WithdrawFor(tt.args.account, tt.args.money) + if (err != nil) != tt.wantErr { + t.Errorf("WithdrawFor() error = %v, wantErr %v", err, tt.wantErr) + } + + if err == nil { + assert.Equal(t, tt.expectedBalance, bank.totalBalance) + } + }) + } +} + +func TestBank_BalanceFor(t *testing.T) { + type fields struct { + accounts []*BankAccount + totalBalance int + } + type args struct { + account *BankAccount + } + tests := []struct { + name string + fields fields + args args + want float64 + }{ + { + "can get balance a user when user has some money", + fields{ + accounts: []*BankAccount{{Name: "Tom", balance: 812}}, + totalBalance: 1800, + }, + args{ + account: &BankAccount{Name: "Tom", balance: 812}, + }, + 8.12, + }, + { + "can get balance a user when user does not have any money", + fields{ + accounts: []*BankAccount{{Name: "Tom", balance: 0}}, + totalBalance: 1800, + }, + args{ + account: &BankAccount{Name: "Tom", balance: 0}, + }, + 0.00, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bank := &Bank{ + accounts: tt.fields.accounts, + totalBalance: tt.fields.totalBalance, + } + if got := bank.BalanceFor(tt.args.account); got != tt.want { + t.Errorf("BalanceFor() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestBank_TotalBalance(t *testing.T) { + type fields struct { + accounts []*BankAccount + totalBalance int + } + tests := []struct { + name string + fields fields + want float64 + }{ + { + "can get balance for the bank", + fields{ + accounts: []*BankAccount{{Name: "Tom", balance: 812}}, + totalBalance: 1876, + }, + 18.76, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bank := &Bank{ + accounts: tt.fields.accounts, + totalBalance: tt.fields.totalBalance, + } + if got := bank.TotalBalance(); got != tt.want { + t.Errorf("TotalBalance() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestBank_TotalAccounts(t *testing.T) { + type fields struct { + accounts []*BankAccount + totalBalance int + } + tests := []struct { + name string + fields fields + want []*BankAccount + }{ + { + "can get all the bank accounts", + fields{ + []*BankAccount{{Name: "Tom", balance: 812}}, + 1000, + }, + []*BankAccount{{Name: "Tom", balance: 812}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bank := &Bank{ + accounts: tt.fields.accounts, + totalBalance: tt.fields.totalBalance, + } + if got := bank.TotalAccounts(); !reflect.DeepEqual(got, tt.want) { + t.Errorf("TotalAccounts() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestBank_AccountFromName(t *testing.T) { + type fields struct { + accounts []*BankAccount + totalBalance int + } + type args struct { + accountName string + } + tests := []struct { + name string + fields fields + args args + want *BankAccount + wantErr bool + }{ + { + "can find account by name", + fields{ + accounts: []*BankAccount{{Name: "Tom", balance: 812}, {Name: "Lily", balance: 0}}, + totalBalance: 812, + }, + args{accountName: "Lily"}, + &BankAccount{Name: "Lily", balance: 0}, + false, + }, + { + "can not find account due to the account not exist", + fields{ + accounts: []*BankAccount{{Name: "Tom", balance: 812}, {Name: "Lily", balance: 0}}, + totalBalance: 812, + }, + args{accountName: "SomeFakeName"}, + nil, + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bank := &Bank{ + accounts: tt.fields.accounts, + totalBalance: tt.fields.totalBalance, + } + got, err := bank.AccountFromName(tt.args.accountName) + if (err != nil) != tt.wantErr { + t.Errorf("AccountFromName() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("AccountFromName() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ad645d7 --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module github.com/wahyd4/cash + +go 1.13 + +require ( + github.com/sirupsen/logrus v1.4.2 + github.com/stretchr/testify v1.4.0 + +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fcce092 --- /dev/null +++ b/go.sum @@ -0,0 +1,21 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/input.txt b/input.txt new file mode 100644 index 0000000..8000b3f --- /dev/null +++ b/input.txt @@ -0,0 +1,12 @@ +OPEN_ACCOUNT Tom +DEPOSIT Tom 520.30 +BALANCE Tom +TOTAL_BALANCE +WITHDRAW Tom 340.00 +BALANCE Tom +OPEN_ACCOUNT Lily +DEPOSIT Lily 1980.55 +BALANCE Lily +WITHDRAW Lily 550.33 +BALANCE Lily +TOTAL_BALANCE diff --git a/main.go b/main.go new file mode 100644 index 0000000..f083328 --- /dev/null +++ b/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "os" + + "github.com/sirupsen/logrus" + "github.com/wahyd4/cash/domains" + "github.com/wahyd4/cash/workflow" +) + +func main() { + if len(os.Args) != 2 { + logrus.Fatal("Please run the cash application by: go run main.go ") + } + + filePathParam := os.Args[1] + + bank := domains.InitBank() + app := workflow.APP{Bank: bank} + + commands, err := app.Load(filePathParam) + + if err != nil { + logrus.Panic("fail to load input" + err.Error()) + } + + if err := app.Run(commands); err != nil { + logrus.Error(err) + } +} diff --git a/no_enough_money.txt b/no_enough_money.txt new file mode 100644 index 0000000..0b4a165 --- /dev/null +++ b/no_enough_money.txt @@ -0,0 +1,6 @@ +OPEN_ACCOUNT Tom +DEPOSIT Tom 123 +BALANCE Tom +WITHDRAW Tom 250 +BALANCE Tom +TOTAL_BALANCE diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..7a907c8 --- /dev/null +++ b/readme.md @@ -0,0 +1,107 @@ +# Cash + +- [Cash](#cash) + - [About the solution](#about-the-solution) + - [Stack](#stack) + - [Design](#design) + - [Assumptions & Trade offs](#assumptions-amp-trade-offs) + - [What would I do more if I have more time](#what-would-i-do-more-if-i-have-more-time) + - [How to run](#how-to-run) + - [docker-compose](#docker-compose) + - [The classic Way](#the-classic-way) + - [Run the application against custom inputs](#run-the-application-against-custom-inputs) + +## About the solution + +### Stack + +- Go +- [Logrus](https://github.com/sirupsen/logrus) for better logging +- [testify/assert](https://github.com/stretchr/testify) some test assertion helpers + +### Design + +In my design, there are `4` core domains, + +- Application, represents the cash application which can load inputs and execute actions. +- BankAccount, contains the information of a single bank account and basic deposit/withdraw actions to the user. +- Bank, the bank entity which holds all the bank accounts and the total balance. All the `deposit/withdraw` actions go to bank and then acknowledged by bank account. +- Command, represent a single action. e.g. `deposit`, `withdraw`, `balance`. way. All commands have been implemented the `ICommand` interface with `Run(bank *Bank)` method, so they can be processed by the application in a standard + +The steps below shows how the application works. + +1. When we start this application it will initialise a `bank` and the `cash application` instance. +2. All the actions recorded in a input file will be parsed and transferred to `command` +3. The Cash application will take the commands as input, process them and print out some basic information. + +### Assumptions & Trade offs + +In order to start implementing this project, I have made a few assumptions below: + +- Only one seesion for any user at any single moment, which means there will no dirty reads/writes for a user +- All the actions will be loaded through a input text file and will be processed following the sequence order, so it means the application currently can only run one command at a time. +- I use Australian Dollar($) as the currency type, use `int` to represent `cent` as the currency unit. + +### What would I do more if I have more time + +There are two things I want to do most in terms of building a proper cash app if I have more time. + +- Make it to be a CLI and support multiple users login and run actions at the same time. +- Add some basic locks for depositing and withdrawing operations at both user and bank level. + +## How to run + +### docker-compose + +Please make sure you have `docker` and `docker-compose` installed + +Then just simply run the commands below + +```bash +# One command to run application +docker-compose up + +# If you want to run another failure case with withdrawing more money than the user has +docker-compose run cash go run main.go no_enough_money.txt + +# Run tests +docker-compose run cash go test ./... -cover +``` + +Or you can try the classic way. + +### The classic Way + +Similar, you should have `go` installed before we start + +```bash +# Install all dependencies +go mod download + +# Run application +go run main.go input.txt + +# Run Tests +go test ./... -cover +``` + + +### Run the application against custom inputs + +You can also build your custom input and define as many commands as you want. + +There is a example below with single bank account. + +``` +OPEN_ACCOUNT Tom +DEPOSIT Tom 520.30 +WITHDRAW Tom 340.00 +BALANCE Tom +TOTAL_BALANCE +``` + +Then test it + +```bash +docker-compose run cash go run main.go yourfile +``` diff --git a/workflow/app.go b/workflow/app.go new file mode 100644 index 0000000..593c599 --- /dev/null +++ b/workflow/app.go @@ -0,0 +1,80 @@ +package workflow + +import ( + "bufio" + "fmt" + "github.com/wahyd4/cash/commands" + "github.com/wahyd4/cash/domains" + "os" + "strconv" + "strings" +) + +const commandSplitter = " " + +// APP holds the bank app instance +type APP struct { + Bank *domains.Bank +} + +// Run all the commands passed in +func (app *APP) Run(commands []commands.ICommand) error { + for _, command := range commands { + if err := command.Run(app.Bank); err != nil { + return err + } + } + return nil +} + +// Load loads inputs and generate commands +func (app *APP) Load(filePath string) ([]commands.ICommand, error) { + appCommands := make([]commands.ICommand, 0) + + file, err := os.Open(filePath) + if err != nil { + return appCommands, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + commandString := scanner.Text() + appCommands = append(appCommands, buildCommand(commandString)) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("fail to read file %s with error: %v", filePath, err) + } + + return appCommands, nil +} + +func buildCommand(commandString string) commands.ICommand { + + commandArgs := strings.Split(commandString, commandSplitter) + switch commandArgs[0] { + case "OPEN_ACCOUNT": + return &commands.OpenAccountCommand{Username: commandArgs[1]} + case "DEPOSIT": + money, _ := strconv.ParseFloat(commandArgs[2], 10) + return &commands.DepositCommand{ + Account: commandArgs[1], + Money: money, + } + case "WITHDRAW": + money, _ := strconv.ParseFloat(commandArgs[2], 10) + return &commands.WithdrawCommand{ + Account: commandArgs[1], + Money: money, + } + case "BALANCE": + return &commands.UserBalanceCommand{ + Account: commandArgs[1], + } + case "TOTAL_BALANCE": + return &commands.TotalBalanceCommand{} + default: + return &commands.TotalBalanceCommand{} + } +} diff --git a/workflow/app_test.go b/workflow/app_test.go new file mode 100644 index 0000000..bc69aaf --- /dev/null +++ b/workflow/app_test.go @@ -0,0 +1,185 @@ +package workflow + +import ( + "errors" + "github.com/stretchr/testify/assert" + "github.com/wahyd4/cash/commands" + "github.com/wahyd4/cash/domains" + "reflect" + "testing" +) + +// simpleCommand a dump implementation for test +type simpleCommand struct { + shouldErr bool +} + +func (command *simpleCommand) Run(bank *domains.Bank) error { + if command.shouldErr { + return errors.New("some error") + } + return nil +} + +func TestAPP_Run(t *testing.T) { + type fields struct { + Bank *domains.Bank + } + type args struct { + commands []commands.ICommand + } + + bank := domains.InitBank() + bank.OpenAccount("Dan") + + tests := []struct { + name string + fields fields + args args + wantErr bool + totalBalanceInFloat float64 + }{ + { + "can finish all commands successfully", + fields{Bank: bank}, + args{[]commands.ICommand{&simpleCommand{false}}}, + false, + 0.00, + }, + { + "fail to run some commands", + fields{Bank: bank}, + args{[]commands.ICommand{&simpleCommand{true}}}, + true, + 0.00, + }, + { + "run real commands", + fields{Bank: bank}, + args{[]commands.ICommand{ + &commands.OpenAccountCommand{Username: "Tom"}, + &commands.DepositCommand{ + Account: "Tom", + Money: 520.30, + }, + &commands.UserBalanceCommand{Account: "Tom"}, + &commands.WithdrawCommand{ + Account: "Tom", + Money: 340.00, + }, + &commands.UserBalanceCommand{Account: "Tom"}, + &commands.TotalBalanceCommand{}, + },}, + false, + 180.30, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := &APP{ + Bank: tt.fields.Bank, + } + err := app.Run(tt.args.commands) + + if (err != nil) != tt.wantErr { + t.Errorf("Run() error = %v, wantErr %v", err, tt.wantErr) + } + + if err == nil { + assert.Equal(t, tt.totalBalanceInFloat, bank.TotalBalance()) + } + }) + } +} + +func TestAPP_Load(t *testing.T) { + bank := domains.InitBank() + + type fields struct { + Bank *domains.Bank + } + type args struct { + filePath string + } + tests := []struct { + name string + fields fields + args args + want []commands.ICommand + wantErr bool + }{ + { + "build commands from file input", + fields{Bank: bank}, + args{filePath: "test_input.txt"}, + []commands.ICommand{ + &commands.OpenAccountCommand{Username: "Tom"}, + &commands.DepositCommand{ + Account: "Tom", + Money: 520.30, + }, + &commands.UserBalanceCommand{Account: "Tom"}, + &commands.WithdrawCommand{ + Account: "Tom", + Money: 340.00, + }, + &commands.UserBalanceCommand{Account: "Tom"}, + &commands.TotalBalanceCommand{}, + }, + false, + }, + { + "build commands from file input for multiple users", + fields{Bank: bank}, + args{filePath: "test_input_multiple_users.txt"}, + []commands.ICommand{ + &commands.OpenAccountCommand{Username: "Tom"}, + &commands.DepositCommand{ + Account: "Tom", + Money: 520.30, + }, + &commands.UserBalanceCommand{Account: "Tom"}, + &commands.WithdrawCommand{ + Account: "Tom", + Money: 340.00, + }, + &commands.UserBalanceCommand{Account: "Tom"}, + &commands.OpenAccountCommand{Username: "Lily"}, + &commands.DepositCommand{ + Account: "Lily", + Money: 1980.55, + }, + &commands.UserBalanceCommand{Account: "Lily"}, + &commands.WithdrawCommand{ + Account: "Lily", + Money: 550.33, + }, + &commands.UserBalanceCommand{Account: "Lily"}, + &commands.TotalBalanceCommand{}, + }, + false, + }, + { + "fail to load inputs", + fields{Bank: bank}, + args{filePath: "doesnt_exist.txt"}, + []commands.ICommand{}, + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := &APP{ + Bank: tt.fields.Bank, + } + got, err := app.Load(tt.args.filePath) + if (err != nil) != tt.wantErr { + t.Errorf("Load() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Load() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/workflow/test_input.txt b/workflow/test_input.txt new file mode 100644 index 0000000..2c13bd9 --- /dev/null +++ b/workflow/test_input.txt @@ -0,0 +1,6 @@ +OPEN_ACCOUNT Tom +DEPOSIT Tom 520.30 +BALANCE Tom +WITHDRAW Tom 340.00 +BALANCE Tom +TOTAL_BALANCE diff --git a/workflow/test_input_multiple_users.txt b/workflow/test_input_multiple_users.txt new file mode 100644 index 0000000..55dff32 --- /dev/null +++ b/workflow/test_input_multiple_users.txt @@ -0,0 +1,11 @@ +OPEN_ACCOUNT Tom +DEPOSIT Tom 520.30 +BALANCE Tom +WITHDRAW Tom 340.00 +BALANCE Tom +OPEN_ACCOUNT Lily +DEPOSIT Lily 1980.55 +BALANCE Lily +WITHDRAW Lily 550.33 +BALANCE Lily +TOTAL_BALANCE