From 8f9bdfa9a10c943c6a98a901c17226b6d60bdb4e Mon Sep 17 00:00:00 2001 From: meskio <meskio@sindominio.net> Date: Sun, 4 Oct 2020 20:31:00 +0200 Subject: [PATCH] Add order api --- api/api.go | 11 +- api/order.go | 256 +++++++++++++++++++++++++++++++++++++++++++++ api/order_test.go | 233 +++++++++++++++++++++++++++++++++++++++++ api/purchase.go | 2 +- api/topup.go | 4 +- api/transaction.go | 8 +- 6 files changed, 507 insertions(+), 7 deletions(-) create mode 100644 api/order.go create mode 100644 api/order_test.go diff --git a/api/api.go b/api/api.go index 20f25c9..6d87d7e 100644 --- a/api/api.go +++ b/api/api.go @@ -19,7 +19,8 @@ func initDB(dbPath string) (*gorm.DB, error) { return nil, err } - db.AutoMigrate(&Member{}, &Product{}, &Purchase{}, &Topup{}, &Transaction{}) + db.AutoMigrate(&Member{}, &Product{}, &Purchase{}, &Topup{}, &Transaction{}, + &OrderPurchase{}, &OrderProduct{}, &Order{}) return db, err } @@ -30,6 +31,7 @@ func Init(dbPath string, signKey string, r *mux.Router) error { } a := api{db, []byte(signKey)} + go a.refundOrders() token, err := a.newToken(0, "admin", false) log.Print(token) @@ -57,5 +59,12 @@ func Init(dbPath string, signKey string, r *mux.Router) error { r.HandleFunc("/purchase", a.authNum(a.AddPurchase)).Methods("POST") r.HandleFunc("/topup", a.authAdminNum(a.AddTopup)).Methods("POST") + + r.HandleFunc("/order", a.auth(a.ListOrders)).Methods("GET") + r.HandleFunc("/order", a.authNum(a.AddOrder)).Methods("POST") + r.HandleFunc("/order/{id:[0-9]+}", a.auth(a.GetOrder)).Methods("GET") + r.HandleFunc("/order/active", a.auth(a.ListActiveOrders)).Methods("GET") + r.HandleFunc("/order/purchase", a.authNum(a.AddOrderPurchase)).Methods("POST") + // TODO: r.HandleFunc("/order/purchase", a.authNum(a.UpdateOrderPurchase)).Methods("PUT") return nil } diff --git a/api/order.go b/api/order.go new file mode 100644 index 0000000..a9c25b6 --- /dev/null +++ b/api/order.go @@ -0,0 +1,256 @@ +package api + +import ( + "encoding/json" + "log" + "net/http" + "time" + + "github.com/gorilla/mux" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type Order struct { + gorm.Model + Name string `json:"name"` + Description string `json:"description"` + MemberNum int `json:"-" gorm:"column:member"` + Member *Member `json:"member,omitempty" gorm:"foreignKey:MemberNum;references:Num"` + Deadline time.Time `json:"deadline"` + Active bool `json:"active" gorm:"index"` + + Products []OrderProduct `json:"products"` + TransactionID *uint `json:"transaction" gorm:"column:transaction"` +} + +type OrderProduct struct { + gorm.Model + Name string `json:"name"` + Price int `json:"price"` + OrderID uint `json:"-"` + + Purchases []OrderPurchase `json:"purchases"` +} + +type OrderPurchase struct { + gorm.Model + OrderProductID uint `json:"product_id"` + OrderProduct Order `json:"product"` + TransactionID uint `json:"-"` + Ammount int `json:"ammount"` +} + +func (a *api) refundOrders() { + const refundSleeptime = 10 * time.Minute + for { + time.Sleep(refundSleeptime) + a.deactivateOrders() + } +} + +func (a *api) deactivateOrders() { + var orders []Order + now := time.Now() + t := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local) + err := a.db.Where("active = ? AND deadline < ?", true, t). + Preload("Products.Purchases"). + Find(&orders).Error + if err != nil { + log.Println("Error refunding orders:", err) + return + } + + for _, order := range orders { + log.Println("Refund order", order.Name) + + total := 0 + for _, product := range order.Products { + for _, purchase := range product.Purchases { + total += product.Price * purchase.Ammount + } + } + + transaction := Transaction{ + MemberNum: order.MemberNum, + Date: time.Now(), + Type: "refund", + Total: total, + } + err = a.db.Transaction(func(tx *gorm.DB) error { + _, err := createTransaction(tx, &transaction) + if err != nil { + return err + } + return tx.Model(&Order{}). + Where("id = ?", order.ID). + Updates(map[string]interface{}{ + "active": false, + "transaction": transaction.ID}). + Error + }) + if err != nil { + log.Printf("Can't create refund: %v\n%v", err, order) + continue + } + } +} + +func (a *api) ListOrders(w http.ResponseWriter, req *http.Request) { + a.listOrders(false, w, req) +} + +func (a *api) ListActiveOrders(w http.ResponseWriter, req *http.Request) { + a.listOrders(true, w, req) +} + +func (a *api) listOrders(active bool, w http.ResponseWriter, req *http.Request) { + var orders []Order + query := a.db.Preload("Products.Purchases"). + Preload(clause.Associations) + if active { + query = query.Where("active = ?", true) + } + err := query.Order("deadline desc"). + Find(&orders).Error + if err != nil { + log.Printf("Can't list orders: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + err = json.NewEncoder(w).Encode(orders) + if err != nil { + log.Printf("Can't encode orders: %v", err) + w.WriteHeader(http.StatusInternalServerError) + } +} + +func (a *api) GetOrder(w http.ResponseWriter, req *http.Request) { + vars := mux.Vars(req) + var order Order + err := a.db.Preload("Products.Purchases"). + Preload(clause.Associations). + First(&order, vars["id"]).Error + if err != nil { + if err.Error() == "record not found" { + w.WriteHeader(http.StatusNotFound) + return + } + log.Printf("Can't get order %s: %v", vars["id"], err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + err = json.NewEncoder(w).Encode(order) + if err != nil { + log.Printf("Can't encode order: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } +} + +func (a *api) AddOrder(num int, w http.ResponseWriter, req *http.Request) { + var order Order + err := json.NewDecoder(req.Body).Decode(&order) + if err != nil { + log.Printf("Can't parse order: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + order.MemberNum = num + order.Active = true + err = a.db.Create(&order).Error + if err != nil { + log.Printf("Can't create order: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + err = json.NewEncoder(w).Encode(order) + if err != nil { + log.Printf("Can't encode order: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } +} + +func (a *api) AddOrderPurchase(num int, w http.ResponseWriter, req *http.Request) { + var purchase []OrderPurchase + err := json.NewDecoder(req.Body).Decode(&purchase) + if err != nil { + log.Printf("Can't parse order: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + if len(purchase) == 0 { + log.Printf("Empty order purchase") + w.WriteHeader(http.StatusBadRequest) + return + } + + var order Order + err = a.db.Where("id = (?)", + a.db.Table("order_products"). + Where("id = ?", purchase[0].OrderProductID). + Select("order_id")). + Preload("Products"). + First(&order).Error + if err != nil { + log.Printf("Can't get order from product %d: %v", purchase[0].OrderProductID, err) + w.WriteHeader(http.StatusNotAcceptable) + return + } + if !order.Active { + log.Printf("Order is not active %d: %v", order.ID, purchase) + w.WriteHeader(http.StatusBadRequest) + return + } + + total := 0 + for _, p := range purchase { + found := false + for _, product := range order.Products { + if product.ID == p.OrderProductID { + total += product.Price * p.Ammount + found = true + break + } + } + + if !found { + log.Printf("Order purchase product %d not in order: %v", p.OrderProductID, purchase) + w.WriteHeader(http.StatusNotAcceptable) + return + } + } + + transaction := Transaction{ + MemberNum: num, + Total: -total, + Type: "order", + Date: time.Now(), + Order: purchase, + } + httpStatus, err := createTransaction(a.db, &transaction) + if err != nil { + log.Println(err) + w.WriteHeader(httpStatus) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + err = json.NewEncoder(w).Encode(transaction) + if err != nil { + log.Printf("Can't encode order transaction: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } +} diff --git a/api/order_test.go b/api/order_test.go new file mode 100644 index 0000000..29887a5 --- /dev/null +++ b/api/order_test.go @@ -0,0 +1,233 @@ +package api + +import ( + "net/http" + "path" + "testing" + "time" +) + +var testOrder = Order{ + Name: "huevos", + Description: "huevos frescos", + Deadline: time.Now().Add(24 * time.Hour), + Products: []OrderProduct{ + { + Name: "huevos", + Price: 234, + }, + }, +} + +func TestOrderAddList(t *testing.T) { + tapi := newTestAPI(t) + defer tapi.close() + tapi.addTestMember() + tapi.addTestOrder() + + var orders []Order + resp := tapi.do("GET", "/order", nil, &orders) + if resp.StatusCode != http.StatusOK { + t.Fatal("Can't get orders:", resp.Status) + } + + if len(orders) != 1 { + t.Fatal("Wrong number of orders", len(orders), orders) + } + if orders[0].Name != testOrder.Name { + t.Error("Wrong name:", orders[0].Name) + } + if len(orders[0].Products) != 1 { + t.Fatal("Wrong number of products", len(orders[0].Products), orders[0].Products) + } + if orders[0].Products[0].Price != testOrder.Products[0].Price { + t.Error("Wrong product price:", orders[0].Products[0].Price) + } +} + +func TestOrderActive(t *testing.T) { + tapi := newTestAPI(t) + defer tapi.close() + tapi.addTestMember() + tapi.addTestOrder() + + var orders []Order + resp := tapi.do("GET", "/order/active", nil, &orders) + if resp.StatusCode != http.StatusOK { + t.Fatal("Can't get orders:", resp.Status) + } + + if len(orders) != 1 { + t.Fatal("Wrong number of orders", len(orders), orders) + } + if orders[0].Name != testOrder.Name { + t.Error("Wrong name:", orders[0].Name) + } +} + +func TestOrderPurchase(t *testing.T) { + tapi := newTestAPI(t) + defer tapi.close() + tapi.addTestMember() + tapi.addTestOrder() + + var orders []Order + resp := tapi.do("GET", "/order/active", nil, &orders) + if resp.StatusCode != http.StatusOK { + t.Fatal("Can't get orders:", resp.Status) + } + + purchase := []OrderPurchase{ + { + OrderProductID: orders[0].Products[0].ID, + Ammount: 3, + }, + } + resp = tapi.do("POST", "/order/purchase", purchase, nil) + if resp.StatusCode != http.StatusCreated { + t.Fatal("Can't create order:", resp.Status) + } + + var transactions []Transaction + resp = tapi.do("GET", "/transaction/mine", nil, &transactions) + if resp.StatusCode != http.StatusOK { + t.Fatal("Can't get transactions:", resp.Status) + } + if len(transactions) != 1 { + t.Fatal("Wrong number of transactions", len(orders), orders) + } + total := 3 * testOrder.Products[0].Price + if transactions[0].Total != -total { + t.Fatal("Wrong total", transactions[0].Total) + } + + var member Member + resp = tapi.do("GET", "/member/me", nil, &member) + if resp.StatusCode != http.StatusOK { + t.Error("Can't find the member:", resp.Status) + } + if member.Balance != testMember.Balance-total { + t.Error("Wrong product balance:", member.Balance) + } +} + +func TestOrderNoDeactivation(t *testing.T) { + tapi := newTestAPI(t) + defer tapi.close() + tapi.addTestMember() + + order := testOrder + now := time.Now() + order.Deadline = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local) + resp := tapi.do("POST", "/order", order, nil) + if resp.StatusCode != http.StatusCreated { + t.Fatal("Can't create order:", resp.Status) + } + + var orders []Order + resp = tapi.do("GET", "/order/active", nil, &orders) + if resp.StatusCode != http.StatusOK { + t.Fatal("Can't get transactions:", resp.Status) + } + if len(orders) != 1 { + t.Fatal("Didn't find my new order") + } + + dbPath := path.Join(tapi.testPath, "test.db") + db, err := initDB(dbPath) + if err != nil { + t.Fatal("Can't initialize the db:", err) + } + a := api{db: db} + a.deactivateOrders() + + resp = tapi.do("GET", "/order/active", nil, &orders) + if resp.StatusCode != http.StatusOK { + t.Fatal("Can't get transactions:", resp.Status) + } + if len(orders) != 1 { + t.Fatal("Didn't find my new order after deactivation") + } +} + +func TestOrderDeactivation(t *testing.T) { + tapi := newTestAPI(t) + defer tapi.close() + tapi.addTestMember() + + order := testOrder + order.Deadline = time.Now().Add(-24 * time.Hour) + resp := tapi.do("POST", "/order", order, nil) + if resp.StatusCode != http.StatusCreated { + t.Fatal("Can't create order:", resp.Status) + } + + var orders []Order + resp = tapi.do("GET", "/order/active", nil, &orders) + if resp.StatusCode != http.StatusOK { + t.Fatal("Can't get transactions:", resp.Status) + } + if len(orders) != 1 { + t.Fatal("Didn't find my new order") + } + + purchase := []OrderPurchase{ + { + OrderProductID: orders[0].Products[0].ID, + Ammount: 3, + }, + } + resp = tapi.doAdmin("POST", "/order/purchase", purchase, nil) + if resp.StatusCode != http.StatusCreated { + t.Fatal("Can't create order:", resp.Status) + } + + dbPath := path.Join(tapi.testPath, "test.db") + db, err := initDB(dbPath) + if err != nil { + t.Fatal("Can't initialize the db:", err) + } + a := api{db: db} + a.deactivateOrders() + + resp = tapi.do("GET", "/order/active", nil, &orders) + if resp.StatusCode != http.StatusOK { + t.Fatal("Can't get transactions:", resp.Status) + } + if len(orders) != 0 { + t.Fatal("I found some orders") + } + + total := 3 * testOrder.Products[0].Price + + var transactions []Transaction + resp = tapi.do("GET", "/transaction/mine", nil, &transactions) + if resp.StatusCode != http.StatusOK { + t.Fatal("Can't get transactions:", resp.Status) + } + if len(transactions) != 1 { + t.Fatal("Wrong number of transactions", len(orders), orders) + } + if transactions[0].Type != "refund" { + t.Fatal("Should be a refund", transactions[0].Type) + } + if transactions[0].Total != total { + t.Fatal("Wrong total:", transactions[0].Total) + } + + var member Member + resp = tapi.do("GET", "/member/me", nil, &member) + if resp.StatusCode != http.StatusOK { + t.Fatal("Can't member:", resp.Status) + } + if member.Balance != testMember.Balance+total { + t.Fatal("Wrong member balance:", member.Balance, testMember.Balance, total) + } +} + +func (tapi *testAPI) addTestOrder() { + resp := tapi.do("POST", "/order", testOrder, nil) + if resp.StatusCode != http.StatusCreated { + tapi.t.Fatal("Can't create order:", resp.Status) + } +} diff --git a/api/purchase.go b/api/purchase.go index 0eec3ed..aa4010c 100644 --- a/api/purchase.go +++ b/api/purchase.go @@ -12,7 +12,7 @@ import ( type Purchase struct { gorm.Model `json:"-"` - TransactionID int `json:"-" gorm:"column:transaction"` + TransactionID uint `json:"-" gorm:"column:transaction"` ProductCode int `json:"code" gorm:"column:product"` Product Product `json:"product" gorm:"foreignKey:ProductCode;references:Code"` Price int `json:"price"` diff --git a/api/topup.go b/api/topup.go index 4ee42a6..e4d9d44 100644 --- a/api/topup.go +++ b/api/topup.go @@ -11,7 +11,7 @@ import ( type Topup struct { gorm.Model `json:"-"` - TransactionID int `json:"-" gorm:"column:transaction"` + TransactionID uint `json:"-" gorm:"column:transaction"` MemberNum int `json:"member" gorm:"column:member"` Member Member `json:"-" gorm:"foreignKey:MemberNum;references:Num"` Comment string `json:"comment"` @@ -33,7 +33,7 @@ func (a *api) AddTopup(adminNum int, w http.ResponseWriter, req *http.Request) { transaction := Transaction{ MemberNum: topup.Member, Date: time.Now(), - Topup: Topup{ + Topup: &Topup{ MemberNum: adminNum, Comment: topup.Comment, }, diff --git a/api/transaction.go b/api/transaction.go index 0ec5c63..2c58a85 100644 --- a/api/transaction.go +++ b/api/transaction.go @@ -16,13 +16,15 @@ import ( type Transaction struct { gorm.Model MemberNum int `json:"-" gorm:"column:member"` - Member Member `json:"member" gorm:"foreignKey:MemberNum;references:Num"` + Member *Member `json:"member,omitempty" gorm:"foreignKey:MemberNum;references:Num"` Date time.Time `json:"date"` Total int `json:"total"` Type string `json:"type"` - Purchase []Purchase `json:"purchase"` - Topup Topup `json:"topup"` + Purchase []Purchase `json:"purchase"` + Topup *Topup `json:"topup"` + Order []OrderPurchase `json:"order"` + Refund *Order `json:"refund"` } func (a *api) ListTransactions(w http.ResponseWriter, req *http.Request) { -- GitLab