From 8bde0ecfc78c5862315b4fe599cc110073c5a461 Mon Sep 17 00:00:00 2001 From: rawdaGastan Date: Mon, 23 Dec 2024 18:44:56 +0200 Subject: [PATCH] add delete user endpoint --- client/src/components/Toast.vue | 2 + server/app/admin_handler.go | 18 +- server/app/app.go | 4 +- server/app/invoice_handler.go | 52 +++-- server/app/k8s_handler.go | 8 +- server/app/payments_handler.go | 55 +++++- server/app/user_handler.go | 265 +++++++++++++++++++++++--- server/app/user_handler_test.go | 1 + server/app/vm_handler.go | 4 +- server/app/voucher_handler.go | 9 +- server/app/voucher_handler_test.go | 6 +- server/deployer/deployer.go | 43 ++++- server/docs/docs.go | 170 ++++++++++++++--- server/docs/swagger.yaml | 129 ++++++++++--- server/go.mod | 10 +- server/go.sum | 12 +- server/internal/config_parser_test.go | 96 +++++++++- server/models/card.go | 11 +- server/models/database.go | 2 +- server/models/invoice.go | 17 +- server/models/k8s.go | 17 ++ server/models/user.go | 29 ++- server/models/vm.go | 6 + 23 files changed, 800 insertions(+), 166 deletions(-) diff --git a/client/src/components/Toast.vue b/client/src/components/Toast.vue index 5c7c8c2d..732e75b3 100644 --- a/client/src/components/Toast.vue +++ b/client/src/components/Toast.vue @@ -8,12 +8,14 @@ import { createToast, clearToasts } from "mosha-vue-toastify"; export default { setup() { const toast = (title, color = "#217dbb") => { + if (title.length > 0) { createToast(title.charAt(0).toUpperCase() + title.slice(1), { position: "bottom-right", hideProgressBar: true, toastBackgroundColor: color, timeout: 8000, }); + } }; const clear = () => { clearToasts(); diff --git a/server/app/admin_handler.go b/server/app/admin_handler.go index a0dc533a..f0799139 100644 --- a/server/app/admin_handler.go +++ b/server/app/admin_handler.go @@ -18,31 +18,31 @@ import ( // AdminAnnouncement struct for data needed when admin sends new announcement type AdminAnnouncement struct { - Subject string `json:"subject" binding:"required"` - Body string `json:"announcement" binding:"required"` + Subject string `json:"subject" validate:"nonzero" binding:"required"` + Body string `json:"announcement" validate:"nonzero" binding:"required"` } // EmailUser struct for data needed when admin sends new email to a user type EmailUser struct { - Subject string `json:"subject" binding:"required"` - Body string `json:"body" binding:"required"` + Subject string `json:"subject" validate:"nonzero" binding:"required"` + Body string `json:"body" validate:"nonzero" binding:"required"` Email string `json:"email" binding:"required" validate:"mail"` } // UpdateMaintenanceInput struct for data needed when user update maintenance type UpdateMaintenanceInput struct { - ON bool `json:"on" binding:"required"` + ON bool `json:"on" validate:"nonzero" binding:"required"` } // SetAdminInput struct for setting users as admins type SetAdminInput struct { - Email string `json:"email" binding:"required"` - Admin bool `json:"admin" binding:"required"` + Email string `json:"email" binding:"required" validate:"mail"` + Admin bool `json:"admin" validate:"nonzero" binding:"required"` } // UpdateNextLaunchInput struct for data needed when updating next launch state type UpdateNextLaunchInput struct { - Launched bool `json:"launched" binding:"required"` + Launched bool `json:"launched" validate:"nonzero" binding:"required"` } // SetPricesInput struct for setting prices as admins @@ -556,7 +556,7 @@ func (a *App) CreateNewAnnouncementHandler(req *http.Request) (interface{}, Resp // @Failure 401 {object} Response // @Failure 404 {object} Response // @Failure 500 {object} Response -// @Router /announcement [post] +// @Router /email [post] func (a *App) SendEmailHandler(req *http.Request) (interface{}, Response) { var emailUser EmailUser err := json.NewDecoder(req.Body).Decode(&emailUser) diff --git a/server/app/app.go b/server/app/app.go index 644255c4..da38e776 100644 --- a/server/app/app.go +++ b/server/app/app.go @@ -137,12 +137,14 @@ func (a *App) registerHandlers() { unAuthUserRouter.HandleFunc("/signup/verify_email", WrapFunc(a.VerifySignUpCodeHandler)).Methods("POST", "OPTIONS") unAuthUserRouter.HandleFunc("/signin", WrapFunc(a.SignInHandler)).Methods("POST", "OPTIONS") unAuthUserRouter.HandleFunc("/refresh_token", WrapFunc(a.RefreshJWTHandler)).Methods("POST", "OPTIONS") + // TODO: rename it unAuthUserRouter.HandleFunc("/forgot_password", WrapFunc(a.ForgotPasswordHandler)).Methods("POST", "OPTIONS") unAuthUserRouter.HandleFunc("/forget_password/verify_email", WrapFunc(a.VerifyForgetPasswordCodeHandler)).Methods("POST", "OPTIONS") userRouter.HandleFunc("/change_password", WrapFunc(a.ChangePasswordHandler)).Methods("PUT", "OPTIONS") userRouter.HandleFunc("", WrapFunc(a.UpdateUserHandler)).Methods("PUT", "OPTIONS") userRouter.HandleFunc("", WrapFunc(a.GetUserHandler)).Methods("GET", "OPTIONS") + userRouter.HandleFunc("", WrapFunc(a.DeleteUserHandler)).Methods("DELETE", "OPTIONS") userRouter.HandleFunc("/apply_voucher", WrapFunc(a.ApplyForVoucherHandler)).Methods("POST", "OPTIONS") userRouter.HandleFunc("/activate_voucher", WrapFunc(a.ActivateVoucherHandler)).Methods("PUT", "OPTIONS") userRouter.HandleFunc("/charge_balance", WrapFunc(a.ChargeBalance)).Methods("PUT", "OPTIONS") @@ -196,7 +198,7 @@ func (a *App) registerHandlers() { voucherRouter.HandleFunc("", WrapFunc(a.ListVouchersHandler)).Methods("GET", "OPTIONS") voucherRouter.HandleFunc("/{id}", WrapFunc(a.UpdateVoucherHandler)).Methods("PUT", "OPTIONS") voucherRouter.HandleFunc("", WrapFunc(a.ApproveAllVouchersHandler)).Methods("PUT", "OPTIONS") - voucherRouter.HandleFunc("/reset", WrapFunc(a.ResetUsersVoucherBalanceHandler)).Methods("PUT", "OPTIONS") + voucherRouter.HandleFunc("/all/reset", WrapFunc(a.ResetUsersVoucherBalanceHandler)).Methods("PUT", "OPTIONS") // middlewares r.Use(middlewares.LoggingMW) diff --git a/server/app/invoice_handler.go b/server/app/invoice_handler.go index 35a90fb8..3833d752 100644 --- a/server/app/invoice_handler.go +++ b/server/app/invoice_handler.go @@ -38,7 +38,7 @@ var methods = []method{ } type PayInvoiceInput struct { - Method method `json:"method" binding:"required"` + Method method `json:"method" validate:"nonzero" binding:"required"` CardPaymentID string `json:"card_payment_id"` } @@ -231,6 +231,7 @@ func (a *App) PayInvoiceHandler(req *http.Request) (interface{}, Response) { user.VoucherBalance = 0 } + // TODO: check all update user with 0 value fields if err = a.db.UpdateUserByID(user); err != nil { log.Error().Err(err).Send() return nil, InternalServerError(errors.New(internalServerErrorMsg)) @@ -305,6 +306,7 @@ func (a *App) PayInvoiceHandler(req *http.Request) (interface{}, Response) { return nil, BadRequest(fmt.Errorf("invalid payment method, only methods allowed %v", methods)) } + paymentDetails.InvoiceID = id err = a.db.PayInvoice(id, paymentDetails) if err == gorm.ErrRecordNotFound { return nil, NotFound(errors.New("invoice is not found")) @@ -316,7 +318,6 @@ func (a *App) PayInvoiceHandler(req *http.Request) (interface{}, Response) { return ResponseMsg{ Message: "Invoice is paid successfully", - Data: nil, }, Ok() } @@ -348,6 +349,8 @@ func (a *App) monthlyInvoices() { log.Error().Err(err).Send() } + // TODO: what if we want to pay using balances and cards together + // 2. Use balance/voucher balance to pay invoices user.Balance, user.VoucherBalance, err = a.db.PayUserInvoices(user.ID.String(), user.Balance, user.VoucherBalance) if err != nil { @@ -379,15 +382,14 @@ func (a *App) monthlyInvoices() { } func (a *App) createInvoice(userID string, now time.Time) error { - usagePercentageInMonth := deployer.UsagePercentageInMonth(now) - firstDayOfMonth := time.Date(now.Year(), now.Month(), 0, 0, 0, 0, 0, time.Local) + monthStart := time.Date(now.Year(), now.Month(), 0, 0, 0, 0, 0, time.Local) - vms, err := a.db.GetAllVms(userID) + vms, err := a.db.GetAllSuccessfulVms(userID) if err != nil && err != gorm.ErrRecordNotFound { return err } - k8s, err := a.db.GetAllK8s(userID) + k8s, err := a.db.GetAllSuccessfulK8s(userID) if err != nil && err != gorm.ErrRecordNotFound { return err } @@ -396,6 +398,16 @@ func (a *App) createInvoice(userID string, now time.Time) error { var total float64 for _, vm := range vms { + usageStart := monthStart + if vm.CreatedAt.After(monthStart) { + usageStart = vm.CreatedAt + } + + usagePercentageInMonth, err := deployer.UsagePercentageInMonth(usageStart, now) + if err != nil { + return err + } + cost := float64(vm.PricePerMonth) * usagePercentageInMonth items = append(items, models.DeploymentItem{ @@ -403,7 +415,7 @@ func (a *App) createInvoice(userID string, now time.Time) error { DeploymentType: "vm", DeploymentID: vm.ID, HasPublicIP: vm.Public, - PeriodInHours: time.Since(firstDayOfMonth).Hours(), + PeriodInHours: time.Since(usageStart).Hours(), Cost: cost, }) @@ -411,6 +423,16 @@ func (a *App) createInvoice(userID string, now time.Time) error { } for _, cluster := range k8s { + usageStart := monthStart + if cluster.CreatedAt.After(monthStart) { + usageStart = cluster.CreatedAt + } + + usagePercentageInMonth, err := deployer.UsagePercentageInMonth(usageStart, now) + if err != nil { + return err + } + cost := float64(cluster.PricePerMonth) * usagePercentageInMonth items = append(items, models.DeploymentItem{ @@ -418,19 +440,21 @@ func (a *App) createInvoice(userID string, now time.Time) error { DeploymentType: "k8s", DeploymentID: cluster.ID, HasPublicIP: cluster.Master.Public, - PeriodInHours: time.Since(firstDayOfMonth).Hours(), + PeriodInHours: time.Since(usageStart).Hours(), Cost: cost, }) total += cost } - if err = a.db.CreateInvoice(&models.Invoice{ - UserID: userID, - Total: total, - Deployments: items, - }); err != nil { - return err + if len(items) > 0 { + if err = a.db.CreateInvoice(&models.Invoice{ + UserID: userID, + Total: total, + Deployments: items, + }); err != nil { + return err + } } return nil diff --git a/server/app/k8s_handler.go b/server/app/k8s_handler.go index 2b59e5e4..70f59942 100644 --- a/server/app/k8s_handler.go +++ b/server/app/k8s_handler.go @@ -21,16 +21,16 @@ import ( // K8sDeployInput deploy k8s cluster input type K8sDeployInput struct { MasterName string `json:"master_name" validate:"min=3,max=20"` - MasterResources string `json:"resources"` - MasterPublic bool `json:"public"` - MasterRegion string `json:"region"` + MasterResources string `json:"resources" validate:"nonzero"` + MasterPublic bool `json:"public" validate:"nonzero"` + MasterRegion string `json:"region" validate:"nonzero"` Workers []WorkerInput `json:"workers"` } // WorkerInput deploy k8s worker input type WorkerInput struct { Name string `json:"name" validate:"min=3,max=20"` - Resources string `json:"resources"` + Resources string `json:"resources" validate:"nonzero"` } // K8sDeployHandler deploy k8s handler diff --git a/server/app/payments_handler.go b/server/app/payments_handler.go index 7c892012..cc77d865 100644 --- a/server/app/payments_handler.go +++ b/server/app/payments_handler.go @@ -17,17 +17,17 @@ import ( ) type AddCardInput struct { - PaymentMethodID string `json:"payment_method_id" binding:"required"` - CardType string `json:"card_type" binding:"required"` + PaymentMethodID string `json:"payment_method_id" binding:"required" validate:"nonzero"` + CardType string `json:"card_type" binding:"required" validate:"nonzero"` } type SetDefaultCardInput struct { - PaymentMethodID string `json:"payment_method_id" binding:"required"` + PaymentMethodID string `json:"payment_method_id" binding:"required" validate:"nonzero"` } type ChargeBalance struct { - PaymentMethodID string `json:"payment_method_id" binding:"required"` - Amount float64 `json:"amount" binding:"required"` + PaymentMethodID string `json:"payment_method_id" binding:"required" validate:"nonzero"` + Amount float64 `json:"amount" binding:"required" validate:"nonzero"` } // Example endpoint: Add a new card @@ -101,7 +101,6 @@ func (a *App) AddCardHandler(req *http.Request) (interface{}, Response) { } if !unique { - log.Error().Err(err).Send() return nil, BadRequest(errors.New("card is added before")) } @@ -132,7 +131,7 @@ func (a *App) AddCardHandler(req *http.Request) (interface{}, Response) { // if no payment is added before then we update the user payment ID with it as a default if len(strings.TrimSpace(user.StripeDefaultPaymentID)) == 0 { // Update the default payment method for future payments - err = updateDefaultPaymentMethod(user.StripeCustomerID, input.PaymentMethodID) + err = updateDefaultPaymentMethod(user.StripeCustomerID, paymentMethod.ID) if err != nil { log.Error().Err(err).Send() return nil, InternalServerError(errors.New(internalServerErrorMsg)) @@ -278,6 +277,15 @@ func (a *App) ListCardHandler(req *http.Request) (interface{}, Response) { func (a *App) DeleteCardHandler(req *http.Request) (interface{}, Response) { userID := req.Context().Value(middlewares.UserIDKey("UserID")).(string) + user, err := a.db.GetUserByID(userID) + if err == gorm.ErrRecordNotFound { + return nil, NotFound(errors.New("user is not found")) + } + if err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + id, err := strconv.Atoi(mux.Vars(req)["id"]) if err != nil { log.Error().Err(err).Send() @@ -315,13 +323,13 @@ func (a *App) DeleteCardHandler(req *http.Request) (interface{}, Response) { var vms []models.VM var k8s []models.K8sCluster if len(cards) == 1 { - vms, err = a.db.GetAllVms(userID) + vms, err = a.db.GetAllSuccessfulVms(userID) if err != nil && err != gorm.ErrRecordNotFound { log.Error().Err(err).Send() return nil, InternalServerError(errors.New(internalServerErrorMsg)) } - k8s, err = a.db.GetAllK8s(userID) + k8s, err = a.db.GetAllSuccessfulK8s(userID) if err != nil && err != gorm.ErrRecordNotFound { log.Error().Err(err).Send() return nil, InternalServerError(errors.New(internalServerErrorMsg)) @@ -332,6 +340,35 @@ func (a *App) DeleteCardHandler(req *http.Request) (interface{}, Response) { return nil, BadRequest(errors.New("you have active deployment and cannot delete the card")) } + // Update the default payment method for future payments (if deleted card is the default) + if card.PaymentMethodID == user.StripeDefaultPaymentID { + var newPaymentMethod string + // no more cards + if len(cards) == 1 { + newPaymentMethod = "" + } + + for _, c := range cards { + if c.PaymentMethodID != user.StripeDefaultPaymentID { + newPaymentMethod = c.PaymentMethodID + if err = updateDefaultPaymentMethod(card.CustomerID, c.PaymentMethodID); err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + break + } + } + + err = a.db.UpdateUserPaymentMethod(userID, newPaymentMethod) + if err == gorm.ErrRecordNotFound { + return nil, NotFound(errors.New("user is not found")) + } + if err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + } + // If user has another cards or no active deployments, so can delete err = detachPaymentMethod(card.PaymentMethodID) if err != nil { diff --git a/server/app/user_handler.go b/server/app/user_handler.go index 4030488c..5395df8a 100644 --- a/server/app/user_handler.go +++ b/server/app/user_handler.go @@ -32,19 +32,19 @@ type SignUpInput struct { // VerifyCodeInput struct takes verification code from user type VerifyCodeInput struct { - Email string `json:"email" binding:"required"` - Code int `json:"code" binding:"required"` + Email string `json:"email" binding:"required" validate:"mail"` + Code int `json:"code" binding:"required" validate:"nonzero"` } // SignInInput struct for data needed when user sign in type SignInInput struct { - Email string `json:"email" binding:"required"` - Password string `json:"password" binding:"required"` + Email string `json:"email" binding:"required" validate:"mail"` + Password string `json:"password" binding:"required" validate:"password"` } // ChangePasswordInput struct for user to change password type ChangePasswordInput struct { - Email string `json:"email" binding:"required"` + Email string `json:"email" binding:"required" validate:"mail"` Password string `json:"password" binding:"required" validate:"password"` ConfirmPassword string `json:"confirm_password" binding:"required" validate:"password"` } @@ -60,7 +60,7 @@ type UpdateUserInput struct { // EmailInput struct for user when forgetting password type EmailInput struct { - Email string `json:"email" binding:"required"` + Email string `json:"email" binding:"required" validate:"mail"` } // ApplyForVoucherInput struct for user to apply for voucher @@ -71,20 +71,20 @@ type ApplyForVoucherInput struct { // AddVoucherInput struct for voucher applied by user type AddVoucherInput struct { - Voucher string `json:"voucher" binding:"required"` + Voucher string `json:"voucher" binding:"required" validate:"nonzero"` } type CodeTimeout struct { - Timeout int `json:"timeout" binding:"required"` + Timeout int `json:"timeout" binding:"required" validate:"nonzero"` } -type AccessToken struct { - Token string `json:"access_token" binding:"required"` +type AccessTokenResponse struct { + Token string `json:"access_token"` } -type RefreshToken struct { - Access string `json:"access_token" binding:"required"` - Refresh string `json:"refresh_token" binding:"required"` +type RefreshTokenResponse struct { + Access string `json:"access_token"` + Refresh string `json:"refresh_token"` } // SignUpHandler creates account for user @@ -258,7 +258,7 @@ func (a *App) VerifySignUpCodeHandler(req *http.Request) (interface{}, Response) // @Accept json // @Produce json // @Param login body SignInInput true "User login input" -// @Success 201 {object} AccessToken +// @Success 201 {object} AccessTokenResponse // @Failure 400 {object} Response // @Failure 401 {object} Response // @Failure 404 {object} Response @@ -298,7 +298,7 @@ func (a *App) SignInHandler(req *http.Request) (interface{}, Response) { return ResponseMsg{ Message: "You are signed in successfully", - Data: AccessToken{Token: token}, + Data: AccessTokenResponse{Token: token}, }, Created() } @@ -310,7 +310,7 @@ func (a *App) SignInHandler(req *http.Request) (interface{}, Response) { // @Accept json // @Produce json // @Security BearerAuth -// @Success 201 {object} RefreshToken +// @Success 201 {object} RefreshTokenResponse // @Failure 400 {object} Response // @Failure 401 {object} Response // @Failure 404 {object} Response @@ -357,7 +357,7 @@ func (a *App) RefreshJWTHandler(req *http.Request) (interface{}, Response) { return ResponseMsg{ Message: "Token is refreshed successfully", - Data: RefreshToken{Access: reqToken, Refresh: newToken}, + Data: RefreshTokenResponse{Access: reqToken, Refresh: newToken}, }, Created() } @@ -368,6 +368,7 @@ func (a *App) RefreshJWTHandler(req *http.Request) (interface{}, Response) { // @Tags User // @Accept json // @Produce json +// @Param forgetPassword body EmailInput true "User forget password input" // @Success 201 {object} CodeTimeout // @Failure 400 {object} Response // @Failure 401 {object} Response @@ -430,14 +431,15 @@ func (a *App) ForgotPasswordHandler(req *http.Request) (interface{}, Response) { // @Tags User // @Accept json // @Produce json -// @Success 201 {object} AccessToken +// @Param forgetPassword body VerifyCodeInput true "User Verify forget password input" +// @Success 201 {object} AccessTokenResponse // @Failure 400 {object} Response // @Failure 401 {object} Response // @Failure 404 {object} Response // @Failure 500 {object} Response -// @Router /user/forgot_password/verify_email [post] +// @Router /user/forget_password/verify_email [post] func (a *App) VerifyForgetPasswordCodeHandler(req *http.Request) (interface{}, Response) { - data := VerifyCodeInput{} + var data VerifyCodeInput err := json.NewDecoder(req.Body).Decode(&data) if err != nil { log.Error().Err(err).Send() @@ -474,7 +476,7 @@ func (a *App) VerifyForgetPasswordCodeHandler(req *http.Request) (interface{}, R return ResponseMsg{ Message: "Code is verified", - Data: AccessToken{Token: token}, + Data: AccessTokenResponse{Token: token}, }, Ok() } @@ -783,21 +785,26 @@ func (a *App) ActivateVoucherHandler(req *http.Request) (interface{}, Response) user.VoucherBalance += float64(voucherBalance.Balance) - user.Balance, user.VoucherBalance, err = a.db.PayUserInvoices(userID, user.Balance, user.VoucherBalance) + balance, vBalance, err := a.db.PayUserInvoices(userID, user.Balance, user.VoucherBalance) if err != nil { log.Error().Err(err).Send() return nil, InternalServerError(errors.New(internalServerErrorMsg)) } - err = a.db.UpdateUserByID(user) - if err == gorm.ErrRecordNotFound { - return nil, NotFound(errors.New("user is not found")) - } + err = a.db.UpdateUserVoucherBalance(user.ID.String(), vBalance) if err != nil { log.Error().Err(err).Send() return nil, InternalServerError(errors.New(internalServerErrorMsg)) } + if balance != user.Balance { + err = a.db.UpdateUserBalance(user.ID.String(), balance) + if err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + } + middlewares.VoucherActivated.WithLabelValues(userID, voucherBalance.Voucher, fmt.Sprint(voucherBalance.Balance)).Inc() return ResponseMsg{ @@ -821,6 +828,7 @@ func (a *App) ActivateVoucherHandler(req *http.Request) (interface{}, Response) // @Failure 500 {object} Response // @Router /user/charge_balance [put] func (a *App) ChargeBalance(req *http.Request) (interface{}, Response) { + userID := req.Context().Value(middlewares.UserIDKey("UserID")).(string) var input ChargeBalance @@ -853,24 +861,219 @@ func (a *App) ChargeBalance(req *http.Request) (interface{}, Response) { user.Balance += float64(input.Amount) - user.Balance, user.VoucherBalance, err = a.db.PayUserInvoices(userID, user.Balance, user.VoucherBalance) + balance, vBalance, err := a.db.PayUserInvoices(userID, user.Balance, user.VoucherBalance) if err != nil { log.Error().Err(err).Send() return nil, InternalServerError(errors.New(internalServerErrorMsg)) } - err = a.db.UpdateUserByID(user) - if err == gorm.ErrRecordNotFound { - return nil, NotFound(errors.New("user is not found")) - } + err = a.db.UpdateUserBalance(user.ID.String(), balance) if err != nil { log.Error().Err(err).Send() return nil, InternalServerError(errors.New(internalServerErrorMsg)) } + if vBalance != user.VoucherBalance { + err = a.db.UpdateUserVoucherBalance(user.ID.String(), vBalance) + if err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + } + return ResponseMsg{ Message: "Balance is charged successfully", // Data: map[string]string{"client_secret": intent.ClientSecret}, Data: nil, }, Ok() } + +// DeleteUserHandler deletes account for user +// Example endpoint: Deletes account for user +// @Summary Deletes account for user +// @Description Deletes account for user +// @Tags User +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} Response +// @Failure 401 {object} Response +// @Failure 404 {object} Response +// @Failure 500 {object} Response +// @Router /user [delete] +func (a *App) DeleteUserHandler(req *http.Request) (interface{}, Response) { + userID := req.Context().Value(middlewares.UserIDKey("UserID")).(string) + user, err := a.db.GetUserByID(userID) + if err == gorm.ErrRecordNotFound { + return nil, NotFound(errors.New("user is not found")) + } + if err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + + // 1. Create last invoice to pay if there were active deployments + if err := a.createInvoice(userID, time.Now()); err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + + invoices, err := a.db.ListUnpaidInvoices(userID) + if err != nil && err != gorm.ErrRecordNotFound { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + + cards, err := a.db.GetUserCards(userID) + if err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + + // 2. Try to pay invoices + for _, invoice := range invoices { + var paymentDetails models.PaymentDetails + var paid bool + + if user.VoucherBalance > invoice.Total { + paymentDetails = models.PaymentDetails{Balance: invoice.Total} + user.VoucherBalance -= invoice.Total + paid = true + } else if user.Balance+user.VoucherBalance > invoice.Total { + paymentDetails = models.PaymentDetails{VoucherBalance: user.VoucherBalance, Balance: (invoice.Total - user.VoucherBalance)} + user.Balance = (invoice.Total - user.VoucherBalance) + user.VoucherBalance = 0 + paid = true + } else { + // 1. use default payment method + if len(user.StripeDefaultPaymentID) != 0 { + if _, err := createPaymentIntent(user.StripeCustomerID, user.StripeDefaultPaymentID, a.config.Currency, invoice.Total); err != nil { + log.Error().Err(err).Send() + // 2. check other user cards + for _, card := range cards { + if user.StripeDefaultPaymentID != card.PaymentMethodID { + if _, err := createPaymentIntent(user.StripeCustomerID, card.PaymentMethodID, a.config.Currency, invoice.Total); err != nil { + log.Error().Err(err).Send() + } else { + paid = true + break + } + } + } + } else { + paid = true + } + } + + if !paid { + log.Error().Err(err).Send() + return nil, BadRequest(errors.New("failed to pay your invoices, please pay them first before deleting your account")) + } + + paymentDetails = models.PaymentDetails{ + Balance: user.Balance, VoucherBalance: user.VoucherBalance, + Card: (invoice.Total - user.Balance - user.VoucherBalance), + } + user.VoucherBalance = 0 + user.Balance = 0 + } + + // TODO: 0 fields are not updated + if err = a.db.UpdateUserByID(user); err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + + fmt.Printf("paymentDetails: %+v\n", paymentDetails) + + err = a.db.PayInvoice(invoice.ID, paymentDetails) + if err == gorm.ErrRecordNotFound { + return nil, NotFound(fmt.Errorf("invoice %d is not found", invoice.ID)) + } + if err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + } + + // 3. Delete user vms + vms, err := a.db.GetAllVms(userID) + if err != nil && err != gorm.ErrRecordNotFound { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + + for _, vm := range vms { + err = a.deployer.CancelDeployment(vm.ContractID, vm.NetworkContractID, "vm", vm.Name) + if err != nil && !strings.Contains(err.Error(), "ContractNotExists") { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + } + + err = a.db.DeleteAllVms(userID) + if err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + + // 4. Delete user k8s + clusters, err := a.db.GetAllK8s(userID) + if err != nil && err != gorm.ErrRecordNotFound { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + + for _, cluster := range clusters { + err = a.deployer.CancelDeployment(uint64(cluster.ClusterContract), uint64(cluster.NetworkContract), "k8s", cluster.Master.Name) + if err != nil && !strings.Contains(err.Error(), "ContractNotExists") { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + } + + if len(clusters) > 0 { + err = a.db.DeleteAllK8s(userID) + if err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + } + + // 5. Remove cards + cards, err = a.db.GetUserCards(userID) + if err != nil && err != gorm.ErrRecordNotFound { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + + for _, card := range cards { + err = detachPaymentMethod(card.PaymentMethodID) + if err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + } + + err = a.db.DeleteAllCards(userID) + if err != nil && err != gorm.ErrRecordNotFound { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + + // 6. TODO: should invoices be deleted? + + // 7. Remove cards + err = a.db.DeleteUser(userID) + if err == gorm.ErrRecordNotFound { + return nil, NotFound(errors.New("user is not found")) + } + if err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + + return ResponseMsg{ + Message: "User is deleted successfully", + }, Ok() +} diff --git a/server/app/user_handler_test.go b/server/app/user_handler_test.go index 027053a6..3e74685f 100644 --- a/server/app/user_handler_test.go +++ b/server/app/user_handler_test.go @@ -622,6 +622,7 @@ func TestChangePasswordHandler(t *testing.T) { t.Run("change password: user not found", func(t *testing.T) { body := []byte(`{ "password":"1234567", + "email":"notfound@gmail.com", "confirm_password":"1234567" }`) diff --git a/server/app/vm_handler.go b/server/app/vm_handler.go index 2e21af72..0cf4554b 100644 --- a/server/app/vm_handler.go +++ b/server/app/vm_handler.go @@ -23,8 +23,8 @@ import ( // DeployVMInput struct takes input of vm from user type DeployVMInput struct { Name string `json:"name" binding:"required" validate:"min=3,max=20"` - Resources string `json:"resources" binding:"required"` - Public bool `json:"public"` + Resources string `json:"resources" binding:"required" validate:"nonzero"` + Public bool `json:"public" validate:"nonzero"` Region string `json:"region"` } diff --git a/server/app/voucher_handler.go b/server/app/voucher_handler.go index dc67051f..ab65066c 100644 --- a/server/app/voucher_handler.go +++ b/server/app/voucher_handler.go @@ -18,12 +18,12 @@ import ( // GenerateVoucherInput struct for data needed when user generate vouchers type GenerateVoucherInput struct { Length int `json:"length" binding:"required" validate:"min=3,max=20"` - Balance uint64 `json:"balance" binding:"required"` + Balance uint64 `json:"balance" binding:"required" validate:"nonzero"` } // UpdateVoucherInput struct for data needed when user update voucher type UpdateVoucherInput struct { - Approved bool `json:"approved" binding:"required"` + Approved bool `json:"approved" binding:"required" validate:"nonzero"` } // GenerateVoucherHandler generates a voucher by admin @@ -254,7 +254,7 @@ func (a *App) ApproveAllVouchersHandler(req *http.Request) (interface{}, Respons // @Failure 401 {object} Response // @Failure 404 {object} Response // @Failure 500 {object} Response -// @Router /voucher/reset [put] +// @Router /voucher/all/reset [put] func (a *App) ResetUsersVoucherBalanceHandler(req *http.Request) (interface{}, Response) { users, err := a.db.ListAllUsers() if err == gorm.ErrRecordNotFound || len(users) == 0 { @@ -264,8 +264,7 @@ func (a *App) ResetUsersVoucherBalanceHandler(req *http.Request) (interface{}, R } for _, user := range users { - user.VoucherBalance = 0 - err = a.db.UpdateUserByID(user) + err = a.db.UpdateUserVoucherBalance(user.ID.String(), 0) if err != nil { log.Error().Err(err).Send() return nil, InternalServerError(errors.New(internalServerErrorMsg)) diff --git a/server/app/voucher_handler_test.go b/server/app/voucher_handler_test.go index a9763126..02ce4f4a 100644 --- a/server/app/voucher_handler_test.go +++ b/server/app/voucher_handler_test.go @@ -25,8 +25,7 @@ func TestGenerateVoucherHandler(t *testing.T) { voucherBody := []byte(`{ "length": 5, - "vms": 10, - "public_ips": 1 + "balance": 10 }`) t.Run("Generate voucher: success", func(t *testing.T) { @@ -49,8 +48,7 @@ func TestGenerateVoucherHandler(t *testing.T) { t.Run("Generate voucher: invalid data", func(t *testing.T) { body := []byte(`{ "length": 2, - "vms": 10, - "public_ips": 1 + "balance": 1 }`) req := authHandlerConfig{ diff --git a/server/deployer/deployer.go b/server/deployer/deployer.go index cd16b8e4..1410b3dd 100644 --- a/server/deployer/deployer.go +++ b/server/deployer/deployer.go @@ -274,23 +274,44 @@ func (d *Deployer) canDeploy(userID string, costPerMonth float64) error { // from the start of current month func (d *Deployer) calculateUserDebtInMonth(userID string) (float64, error) { var debt float64 - usagePercentageInMonth := UsagePercentageInMonth(time.Now()) + now := time.Now() + monthStart := time.Date(now.Year(), now.Month(), 0, 0, 0, 0, 0, time.Local) - vms, err := d.db.GetAllVms(userID) + vms, err := d.db.GetAllSuccessfulVms(userID) if err != nil { return 0, err } for _, vm := range vms { + usageStart := monthStart + if vm.CreatedAt.After(monthStart) { + usageStart = vm.CreatedAt + } + + usagePercentageInMonth, err := UsagePercentageInMonth(usageStart, now) + if err != nil { + return 0, err + } + debt += float64(vm.PricePerMonth) * usagePercentageInMonth } - clusters, err := d.db.GetAllK8s(userID) + clusters, err := d.db.GetAllSuccessfulK8s(userID) if err != nil { return 0, err } for _, c := range clusters { + usageStart := monthStart + if c.CreatedAt.After(monthStart) { + usageStart = c.CreatedAt + } + + usagePercentageInMonth, err := UsagePercentageInMonth(usageStart, now) + if err != nil { + return 0, err + } + debt += float64(c.PricePerMonth) * usagePercentageInMonth } @@ -299,8 +320,16 @@ func (d *Deployer) calculateUserDebtInMonth(userID string) (float64, error) { // UsagePercentageInMonth calculates percentage of hours till specific time during the month // according to total hours of the same month -func UsagePercentageInMonth(end time.Time) float64 { - start := time.Date(end.Year(), end.Month(), 0, 0, 0, 0, 0, time.UTC) - endMonth := time.Date(end.Year(), end.Month()+1, 0, 0, 0, 0, 0, time.UTC) - return end.Sub(start).Hours() / endMonth.Sub(start).Hours() +func UsagePercentageInMonth(start time.Time, end time.Time) (float64, error) { + if start.Month() != end.Month() || start.Year() != end.Year() { + return 0, errors.New("start and end time should be the same month and year") + } + + startMonth := time.Date(start.Year(), start.Month(), 0, 0, 0, 0, 0, time.UTC) + endMonth := time.Date(start.Year(), start.Month()+1, 0, 0, 0, 0, 0, time.UTC) + + totalHoursInMonth := endMonth.Sub(startMonth).Hours() + usedHours := end.Sub(start).Hours() + + return usedHours / totalHoursInMonth, nil } diff --git a/server/docs/docs.go b/server/docs/docs.go index 9f75e000..c981fc46 100644 --- a/server/docs/docs.go +++ b/server/docs/docs.go @@ -30,7 +30,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "Creates a new administrator email and sends it to a specific user as an email and notification", + "description": "Creates a new administrator announcement and sends it to all users as an email and notification", "consumes": [ "application/json" ], @@ -40,15 +40,15 @@ const docTemplate = `{ "tags": [ "Admin" ], - "summary": "Creates a new administrator email and sends it to a specific user as an email and notification", + "summary": "Creates a new administrator announcement and sends it to all users as an email and notification", "parameters": [ { - "description": "email to be sent", - "name": "email", + "description": "announcement to be created", + "name": "announcement", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/app.EmailUser" + "$ref": "#/definitions/app.AdminAnnouncement" } } ], @@ -244,6 +244,59 @@ const docTemplate = `{ } } }, + "/email": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates a new administrator email and sends it to a specific user as an email and notification", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "Creates a new administrator email and sends it to a specific user as an email and notification", + "parameters": [ + { + "description": "email to be sent", + "name": "email", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/app.EmailUser" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": {} + }, + "400": { + "description": "Bad Request", + "schema": {} + }, + "401": { + "description": "Unauthorized", + "schema": {} + }, + "404": { + "description": "Not Found", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + } + }, "/invoice": { "get": { "security": [ @@ -1234,6 +1287,42 @@ const docTemplate = `{ "schema": {} } } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Deletes account for user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "User" + ], + "summary": "Deletes account for user", + "responses": { + "200": { + "description": "OK", + "schema": {} + }, + "401": { + "description": "Unauthorized", + "schema": {} + }, + "404": { + "description": "Not Found", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } } }, "/user/activate_voucher": { @@ -1697,9 +1786,9 @@ const docTemplate = `{ } } }, - "/user/forgot_password": { + "/user/forget_password/verify_email": { "post": { - "description": "Send code to forget password email for verification", + "description": "Verify user's email to reset password", "consumes": [ "application/json" ], @@ -1709,12 +1798,23 @@ const docTemplate = `{ "tags": [ "User" ], - "summary": "Send code to forget password email for verification", + "summary": "Verify user's email to reset password", + "parameters": [ + { + "description": "User Verify forget password input", + "name": "forgetPassword", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/app.VerifyCodeInput" + } + } + ], "responses": { "201": { "description": "Created", "schema": { - "$ref": "#/definitions/app.CodeTimeout" + "$ref": "#/definitions/app.AccessTokenResponse" } }, "400": { @@ -1736,9 +1836,9 @@ const docTemplate = `{ } } }, - "/user/forgot_password/verify_email": { + "/user/forgot_password": { "post": { - "description": "Verify user's email to reset password", + "description": "Send code to forget password email for verification", "consumes": [ "application/json" ], @@ -1748,12 +1848,23 @@ const docTemplate = `{ "tags": [ "User" ], - "summary": "Verify user's email to reset password", + "summary": "Send code to forget password email for verification", + "parameters": [ + { + "description": "User forget password input", + "name": "forgetPassword", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/app.EmailInput" + } + } + ], "responses": { "201": { "description": "Created", "schema": { - "$ref": "#/definitions/app.AccessToken" + "$ref": "#/definitions/app.CodeTimeout" } }, "400": { @@ -1797,7 +1908,7 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/app.RefreshToken" + "$ref": "#/definitions/app.RefreshTokenResponse" } }, "400": { @@ -1847,7 +1958,7 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/app.AccessToken" + "$ref": "#/definitions/app.AccessTokenResponse" } }, "400": { @@ -2380,7 +2491,7 @@ const docTemplate = `{ } } }, - "/voucher/reset": { + "/voucher/all/reset": { "put": { "security": [ { @@ -2486,11 +2597,8 @@ const docTemplate = `{ } }, "definitions": { - "app.AccessToken": { + "app.AccessTokenResponse": { "type": "object", - "required": [ - "access_token" - ], "properties": { "access_token": { "type": "string" @@ -2622,6 +2730,17 @@ const docTemplate = `{ } } }, + "app.EmailInput": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string" + } + } + }, "app.EmailUser": { "type": "object", "required": [ @@ -2714,12 +2833,8 @@ const docTemplate = `{ } } }, - "app.RefreshToken": { + "app.RefreshTokenResponse": { "type": "object", - "required": [ - "access_token", - "refresh_token" - ], "properties": { "access_token": { "type": "string" @@ -3190,6 +3305,9 @@ const docTemplate = `{ "card": { "type": "number" }, + "id": { + "type": "integer" + }, "invoice_id": { "type": "integer" }, @@ -3241,7 +3359,7 @@ const docTemplate = `{ "stripe_customer_id": { "type": "string" }, - "stripe_payment_method_id": { + "stripe_default_payment_id": { "type": "string" }, "updated_at": { diff --git a/server/docs/swagger.yaml b/server/docs/swagger.yaml index 047a5138..74838d92 100644 --- a/server/docs/swagger.yaml +++ b/server/docs/swagger.yaml @@ -1,10 +1,8 @@ definitions: - app.AccessToken: + app.AccessTokenResponse: properties: access_token: type: string - required: - - access_token type: object app.AddCardInput: properties: @@ -90,6 +88,13 @@ definitions: - name - resources type: object + app.EmailInput: + properties: + email: + type: string + required: + - email + type: object app.EmailUser: properties: body: @@ -152,15 +157,12 @@ definitions: required: - method type: object - app.RefreshToken: + app.RefreshTokenResponse: properties: access_token: type: string refresh_token: type: string - required: - - access_token - - refresh_token type: object app.SetAdminInput: properties: @@ -474,6 +476,8 @@ definitions: type: number card: type: number + id: + type: integer invoice_id: type: integer voucher_balance: @@ -504,7 +508,7 @@ definitions: type: string stripe_customer_id: type: string - stripe_payment_method_id: + stripe_default_payment_id: type: string updated_at: type: string @@ -632,15 +636,15 @@ paths: post: consumes: - application/json - description: Creates a new administrator email and sends it to a specific user + description: Creates a new administrator announcement and sends it to all users as an email and notification parameters: - - description: email to be sent + - description: announcement to be created in: body - name: email + name: announcement required: true schema: - $ref: '#/definitions/app.EmailUser' + $ref: '#/definitions/app.AdminAnnouncement' produces: - application/json responses: @@ -661,8 +665,8 @@ paths: schema: {} security: - BearerAuth: [] - summary: Creates a new administrator email and sends it to a specific user as - an email and notification + summary: Creates a new administrator announcement and sends it to all users + as an email and notification tags: - Admin /balance: @@ -776,6 +780,43 @@ paths: summary: Get users' deployments count tags: - Admin + /email: + post: + consumes: + - application/json + description: Creates a new administrator email and sends it to a specific user + as an email and notification + parameters: + - description: email to be sent + in: body + name: email + required: true + schema: + $ref: '#/definitions/app.EmailUser' + produces: + - application/json + responses: + "201": + description: Created + schema: {} + "400": + description: Bad Request + schema: {} + "401": + description: Unauthorized + schema: {} + "404": + description: Not Found + schema: {} + "500": + description: Internal Server Error + schema: {} + security: + - BearerAuth: [] + summary: Creates a new administrator email and sends it to a specific user as + an email and notification + tags: + - Admin /invoice: get: consumes: @@ -1372,6 +1413,30 @@ paths: tags: - Admin /user: + delete: + consumes: + - application/json + description: Deletes account for user + produces: + - application/json + responses: + "200": + description: OK + schema: {} + "401": + description: Unauthorized + schema: {} + "404": + description: Not Found + schema: {} + "500": + description: Internal Server Error + schema: {} + security: + - BearerAuth: [] + summary: Deletes account for user + tags: + - User get: consumes: - application/json @@ -1739,18 +1804,25 @@ paths: summary: Charge user balance tags: - User - /user/forgot_password: + /user/forget_password/verify_email: post: consumes: - application/json - description: Send code to forget password email for verification + description: Verify user's email to reset password + parameters: + - description: User Verify forget password input + in: body + name: forgetPassword + required: true + schema: + $ref: '#/definitions/app.VerifyCodeInput' produces: - application/json responses: "201": description: Created schema: - $ref: '#/definitions/app.CodeTimeout' + $ref: '#/definitions/app.AccessTokenResponse' "400": description: Bad Request schema: {} @@ -1763,21 +1835,28 @@ paths: "500": description: Internal Server Error schema: {} - summary: Send code to forget password email for verification + summary: Verify user's email to reset password tags: - User - /user/forgot_password/verify_email: + /user/forgot_password: post: consumes: - application/json - description: Verify user's email to reset password + description: Send code to forget password email for verification + parameters: + - description: User forget password input + in: body + name: forgetPassword + required: true + schema: + $ref: '#/definitions/app.EmailInput' produces: - application/json responses: "201": description: Created schema: - $ref: '#/definitions/app.AccessToken' + $ref: '#/definitions/app.CodeTimeout' "400": description: Bad Request schema: {} @@ -1790,7 +1869,7 @@ paths: "500": description: Internal Server Error schema: {} - summary: Verify user's email to reset password + summary: Send code to forget password email for verification tags: - User /user/refresh_token: @@ -1804,7 +1883,7 @@ paths: "201": description: Created schema: - $ref: '#/definitions/app.RefreshToken' + $ref: '#/definitions/app.RefreshTokenResponse' "400": description: Bad Request schema: {} @@ -1840,7 +1919,7 @@ paths: "201": description: Created schema: - $ref: '#/definitions/app.AccessToken' + $ref: '#/definitions/app.AccessTokenResponse' "400": description: Bad Request schema: {} @@ -2237,7 +2316,7 @@ paths: summary: Update (approve-reject) a voucher tags: - Voucher (only admins) - /voucher/reset: + /voucher/all/reset: put: consumes: - application/json diff --git a/server/go.mod b/server/go.mod index 29d73c46..5cbd4efa 100644 --- a/server/go.mod +++ b/server/go.mod @@ -2,10 +2,9 @@ module github.com/codescalers/cloud4students go 1.22.0 -toolchain go1.23.4 - require ( github.com/caitlin615/nist-password-validator v0.0.0-20190321104149-45ab5d3140de + github.com/cenkalti/backoff v2.2.1+incompatible github.com/go-redis/redis v6.15.9+incompatible github.com/golang-jwt/jwt/v4 v4.5.1 github.com/google/uuid v1.6.0 @@ -32,7 +31,6 @@ require ( github.com/ChainSafe/go-schnorrkel v1.1.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/cenkalti/backoff v2.2.1+incompatible // indirect github.com/cenkalti/backoff/v3 v3.2.2 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/centrifuge/go-substrate-rpc-client/v4 v4.0.12 // indirect @@ -70,13 +68,13 @@ require ( github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/mimoo/StrobeGo v0.0.0-20220103164710-9a04d6ca976b // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/onsi/gomega v1.33.1 // indirect + github.com/onsi/gomega v1.34.2 // indirect github.com/pierrec/xxHash v0.1.5 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect - github.com/rogpeppe/go-internal v1.12.0 // indirect + github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/rs/cors v1.10.1 // indirect github.com/sendgrid/rest v2.6.9+incompatible // indirect github.com/spf13/pflag v1.0.5 // indirect @@ -88,7 +86,7 @@ require ( github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/vedhavyas/go-subkey v1.0.3 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc // indirect + golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect golang.org/x/net v0.32.0 // indirect golang.org/x/sync v0.10.0 // indirect golang.org/x/sys v0.28.0 // indirect diff --git a/server/go.sum b/server/go.sum index 43c9a991..fb46f78b 100644 --- a/server/go.sum +++ b/server/go.sum @@ -135,8 +135,8 @@ github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= -github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk= -github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0= +github.com/onsi/gomega v1.34.2 h1:pNCwDkzrsv7MS9kpaQvVb1aVLahQXyJ/Tv5oAZMI3i8= +github.com/onsi/gomega v1.34.2/go.mod h1:v1xfxRgk0KIsG+QOdm7p8UosrOzPYRo60fd3B/1Dukc= github.com/pierrec/xxHash v0.1.5 h1:n/jBpwTHiER4xYvK3/CdPVnLDPchj8eTJFFLUb4QHBo= github.com/pierrec/xxHash v0.1.5/go.mod h1:w2waW5Zoa/Wc4Yqe0wgrIYAGKqRMf7czn2HNKXmuL+I= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -152,8 +152,8 @@ github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo= github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= @@ -211,8 +211,8 @@ golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.30.0 h1:RwoQn3GkWiMkzlX562cLB7OxWvjH1L8xutO2WoJcRoY= golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc h1:ao2WRsKSzW6KuUY9IWPwWahcHCgR0s52IfwutMfEbdM= -golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= +golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= +golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= diff --git a/server/internal/config_parser_test.go b/server/internal/config_parser_test.go index 41e6d67a..b640280e 100644 --- a/server/internal/config_parser_test.go +++ b/server/internal/config_parser_test.go @@ -35,7 +35,14 @@ var rightConfig = ` "file": "testing.db" }, "version": "v1", - "salt": "salt" + "currency": "eur", + "prices": { + "public_ip": 2, + "small_vm": 10, + "medium_vm": 20, + "large_vm": 30 + }, + "stripe_secret": "sk_test" } ` @@ -331,7 +338,82 @@ func TestParseConf(t *testing.T) { }) - t.Run("no salt configuration", func(t *testing.T) { + t.Run("no currency configuration", func(t *testing.T) { + config := + ` +{ + "server": { + "host": "localhost", + "port": ":3000" + }, + "mailSender": { + "email": "email", + "sendgrid_key": "my sendgrid_key", + "timeout": 60 + }, + "account": { + "mnemonics": "my mnemonics", + "network": "my network" + }, + "database": { + "file": "testing.db" + }, + "token": { + "secret": "secret", + "timeout": 10 + }, + "version": "v1", +} + ` + dir := t.TempDir() + configPath := filepath.Join(dir, "/config.json") + + err := os.WriteFile(configPath, []byte(config), 0644) + assert.NoError(t, err) + + _, err = ReadConfFile(configPath) + assert.Error(t, err, "currency is required") + }) + + t.Run("no prices configuration", func(t *testing.T) { + config := + ` +{ + "server": { + "host": "localhost", + "port": ":3000" + }, + "mailSender": { + "email": "email", + "sendgrid_key": "my sendgrid_key", + "timeout": 60 + }, + "account": { + "mnemonics": "my mnemonics", + "network": "my network" + }, + "database": { + "file": "testing.db" + }, + "token": { + "secret": "secret", + "timeout": 10 + }, + "version": "v1", + "currency": "eur", +} + ` + dir := t.TempDir() + configPath := filepath.Join(dir, "/config.json") + + err := os.WriteFile(configPath, []byte(config), 0644) + assert.NoError(t, err) + + _, err = ReadConfFile(configPath) + assert.Error(t, err, "prices is required") + }) + + t.Run("no stripe secret configuration", func(t *testing.T) { config := ` { @@ -356,7 +438,13 @@ func TestParseConf(t *testing.T) { "timeout": 10 }, "version": "v1", - "salt": "" + "currency": "eur", + "prices": { + "public_ip": 2, + "small_vm": 10, + "medium_vm": 20, + "large_vm": 30 + }, } ` dir := t.TempDir() @@ -366,7 +454,7 @@ func TestParseConf(t *testing.T) { assert.NoError(t, err) _, err = ReadConfFile(configPath) - assert.Error(t, err, "salt is required") + assert.Error(t, err, "stripe_secret is required") }) } diff --git a/server/models/card.go b/server/models/card.go index ba34208d..1521e57d 100644 --- a/server/models/card.go +++ b/server/models/card.go @@ -1,6 +1,9 @@ package models -import "gorm.io/gorm" +import ( + "gorm.io/gorm" + "gorm.io/gorm/clause" +) type Card struct { ID int `json:"id" gorm:"primaryKey"` @@ -55,3 +58,9 @@ func (d *DB) DeleteCard(id int) error { var card Card return d.db.Delete(&card, id).Error } + +// DeleteAllCards deletes all cards of user +func (d *DB) DeleteAllCards(userID string) error { + var cards []Card + return d.db.Clauses(clause.Returning{}).Where("user_id = ?", userID).Delete(&cards).Error +} diff --git a/server/models/database.go b/server/models/database.go index ab8ba3fa..c0b0fc4e 100644 --- a/server/models/database.go +++ b/server/models/database.go @@ -31,7 +31,7 @@ func (d *DB) Connect(file string) error { func (d *DB) Migrate() error { err := d.db.AutoMigrate( &User{}, &State{}, &Card{}, &Invoice{}, &VM{}, &K8sCluster{}, &Master{}, &Worker{}, - &Voucher{}, &Maintenance{}, &Notification{}, &NextLaunch{}, + &Voucher{}, &Maintenance{}, &Notification{}, &NextLaunch{}, &DeploymentItem{}, &PaymentDetails{}, ) if err != nil { return err diff --git a/server/models/invoice.go b/server/models/invoice.go index f1549b1b..314d2a8e 100644 --- a/server/models/invoice.go +++ b/server/models/invoice.go @@ -32,6 +32,7 @@ type DeploymentItem struct { } type PaymentDetails struct { + ID int `json:"id" gorm:"primaryKey"` InvoiceID int `json:"invoice_id"` Card float64 `json:"card"` Balance float64 `json:"balance"` @@ -74,10 +75,14 @@ func (d *DB) UpdateInvoiceLastRemainderDate(id int) error { // PayInvoice updates paid with true and paid at field with current time in the invoice func (d *DB) PayInvoice(id int, payment PaymentDetails) error { var invoice Invoice + + if err := d.db.Model(&invoice).Association("PaymentDetails").Append(&payment); err != nil { + return err + } + result := d.db.Model(&invoice). Where("id = ?", id). Update("paid", true). - Update("payment_details", payment). Update("paid_at", time.Now()) if result.RowsAffected == 0 { @@ -86,15 +91,11 @@ func (d *DB) PayInvoice(id int, payment PaymentDetails) error { return result.Error } -// PayUserInvoices tries to pay invoices with a given balance +// PayUserInvoices tries to pay invoices with a given balance // TODO: add cards func (d *DB) PayUserInvoices(userID string, balance, voucherBalance float64) (float64, float64, error) { // get unpaid invoices - var invoices []Invoice - if err := d.db. - Order("total desc"). - Where("user_id = ?", userID). - Where("paid = ?", false). - Find(&invoices).Error; err != nil && err != gorm.ErrRecordNotFound { + invoices, err := d.ListUnpaidInvoices(userID) + if err != nil && err != gorm.ErrRecordNotFound { return 0, 0, err } diff --git a/server/models/k8s.go b/server/models/k8s.go index 0ffb58c1..3be657a2 100644 --- a/server/models/k8s.go +++ b/server/models/k8s.go @@ -101,6 +101,22 @@ func (d *DB) GetAllK8s(userID string) ([]K8sCluster, error) { return k8sClusters, nil } +// GetAllSuccessfulK8s returns all K8s of user that have a state succeeded +func (d *DB) GetAllSuccessfulK8s(userID string) ([]K8sCluster, error) { + var k8sClusters []K8sCluster + err := d.db.Find(&k8sClusters, "user_id = ? and state = 'CREATED'", userID).Error + if err != nil { + return nil, err + } + for i := range k8sClusters { + k8sClusters[i], err = d.GetK8s(k8sClusters[i].ID) + if err != nil { + return nil, err + } + } + return k8sClusters, nil +} + // DeleteK8s deletes a k8s cluster func (d *DB) DeleteK8s(id int) error { var k8s K8sCluster @@ -118,6 +134,7 @@ func (d *DB) DeleteAllK8s(userID string) error { if err != nil { return err } + return d.db.Select("Master", "Workers").Delete(&k8sClusters).Error } diff --git a/server/models/user.go b/server/models/user.go index 7915e0ea..35467134 100644 --- a/server/models/user.go +++ b/server/models/user.go @@ -13,7 +13,7 @@ import ( type User struct { ID uuid.UUID `gorm:"primary_key; unique; type:uuid; column:id"` StripeCustomerID string `json:"stripe_customer_id"` - StripeDefaultPaymentID string `json:"stripe_payment_method_id"` + StripeDefaultPaymentID string `json:"stripe_default_payment_id"` FirstName string `json:"first_name" binding:"required"` LastName string `json:"last_name" binding:"required"` Email string `json:"email" gorm:"unique" binding:"required"` @@ -105,9 +105,32 @@ func (d *DB) UpdateAdminUserByID(id string, admin bool) error { return d.db.Model(&User{}).Where("id = ?", id).Updates(map[string]interface{}{"admin": admin, "updated_at": time.Now()}).Error } +// UpdateUserPaymentMethod updates user payment method ID +func (d *DB) UpdateUserPaymentMethod(id string, paymentID string) error { + var res User + return d.db.Model(&res).Where("id = ?", id).Update("stripe_default_payment_id", paymentID).Error +} + +// UpdateUserBalance updates user balance +func (d *DB) UpdateUserBalance(id string, balance float64) error { + var res User + return d.db.Model(&res).Where("id = ?", id).Update("balance", balance).Error +} + +// UpdateUserVoucherBalance updates user voucher balance +func (d *DB) UpdateUserVoucherBalance(id string, balance float64) error { + var res User + return d.db.Model(&res).Where("id = ?", id).Update("voucher_balance", balance).Error +} + // UpdateUserVerification updates if user is verified or not func (d *DB) UpdateUserVerification(id string, verified bool) error { var res User - result := d.db.Model(&res).Where("id=?", id).Update("verified", verified) - return result.Error + return d.db.Model(&res).Where("id = ?", id).Update("verified", verified).Error +} + +// DeleteUser deletes user by its id +func (d *DB) DeleteUser(id string) error { + var user User + return d.db.Where("id = ?", id).Delete(&user).Error } diff --git a/server/models/vm.go b/server/models/vm.go index 69918584..c0384b9d 100644 --- a/server/models/vm.go +++ b/server/models/vm.go @@ -47,6 +47,12 @@ func (d *DB) GetAllVms(userID string) ([]VM, error) { return vms, d.db.Where("user_id = ?", userID).Find(&vms).Error } +// GetAllSuccessfulVms returns all vms of user that have a state succeeded +func (d *DB) GetAllSuccessfulVms(userID string) ([]VM, error) { + var vms []VM + return vms, d.db.Where("user_id = ? and state = 'CREATED'", userID).Find(&vms).Error +} + // UpdateVM updates information of vm. empty and unchanged fields are not updated. func (d *DB) UpdateVM(vm VM) error { return d.db.Model(&VM{}).Where("id = ?", vm.ID).Updates(vm).Error