From cfbdc9907c563f3011ee931ceda2178e63d64c12 Mon Sep 17 00:00:00 2001 From: rawdaGastan Date: Wed, 3 Sep 2025 15:32:30 +0300 Subject: [PATCH 01/16] update payments to fullfill discount and be restricted to transfer records [withdraw-deposit] --- backend/app/admin_handler.go | 88 +++----- backend/app/app.go | 15 +- backend/app/balance_monitor.go | 193 +++++++++++++++-- backend/app/invoice_handler.go | 5 +- backend/app/node_handler.go | 41 ++++ backend/app/settle_usage.go | 175 +++++++++++++++ backend/app/setup.go | 4 +- backend/app/user_handler.go | 202 +++++------------- backend/cmd/root.go | 15 +- backend/go.mod | 10 +- backend/go.sum | 20 +- backend/internal/activities/constants.go | 4 - .../internal/activities/node_activities.go | 5 + .../internal/activities/user_activities.go | 103 --------- backend/internal/activities/workflow.go | 17 -- backend/internal/chain_account.go | 6 +- backend/internal/config.go | 46 ++-- backend/internal/contracts_billing.go | 20 +- backend/models/db.go | 14 +- backend/models/gorm.go | 90 ++++---- backend/models/pending_record.go | 21 -- backend/models/transfer_record.go | 27 +++ 22 files changed, 638 insertions(+), 483 deletions(-) create mode 100644 backend/app/settle_usage.go delete mode 100644 backend/models/pending_record.go create mode 100644 backend/models/transfer_record.go diff --git a/backend/app/admin_handler.go b/backend/app/admin_handler.go index 33960b6d9..c85f7f335 100644 --- a/backend/app/admin_handler.go +++ b/backend/app/admin_handler.go @@ -1,12 +1,10 @@ package app import ( - "context" "errors" "fmt" "io" "kubecloud/internal" - "kubecloud/internal/activities" "kubecloud/models" "mime/multipart" "net/http" @@ -47,12 +45,6 @@ type CreditUserResponse struct { Memo string `json:"memo"` } -type PendingRecordsResponse struct { - models.PendingRecord - USDAmount float64 `json:"usd_amount"` - TransferredUSDAmount float64 `json:"transferred_usd_amount"` -} - // AdminMailInput represents the form data for sending emails to all users type AdminMailInput struct { Subject string `form:"subject" binding:"required"` @@ -325,79 +317,67 @@ func (h *Handler) CreditUserHandler(c *gin.Context) { CreatedAt: time.Now(), } - wf, err := h.ewfEngine.NewWorkflow(activities.WorkflowAdminCreditBalance) - if err != nil { + if err := h.db.CreateTransaction(&transaction); err != nil { + log.Error().Err(err).Msg("Failed to create credit transaction") + InternalServerError(c) + return + } + + user.CreditedBalance += internal.FromUSDToUSDMillicent(request.AmountUSD) + if err := h.db.UpdateUserByID(&user); err != nil { log.Error().Err(err).Send() InternalServerError(c) return } - if err := h.db.CreateTransaction(&transaction); err != nil { - log.Error().Err(err).Msg("Failed to create credit transaction") + tftAmount, err := internal.GetUserTFTBalance(h.substrateClient, user.Mnemonic) + if err != nil { + log.Error().Err(err).Send() InternalServerError(c) return } - wf.State = map[string]interface{}{ - "user_id": user.ID, - "amount": internal.FromUSDToUSDMillicent(request.AmountUSD), - "mnemonic": user.Mnemonic, - "username": user.Username, - "transfer_mode": models.AdminCreditMode, + if tftAmount == 0 { + if err := h.db.CreateTransferRecord(&models.TransferRecord{ + UserID: id, + Username: user.Username, + TFTAmount: uint64(h.config.MinimumTFTAmountInWallet) * 1e7, + Operation: models.DepositOperation, + }); err != nil { + log.Error().Err(err).Send() + InternalServerError(c) + return + } } - h.ewfEngine.RunAsync(context.Background(), wf) - Success(c, http.StatusCreated, "Transaction is created successfully, Money transfer is in progress", CreditUserResponse{ + Success(c, http.StatusCreated, fmt.Sprintf("User is credited with %v$ successfully", request.AmountUSD), CreditUserResponse{ User: user.Email, AmountUSD: request.AmountUSD, Memo: request.Memo, }) } -// @Summary List pending records -// @Description Returns all pending records in the system +// @Summary List transfer records +// @Description Returns all transfer records in the system // @Tags admin -// @ID list-pending-records +// @ID list-transfer-records // @Accept json // @Produce json -// @Success 200 {array} PendingRecordsResponse +// @Success 200 {array} []models.TransferRecord // @Failure 500 {object} APIResponse // @Security AdminMiddleware -// @Router /pending-records [get] -// ListPendingRecordsHandler returns all pending records in the system -func (h *Handler) ListPendingRecordsHandler(c *gin.Context) { - pendingRecords, err := h.db.ListAllPendingRecords() +// @Router /transfer-records [get] +// ListTransferRecordsHandler returns all transfer records in the system +func (h *Handler) ListTransferRecordsHandler(c *gin.Context) { + transferRecords, err := h.db.ListTransferRecords() if err != nil { - log.Error().Err(err).Msg("failed to list all pending records") + log.Error().Err(err).Msg("failed to list all transfer records") InternalServerError(c) return } - var pendingRecordsResponse []PendingRecordsResponse - for _, record := range pendingRecords { - usdAmount, err := internal.FromTFTtoUSDMillicent(h.substrateClient, record.TFTAmount) - if err != nil { - log.Error().Err(err).Msg("failed to convert tft to usd amount") - InternalServerError(c) - return - } - - usdTransferredAmount, err := internal.FromTFTtoUSDMillicent(h.substrateClient, record.TransferredTFTAmount) - if err != nil { - log.Error().Err(err).Msg("failed to convert tft to usd transferred amount") - InternalServerError(c) - return - } - - pendingRecordsResponse = append(pendingRecordsResponse, PendingRecordsResponse{ - PendingRecord: record, - USDAmount: internal.FromUSDMilliCentToUSD(usdAmount), - TransferredUSDAmount: internal.FromUSDMilliCentToUSD(usdTransferredAmount), - }) - } - - Success(c, http.StatusOK, "Pending records are retrieved successfully", map[string]any{ - "pending_records": pendingRecordsResponse, + Success(c, http.StatusOK, "Transfer records are retrieved successfully", map[string]any{ + "transfer_records": transferRecords, }) } diff --git a/backend/app/app.go b/backend/app/app.go index 458954520..b2ef0d85f 100644 --- a/backend/app/app.go +++ b/backend/app/app.go @@ -165,7 +165,7 @@ func NewApp(ctx context.Context, config internal.Configuration) (*App, error) { handler := NewHandler(tokenHandler, db, config, mailService, gridProxy, substrateClient, graphqlClient, firesquidClient, redisClient, sseManager, ewfEngine, config.SystemAccount.Network, sshPublicKey, - systemIdentity, kycClient, sponsorKeyPair, sponsorAddress, metrics) + systemIdentity, kycClient, sponsorKeyPair, sponsorAddress, metrics, gridClient) app := &App{ router: router, @@ -226,7 +226,7 @@ func (app *App) registerHandlers() { usersGroup.POST("/mail", app.handlers.SendMailToAllUsersHandler) adminGroup.GET("/invoices", app.handlers.ListAllInvoicesHandler) - adminGroup.GET("/pending-records", app.handlers.ListPendingRecordsHandler) + adminGroup.GET("/transfer-records", app.handlers.ListTransferRecordsHandler) vouchersGroup := adminGroup.Group("/vouchers") { @@ -262,11 +262,9 @@ func (app *App) registerHandlers() { authGroup.POST("/nodes/:node_id", app.handlers.ReserveNodeHandler) authGroup.DELETE("/nodes/unreserve/:contract_id", app.handlers.UnreserveNodeHandler) authGroup.POST("/balance/charge", app.handlers.ChargeBalance) - authGroup.GET("/balance", app.handlers.GetUserBalance) authGroup.PUT("/redeem/:voucher_code", app.handlers.RedeemVoucherHandler) authGroup.GET("/invoice/:invoice_id", app.handlers.DownloadInvoiceHandler) authGroup.GET("/invoice", app.handlers.ListUserInvoicesHandler) - authGroup.GET("/pending-records", app.handlers.ListUserPendingRecordsHandler) // SSH Key management authGroup.GET("/ssh-keys", app.handlers.ListSSHKeysHandler) authGroup.POST("/ssh-keys", app.handlers.AddSSHKeyHandler) @@ -306,16 +304,17 @@ func (app *App) registerHandlers() { app.router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) } -func (app *App) StartBackgroundWorkers() { +func (app *App) StartBackgroundWorkers(ctx context.Context) { go app.handlers.MonthlyInvoicesHandler() go app.handlers.TrackUserDebt(app.gridClient) - go app.handlers.MonitorSystemBalanceAndHandleSettlement() + go app.handlers.MonitorSystemBalanceAndHandleSettlement(ctx) + go app.handlers.DeductBalanceBasedOnUsage() } // Run starts the server -func (app *App) Run() error { +func (app *App) Run(ctx context.Context) error { internal.InitValidator() - app.StartBackgroundWorkers() + app.StartBackgroundWorkers(ctx) app.handlers.ewfEngine.ResumeRunningWorkflows() app.httpServer = &http.Server{ Addr: fmt.Sprintf(":%s", app.config.Server.Port), diff --git a/backend/app/balance_monitor.go b/backend/app/balance_monitor.go index 88359a875..2d7de6cef 100644 --- a/backend/app/balance_monitor.go +++ b/backend/app/balance_monitor.go @@ -1,24 +1,32 @@ package app import ( + "context" "kubecloud/internal" "kubecloud/models" "time" "github.com/pkg/errors" "github.com/rs/zerolog/log" + substrate "github.com/threefoldtech/tfchain/clients/tfchain-client-go" + "github.com/threefoldtech/tfgrid-sdk-go/grid-client/calculator" + "github.com/threefoldtech/tfgrid-sdk-go/grid-proxy/pkg/types" ) -func (h *Handler) MonitorSystemBalanceAndHandleSettlement() { - balanceTicker := time.NewTicker(time.Duration(h.config.MonitorBalanceIntervalInMinutes) * time.Minute) +func (h *Handler) MonitorSystemBalanceAndHandleSettlement(ctx context.Context) { + settleTransfersTicker := time.NewTicker(time.Duration(h.config.SettleTransferRecordsIntervalInMinutes) * time.Minute) adminNotifyTicker := time.NewTicker(time.Duration(h.config.NotifyAdminsForPendingRecordsInHours) * time.Hour) - defer balanceTicker.Stop() + zeroUSDBalanceTicker := time.NewTicker(time.Minute) + fundUserTFTBalanceTicker := time.NewTicker(24 * time.Hour) + defer settleTransfersTicker.Stop() defer adminNotifyTicker.Stop() + defer zeroUSDBalanceTicker.Stop() + defer fundUserTFTBalanceTicker.Stop() for { select { - case <-balanceTicker.C: - records, err := h.db.ListOnlyPendingRecords() + case <-settleTransfersTicker.C: + records, err := h.db.ListPendingTransferRecords() if err != nil { continue } @@ -28,7 +36,7 @@ func (h *Handler) MonitorSystemBalanceAndHandleSettlement() { } case <-adminNotifyTicker.C: - records, err := h.db.ListOnlyPendingRecords() + records, err := h.db.ListPendingTransferRecords() if err != nil { continue } @@ -38,17 +46,82 @@ func (h *Handler) MonitorSystemBalanceAndHandleSettlement() { log.Error().Err(err).Send() } } + + case <-zeroUSDBalanceTicker.C: + users, err := h.db.ListAllUsers() + if err != nil { + continue + } + + if err := h.resetUsersTFTsWithNoUSDBalance(users); err != nil { + log.Error().Err(err).Send() + } + + case <-fundUserTFTBalanceTicker.C: + users, err := h.db.ListAllUsers() + if err != nil { + continue + } + + for _, user := range users { + if err = h.fundUsersToClaimDiscount(ctx, user.ID, user.Username, user.Mnemonic, discount(h.config.AppliedDiscount)); err != nil { + log.Error().Err(err).Msgf("Failed to fund user %d to claim discount", user.ID) + } + } } } } -func (h *Handler) settlePendingPayments(records []models.PendingRecord) error { +func (h *Handler) resetUsersTFTsWithNoUSDBalance(users []models.User) error { + for _, user := range users { + if user.CreditedBalance+user.CreditCardBalance == 0 { + log.Info().Msgf("User %d has no USD balance, withdrawing all TFTs", user.ID) + + userTFTBalance, err := internal.GetUserTFTBalance(h.substrateClient, user.Mnemonic) + if err != nil { + log.Error().Err(err).Msgf("Failed to get user TFT balance for user %d", user.ID) + continue + } + + transferRecord := models.TransferRecord{ + UserID: user.ID, + Username: user.Username, + TFTAmount: userTFTBalance, + Operation: models.WithdrawOperation, + State: models.SuccessState, + } + + if err = h.withdrawTFTsFromUser(user.ID, user.Mnemonic, userTFTBalance); err != nil { + log.Error().Err(err).Msgf("Failed to withdraw all TFTs for user %d", user.ID) + + // TODO: handle retries + transferRecord.State = models.FailedState + transferRecord.Failure = err.Error() + } + + if err := h.db.CreateTransferRecord(&transferRecord); err != nil { + log.Error().Err(err).Msgf("Failed to create transfer record for user %d", user.ID) + } + } + } + + return nil +} + +func (h *Handler) settlePendingPayments(records []models.TransferRecord) error { for _, record := range records { + if record.Operation == models.WithdrawOperation { + continue + } + // Already settled - if record.TransferredTFTAmount >= record.TFTAmount { + if record.State == models.SuccessState { continue } + transferState := models.SuccessState + var transferFailure string + // getting balance every time to ensure we have the latest balance systemTFTBalance, err := internal.GetUserTFTBalance(h.substrateClient, h.config.SystemAccount.Mnemonic) if err != nil { @@ -56,41 +129,123 @@ func (h *Handler) settlePendingPayments(records []models.PendingRecord) error { continue } - amountToTransfer := record.TFTAmount - record.TransferredTFTAmount - if systemTFTBalance < amountToTransfer { + if systemTFTBalance < record.TFTAmount { log.Warn().Msgf("Insufficient system balance to settle pending record ID %d", record.ID) continue } - if err = h.transferTFTsToUser(record.UserID, record.ID, amountToTransfer); err != nil { - log.Error().Err(err).Send() - continue + if err = h.transferTFTsToUser(record.UserID, record.TFTAmount); err != nil { + log.Error().Err(err).Msgf("Failed to settle pending record ID %d", record.ID) + + transferState = models.FailedState + transferFailure = err.Error() + } + + if err := h.db.UpdateTransferRecordState(record.ID, transferState, transferFailure); err != nil { + log.Error().Err(err).Msgf("Failed to update pending record ID %d state", record.ID) } } return nil } -func (h *Handler) transferTFTsToUser(userID, recordID int, amountToTransfer uint64) error { +func (h *Handler) transferTFTsToUser(userID int, amountToTransfer uint64) error { user, err := h.db.GetUserByID(userID) if err != nil { - return errors.Wrapf(err, "failed to get user for pending record ID %d", recordID) + return errors.Wrapf(err, "failed to get user %d", userID) } err = internal.TransferTFTs(h.substrateClient, amountToTransfer, user.Mnemonic, h.systemIdentity) if err != nil { - return errors.Wrapf(err, "Failed to transfer TFTs for pending record ID %d", recordID) + return errors.Wrapf(err, "Failed to transfer TFTs to user %d", userID) + } + + return nil +} + +func (h *Handler) withdrawTFTsFromUser(userID int, userMnemonic string, amountToWithdraw uint64) error { + userIdentity, err := substrate.NewIdentityFromSr25519Phrase(userMnemonic) + if err != nil { + return errors.Wrapf(err, "Failed to create identity for user %d", userID) } - err = h.db.UpdatePendingRecordTransferredAmount(recordID, amountToTransfer) + err = internal.TransferTFTs(h.substrateClient, amountToWithdraw, h.config.SystemAccount.Mnemonic, userIdentity) if err != nil { - return errors.Wrapf(err, "Failed to update transferred amount for pending record ID %d", recordID) + return errors.Wrapf(err, "Failed to transfer TFTs to user %d", userID) } return nil } -func (h *Handler) notifyAdminWithPendingRecords(records []models.PendingRecord) error { +func (h *Handler) fundUsersToClaimDiscount(ctx context.Context, userID int, Username, userMnemonic string, configuredDiscount discount) error { + rentedNodes, _, err := h.getRentedNodesForUser(ctx, userID, true) + if err != nil { + return err + } + + dailyUsageInUSDMillicent, err := h.calculateResourcesUsageInUSDApplyingDiscount(ctx, userID, userMnemonic, rentedNodes, configuredDiscount) + if err != nil { + log.Error().Err(err).Msgf("Failed to calculate resources usage in USD for user %d", userID) + return err + } + + if dailyUsageInUSDMillicent > 0 { + tftAmount, err := internal.FromUSDMillicentToTFT(h.substrateClient, dailyUsageInUSDMillicent) + if err != nil { + log.Error().Err(err).Msgf("Failed to convert USD to TFTs for user %d", userID) + return err + } + + if err := h.db.CreateTransferRecord(&models.TransferRecord{ + UserID: userID, + Username: Username, + TFTAmount: tftAmount, + Operation: models.DepositOperation, + }); err != nil { + log.Error().Err(err).Msgf("Failed to create transfer record for user %d", userID) + return err + } + } + + return nil +} + +func (h *Handler) calculateResourcesUsageInUSDApplyingDiscount( + ctx context.Context, + userID int, + userMnemonic string, + rentedNodes []types.Node, + configuredDiscount discount, +) (uint64, error) { + userIdentity, err := substrate.NewIdentityFromSr25519Phrase(userMnemonic) + if err != nil { + return 0, errors.Wrapf(err, "Failed to create identity for user %d", userID) + } + + calculator := calculator.NewCalculator(h.gridClient.SubstrateConn, userIdentity) + + var totalResourcesCostMillicent uint64 + for _, node := range rentedNodes { + resourcesCost, err := calculator.CalculateCost( + node.TotalResources.CRU, + uint64(node.TotalResources.MRU), + uint64(node.TotalResources.HRU), + uint64(node.TotalResources.SRU), + len(node.PublicConfig.Ipv4) > 0, + len(node.CertificationType) > 0, + ) + if err != nil { + return 0, err + } + + // resources cost per month + totalResourcesCostMillicent += internal.FromUSDToUSDMillicent(resourcesCost) + } + + return uint64(float64(totalResourcesCostMillicent) * getDiscountPackage(configuredDiscount).DurationInMonth), nil +} + +func (h *Handler) notifyAdminWithPendingRecords(records []models.TransferRecord) error { subject, body := h.mailService.NotifyAdminsMailContent(len(records), h.config.Server.Host) admins, err := h.db.ListAdmins() diff --git a/backend/app/invoice_handler.go b/backend/app/invoice_handler.go index bfffab6f5..dd35bd62e 100644 --- a/backend/app/invoice_handler.go +++ b/backend/app/invoice_handler.go @@ -188,12 +188,13 @@ func (h *Handler) createUserInvoice(user models.User) error { var totalInvoiceCostUSD float64 for _, record := range records { - billReports, err := internal.ListContractBillReportsPerMonth(h.graphqlClient, record.ContractID, now) + // get bill reports for the last month + billReports, err := internal.ListContractBillReports(h.graphqlClient, record.ContractID, now.AddDate(0, -1, 0), now) if err != nil { return err } - totalAmountTFT, err := internal.AmountBilledPerMonth(billReports) + totalAmountTFT, err := internal.CalculateTotalAmountBilledForReports(billReports) if err != nil { return err } diff --git a/backend/app/node_handler.go b/backend/app/node_handler.go index 7b9d0f6d8..f6639cb8a 100644 --- a/backend/app/node_handler.go +++ b/backend/app/node_handler.go @@ -5,6 +5,7 @@ import ( "fmt" "kubecloud/internal" "kubecloud/internal/activities" + "kubecloud/models" "net/http" "net/url" "reflect" @@ -209,6 +210,46 @@ func (h *Handler) ReserveNodeHandler(c *gin.Context) { return } + // fund user to fulfill discount + rentedNodes, _, err := h.getRentedNodesForUser(c.Request.Context(), userID, true) + if err != nil { + log.Error().Err(err).Send() + InternalServerError(c) + return + } + + // add newly rented node + rentedNodes = append(rentedNodes, node) + + // calculate resources usage in USD applying discount + dailyUsageInUSDMillicent, err := h.calculateResourcesUsageInUSDApplyingDiscount(c.Request.Context(), userID, user.Mnemonic, rentedNodes, discount(h.config.AppliedDiscount)) + if err != nil { + log.Error().Err(err).Send() + InternalServerError(c) + return + } + + // fund user to fulfill discount + if dailyUsageInUSDMillicent > 0 { + tftAmount, err := internal.FromUSDMillicentToTFT(h.substrateClient, dailyUsageInUSDMillicent) + if err != nil { + log.Error().Err(err).Send() + InternalServerError(c) + return + } + + if err := h.db.CreateTransferRecord(&models.TransferRecord{ + UserID: userID, + Username: user.Username, + TFTAmount: tftAmount, + Operation: models.DepositOperation, + }); err != nil { + log.Error().Err(err).Send() + InternalServerError(c) + return + } + } + wf, err := h.ewfEngine.NewWorkflow(activities.WorkflowReserveNode) if err != nil { log.Error().Err(err).Send() diff --git a/backend/app/settle_usage.go b/backend/app/settle_usage.go new file mode 100644 index 000000000..f96c75665 --- /dev/null +++ b/backend/app/settle_usage.go @@ -0,0 +1,175 @@ +package app + +import ( + "kubecloud/internal" + "math" + "strconv" + "time" + + "github.com/rs/zerolog/log" +) + +type discount string + +type DiscountPackage struct { + DurationInMonth float64 + Discount int +} + +func (h *Handler) DeductBalanceBasedOnUsage() { + usageDeductionTicker := time.NewTicker(24 * time.Hour) + defer usageDeductionTicker.Stop() + + for range usageDeductionTicker.C { + users, err := h.db.ListAllUsers() + if err != nil { + log.Error().Err(err).Msg("Failed to list users") + continue + } + + for _, user := range users { + usageInUSDMillicent, err := h.getUserDailyUsageInUSD(user.ID) + if err != nil { + log.Error().Err(err).Msgf("Failed to get usage for user %d", user.ID) + continue + } + + if err := h.db.DeductUserBalance(&user, usageInUSDMillicent); err != nil { + log.Error().Err(err).Msgf("Failed to deduct balance for user %d", user.ID) + } + } + } +} + +func (h *Handler) getUserDailyUsageInUSD(userID int) (uint64, error) { + records, err := h.db.ListUserNodes(userID) + if err != nil { + return 0, err + } + + if len(records) == 0 { + return 0, nil + } + + now := time.Now() + + // Define the start of the day at 00:00 + startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local) + // Define the end of the day (next day at 00:00) + endOfDay := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.Local) + + var totalDailyUsageInUSDMillicent uint64 + + for _, record := range records { + // get bill reports for the day + billReports, err := internal.ListContractBillReports(h.graphqlClient, record.ContractID, startOfDay, endOfDay) + if err != nil { + return 0, err + } + + totalAmountBilledInUSDMillicent, err := h.calculateTotalUsageOfReportsInUSDMillicent(billReports.Reports) + if err != nil { + return 0, err + } + + totalDailyUsageInUSDMillicent += totalAmountBilledInUSDMillicent + } + + return totalDailyUsageInUSDMillicent, nil +} + +func (h *Handler) calculateTotalUsageOfReportsInUSDMillicent(reports []internal.Report) (uint64, error) { + var totalAmountBilledInUSDMillicent uint64 + for _, report := range reports { + amountInTFT, err := removeDiscountFromReport(&report) + if err != nil { + return 0, err + } + + amountInUSDMillicent, err := h.fromTFTtoUSDMillicent(amountInTFT, report) + if err != nil { + return 0, err + } + + totalAmountBilledInUSDMillicent += amountInUSDMillicent + } + + return totalAmountBilledInUSDMillicent, nil +} + +func (h *Handler) fromTFTtoUSDMillicent(amount uint64, report internal.Report) (uint64, error) { + price, err := h.getBillingRateAt(report) + if err != nil { + return 0, err + } + + usdMillicentBalance := uint64(math.Round((float64(amount) / 1e7) * float64(price))) + return usdMillicentBalance, nil +} + +func removeDiscountFromReport(report *internal.Report) (uint64, error) { + discountPackage := getDiscountPackage(discount(report.DiscountRecieved)) + + amountBilled, err := strconv.ParseInt(report.AmountBilled, 10, 64) + if err != nil { + return 0, err + } + + amountBilledWithNoDsiscount := float64(amountBilled) / float64(1-discountPackage.Discount/100) + return uint64(amountBilledWithNoDsiscount), nil +} + +func getDiscountPackage(discountInput discount) DiscountPackage { + discountPackages := map[discount]DiscountPackage{ + "none": { + DurationInMonth: 0, + Discount: 0, + }, + "default": { + DurationInMonth: 1.5, + Discount: 20, + }, + "bronze": { + DurationInMonth: 3, + Discount: 30, + }, + "silver": { + DurationInMonth: 6, + Discount: 40, + }, + "gold": { + DurationInMonth: 10, + Discount: 60, + }, + } + + return discountPackages[discountInput] +} + +func (h *Handler) getBillingRateAt(report internal.Report) (float64, error) { + block_duration := 6 // in seconds + now := time.Now().Unix() + + reportTimestamp, err := strconv.ParseInt(report.Timestamp, 10, 64) + if err != nil { + return 0, err + } + + timeBetweenNowAndReport := now - reportTimestamp // seconds + + // Calculate number of blocks since report + numberOfBlocks := math.Round(float64(timeBetweenNowAndReport) / float64(block_duration)) + + nowBlock, err := h.substrateClient.GetCurrentHeight() + if err != nil { + return 0, err + } + reportBlock := nowBlock - uint32(numberOfBlocks) + + tftPrice, err := h.substrateClient.GetTFTBillingRateAt(uint64(reportBlock)) + if err != nil { + return 0, err + } + + return float64(tftPrice), nil +} diff --git a/backend/app/setup.go b/backend/app/setup.go index 2faec50c7..23d648875 100644 --- a/backend/app/setup.go +++ b/backend/app/setup.go @@ -100,8 +100,10 @@ func SetUp(t testing.TB) (*App, error) { "private_key_path": "%s", "public_key_path": "%s" }, - "monitor_balance_interval_in_minutes": 2, + "settle_transfer_records_interval_in_minutes": 2, "notify_admins_for_pending_records_in_hours": 1, + "applied_discount": "gold", + "minimum_tft_amount_in_wallet": 10, "kyc_verifier_api_url": "https://kyc.dev.grid.tf", "kyc_challenge_domain": "kyc.dev.grid.tf" } diff --git a/backend/app/user_handler.go b/backend/app/user_handler.go index 429105a66..9db92f934 100644 --- a/backend/app/user_handler.go +++ b/backend/app/user_handler.go @@ -17,6 +17,7 @@ import ( "github.com/stripe/stripe-go/v82" "github.com/stripe/stripe-go/v82/paymentmethod" substrate "github.com/threefoldtech/tfchain/clients/tfchain-client-go" + "github.com/threefoldtech/tfgrid-sdk-go/grid-client/deployer" "github.com/threefoldtech/tfgrid-sdk-go/grid-client/graphql" proxy "github.com/threefoldtech/tfgrid-sdk-go/grid-proxy/pkg/client" "github.com/xmonader/ewf" @@ -48,6 +49,7 @@ type Handler struct { sponsorKeyPair subkey.KeyPair sponsorAddress string metrics *metrics.Metrics + gridClient deployer.TFPluginClient } // NewHandler create new handler @@ -58,7 +60,8 @@ func NewHandler(tokenManager internal.TokenManager, db models.DB, redis *internal.RedisClient, sseManager *internal.SSEManager, ewfEngine *ewf.Engine, gridNet string, sshPublicKey string, systemIdentity substrate.Identity, kycClient *internal.KYCClient, sponsorKeyPair subkey.KeyPair, sponsorAddress string, - metrics *metrics.Metrics) *Handler { + metrics *metrics.Metrics, + gridClient deployer.TFPluginClient) *Handler { return &Handler{ tokenManager: tokenManager, @@ -79,6 +82,7 @@ func NewHandler(tokenManager internal.TokenManager, db models.DB, sponsorKeyPair: sponsorKeyPair, sponsorAddress: sponsorAddress, metrics: metrics, + gridClient: gridClient, } } @@ -143,13 +147,6 @@ type ChargeBalanceResponse struct { Email string `json:"email"` } -// UserBalanceResponse struct holds the response data for user balance -type UserBalanceResponse struct { - BalanceUSD float64 `json:"balance_usd"` - DebtUSD float64 `json:"debt_usd"` - PendingBalanceUSD float64 `json:"pending_balance_usd"` -} - // SSHKeyInput struct for adding SSH keys type SSHKeyInput struct { Name string `json:"name" validate:"required"` @@ -176,11 +173,6 @@ type RedeemVoucherResponse struct { Email string `json:"email"` } -type GetUserResponse struct { - models.User - PendingBalanceUSD float64 `json:"pending_balance_usd"` -} - // RegisterHandler registers user to the system // @Summary Register user (with KYC sponsorship) // @Description Registers a new user, sets up blockchain account, and creates KYC sponsorship. Sends verification code to email. @@ -685,6 +677,26 @@ func (h *Handler) ChargeBalance(c *gin.Context) { return } + tftAmount, err := internal.GetUserTFTBalance(h.substrateClient, user.Mnemonic) + if err != nil { + log.Error().Err(err).Send() + InternalServerError(c) + return + } + + if tftAmount == 0 { + if err := h.db.CreateTransferRecord(&models.TransferRecord{ + UserID: userID, + Username: user.Username, + TFTAmount: uint64(h.config.MinimumTFTAmountInWallet) * 1e7, + Operation: models.DepositOperation, + }); err != nil { + log.Error().Err(err).Send() + InternalServerError(c) + return + } + } + wf, err := h.ewfEngine.NewWorkflow(activities.WorkflowChargeBalance) if err != nil { log.Error().Err(err).Send() @@ -697,9 +709,6 @@ func (h *Handler) ChargeBalance(c *gin.Context) { "stripe_customer_id": user.StripeCustomerID, "payment_method_id": paymentMethod.ID, "amount": internal.FromUSDToUSDMillicent(request.Amount), - "mnemonic": user.Mnemonic, - "username": user.Username, - "transfer_mode": models.ChargeBalanceMode, } h.ewfEngine.RunAsync(context.Background(), wf) @@ -715,7 +724,7 @@ func (h *Handler) ChargeBalance(c *gin.Context) { // @Tags users // @ID get-user // @Produce json -// @Success 200 {object} GetUserResponse "User is retrieved successfully" +// @Success 200 {object} models.User "User is retrieved successfully" // @Failure 404 {object} APIResponse "User is not found" // @Failure 500 {object} APIResponse // @Router /user [get] @@ -730,85 +739,8 @@ func (h *Handler) GetUserHandler(c *gin.Context) { return } - pendingRecords, err := h.db.ListUserPendingRecords(userID) - if err != nil { - log.Error().Err(err).Msg("failed to list pending records") - InternalServerError(c) - return - } - - var tftPendingAmount uint64 - for _, record := range pendingRecords { - tftPendingAmount += record.TFTAmount - record.TransferredTFTAmount - } - - usdMillicentPendingAmount, err := internal.FromTFTtoUSDMillicent(h.substrateClient, tftPendingAmount) - if err != nil { - log.Error().Err(err).Msg("failed to convert tft to usd millicent") - InternalServerError(c) - return - } - - userResponse := GetUserResponse{ - User: user, - PendingBalanceUSD: internal.FromUSDMilliCentToUSD(usdMillicentPendingAmount), - } - Success(c, http.StatusOK, "User is retrieved successfully", gin.H{ - "user": userResponse, - }) -} - -// @Summary Get user balance -// @Description Retrieves the user's balance in USD -// @Tags users -// @ID get-user-balance -// @Produce json -// @Success 200 {object} UserBalanceResponse "Balance fetched successfully" -// @Failure 404 {object} APIResponse "User is not found" -// @Failure 500 {object} APIResponse -// @Router /user/balance [get] -// GetUserBalance returns user's balance in usd -func (h *Handler) GetUserBalance(c *gin.Context) { - userID := c.GetInt("user_id") - - user, err := h.db.GetUserByID(userID) - if err != nil { - log.Error().Err(err).Send() - Error(c, http.StatusNotFound, "User is not found", "") - return - } - - usdMillicentBalance, err := internal.GetUserBalanceUSDMillicent(h.substrateClient, user.Mnemonic) - if err != nil { - log.Error().Err(err).Send() - InternalServerError(c) - return - } - - pendingRecords, err := h.db.ListUserPendingRecords(userID) - if err != nil { - log.Error().Err(err).Msg("failed to list pending records") - InternalServerError(c) - return - } - - var tftPendingAmount uint64 - for _, record := range pendingRecords { - tftPendingAmount += record.TFTAmount - record.TransferredTFTAmount - } - - usdPendingAmount, err := internal.FromTFTtoUSDMillicent(h.substrateClient, tftPendingAmount) - if err != nil { - log.Error().Err(err).Msg("failed to convert tft to usd millicent") - InternalServerError(c) - return - } - - Success(c, http.StatusOK, "Balance is fetched", UserBalanceResponse{ - BalanceUSD: internal.FromUSDMilliCentToUSD(usdMillicentBalance), - DebtUSD: internal.FromUSDMilliCentToUSD(user.Debt), - PendingBalanceUSD: internal.FromUSDMilliCentToUSD(usdPendingAmount), + "user": user, }) } @@ -865,23 +797,34 @@ func (h *Handler) RedeemVoucherHandler(c *gin.Context) { return } - wf, err := h.ewfEngine.NewWorkflow(activities.WorkflowRedeemVoucher) + user.CreditedBalance += internal.FromUSDToUSDMillicent(voucher.Value) + if err := h.db.UpdateUserByID(&user); err != nil { + log.Error().Err(err).Send() + InternalServerError(c) + return + } + + tftAmount, err := internal.GetUserTFTBalance(h.substrateClient, user.Mnemonic) if err != nil { log.Error().Err(err).Send() InternalServerError(c) return } - wf.State = map[string]interface{}{ - "user_id": user.ID, - "amount": internal.FromUSDToUSDMillicent(voucher.Value), - "mnemonic": user.Mnemonic, - "username": user.Username, - "transfer_mode": models.RedeemVoucherMode, + + if tftAmount == 0 { + if err := h.db.CreateTransferRecord(&models.TransferRecord{ + UserID: userID, + Username: user.Username, + TFTAmount: uint64(h.config.MinimumTFTAmountInWallet) * 1e7, + Operation: models.DepositOperation, + }); err != nil { + log.Error().Err(err).Send() + InternalServerError(c) + return + } } - h.ewfEngine.RunAsync(context.Background(), wf) - Success(c, http.StatusOK, "Voucher is redeemed successfully. Money transfer in progress.", RedeemVoucherResponse{ - WorkflowID: wf.UUID, + Success(c, http.StatusOK, fmt.Sprintf("Voucher with value %v$ is redeemed successfully.", voucher.Value), RedeemVoucherResponse{ VoucherCode: voucher.Code, Amount: voucher.Value, Email: user.Email, @@ -1053,55 +996,6 @@ func (h *Handler) GetWorkflowStatus(c *gin.Context) { Success(c, http.StatusOK, "Status returned successfully", workflow.Status) } -// @Summary List user pending records -// @Description Returns user pending records in the system -// @Tags users -// @ID list-user-pending-records -// @Accept json -// @Produce json -// @Success 200 {array} PendingRecordsResponse -// @Failure 500 {object} APIResponse -// @Security BearerAuth -// @Router /user/pending-records [get] -// ListUserPendingRecordsHandler returns user pending records in the system -func (h *Handler) ListUserPendingRecordsHandler(c *gin.Context) { - userID := c.GetInt("user_id") - - pendingRecords, err := h.db.ListUserPendingRecords(userID) - if err != nil { - log.Error().Err(err).Msg("failed to list pending records") - InternalServerError(c) - return - } - - var pendingRecordsResponse []PendingRecordsResponse - for _, record := range pendingRecords { - usdMillicentAmount, err := internal.FromTFTtoUSDMillicent(h.substrateClient, record.TFTAmount) - if err != nil { - log.Error().Err(err).Msg("failed to convert tft to usd amount") - InternalServerError(c) - return - } - - usdMillicentTransferredAmount, err := internal.FromTFTtoUSDMillicent(h.substrateClient, record.TransferredTFTAmount) - if err != nil { - log.Error().Err(err).Msg("failed to convert tft to usd transferred amount") - InternalServerError(c) - return - } - - pendingRecordsResponse = append(pendingRecordsResponse, PendingRecordsResponse{ - PendingRecord: record, - USDAmount: internal.FromUSDMilliCentToUSD(usdMillicentAmount), - TransferredUSDAmount: internal.FromUSDMilliCentToUSD(usdMillicentTransferredAmount), - }) - } - - Success(c, http.StatusOK, "Pending records are retrieved successfully", map[string]any{ - "pending_records": pendingRecordsResponse, - }) -} - func isUserRegistered(user models.User) bool { return user.Sponsored && user.Verified && diff --git a/backend/cmd/root.go b/backend/cmd/root.go index 0b4dd49e0..8604a1050 100644 --- a/backend/cmd/root.go +++ b/backend/cmd/root.go @@ -163,14 +163,23 @@ func addFlags() error { } // === Monitor Balance Interval In Hours === - if err := bindIntFlag(rootCmd, "monitor_balance_interval_in_minutes", 1, "Number of minutes to monitor balance"); err != nil { - return fmt.Errorf("failed to bind monitor_balance_interval_in_minutes flag: %w", err) + if err := bindIntFlag(rootCmd, "settle_transfer_records_interval_in_minutes", 1, "Number of minutes to monitor balance"); err != nil { + return fmt.Errorf("failed to bind settle_transfer_records_interval_in_minutes flag: %w", err) } if err := bindIntFlag(rootCmd, "notify_admins_for_pending_records_in_hours", 1, "Number of hours to notify admins about pending records"); err != nil { return fmt.Errorf("failed to bind notify_admins_for_pending_records_in_hours flag: %w", err) } + // === Applied Discount === + if err := bindStringFlag(rootCmd, "applied_discount", "", "Applied discount to fund users"); err != nil { + return fmt.Errorf("failed to bind applied_discount flag: %w", err) + } + + if err := bindIntFlag(rootCmd, "minimum_tft_amount_in_wallet", 10, "Minimum TFT amount in wallet"); err != nil { + return fmt.Errorf("failed to bind minimum_tft_amount_in_wallet flag: %w", err) + } + // === KYC Verifier === if err := bindStringFlag(rootCmd, "kyc_verifier_api_url", "", "KYC verifier API URL"); err != nil { return fmt.Errorf("failed to bind kyc_verifier_api_url flag: %w", err) @@ -293,7 +302,7 @@ func gracefulShutdown(app *app.App) error { go func() { log.Info().Msg("Starting KubeCloud server") - if err := app.Run(); err != nil && err != http.ErrServerClosed { + if err := app.Run(ctx); err != nil && err != http.ErrServerClosed { log.Error().Err(err).Msg("Failed to start server") stop() } diff --git a/backend/go.mod b/backend/go.mod index 4b8990456..a0872faf4 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -20,9 +20,9 @@ require ( github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.0 github.com/swaggo/swag v1.16.6 - github.com/threefoldtech/tfchain/clients/tfchain-client-go v0.0.0-20250703093252-e7500b106618 - github.com/threefoldtech/tfgrid-sdk-go/grid-client v0.16.9-0.20250714083056-4943cdc054d8 - github.com/threefoldtech/tfgrid-sdk-go/grid-proxy v0.16.9-0.20250714083056-4943cdc054d8 + github.com/threefoldtech/tfchain/clients/tfchain-client-go v0.0.0-20250901133903-8d32a808fb79 + github.com/threefoldtech/tfgrid-sdk-go/grid-client v0.16.10 + github.com/threefoldtech/tfgrid-sdk-go/grid-proxy v0.16.10 github.com/tyler-smith/go-bip39 v1.1.0 github.com/vedhavyas/go-subkey v1.0.3 github.com/xmonader/ewf v0.0.0-20250727220238-fa576295fe80 @@ -87,11 +87,11 @@ require ( github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/threefoldtech/tfgrid-sdk-go/rmb-sdk-go v0.16.9-0.20250714083056-4943cdc054d8 // indirect + github.com/threefoldtech/tfgrid-sdk-go/rmb-sdk-go v0.16.10 // indirect github.com/threefoldtech/zosbase v0.1.10 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect - golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect + golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect golang.org/x/mod v0.25.0 // indirect golang.org/x/tools v0.34.0 // indirect gonum.org/v1/gonum v0.16.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index df8237ae4..da13f5d5e 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -272,14 +272,14 @@ github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+z github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo= github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= -github.com/threefoldtech/tfchain/clients/tfchain-client-go v0.0.0-20250703093252-e7500b106618 h1:9SguA6nBFcZZmSjnIfjE7si7RFsB/maysVxYTBNCsgM= -github.com/threefoldtech/tfchain/clients/tfchain-client-go v0.0.0-20250703093252-e7500b106618/go.mod h1:cOL5YgHUmDG5SAXrsZxFjUECRQQuAqOoqvXhZG5sEUw= -github.com/threefoldtech/tfgrid-sdk-go/grid-client v0.16.9-0.20250714083056-4943cdc054d8 h1:n8ccWQ8BXgIMsaTlF0luKUO8vwSpKEPMnr8yIXNT+/Q= -github.com/threefoldtech/tfgrid-sdk-go/grid-client v0.16.9-0.20250714083056-4943cdc054d8/go.mod h1:1qWrhZTDrEPg0l/irq4NXFxesP//P4VpN6yE3MZj4M4= -github.com/threefoldtech/tfgrid-sdk-go/grid-proxy v0.16.9-0.20250714083056-4943cdc054d8 h1:/Fcz0+IRxkRSuuzmrdAT+tpe5+PurAxfxfWnJTykpXI= -github.com/threefoldtech/tfgrid-sdk-go/grid-proxy v0.16.9-0.20250714083056-4943cdc054d8/go.mod h1:Wn70gVjYZBuRrN1xJ8kZIHFXEzSJIkHSlKc/JXo6tcM= -github.com/threefoldtech/tfgrid-sdk-go/rmb-sdk-go v0.16.9-0.20250714083056-4943cdc054d8 h1:qNjKKJrPzerJSAFxBrp4abf0kVOlbOaDKs6D5JiGXQU= -github.com/threefoldtech/tfgrid-sdk-go/rmb-sdk-go v0.16.9-0.20250714083056-4943cdc054d8/go.mod h1:xk9EyNJ2j0Wh5BB8ZdVeLyJvOqFiecmN4DWteTwTLSA= +github.com/threefoldtech/tfchain/clients/tfchain-client-go v0.0.0-20250901133903-8d32a808fb79 h1:2P2Ib2RcXxhJY18kdjNXzE+3h6kGFSjdz2QXcySwEaU= +github.com/threefoldtech/tfchain/clients/tfchain-client-go v0.0.0-20250901133903-8d32a808fb79/go.mod h1:cOL5YgHUmDG5SAXrsZxFjUECRQQuAqOoqvXhZG5sEUw= +github.com/threefoldtech/tfgrid-sdk-go/grid-client v0.16.10 h1:rpmf9q6HC6TWs8FMG3vRVtGmAZeR5zR3dWVogHpXQ4I= +github.com/threefoldtech/tfgrid-sdk-go/grid-client v0.16.10/go.mod h1:foMnonEoIibjKAC9nG3LEQLKkFItAzy6Ow50teAPL7c= +github.com/threefoldtech/tfgrid-sdk-go/grid-proxy v0.16.10 h1:Bew6NQS8TNnqfGg8Ju3q9SvRcUNQfilFAuMSN+FHGVk= +github.com/threefoldtech/tfgrid-sdk-go/grid-proxy v0.16.10/go.mod h1:x5fcAu+Kc8zyuROQJ3JsEdueNvw9SnmsrxLqtpnAnpg= +github.com/threefoldtech/tfgrid-sdk-go/rmb-sdk-go v0.16.10 h1:GG0ekj2v39RmXzxZdXt4wXtznaKsHJDHCEcWf7I995s= +github.com/threefoldtech/tfgrid-sdk-go/rmb-sdk-go v0.16.10/go.mod h1:5pgiqfVQ/euebgabDLkdaVHH6ZBe8k37VRlNUhVo+KU= github.com/threefoldtech/zosbase v0.1.10 h1:wRm0KLIjNUmfp92ZU/0xax/SbcFVtMIbmiHSAqFdX/w= github.com/threefoldtech/zosbase v0.1.10/go.mod h1:PzZ9jW1lYFgA0/F4vStP/6CIhQsCdD7DTrum3AYiAWA= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= @@ -316,8 +316,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.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= -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/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY= +golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= diff --git a/backend/internal/activities/constants.go b/backend/internal/activities/constants.go index 02fa8ebdc..2cf74e645 100644 --- a/backend/internal/activities/constants.go +++ b/backend/internal/activities/constants.go @@ -3,10 +3,8 @@ package activities const ( // Workflow names WorkflowChargeBalance = "charge-balance" - WorkflowAdminCreditBalance = "admin-credit-balance" WorkflowUserRegistration = "user-registration" WorkflowUserVerification = "user-verification" - WorkflowRedeemVoucher = "redeem-voucher" WorkflowReserveNode = "reserve-node" WorkflowUnreserveNode = "unreserve-node" WorkflowDeleteCluster = "delete-cluster" @@ -17,7 +15,6 @@ const ( // Step names StepCreatePaymentIntent = "create_payment_intent" - StepCreatePendingRecord = "create_pending_record" StepUpdateCreditCardBalance = "update_user_balance" StepSendVerificationEmail = "send_verification_email" StepCreateUser = "create_user" @@ -29,7 +26,6 @@ const ( StepCreateIdentity = "create_identity" StepReserveNode = "reserve_node" StepUnreserveNode = "unreserve-node" - StepUpdateCreditedBalance = "update-credited-balance" StepRemoveNode = "remove-node" StepStoreDeployment = "store-deployment" StepAddNode = "add-node" diff --git a/backend/internal/activities/node_activities.go b/backend/internal/activities/node_activities.go index 961b7e2fc..dc272f6f8 100644 --- a/backend/internal/activities/node_activities.go +++ b/backend/internal/activities/node_activities.go @@ -87,6 +87,11 @@ func UnreserveNodeStep(db models.DB, substrateClient *substrate.Substrate) ewf.S return fmt.Errorf("failed to cancel contract: %w", err) } + err = db.DeleteUserNode(contractID) + if err != nil { + return fmt.Errorf("failed to delete user node: %w", err) + } + return nil } } diff --git a/backend/internal/activities/user_activities.go b/backend/internal/activities/user_activities.go index 06f1fc19b..8e0170241 100644 --- a/backend/internal/activities/user_activities.go +++ b/backend/internal/activities/user_activities.go @@ -362,73 +362,6 @@ func CreatePaymentIntentStep(currency string, metrics *metrics.Metrics) ewf.Step } } -func CreatePendingRecord(substrateClient *substrate.Substrate, db models.DB, systemMnemonic string, sse *internal.SSEManager) ewf.StepFn { - return func(ctx context.Context, state ewf.State) error { - amountVal, ok := state["amount"] - if !ok { - return fmt.Errorf("missing 'amount' in state") - } - - amount, ok := amountVal.(uint64) - if !ok { - return fmt.Errorf("'amount' in state is not a uint64") - } - amountUSD := internal.FromUSDMilliCentToUSD(amount) - - userIDVal, ok := state["user_id"] - if !ok { - return fmt.Errorf("missing 'user_id' in state") - } - userID, ok := userIDVal.(int) - if !ok { - return fmt.Errorf("'user_id' in state is not an int") - } - - usernameVal, ok := state["username"] - if !ok { - return fmt.Errorf("missing 'username' in state") - } - username, ok := usernameVal.(string) - if !ok { - return fmt.Errorf("'username' in state is not a string") - } - - transferModeVal, ok := state["transfer_mode"] - if !ok { - return fmt.Errorf("missing 'transfer_mode' in state") - } - transferMode, ok := transferModeVal.(string) - if !ok { - return fmt.Errorf("'transfer_mode' in state is not a string") - } - - requestedTFTs, err := internal.FromUSDMillicentToTFT(substrateClient, amount) - if err != nil { - log.Error().Err(err).Msg("error converting usd") - return err - } - - if err = db.CreatePendingRecord(&models.PendingRecord{ - UserID: userID, - Username: username, - TFTAmount: requestedTFTs, - TransferMode: transferMode, - }); err != nil { - log.Error().Err(err).Send() - return err - } - - if transferMode == models.RedeemVoucherMode && sse != nil { - notificationData := map[string]interface{}{ - "message": fmt.Sprintf("Voucher redeemed successfully for %.2f$", amountUSD), - } - sse.Notify(fmt.Sprintf("%d", userID), internal.Success, notificationData) - } - - return nil - } -} - func UpdateCreditCardBalanceStep(db models.DB) ewf.StepFn { return func(ctx context.Context, state ewf.State) error { userIDVal, ok := state["user_id"] @@ -459,42 +392,6 @@ func UpdateCreditCardBalanceStep(db models.DB) ewf.StepFn { return fmt.Errorf("error updating user: %w", err) } - state["new_balance"] = user.CreditCardBalance - state["mnemonic"] = user.Mnemonic - return nil - } -} - -func UpdateCreditedBalanceStep(db models.DB) ewf.StepFn { - return func(ctx context.Context, state ewf.State) error { - userIDVal, ok := state["user_id"] - if !ok { - return fmt.Errorf("missing 'user_id' in state") - } - userID, ok := userIDVal.(int) - if !ok { - return fmt.Errorf("'user_id' in state is not an int") - } - - amountVal, ok := state["amount"] - if !ok { - return fmt.Errorf("missing 'amount' in state") - } - amount, ok := amountVal.(uint64) - if !ok { - return fmt.Errorf("'amount' in state is not a uint64") - } - - user, err := db.GetUserByID(userID) - if err != nil { - return fmt.Errorf("user is not found: %w", err) - } - - user.CreditedBalance += amount - if err := db.UpdateUserByID(&user); err != nil { - return fmt.Errorf("error updating user: %w", err) - } - state["new_balance"] = user.CreditedBalance return nil } } diff --git a/backend/internal/activities/workflow.go b/backend/internal/activities/workflow.go index 0c4b86e6f..a2b44267e 100644 --- a/backend/internal/activities/workflow.go +++ b/backend/internal/activities/workflow.go @@ -31,12 +31,10 @@ func RegisterEWFWorkflows( engine.Register(StepCreateKYCSponsorship, CreateKYCSponsorship(kycClient, sse, sponsorAddress, sponsorKeyPair, db)) engine.Register(StepSendWelcomeEmail, SendWelcomeEmailStep(mail, config, metrics)) engine.Register(StepCreatePaymentIntent, CreatePaymentIntentStep(config.Currency, metrics)) - engine.Register(StepCreatePendingRecord, CreatePendingRecord(substrate, db, config.SystemAccount.Mnemonic, sse)) engine.Register(StepUpdateCreditCardBalance, UpdateCreditCardBalanceStep(db)) engine.Register(StepCreateIdentity, CreateIdentityStep()) engine.Register(StepReserveNode, ReserveNodeStep(db, substrate)) engine.Register(StepUnreserveNode, UnreserveNodeStep(db, substrate)) - engine.Register(StepUpdateCreditedBalance, UpdateCreditedBalanceStep(db)) registerWorkflowTemplate := newKubecloudWorkflowTemplate() registerWorkflowTemplate.Steps = []ewf.Step{ @@ -80,24 +78,9 @@ func RegisterEWFWorkflows( chargeBalanceTemplate.Steps = []ewf.Step{ {Name: StepCreatePaymentIntent, RetryPolicy: &ewf.RetryPolicy{MaxAttempts: 2, BackOff: ewf.ConstantBackoff(2 * time.Second)}}, {Name: StepUpdateCreditCardBalance, RetryPolicy: &ewf.RetryPolicy{MaxAttempts: 2, BackOff: ewf.ConstantBackoff(2 * time.Second)}}, - {Name: StepCreatePendingRecord, RetryPolicy: &ewf.RetryPolicy{MaxAttempts: 2, BackOff: ewf.ConstantBackoff(2 * time.Second)}}, } engine.RegisterTemplate(WorkflowChargeBalance, &chargeBalanceTemplate) - adminCreditBalanceTemplate := newKubecloudWorkflowTemplate() - adminCreditBalanceTemplate.Steps = []ewf.Step{ - {Name: StepUpdateCreditedBalance, RetryPolicy: &ewf.RetryPolicy{MaxAttempts: 2, BackOff: ewf.ConstantBackoff(2 * time.Second)}}, - {Name: StepCreatePendingRecord, RetryPolicy: &ewf.RetryPolicy{MaxAttempts: 2, BackOff: ewf.ConstantBackoff(2 * time.Second)}}, - } - engine.RegisterTemplate(WorkflowAdminCreditBalance, &adminCreditBalanceTemplate) - - redeemVoucherTemplate := newKubecloudWorkflowTemplate() - redeemVoucherTemplate.Steps = []ewf.Step{ - {Name: StepUpdateCreditedBalance, RetryPolicy: &ewf.RetryPolicy{MaxAttempts: 2, BackOff: ewf.ConstantBackoff(2 * time.Second)}}, - {Name: StepCreatePendingRecord, RetryPolicy: &ewf.RetryPolicy{MaxAttempts: 2, BackOff: ewf.ConstantBackoff(2 * time.Second)}}, - } - engine.RegisterTemplate(WorkflowRedeemVoucher, &redeemVoucherTemplate) - reserveNodeTemplate := newKubecloudWorkflowTemplate() reserveNodeTemplate.Steps = []ewf.Step{ {Name: StepCreateIdentity, RetryPolicy: &ewf.RetryPolicy{MaxAttempts: 2, BackOff: ewf.ConstantBackoff(2 * time.Second)}}, diff --git a/backend/internal/chain_account.go b/backend/internal/chain_account.go index 7d3a16793..a6509f9cc 100644 --- a/backend/internal/chain_account.go +++ b/backend/internal/chain_account.go @@ -91,14 +91,14 @@ func ActivateAccount(substrateAccountID string, url string) error { } // TransferTFTs transfer balance to users' account -func TransferTFTs(substrateClient *substrate.Substrate, tftBalance uint64, userMnemonic string, systemIdentity substrate.Identity) error { +func TransferTFTs(substrateClient *substrate.Substrate, tftBalance uint64, destinationMnemonic string, sourceIdentity substrate.Identity) error { // Create identity of user from mnemonic - userIdentity, err := substrate.NewIdentityFromSr25519Phrase(userMnemonic) + destinationIdentity, err := substrate.NewIdentityFromSr25519Phrase(destinationMnemonic) if err != nil { return err } - return substrateClient.Transfer(systemIdentity, tftBalance, substrate.AccountID(userIdentity.PublicKey())) + return substrateClient.Transfer(sourceIdentity, tftBalance, substrate.AccountID(destinationIdentity.PublicKey())) } // GetUserBalanceUSDMillicent gets balance of user in USD Millicent diff --git a/backend/internal/config.go b/backend/internal/config.go index cb1143d6e..b46a2bb08 100644 --- a/backend/internal/config.go +++ b/backend/internal/config.go @@ -10,28 +10,30 @@ import ( ) type Configuration struct { - Server Server `json:"server" validate:"required,dive"` - Database DB `json:"database" validate:"required"` - JwtToken JwtToken `json:"jwt_token" validate:"required"` - Admins []string `json:"admins" validate:"required"` - MailSender MailSender `json:"mailSender"` - Currency string `json:"currency" validate:"required"` - StripeSecret string `json:"stripe_secret" validate:"required"` - VoucherNameLength int `json:"voucher_name_length" validate:"required,gt=0"` - GridProxyURL string `json:"gridproxy_url" validate:"required"` - TFChainURL string `json:"tfchain_url" validate:"required"` - TermsANDConditions TermsANDConditions `json:"terms_and_conditions"` - ActivationServiceURL string `json:"activation_service_url" validate:"required"` - GraphqlURL string `json:"graphql_url" validate:"required"` - FiresquidURL string `json:"firesquid_url" validate:"required"` - SystemAccount GridAccount `json:"system_account"` - Redis Redis `json:"redis" validate:"required,dive"` - DeployerWorkersNum int `json:"deployer_workers_num" default:"1"` - Invoice InvoiceCompanyData `json:"invoice"` - SSH SSHConfig `json:"ssh" validate:"required,dive"` - Debug bool `json:"debug"` - MonitorBalanceIntervalInMinutes int `json:"monitor_balance_interval_in_minutes" validate:"required,gt=0"` - NotifyAdminsForPendingRecordsInHours int `json:"notify_admins_for_pending_records_in_hours" validate:"required,gt=0"` + Server Server `json:"server" validate:"required,dive"` + Database DB `json:"database" validate:"required"` + JwtToken JwtToken `json:"jwt_token" validate:"required"` + Admins []string `json:"admins" validate:"required"` + MailSender MailSender `json:"mailSender"` + Currency string `json:"currency" validate:"required"` + StripeSecret string `json:"stripe_secret" validate:"required"` + VoucherNameLength int `json:"voucher_name_length" validate:"required,gt=0"` + GridProxyURL string `json:"gridproxy_url" validate:"required"` + TFChainURL string `json:"tfchain_url" validate:"required"` + TermsANDConditions TermsANDConditions `json:"terms_and_conditions"` + ActivationServiceURL string `json:"activation_service_url" validate:"required"` + GraphqlURL string `json:"graphql_url" validate:"required"` + FiresquidURL string `json:"firesquid_url" validate:"required"` + SystemAccount GridAccount `json:"system_account"` + Redis Redis `json:"redis" validate:"required,dive"` + DeployerWorkersNum int `json:"deployer_workers_num" default:"1"` + Invoice InvoiceCompanyData `json:"invoice"` + SSH SSHConfig `json:"ssh" validate:"required,dive"` + Debug bool `json:"debug"` + SettleTransferRecordsIntervalInMinutes int `json:"settle_transfer_records_interval_in_minutes" validate:"required,gt=0"` + NotifyAdminsForPendingRecordsInHours int `json:"notify_admins_for_pending_records_in_hours" validate:"required,gt=0"` + AppliedDiscount string `json:"applied_discount" validate:"required"` + MinimumTFTAmountInWallet int `json:"minimum_tft_amount_in_wallet" default:"10" validate:"required,gt=0"` // KYC Verifier config KYCVerifierAPIURL string `json:"kyc_verifier_api_url" validate:"required,url"` diff --git a/backend/internal/contracts_billing.go b/backend/internal/contracts_billing.go index 7bf621399..b0f991c1e 100644 --- a/backend/internal/contracts_billing.go +++ b/backend/internal/contracts_billing.go @@ -17,16 +17,15 @@ type ContractBillReports struct { } type Report struct { - ContractID string `json:"contractID"` - Timestamp string `json:"timestamp"` - AmountBilled string `json:"amountBilled"` + ContractID string `json:"contractID"` + Timestamp string `json:"timestamp"` + AmountBilled string `json:"amountBilled"` + DiscountRecieved string `json:"discountRecieved"` } -// ListContractBillReportsPerMonth returns bill reports for contract ID month ago -func ListContractBillReportsPerMonth(graphqlClient graphql.GraphQl, contractID uint64, currentTime time.Time) (ContractBillReports, error) { - monthAgo := currentTime.AddDate(0, -1, 0) - - options := fmt.Sprintf(`(where: {contractID_eq: %v, timestamp_lte: %v, timestamp_gte: %v}, orderBy: id_ASC)`, contractID, currentTime.Unix(), monthAgo.Unix()) +// ListContractBillReports returns bill reports for contract ID month ago +func ListContractBillReports(graphqlClient graphql.GraphQl, contractID uint64, startTime, endTime time.Time) (ContractBillReports, error) { + options := fmt.Sprintf(`(where: {contractID_eq: %v, timestamp_lte: %v, timestamp_gte: %v}, orderBy: id_ASC)`, contractID, endTime.Unix(), startTime.Unix()) billingReportsCount, err := graphqlClient.GetItemTotalCount("contractBillReports", options) if err != nil { return ContractBillReports{}, err @@ -36,8 +35,9 @@ func ListContractBillReportsPerMonth(graphqlClient graphql.GraphQl, contractID u contractID timestamp amountBilled + discountRecieved } - }`, contractID, currentTime.Unix(), monthAgo.Unix()), + }`, contractID, endTime.Unix(), startTime.Unix()), map[string]interface{}{ "billingReportsCount": billingReportsCount, }) @@ -60,7 +60,7 @@ func ListContractBillReportsPerMonth(graphqlClient graphql.GraphQl, contractID u } // TODO: check returned float or int -func AmountBilledPerMonth(reports ContractBillReports) (uint64, error) { +func CalculateTotalAmountBilledForReports(reports ContractBillReports) (uint64, error) { var totalAmount uint64 for _, report := range reports.Reports { amount, err := strconv.ParseInt(report.AmountBilled, 10, 64) diff --git a/backend/models/db.go b/backend/models/db.go index e6fe500cc..f4d25be84 100644 --- a/backend/models/db.go +++ b/backend/models/db.go @@ -15,6 +15,7 @@ type DB interface { GetUserByEmail(email string) (User, error) GetUserByID(userID int) (User, error) UpdateUserByID(user *User) error + DeductUserBalance(user *User, amount uint64) error UpdatePassword(email string, hashedPassword []byte) error ListAllUsers() ([]User, error) ListAdmins() ([]User, error) @@ -32,6 +33,7 @@ type DB interface { UpdateInvoicePDF(id int, data []byte) error CreateUserNode(userNode *UserNodes) error ListUserNodes(userID int) ([]UserNodes, error) + DeleteUserNode(contractID uint32) error // SSH Key methods CreateSSHKey(sshKey *SSHKey) error ListUserSSHKeys(userID int) ([]SSHKey, error) @@ -53,12 +55,12 @@ type DB interface { UpdateCluster(cluster *Cluster) error DeleteCluster(userID string, projectName string) error DeleteAllUserClusters(userID string) error - // pending records methods - CreatePendingRecord(record *PendingRecord) error - ListAllPendingRecords() ([]PendingRecord, error) - ListOnlyPendingRecords() ([]PendingRecord, error) - ListUserPendingRecords(userID int) ([]PendingRecord, error) - UpdatePendingRecordTransferredAmount(id int, amount uint64) error + // Transfer records methods + CreateTransferRecord(record *TransferRecord) error + ListTransferRecords() ([]TransferRecord, error) + ListUserTransferRecords(userID int) ([]TransferRecord, error) + ListPendingTransferRecords() ([]TransferRecord, error) + UpdateTransferRecordState(recordID int, state state, failure string) error // stats methods CountAllUsers() (int64, error) CountAllClusters() (int64, error) diff --git a/backend/models/gorm.go b/backend/models/gorm.go index a024dd7fb..99eed4686 100644 --- a/backend/models/gorm.go +++ b/backend/models/gorm.go @@ -33,36 +33,13 @@ func NewGormStorage(dialector gorm.Dialector) (DB, error) { &Notification{}, &SSHKey{}, &Cluster{}, - &PendingRecord{}, + &TransferRecord{}, ) if err != nil { return nil, err } - gormDB := &GormDB{db: db} - return gormDB, gormDB.UpdatePendingRecordsWithUsername() -} - -// TODO: TO BE REMOVED -func (s *GormDB) UpdatePendingRecordsWithUsername() error { - var pendingRecords []PendingRecord - if err := s.db.Find(&pendingRecords).Where("username IS NULL").Error; err != nil { - return fmt.Errorf("failed to find pending records: %w", err) - } - - for _, record := range pendingRecords { - user, err := s.GetUserByID(record.UserID) - if err != nil { - return fmt.Errorf("failed to get user by ID %d: %w", record.UserID, err) - } - - // Update the record with the username - if err := s.db.Model(&record).Update("username", user.Username).Error; err != nil { - return fmt.Errorf("failed to update pending record with username: %w", err) - } - } - - return nil + return &GormDB{db: db}, nil } func (s *GormDB) GetDB() *gorm.DB { @@ -114,6 +91,36 @@ func (s *GormDB) UpdateUserByID(user *User) error { Updates(user).Error } +func (s *GormDB) DeductUserBalance(user *User, amount uint64) error { + if user.CreditedBalance >= amount { + return s.db.Model(&User{}). + Where("id = ?", user.ID). + UpdateColumn("credited_balance", gorm.Expr("credited_balance - ?", amount)). + Error + } + + if user.CreditCardBalance >= amount-user.CreditedBalance { + return s.db.Model(&User{}). + Where("id = ?", user.ID). + UpdateColumn("credited_balance", gorm.Expr("credited_balance - ?", user.CreditedBalance)). + UpdateColumn("credit_card_balance", gorm.Expr("credit_card_balance - ?", amount-user.CreditedBalance)). + Error + } + + if user.CreditCardBalance >= amount { + return s.db.Model(&User{}). + Where("id = ?", user.ID). + UpdateColumn("credit_card_balance", gorm.Expr("credit_card_balance - ?", amount)). + Error + } + + // if credit card balance is not enough, add debt + return s.db.Model(&User{}). + Where("id = ?", user.ID). + UpdateColumn("debt", gorm.Expr("debt + ?", amount)). + Error +} + // UpdatePassword updates password of user by its email func (s *GormDB) UpdatePassword(email string, hashedPassword []byte) error { result := s.db.Model(&User{}). @@ -282,6 +289,10 @@ func (s *GormDB) CreateUserNode(userNode *UserNodes) error { return s.db.Create(&userNode).Error } +func (s *GormDB) DeleteUserNode(contractID uint32) error { + return s.db.Delete(&UserNodes{}, "contract_id = ?", contractID).Error +} + // ListUserNodes returns all nodes records for user by its ID func (s *GormDB) ListUserNodes(userID int) ([]UserNodes, error) { var userNodes []UserNodes @@ -365,32 +376,29 @@ func (s *GormDB) DeleteAllUserClusters(userID string) error { return s.db.Where("user_id = ?", userID).Delete(&Cluster{}).Error } -func (s *GormDB) CreatePendingRecord(record *PendingRecord) error { +func (s *GormDB) CreateTransferRecord(record *TransferRecord) error { record.CreatedAt = time.Now() return s.db.Create(record).Error } -func (s *GormDB) ListAllPendingRecords() ([]PendingRecord, error) { - var pendingRecords []PendingRecord - return pendingRecords, s.db.Find(&pendingRecords).Error +func (s *GormDB) ListTransferRecords() ([]TransferRecord, error) { + var TransferRecords []TransferRecord + return TransferRecords, s.db.Find(&TransferRecords).Error } -func (s *GormDB) ListOnlyPendingRecords() ([]PendingRecord, error) { - var pendingRecords []PendingRecord - return pendingRecords, s.db.Where("tft_amount > transferred_tft_amount").Find(&pendingRecords).Error +func (s *GormDB) ListUserTransferRecords(userID int) ([]TransferRecord, error) { + var TransferRecords []TransferRecord + return TransferRecords, s.db.Where("user_id = ?", userID).Find(&TransferRecords).Error } -func (s *GormDB) ListUserPendingRecords(userID int) ([]PendingRecord, error) { - var pendingRecords []PendingRecord - return pendingRecords, s.db.Where("user_id = ?", userID).Find(&pendingRecords).Error +func (s *GormDB) ListPendingTransferRecords() ([]TransferRecord, error) { + var TransferRecords []TransferRecord + return TransferRecords, s.db.Where("state = ?", PendingState).Find(&TransferRecords).Error } -func (s *GormDB) UpdatePendingRecordTransferredAmount(id int, amount uint64) error { - return s.db.Model(&PendingRecord{}). - Where("id = ?", id). - UpdateColumn("transferred_tft_amount", gorm.Expr("transferred_tft_amount + ?", amount)). - UpdateColumn("updated_at", gorm.Expr("?", time.Now())). - Error +func (s *GormDB) UpdateTransferRecordState(recordID int, state state, failure string) error { + return s.db.Model(&TransferRecord{}).Where("id = ?", recordID).Updates( + map[string]interface{}{"state": state, "failure": failure, "updated_at": time.Now()}).Error } // CountAllUsers returns the total number of users in the system diff --git a/backend/models/pending_record.go b/backend/models/pending_record.go deleted file mode 100644 index 476d316d7..000000000 --- a/backend/models/pending_record.go +++ /dev/null @@ -1,21 +0,0 @@ -package models - -import "time" - -const ( - ChargeBalanceMode = "charge_balance" - RedeemVoucherMode = "redeem_voucher" - AdminCreditMode = "admin_credit" -) - -type PendingRecord struct { - ID int `json:"id" gorm:"primaryKey;autoIncrement"` - UserID int `json:"user_id" gorm:"not null"` - Username string `json:"username"` - // TFTs are multiplied by 1e7 - TFTAmount uint64 `json:"tft_amount" gorm:"not null"` - TransferredTFTAmount uint64 `json:"transferred_tft_amount" gorm:"not null"` - TransferMode string `json:"transfer_mode"` - CreatedAt time.Time `json:"created_at" gorm:"not null"` - UpdatedAt time.Time `json:"updated_at" gorm:"not null"` -} diff --git a/backend/models/transfer_record.go b/backend/models/transfer_record.go new file mode 100644 index 000000000..a499408e8 --- /dev/null +++ b/backend/models/transfer_record.go @@ -0,0 +1,27 @@ +package models + +import "time" + +type operation string +type state string + +const ( + WithdrawOperation operation = "withdraw" + DepositOperation operation = "deposit" + + FailedState state = "failed" + SuccessState state = "success" + PendingState state = "pending" +) + +type TransferRecord struct { + ID int `json:"id" gorm:"primaryKey;autoIncrement"` + UserID int `json:"user_id" gorm:"not null"` + Username string `json:"username"` + TFTAmount uint64 `json:"tft_amount" gorm:"not null"` // TFTs are multiplied by 1e7 + Operation operation `json:"operation" gorm:"not null"` + State state `json:"state" gorm:"not null;default:pending"` + Failure string `json:"failure" gorm:"not null"` + CreatedAt time.Time `json:"created_at" gorm:"not null"` + UpdatedAt time.Time `json:"updated_at" gorm:"not null"` +} From 684727791b9ef19c528c66aa1ed75b0ea87381d6 Mon Sep 17 00:00:00 2001 From: rawdaGastan Date: Mon, 8 Sep 2025 16:48:44 +0300 Subject: [PATCH 02/16] depend on usd in invoices and fix tests --- backend/app/admin_handler_test.go | 20 +++--- backend/app/invoice_handler.go | 12 ++-- backend/app/settle_usage.go | 2 + backend/app/user_handler_test.go | 101 +----------------------------- 4 files changed, 18 insertions(+), 117 deletions(-) diff --git a/backend/app/admin_handler_test.go b/backend/app/admin_handler_test.go index be8027134..0da1dd6dd 100644 --- a/backend/app/admin_handler_test.go +++ b/backend/app/admin_handler_test.go @@ -332,7 +332,7 @@ func TestCreditUserHandler(t *testing.T) { var result map[string]interface{} err := json.Unmarshal(resp.Body.Bytes(), &result) assert.NoError(t, err) - assert.Equal(t, "Transaction is created successfully, Money transfer is in progress", result["message"]) + assert.Equal(t, "User is credited with 1$ successfully", result["message"]) assert.NotNil(t, result["data"]) data, ok := result["data"].(map[string]interface{}) assert.True(t, ok) @@ -411,7 +411,7 @@ func TestCreditUserHandler(t *testing.T) { }) } -func TestListPendingRecordsHandler(t *testing.T) { +func TestListTransferRecordsHandler(t *testing.T) { app, err := SetUp(t) require.NoError(t, err) router := app.router @@ -419,34 +419,34 @@ func TestListPendingRecordsHandler(t *testing.T) { adminUser := CreateTestUser(t, app, "admin@example.com", "Admin User", []byte("securepassword"), true, true, false, 0, time.Now()) nonAdminUser := CreateTestUser(t, app, "user@example.com", "Normal User", []byte("securepassword"), true, false, false, 0, time.Now()) - t.Run("Test ListPendingRecordsHandler successfully", func(t *testing.T) { + t.Run("Test ListTransferRecordsHandler successfully", func(t *testing.T) { token := GetAuthToken(t, app, adminUser.ID, adminUser.Email, adminUser.Username, true) - req, _ := http.NewRequest("GET", "/api/v1/pending-records", nil) + req, _ := http.NewRequest("GET", "/api/v1/transfer-records", nil) req.Header.Set("Authorization", "Bearer "+token) resp := httptest.NewRecorder() router.ServeHTTP(resp, req) assert.Equal(t, http.StatusOK, resp.Code) }) - t.Run("Test ListPendingRecordsHandler with no token", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/api/v1/pending-records", nil) + t.Run("Test ListTransferRecordsHandler with no token", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/v1/transfer-records", nil) resp := httptest.NewRecorder() router.ServeHTTP(resp, req) assert.Equal(t, http.StatusUnauthorized, resp.Code) }) - t.Run("Test ListPendingRecordsHandler with non-admin user", func(t *testing.T) { + t.Run("Test ListTransferRecordsHandler with non-admin user", func(t *testing.T) { token := GetAuthToken(t, app, nonAdminUser.ID, nonAdminUser.Email, nonAdminUser.Username, false) - req, _ := http.NewRequest("GET", "/api/v1/pending-records", nil) + req, _ := http.NewRequest("GET", "/api/v1/transfer-records", nil) req.Header.Set("Authorization", "Bearer "+token) resp := httptest.NewRecorder() router.ServeHTTP(resp, req) assert.Equal(t, http.StatusForbidden, resp.Code) }) - t.Run("Test ListPendingRecordsHandler with non-existing user", func(t *testing.T) { + t.Run("Test ListTransferRecordsHandler with non-existing user", func(t *testing.T) { token := GetAuthToken(t, app, adminUser.ID, adminUser.Email, adminUser.Username, true) - req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/pending-records/%d", nonAdminUser.ID+1), nil) + req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/transfer-records/%d", nonAdminUser.ID+1), nil) req.Header.Set("Authorization", "Bearer "+token) resp := httptest.NewRecorder() router.ServeHTTP(resp, req) diff --git a/backend/app/invoice_handler.go b/backend/app/invoice_handler.go index c27a119ba..d8e917c98 100644 --- a/backend/app/invoice_handler.go +++ b/backend/app/invoice_handler.go @@ -10,8 +10,9 @@ import ( "time" - "github.com/gin-gonic/gin" "kubecloud/internal/logger" + + "github.com/gin-gonic/gin" ) // @Summary Get all invoices @@ -194,14 +195,11 @@ func (h *Handler) createUserInvoice(user models.User) error { return err } - totalAmountTFT, err := internal.CalculateTotalAmountBilledForReports(billReports) - if err != nil { - return err - } - totalAmountUSDMillicent, err := internal.FromTFTtoUSDMillicent(h.substrateClient, totalAmountTFT) + totalAmountBilledInUSDMillicent, err := h.calculateTotalUsageOfReportsInUSDMillicent(billReports.Reports) if err != nil { return err } + rentRecordStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) if record.CreatedAt.After(rentRecordStart) { rentRecordStart = record.CreatedAt @@ -218,7 +216,7 @@ func (h *Handler) createUserInvoice(user models.User) error { totalHours = GetHoursOfGivenPeriod(rentRecordStart, cancellationDate) } - totalAmountUSD := internal.FromUSDMilliCentToUSD(totalAmountUSDMillicent) + totalAmountUSD := internal.FromUSDMilliCentToUSD(totalAmountBilledInUSDMillicent) nodeItems = append(nodeItems, models.NodeItem{ NodeID: record.NodeID, diff --git a/backend/app/settle_usage.go b/backend/app/settle_usage.go index f96c75665..1139fd505 100644 --- a/backend/app/settle_usage.go +++ b/backend/app/settle_usage.go @@ -16,6 +16,8 @@ type DiscountPackage struct { Discount int } +// DeductBalanceBasedOnUsage deducts the user balance based on the usage +// This function is called every 24 hours func (h *Handler) DeductBalanceBasedOnUsage() { usageDeductionTicker := time.NewTicker(24 * time.Hour) defer usageDeductionTicker.Stop() diff --git a/backend/app/user_handler_test.go b/backend/app/user_handler_test.go index f4d9a9517..397c64947 100644 --- a/backend/app/user_handler_test.go +++ b/backend/app/user_handler_test.go @@ -614,47 +614,6 @@ func TestGetUserHandler(t *testing.T) { } -func TestGetUserBalanceHandler(t *testing.T) { - app, err := SetUp(t) - require.NoError(t, err) - router := app.router - t.Run("Test Get balance successfully", func(t *testing.T) { - - user := CreateTestUser(t, app, "balanceuser@example.com", "Balance User", []byte("securepassword"), true, false, true, 0, time.Now()) - - assert.NoError(t, err) - token := GetAuthToken(t, app, user.ID, user.Email, user.Username, false) - req, _ := http.NewRequest("GET", "/api/v1/user/balance", nil) - req.Header.Set("Authorization", "Bearer "+token) - resp := httptest.NewRecorder() - router.ServeHTTP(resp, req) - assert.Equal(t, http.StatusOK, resp.Code) - var result map[string]interface{} - err = json.Unmarshal(resp.Body.Bytes(), &result) - assert.NoError(t, err) - assert.Equal(t, "Balance is fetched", result["message"]) - assert.NotNil(t, result["data"]) - data := result["data"].(map[string]interface{}) - assert.Contains(t, data, "balance_usd") - assert.Contains(t, data, "debt_usd") - }) - - t.Run("Test Get balance for non-existing user", func(t *testing.T) { - - token := GetAuthToken(t, app, 999, "notfound@example.com", "Not Found", false) - req, _ := http.NewRequest("GET", "/api/v1/user/balance", nil) - req.Header.Set("Authorization", "Bearer "+token) - resp := httptest.NewRecorder() - router.ServeHTTP(resp, req) - assert.Equal(t, http.StatusNotFound, resp.Code) - var result map[string]interface{} - err = json.Unmarshal(resp.Body.Bytes(), &result) - assert.NoError(t, err) - assert.Contains(t, result["message"], "User is not found") - }) - -} - func TestRedeemVoucherHandler(t *testing.T) { app, err := SetUp(t) require.NoError(t, err) @@ -682,9 +641,7 @@ func TestRedeemVoucherHandler(t *testing.T) { var result map[string]interface{} err = json.Unmarshal(resp.Body.Bytes(), &result) assert.NoError(t, err) - assert.Equal(t, "Voucher is redeemed successfully. Money transfer in progress.", result["message"]) - assert.NotNil(t, result["data"]) - assert.NotEmpty(t, result["data"].(map[string]interface{})["workflow_id"]) + assert.Equal(t, "Voucher with value 50$ is redeemed successfully.", result["message"]) }) t.Run("Test redeem non-existing voucher", func(t *testing.T) { @@ -899,59 +856,3 @@ func TestAddSSHKeyHandler(t *testing.T) { }) } - -func TestListUserPendingRecordsHandler(t *testing.T) { - app, err := SetUp(t) - require.NoError(t, err) - router := app.router - user := CreateTestUser(t, app, "pendinguser@example.com", "Pending User", []byte("securepassword"), true, false, false, 0, time.Now()) - token := GetAuthToken(t, app, user.ID, user.Email, user.Username, false) - t.Run("Test list user pending records successfully", func(t *testing.T) { - req, err := http.NewRequest("GET", "/api/v1/user/pending-records", nil) - assert.NoError(t, err) - req.Header.Set("Authorization", "Bearer "+token) - resp := httptest.NewRecorder() - router.ServeHTTP(resp, req) - assert.Equal(t, http.StatusOK, resp.Code) - var result map[string]interface{} - err = json.Unmarshal(resp.Body.Bytes(), &result) - assert.NoError(t, err) - assert.Equal(t, "Pending records are retrieved successfully", result["message"]) - assert.NotNil(t, result["data"]) - }) - - t.Run("Test list user pending records with no records", func(t *testing.T) { - req, err := http.NewRequest("GET", "/api/v1/user/pending-records", nil) - assert.NoError(t, err) - - req.Header.Set("Authorization", "Bearer "+token) - resp := httptest.NewRecorder() - router.ServeHTTP(resp, req) - assert.Equal(t, http.StatusOK, resp.Code) - var result map[string]interface{} - err = json.Unmarshal(resp.Body.Bytes(), &result) - assert.NoError(t, err) - assert.Equal(t, "Pending records are retrieved successfully", result["message"]) - assert.NotNil(t, result["data"]) - }) - - t.Run("Test list user pending records with no token", func(t *testing.T) { - req, err := http.NewRequest("GET", "/api/v1/user/pending-records", nil) - assert.NoError(t, err) - - resp := httptest.NewRecorder() - router.ServeHTTP(resp, req) - assert.Equal(t, http.StatusUnauthorized, resp.Code) - }) - - t.Run("Test list user pending records with invalid token", func(t *testing.T) { - req, err := http.NewRequest("GET", "/api/v1/user/pending-records", nil) - assert.NoError(t, err) - - req.Header.Set("Authorization", "Bearer invalidtoken") - resp := httptest.NewRecorder() - router.ServeHTTP(resp, req) - assert.Equal(t, http.StatusUnauthorized, resp.Code) - }) - -} From be8fe7a3a541f61941cff1a711f054a12f44b45a Mon Sep 17 00:00:00 2001 From: rawdaGastan Date: Wed, 17 Sep 2025 14:58:57 +0300 Subject: [PATCH 03/16] save last date the user got billed --- backend/app/settle_usage.go | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/backend/app/settle_usage.go b/backend/app/settle_usage.go index 1139fd505..c1dc81606 100644 --- a/backend/app/settle_usage.go +++ b/backend/app/settle_usage.go @@ -4,11 +4,16 @@ import ( "kubecloud/internal" "math" "strconv" + "sync" "time" "github.com/rs/zerolog/log" ) +// Map to store the last calculation time for each user +var lastCalculationTimeByUser = make(map[int]time.Time) +var lastCalculationTimeByUserMutex = sync.RWMutex{} + type discount string type DiscountPackage struct { @@ -54,17 +59,23 @@ func (h *Handler) getUserDailyUsageInUSD(userID int) (uint64, error) { } now := time.Now() - - // Define the start of the day at 00:00 - startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local) // Define the end of the day (next day at 00:00) endOfDay := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.Local) + // Get the last calculation time for this user, or use a default if not available + lastCalculationTimeByUserMutex.RLock() + lastCalcTime, exists := lastCalculationTimeByUser[userID] + lastCalculationTimeByUserMutex.RUnlock() + if !exists { + // If this is the first time, use the start of the day as default + lastCalcTime = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local) + } + var totalDailyUsageInUSDMillicent uint64 for _, record := range records { - // get bill reports for the day - billReports, err := internal.ListContractBillReports(h.graphqlClient, record.ContractID, startOfDay, endOfDay) + // Get bill reports from the last calculation time to the end of day + billReports, err := internal.ListContractBillReports(h.graphqlClient, record.ContractID, lastCalcTime, endOfDay) if err != nil { return 0, err } @@ -77,6 +88,11 @@ func (h *Handler) getUserDailyUsageInUSD(userID int) (uint64, error) { totalDailyUsageInUSDMillicent += totalAmountBilledInUSDMillicent } + // Update the last calculation time for this user + lastCalculationTimeByUserMutex.Lock() + lastCalculationTimeByUser[userID] = now + lastCalculationTimeByUserMutex.Unlock() + return totalDailyUsageInUSDMillicent, nil } From 3101c50df916932d2ebc5cda3b4282a765acdcad Mon Sep 17 00:00:00 2001 From: rawdaGastan Date: Wed, 17 Sep 2025 15:40:06 +0300 Subject: [PATCH 04/16] handle margin in tft zero balance --- backend/app/admin_handler.go | 27 +++++++++-------- backend/app/balance_monitor.go | 42 +++++++++++++++++++-------- backend/app/node_handler.go | 2 +- backend/app/user_handler.go | 53 +++++++++++++++++----------------- 4 files changed, 70 insertions(+), 54 deletions(-) diff --git a/backend/app/admin_handler.go b/backend/app/admin_handler.go index 977ccbc9b..94a44352b 100644 --- a/backend/app/admin_handler.go +++ b/backend/app/admin_handler.go @@ -325,31 +325,30 @@ func (h *Handler) CreditUserHandler(c *gin.Context) { return } - user.CreditedBalance += internal.FromUSDToUSDMillicent(request.AmountUSD) + millicentAmount := internal.FromUSDToUSDMillicent(request.AmountUSD) + user.CreditedBalance += millicentAmount if err := h.db.UpdateUserByID(&user); err != nil { log.Error().Err(err).Send() InternalServerError(c) return } - tftAmount, err := internal.GetUserTFTBalance(h.substrateClient, user.Mnemonic) + tftAmount, err := internal.FromUSDMillicentToTFT(h.substrateClient, millicentAmount) if err != nil { - log.Error().Err(err).Send() + log.Error().Err(err).Msg("Failed to convert USD millicent to TFT") InternalServerError(c) return } - if tftAmount == 0 { - if err := h.db.CreateTransferRecord(&models.TransferRecord{ - UserID: id, - Username: user.Username, - TFTAmount: uint64(h.config.MinimumTFTAmountInWallet) * 1e7, - Operation: models.DepositOperation, - }); err != nil { - log.Error().Err(err).Send() - InternalServerError(c) - return - } + if err := h.db.CreateTransferRecord(&models.TransferRecord{ + UserID: id, + Username: user.Username, + TFTAmount: tftAmount, + Operation: models.DepositOperation, + }); err != nil { + log.Error().Err(err).Send() + InternalServerError(c) + return } Success(c, http.StatusCreated, fmt.Sprintf("User is credited with %v$ successfully", request.AmountUSD), CreditUserResponse{ diff --git a/backend/app/balance_monitor.go b/backend/app/balance_monitor.go index 47637be55..27944f269 100644 --- a/backend/app/balance_monitor.go +++ b/backend/app/balance_monitor.go @@ -15,17 +15,26 @@ import ( "github.com/threefoldtech/tfgrid-sdk-go/grid-proxy/pkg/types" ) +const zeroTFTBalanceValue = 0.05 * 1e7 // 0.05 TFT + func (h *Handler) MonitorSystemBalanceAndHandleSettlement(ctx context.Context) { settleTransfersTicker := time.NewTicker(time.Duration(h.config.SettleTransferRecordsIntervalInMinutes) * time.Minute) adminNotifyTicker := time.NewTicker(time.Duration(h.config.NotifyAdminsForPendingRecordsInHours) * time.Hour) zeroUSDBalanceTicker := time.NewTicker(time.Minute) + zeroTFTBalanceTicker := time.NewTicker(time.Minute) fundUserTFTBalanceTicker := time.NewTicker(24 * time.Hour) defer settleTransfersTicker.Stop() defer adminNotifyTicker.Stop() defer zeroUSDBalanceTicker.Stop() + defer zeroTFTBalanceTicker.Stop() defer fundUserTFTBalanceTicker.Stop() for { + users, err := h.db.ListAllUsers() + if err != nil { + continue + } + select { case <-settleTransfersTicker.C: records, err := h.db.ListPendingTransferRecords() @@ -50,21 +59,27 @@ func (h *Handler) MonitorSystemBalanceAndHandleSettlement(ctx context.Context) { } case <-zeroUSDBalanceTicker.C: - users, err := h.db.ListAllUsers() - if err != nil { - continue - } - if err := h.resetUsersTFTsWithNoUSDBalance(users); err != nil { log.Error().Err(err).Send() } - case <-fundUserTFTBalanceTicker.C: - users, err := h.db.ListAllUsers() - if err != nil { - continue + case <-zeroTFTBalanceTicker.C: + for _, user := range users { + if user.CreditedBalance+user.CreditCardBalance > zeroTFTBalanceValue { + continue + } + + if err := h.db.CreateTransferRecord(&models.TransferRecord{ + UserID: user.ID, + Username: user.Username, + TFTAmount: uint64(h.config.MinimumTFTAmountInWallet) * 1e7, + Operation: models.DepositOperation, + }); err != nil { + log.Error().Err(err).Msgf("Failed to create transfer record for user %d", user.ID) + } } + case <-fundUserTFTBalanceTicker.C: for _, user := range users { if err = h.fundUsersToClaimDiscount(ctx, user.ID, user.Username, user.Mnemonic, discount(h.config.AppliedDiscount)); err != nil { log.Error().Err(err).Msgf("Failed to fund user %d to claim discount", user.ID) @@ -77,7 +92,7 @@ func (h *Handler) MonitorSystemBalanceAndHandleSettlement(ctx context.Context) { func (h *Handler) resetUsersTFTsWithNoUSDBalance(users []models.User) error { for _, user := range users { if user.CreditedBalance+user.CreditCardBalance == 0 { - log.Info().Msgf("User %d has no USD balance, withdrawing all TFTs", user.ID) + log.Info().Msgf("User %d has no USD balance, withdrawing all TFTs except for %d", user.ID, h.config.MinimumTFTAmountInWallet) userTFTBalance, err := internal.GetUserTFTBalance(h.substrateClient, user.Mnemonic) if err != nil { @@ -85,6 +100,10 @@ func (h *Handler) resetUsersTFTsWithNoUSDBalance(users []models.User) error { continue } + if userTFTBalance <= uint64(h.config.MinimumTFTAmountInWallet)*1e7 { + continue + } + transferRecord := models.TransferRecord{ UserID: user.ID, Username: user.Username, @@ -185,7 +204,7 @@ func (h *Handler) fundUsersToClaimDiscount(ctx context.Context, userID int, User return err } - dailyUsageInUSDMillicent, err := h.calculateResourcesUsageInUSDApplyingDiscount(ctx, userID, userMnemonic, rentedNodes, configuredDiscount) + dailyUsageInUSDMillicent, err := h.calculateResourcesUsageInUSDApplyingDiscount(userID, userMnemonic, rentedNodes, configuredDiscount) if err != nil { log.Error().Err(err).Msgf("Failed to calculate resources usage in USD for user %d", userID) return err @@ -213,7 +232,6 @@ func (h *Handler) fundUsersToClaimDiscount(ctx context.Context, userID int, User } func (h *Handler) calculateResourcesUsageInUSDApplyingDiscount( - ctx context.Context, userID int, userMnemonic string, rentedNodes []types.Node, diff --git a/backend/app/node_handler.go b/backend/app/node_handler.go index 6fc25f639..7d86fdee6 100644 --- a/backend/app/node_handler.go +++ b/backend/app/node_handler.go @@ -274,7 +274,7 @@ func (h *Handler) ReserveNodeHandler(c *gin.Context) { rentedNodes = append(rentedNodes, node) // calculate resources usage in USD applying discount - dailyUsageInUSDMillicent, err := h.calculateResourcesUsageInUSDApplyingDiscount(c.Request.Context(), userID, user.Mnemonic, rentedNodes, discount(h.config.AppliedDiscount)) + dailyUsageInUSDMillicent, err := h.calculateResourcesUsageInUSDApplyingDiscount(userID, user.Mnemonic, rentedNodes, discount(h.config.AppliedDiscount)) if err != nil { logger.GetLogger().Error().Err(err).Send() InternalServerError(c) diff --git a/backend/app/user_handler.go b/backend/app/user_handler.go index 2b41d758a..19f350f62 100644 --- a/backend/app/user_handler.go +++ b/backend/app/user_handler.go @@ -679,24 +679,23 @@ func (h *Handler) ChargeBalance(c *gin.Context) { return } - tftAmount, err := internal.GetUserTFTBalance(h.substrateClient, user.Mnemonic) + millicentAmount := internal.FromUSDToUSDMillicent(request.Amount) + tftAmount, err := internal.FromUSDMillicentToTFT(h.substrateClient, millicentAmount) if err != nil { - log.Error().Err(err).Send() + logger.GetLogger().Error().Err(err).Send() InternalServerError(c) return } - if tftAmount == 0 { - if err := h.db.CreateTransferRecord(&models.TransferRecord{ - UserID: userID, - Username: user.Username, - TFTAmount: uint64(h.config.MinimumTFTAmountInWallet) * 1e7, - Operation: models.DepositOperation, - }); err != nil { - log.Error().Err(err).Send() - InternalServerError(c) - return - } + if err := h.db.CreateTransferRecord(&models.TransferRecord{ + UserID: userID, + Username: user.Username, + TFTAmount: tftAmount, + Operation: models.DepositOperation, + }); err != nil { + log.Error().Err(err).Send() + InternalServerError(c) + return } wf, err := h.ewfEngine.NewWorkflow(activities.WorkflowChargeBalance) @@ -710,7 +709,7 @@ func (h *Handler) ChargeBalance(c *gin.Context) { "user_id": userID, "stripe_customer_id": user.StripeCustomerID, "payment_method_id": paymentMethod.ID, - "amount": internal.FromUSDToUSDMillicent(request.Amount), + "amount": millicentAmount, } h.ewfEngine.RunAsync(context.Background(), wf) @@ -799,31 +798,31 @@ func (h *Handler) RedeemVoucherHandler(c *gin.Context) { return } - user.CreditedBalance += internal.FromUSDToUSDMillicent(voucher.Value) + millicentAmount := internal.FromUSDToUSDMillicent(voucher.Value) + user.CreditedBalance += millicentAmount if err := h.db.UpdateUserByID(&user); err != nil { log.Error().Err(err).Send() InternalServerError(c) return } - tftAmount, err := internal.GetUserTFTBalance(h.substrateClient, user.Mnemonic) + tftAmount, err := internal.FromUSDMillicentToTFT(h.substrateClient, millicentAmount) if err != nil { logger.GetLogger().Error().Err(err).Send() InternalServerError(c) return } - if tftAmount == 0 { - if err := h.db.CreateTransferRecord(&models.TransferRecord{ - UserID: userID, - Username: user.Username, - TFTAmount: uint64(h.config.MinimumTFTAmountInWallet) * 1e7, - Operation: models.DepositOperation, - }); err != nil { - log.Error().Err(err).Send() - InternalServerError(c) - return - } + if err := h.db.CreateTransferRecord(&models.TransferRecord{ + UserID: userID, + Username: user.Username, + TFTAmount: tftAmount, + Operation: models.DepositOperation, + }); err != nil { + log.Error().Err(err).Send() + InternalServerError(c) + return + } Success(c, http.StatusOK, fmt.Sprintf("Voucher with value %v$ is redeemed successfully.", voucher.Value), RedeemVoucherResponse{ From 834f96876edeb475bceca0817cbd233416616215 Mon Sep 17 00:00:00 2001 From: rawdaGastan Date: Wed, 17 Sep 2025 16:44:01 +0300 Subject: [PATCH 05/16] handle shared nodes --- backend/app/balance_monitor.go | 27 ++++++++++++++++--- backend/app/deployment_handler.go | 43 ++++++++++++++++++++++++++++++- backend/app/node_handler.go | 6 +++-- 3 files changed, 70 insertions(+), 6 deletions(-) diff --git a/backend/app/balance_monitor.go b/backend/app/balance_monitor.go index 27944f269..4f0c64ce2 100644 --- a/backend/app/balance_monitor.go +++ b/backend/app/balance_monitor.go @@ -3,6 +3,7 @@ package app import ( "context" "kubecloud/internal" + "kubecloud/kubedeployer" "kubecloud/models" "time" @@ -65,7 +66,9 @@ func (h *Handler) MonitorSystemBalanceAndHandleSettlement(ctx context.Context) { case <-zeroTFTBalanceTicker.C: for _, user := range users { - if user.CreditedBalance+user.CreditCardBalance > zeroTFTBalanceValue { + + // TODO: if user has workloads deployed, skip + if user.CreditedBalance+user.CreditCardBalance-user.Debt > zeroTFTBalanceValue { continue } @@ -91,7 +94,7 @@ func (h *Handler) MonitorSystemBalanceAndHandleSettlement(ctx context.Context) { func (h *Handler) resetUsersTFTsWithNoUSDBalance(users []models.User) error { for _, user := range users { - if user.CreditedBalance+user.CreditCardBalance == 0 { + if user.CreditedBalance+user.CreditCardBalance-user.Debt <= 0 { log.Info().Msgf("User %d has no USD balance, withdrawing all TFTs except for %d", user.ID, h.config.MinimumTFTAmountInWallet) userTFTBalance, err := internal.GetUserTFTBalance(h.substrateClient, user.Mnemonic) @@ -204,7 +207,7 @@ func (h *Handler) fundUsersToClaimDiscount(ctx context.Context, userID int, User return err } - dailyUsageInUSDMillicent, err := h.calculateResourcesUsageInUSDApplyingDiscount(userID, userMnemonic, rentedNodes, configuredDiscount) + dailyUsageInUSDMillicent, err := h.calculateResourcesUsageInUSDApplyingDiscount(userID, userMnemonic, rentedNodes, []kubedeployer.Node{}, configuredDiscount) if err != nil { log.Error().Err(err).Msgf("Failed to calculate resources usage in USD for user %d", userID) return err @@ -235,6 +238,7 @@ func (h *Handler) calculateResourcesUsageInUSDApplyingDiscount( userID int, userMnemonic string, rentedNodes []types.Node, + sharedNodes []kubedeployer.Node, configuredDiscount discount, ) (uint64, error) { userIdentity, err := substrate.NewIdentityFromSr25519Phrase(userMnemonic) @@ -262,6 +266,23 @@ func (h *Handler) calculateResourcesUsageInUSDApplyingDiscount( totalResourcesCostMillicent += internal.FromUSDToUSDMillicent(resourcesCost) } + for _, node := range sharedNodes { + resourcesCost, err := calculator.CalculateCost( + uint64(node.CPU), + uint64(node.Memory), + 0, + uint64(node.DiskSize+node.RootSize), + false, + false, + ) + if err != nil { + return 0, err + } + + // resources cost per month + totalResourcesCostMillicent += internal.FromUSDToUSDMillicent(resourcesCost) + } + return uint64(float64(totalResourcesCostMillicent) * getDiscountPackage(configuredDiscount).DurationInMonth), nil } diff --git a/backend/app/deployment_handler.go b/backend/app/deployment_handler.go index 02dc0760e..4bb7cf3ad 100644 --- a/backend/app/deployment_handler.go +++ b/backend/app/deployment_handler.go @@ -7,17 +7,20 @@ import ( "kubecloud/internal/activities" "kubecloud/internal/statemanager" "kubecloud/kubedeployer" + "kubecloud/models" "net" "net/http" "os" "strings" "time" + "kubecloud/internal/logger" + "github.com/gin-gonic/gin" + "github.com/threefoldtech/tfgrid-sdk-go/grid-proxy/pkg/types" "github.com/xmonader/ewf" "golang.org/x/crypto/ssh" "gorm.io/gorm" - "kubecloud/internal/logger" ) // Response represents the response structure for deployment requests @@ -409,6 +412,44 @@ func (h *Handler) HandleDeployCluster(c *gin.Context) { return } + user, err := h.db.GetUserByID(config.UserID) + if err != nil { + logger.GetLogger().Error().Err(err).Send() + InternalServerError(c) + return + } + + // TODO: would us consider history of nodes? + // calculate resources usage in USD applying discount + dailyUsageInUSDMillicent, err := h.calculateResourcesUsageInUSDApplyingDiscount(user.ID, user.Mnemonic, []types.Node{}, cluster.Nodes, discount(h.config.AppliedDiscount)) + if err != nil { + logger.GetLogger().Error().Err(err).Send() + InternalServerError(c) + return + } + + // fund user to fulfill discount + // TODO: what if many requests done in the same time? would it trigger many transfers? + if dailyUsageInUSDMillicent > 0 && user.CreditCardBalance+user.CreditedBalance-user.Debt < dailyUsageInUSDMillicent { + tftAmount, err := internal.FromUSDMillicentToTFT(h.substrateClient, dailyUsageInUSDMillicent) + if err != nil { + logger.GetLogger().Error().Err(err).Send() + InternalServerError(c) + return + } + + if err := h.db.CreateTransferRecord(&models.TransferRecord{ + UserID: user.ID, + Username: user.Username, + TFTAmount: tftAmount, + Operation: models.DepositOperation, + }); err != nil { + logger.GetLogger().Error().Err(err).Send() + InternalServerError(c) + return + } + } + projectName := kubedeployer.GetProjectName(config.UserID, cluster.Name) _, err = h.db.GetClusterByName(config.UserID, projectName) if err == nil { diff --git a/backend/app/node_handler.go b/backend/app/node_handler.go index 7d86fdee6..90211f8c2 100644 --- a/backend/app/node_handler.go +++ b/backend/app/node_handler.go @@ -5,6 +5,7 @@ import ( "fmt" "kubecloud/internal" "kubecloud/internal/activities" + "kubecloud/kubedeployer" "kubecloud/models" "net/http" "net/url" @@ -274,7 +275,7 @@ func (h *Handler) ReserveNodeHandler(c *gin.Context) { rentedNodes = append(rentedNodes, node) // calculate resources usage in USD applying discount - dailyUsageInUSDMillicent, err := h.calculateResourcesUsageInUSDApplyingDiscount(userID, user.Mnemonic, rentedNodes, discount(h.config.AppliedDiscount)) + dailyUsageInUSDMillicent, err := h.calculateResourcesUsageInUSDApplyingDiscount(userID, user.Mnemonic, rentedNodes, []kubedeployer.Node{}, discount(h.config.AppliedDiscount)) if err != nil { logger.GetLogger().Error().Err(err).Send() InternalServerError(c) @@ -282,7 +283,8 @@ func (h *Handler) ReserveNodeHandler(c *gin.Context) { } // fund user to fulfill discount - if dailyUsageInUSDMillicent > 0 { + // TODO: what if many requests done in the same time? would it trigger many transfers? + if dailyUsageInUSDMillicent > 0 && user.CreditCardBalance+user.CreditedBalance-user.Debt < dailyUsageInUSDMillicent { tftAmount, err := internal.FromUSDMillicentToTFT(h.substrateClient, dailyUsageInUSDMillicent) if err != nil { logger.GetLogger().Error().Err(err).Send() From 59721897bae30022c5b88739e47978054e307f50 Mon Sep 17 00:00:00 2001 From: rawdaGastan Date: Mon, 22 Sep 2025 18:07:14 +0300 Subject: [PATCH 06/16] check history of pending amount in transfer records --- backend/app/balance_monitor.go | 35 ++++++++++++++++++++++--------- backend/app/deployment_handler.go | 30 ++++++++++++++++---------- backend/app/node_handler.go | 20 ++++++++++++++++-- backend/models/db.go | 1 + backend/models/gorm.go | 12 +++++++++++ backend/models/invoice.go | 15 +++++++------ 6 files changed, 82 insertions(+), 31 deletions(-) diff --git a/backend/app/balance_monitor.go b/backend/app/balance_monitor.go index c194591a5..7271ba651 100644 --- a/backend/app/balance_monitor.go +++ b/backend/app/balance_monitor.go @@ -84,7 +84,7 @@ func (h *Handler) MonitorSystemBalanceAndHandleSettlement(ctx context.Context) { case <-fundUserTFTBalanceTicker.C: for _, user := range users { - if err = h.fundUsersToClaimDiscount(ctx, user.ID, user.Username, user.Mnemonic, discount(h.config.AppliedDiscount)); err != nil { + if err = h.fundUsersToClaimDiscount(ctx, user, discount(h.config.AppliedDiscount)); err != nil { log.Error().Err(err).Msgf("Failed to fund user %d to claim discount", user.ID) } } @@ -201,32 +201,47 @@ func (h *Handler) withdrawTFTsFromUser(userID int, userMnemonic string, amountTo return nil } -func (h *Handler) fundUsersToClaimDiscount(ctx context.Context, userID int, Username, userMnemonic string, configuredDiscount discount) error { - rentedNodes, _, err := h.getRentedNodesForUser(ctx, userID, true) +func (h *Handler) fundUsersToClaimDiscount(ctx context.Context, user models.User, configuredDiscount discount) error { + rentedNodes, _, err := h.getRentedNodesForUser(ctx, user.ID, true) if err != nil { return err } - dailyUsageInUSDMillicent, err := h.calculateResourcesUsageInUSDApplyingDiscount(userID, userMnemonic, rentedNodes, []kubedeployer.Node{}, configuredDiscount) + dailyUsageInUSDMillicent, err := h.calculateResourcesUsageInUSDApplyingDiscount(user.ID, user.Mnemonic, rentedNodes, []kubedeployer.Node{}, configuredDiscount) if err != nil { - log.Error().Err(err).Msgf("Failed to calculate resources usage in USD for user %d", userID) + logger.GetLogger().Error().Err(err).Msgf("Failed to calculate resources usage in USD for user %d", user.ID) return err } - if dailyUsageInUSDMillicent > 0 { + dailyUsageInTFT, err := internal.FromUSDMillicentToTFT(h.substrateClient, dailyUsageInUSDMillicent) + if err != nil { + logger.GetLogger().Error().Err(err).Send() + return err + } + + totalPendingTFTAmount, err := h.db.CalculateTotalPendingTFTAmountPerUser(user.ID) + if err != nil { + logger.GetLogger().Error().Err(err).Send() + return err + } + + // make sure no old payments will fund more than needed + if totalPendingTFTAmount < dailyUsageInTFT && + dailyUsageInUSDMillicent > 0 && + user.CreditCardBalance+user.CreditedBalance-user.Debt < dailyUsageInUSDMillicent { tftAmount, err := internal.FromUSDMillicentToTFT(h.substrateClient, dailyUsageInUSDMillicent) if err != nil { - log.Error().Err(err).Msgf("Failed to convert USD to TFTs for user %d", userID) + log.Error().Err(err).Msgf("Failed to convert USD to TFTs for user %d", user.ID) return err } if err := h.db.CreateTransferRecord(&models.TransferRecord{ - UserID: userID, - Username: Username, + UserID: user.ID, + Username: user.Username, TFTAmount: tftAmount, Operation: models.DepositOperation, }); err != nil { - log.Error().Err(err).Msgf("Failed to create transfer record for user %d", userID) + log.Error().Err(err).Msgf("Failed to create transfer record for user %d", user.ID) return err } } diff --git a/backend/app/deployment_handler.go b/backend/app/deployment_handler.go index 6fe92dd7e..fad6bba8e 100644 --- a/backend/app/deployment_handler.go +++ b/backend/app/deployment_handler.go @@ -322,7 +322,6 @@ func (h *Handler) HandleDeployCluster(c *gin.Context) { return } - // TODO: would us consider history of nodes? // calculate resources usage in USD applying discount dailyUsageInUSDMillicent, err := h.calculateResourcesUsageInUSDApplyingDiscount(user.ID, user.Mnemonic, []types.Node{}, cluster.Nodes, discount(h.config.AppliedDiscount)) if err != nil { @@ -331,20 +330,29 @@ func (h *Handler) HandleDeployCluster(c *gin.Context) { return } - // fund user to fulfill discount - // TODO: what if many requests done in the same time? would it trigger many transfers? - if dailyUsageInUSDMillicent > 0 && user.CreditCardBalance+user.CreditedBalance-user.Debt < dailyUsageInUSDMillicent { - tftAmount, err := internal.FromUSDMillicentToTFT(h.substrateClient, dailyUsageInUSDMillicent) - if err != nil { - logger.GetLogger().Error().Err(err).Send() - InternalServerError(c) - return - } + dailyUsageInTFT, err := internal.FromUSDMillicentToTFT(h.substrateClient, dailyUsageInUSDMillicent) + if err != nil { + logger.GetLogger().Error().Err(err).Send() + InternalServerError(c) + return + } + totalPendingTFTAmount, err := h.db.CalculateTotalPendingTFTAmountPerUser(user.ID) + if err != nil { + logger.GetLogger().Error().Err(err).Send() + InternalServerError(c) + return + } + + // fund user to fulfill discount + // make sure no old payments will fund more than needed + if totalPendingTFTAmount < dailyUsageInTFT && + dailyUsageInUSDMillicent > 0 && + user.CreditCardBalance+user.CreditedBalance-user.Debt < dailyUsageInUSDMillicent { if err := h.db.CreateTransferRecord(&models.TransferRecord{ UserID: user.ID, Username: user.Username, - TFTAmount: tftAmount, + TFTAmount: dailyUsageInTFT, Operation: models.DepositOperation, }); err != nil { logger.GetLogger().Error().Err(err).Send() diff --git a/backend/app/node_handler.go b/backend/app/node_handler.go index d3086aca8..315b1046e 100644 --- a/backend/app/node_handler.go +++ b/backend/app/node_handler.go @@ -284,9 +284,25 @@ func (h *Handler) ReserveNodeHandler(c *gin.Context) { return } + dailyUsageInTFT, err := internal.FromUSDMillicentToTFT(h.substrateClient, dailyUsageInUSDMillicent) + if err != nil { + logger.GetLogger().Error().Err(err).Send() + InternalServerError(c) + return + } + + totalPendingTFTAmount, err := h.db.CalculateTotalPendingTFTAmountPerUser(user.ID) + if err != nil { + logger.GetLogger().Error().Err(err).Send() + InternalServerError(c) + return + } + // fund user to fulfill discount - // TODO: what if many requests done in the same time? would it trigger many transfers? - if dailyUsageInUSDMillicent > 0 && user.CreditCardBalance+user.CreditedBalance-user.Debt < dailyUsageInUSDMillicent { + // make sure no old payments will fund more than needed + if totalPendingTFTAmount < dailyUsageInTFT && + dailyUsageInUSDMillicent > 0 && + user.CreditCardBalance+user.CreditedBalance-user.Debt < dailyUsageInUSDMillicent { tftAmount, err := internal.FromUSDMillicentToTFT(h.substrateClient, dailyUsageInUSDMillicent) if err != nil { logger.GetLogger().Error().Err(err).Send() diff --git a/backend/models/db.go b/backend/models/db.go index b8826f35d..8cd29a545 100644 --- a/backend/models/db.go +++ b/backend/models/db.go @@ -62,6 +62,7 @@ type DB interface { ListUserTransferRecords(userID int) ([]TransferRecord, error) ListPendingTransferRecords() ([]TransferRecord, error) UpdateTransferRecordState(recordID int, state state, failure string) error + CalculateTotalPendingTFTAmountPerUser(userID int) (uint64, error) // stats methods CountAllUsers() (int64, error) CountAllClusters() (int64, error) diff --git a/backend/models/gorm.go b/backend/models/gorm.go index 2a8fc7580..5de71e05b 100644 --- a/backend/models/gorm.go +++ b/backend/models/gorm.go @@ -391,6 +391,18 @@ func (s *GormDB) ListTransferRecords() ([]TransferRecord, error) { return TransferRecords, s.db.Find(&TransferRecords).Error } +func (s *GormDB) CalculateTotalPendingTFTAmountPerUser(userID int) (uint64, error) { + var totalAmount uint64 + err := s.db.Model(&TransferRecord{}). + Select("SUM(tft_amount)"). + Where("user_id = ? AND state = ? AND state = ?", userID, PendingState, PendingState). + Scan(&totalAmount).Error + if err != nil { + return 0, err + } + return totalAmount, nil +} + func (s *GormDB) ListUserTransferRecords(userID int) ([]TransferRecord, error) { var TransferRecords []TransferRecord return TransferRecords, s.db.Where("user_id = ?", userID).Find(&TransferRecords).Error diff --git a/backend/models/invoice.go b/backend/models/invoice.go index 2f756377d..f9c101c44 100644 --- a/backend/models/invoice.go +++ b/backend/models/invoice.go @@ -5,14 +5,13 @@ import ( ) type Invoice struct { - ID int `json:"id" gorm:"primaryKey"` - UserID int `json:"user_id" binding:"required"` - Total float64 `json:"total"` - Nodes []NodeItem `json:"nodes" gorm:"foreignKey:invoice_id"` - // TODO: - Tax float64 `json:"tax"` - CreatedAt time.Time `json:"created_at"` - FileData []byte `json:"-" gorm:"type:blob;column:file_data"` + ID int `json:"id" gorm:"primaryKey"` + UserID int `json:"user_id" binding:"required"` + Total float64 `json:"total"` + Nodes []NodeItem `json:"nodes" gorm:"foreignKey:invoice_id"` + Tax float64 `json:"tax"` + CreatedAt time.Time `json:"created_at"` + FileData []byte `json:"-" gorm:"type:blob;column:file_data"` } type NodeItem struct { From a7c87ebe13370e4e651a96fdb61b57e92d974200 Mon Sep 17 00:00:00 2001 From: rawdaGastan Date: Mon, 22 Sep 2025 18:10:53 +0300 Subject: [PATCH 07/16] skip funding users if they have already deployments --- backend/app/balance_monitor.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/backend/app/balance_monitor.go b/backend/app/balance_monitor.go index 7271ba651..234b18a22 100644 --- a/backend/app/balance_monitor.go +++ b/backend/app/balance_monitor.go @@ -66,8 +66,17 @@ func (h *Handler) MonitorSystemBalanceAndHandleSettlement(ctx context.Context) { case <-zeroTFTBalanceTicker.C: for _, user := range users { + clusters, err := h.db.ListUserClusters(user.ID) + if err != nil { + log.Error().Err(err).Msgf("Failed to list user clusters") + continue + } + + if len(clusters) > 0 { + // user has deployed workloads, skip + continue + } - // TODO: if user has workloads deployed, skip if user.CreditedBalance+user.CreditCardBalance-user.Debt > zeroTFTBalanceValue { continue } From da5da2a817d55cd4d0999282b0e52a17122204e8 Mon Sep 17 00:00:00 2001 From: rawdaGastan Date: Mon, 22 Sep 2025 18:13:15 +0300 Subject: [PATCH 08/16] use logger --- backend/app/admin_handler.go | 9 ++++----- backend/app/admin_handler_test.go | 2 +- backend/app/balance_monitor.go | 27 +++++++++++++-------------- backend/app/settle_usage.go | 9 ++++----- backend/app/user_handler.go | 7 +++---- backend/app/user_handler_test.go | 2 +- 6 files changed, 26 insertions(+), 30 deletions(-) diff --git a/backend/app/admin_handler.go b/backend/app/admin_handler.go index 36db5ac44..a394d5cab 100644 --- a/backend/app/admin_handler.go +++ b/backend/app/admin_handler.go @@ -19,7 +19,6 @@ import ( "github.com/gin-gonic/gin" "github.com/hashicorp/go-multierror" - "github.com/rs/zerolog/log" "gorm.io/gorm" ) @@ -340,14 +339,14 @@ func (h *Handler) CreditUserHandler(c *gin.Context) { millicentAmount := internal.FromUSDToUSDMillicent(request.AmountUSD) user.CreditedBalance += millicentAmount if err := h.db.UpdateUserByID(&user); err != nil { - log.Error().Err(err).Send() + logger.GetLogger().Error().Err(err).Send() InternalServerError(c) return } tftAmount, err := internal.FromUSDMillicentToTFT(h.substrateClient, millicentAmount) if err != nil { - log.Error().Err(err).Msg("Failed to convert USD millicent to TFT") + logger.GetLogger().Error().Err(err).Msg("Failed to convert USD millicent to TFT") InternalServerError(c) return } @@ -358,7 +357,7 @@ func (h *Handler) CreditUserHandler(c *gin.Context) { TFTAmount: tftAmount, Operation: models.DepositOperation, }); err != nil { - log.Error().Err(err).Send() + logger.GetLogger().Error().Err(err).Send() InternalServerError(c) return } @@ -384,7 +383,7 @@ func (h *Handler) CreditUserHandler(c *gin.Context) { func (h *Handler) ListTransferRecordsHandler(c *gin.Context) { transferRecords, err := h.db.ListTransferRecords() if err != nil { - log.Error().Err(err).Msg("failed to list all transfer records") + logger.GetLogger().Error().Err(err).Msg("failed to list all transfer records") InternalServerError(c) return } diff --git a/backend/app/admin_handler_test.go b/backend/app/admin_handler_test.go index ffd5435ef..0da1dd6dd 100644 --- a/backend/app/admin_handler_test.go +++ b/backend/app/admin_handler_test.go @@ -328,7 +328,7 @@ func TestCreditUserHandler(t *testing.T) { req.Header.Set("Content-Type", "application/json") resp := httptest.NewRecorder() router.ServeHTTP(resp, req) - assert.Equal(t, http.StatusAccepted, resp.Code) + assert.Equal(t, http.StatusCreated, resp.Code) var result map[string]interface{} err := json.Unmarshal(resp.Body.Bytes(), &result) assert.NoError(t, err) diff --git a/backend/app/balance_monitor.go b/backend/app/balance_monitor.go index 234b18a22..aef20fbd3 100644 --- a/backend/app/balance_monitor.go +++ b/backend/app/balance_monitor.go @@ -10,7 +10,6 @@ import ( "kubecloud/internal/logger" "github.com/pkg/errors" - "github.com/rs/zerolog/log" substrate "github.com/threefoldtech/tfchain/clients/tfchain-client-go" "github.com/threefoldtech/tfgrid-sdk-go/grid-client/calculator" "github.com/threefoldtech/tfgrid-sdk-go/grid-proxy/pkg/types" @@ -61,14 +60,14 @@ func (h *Handler) MonitorSystemBalanceAndHandleSettlement(ctx context.Context) { case <-zeroUSDBalanceTicker.C: if err := h.resetUsersTFTsWithNoUSDBalance(users); err != nil { - log.Error().Err(err).Send() + logger.GetLogger().Error().Err(err).Send() } case <-zeroTFTBalanceTicker.C: for _, user := range users { clusters, err := h.db.ListUserClusters(user.ID) if err != nil { - log.Error().Err(err).Msgf("Failed to list user clusters") + logger.GetLogger().Error().Err(err).Msgf("Failed to list user clusters") continue } @@ -87,14 +86,14 @@ func (h *Handler) MonitorSystemBalanceAndHandleSettlement(ctx context.Context) { TFTAmount: uint64(h.config.MinimumTFTAmountInWallet) * 1e7, Operation: models.DepositOperation, }); err != nil { - log.Error().Err(err).Msgf("Failed to create transfer record for user %d", user.ID) + logger.GetLogger().Error().Err(err).Msgf("Failed to create transfer record for user %d", user.ID) } } case <-fundUserTFTBalanceTicker.C: for _, user := range users { if err = h.fundUsersToClaimDiscount(ctx, user, discount(h.config.AppliedDiscount)); err != nil { - log.Error().Err(err).Msgf("Failed to fund user %d to claim discount", user.ID) + logger.GetLogger().Error().Err(err).Msgf("Failed to fund user %d to claim discount", user.ID) } } } @@ -104,11 +103,11 @@ func (h *Handler) MonitorSystemBalanceAndHandleSettlement(ctx context.Context) { func (h *Handler) resetUsersTFTsWithNoUSDBalance(users []models.User) error { for _, user := range users { if user.CreditedBalance+user.CreditCardBalance-user.Debt <= 0 { - log.Info().Msgf("User %d has no USD balance, withdrawing all TFTs except for %d", user.ID, h.config.MinimumTFTAmountInWallet) + logger.GetLogger().Info().Msgf("User %d has no USD balance, withdrawing all TFTs except for %d", user.ID, h.config.MinimumTFTAmountInWallet) userTFTBalance, err := internal.GetUserTFTBalance(h.substrateClient, user.Mnemonic) if err != nil { - log.Error().Err(err).Msgf("Failed to get user TFT balance for user %d", user.ID) + logger.GetLogger().Error().Err(err).Msgf("Failed to get user TFT balance for user %d", user.ID) continue } @@ -125,7 +124,7 @@ func (h *Handler) resetUsersTFTsWithNoUSDBalance(users []models.User) error { } if err = h.withdrawTFTsFromUser(user.ID, user.Mnemonic, userTFTBalance); err != nil { - log.Error().Err(err).Msgf("Failed to withdraw all TFTs for user %d", user.ID) + logger.GetLogger().Error().Err(err).Msgf("Failed to withdraw all TFTs for user %d", user.ID) // TODO: handle retries transferRecord.State = models.FailedState @@ -133,7 +132,7 @@ func (h *Handler) resetUsersTFTsWithNoUSDBalance(users []models.User) error { } if err := h.db.CreateTransferRecord(&transferRecord); err != nil { - log.Error().Err(err).Msgf("Failed to create transfer record for user %d", user.ID) + logger.GetLogger().Error().Err(err).Msgf("Failed to create transfer record for user %d", user.ID) } } } @@ -163,19 +162,19 @@ func (h *Handler) settlePendingPayments(records []models.TransferRecord) error { } if systemTFTBalance < record.TFTAmount { - log.Warn().Msgf("Insufficient system balance to settle pending record ID %d", record.ID) + logger.GetLogger().Warn().Msgf("Insufficient system balance to settle pending record ID %d", record.ID) continue } if err = h.transferTFTsToUser(record.UserID, record.TFTAmount); err != nil { - log.Error().Err(err).Msgf("Failed to settle pending record ID %d", record.ID) + logger.GetLogger().Error().Err(err).Msgf("Failed to settle pending record ID %d", record.ID) transferState = models.FailedState transferFailure = err.Error() } if err := h.db.UpdateTransferRecordState(record.ID, transferState, transferFailure); err != nil { - log.Error().Err(err).Msgf("Failed to update pending record ID %d state", record.ID) + logger.GetLogger().Error().Err(err).Msgf("Failed to update pending record ID %d state", record.ID) } } @@ -240,7 +239,7 @@ func (h *Handler) fundUsersToClaimDiscount(ctx context.Context, user models.User user.CreditCardBalance+user.CreditedBalance-user.Debt < dailyUsageInUSDMillicent { tftAmount, err := internal.FromUSDMillicentToTFT(h.substrateClient, dailyUsageInUSDMillicent) if err != nil { - log.Error().Err(err).Msgf("Failed to convert USD to TFTs for user %d", user.ID) + logger.GetLogger().Error().Err(err).Msgf("Failed to convert USD to TFTs for user %d", user.ID) return err } @@ -250,7 +249,7 @@ func (h *Handler) fundUsersToClaimDiscount(ctx context.Context, user models.User TFTAmount: tftAmount, Operation: models.DepositOperation, }); err != nil { - log.Error().Err(err).Msgf("Failed to create transfer record for user %d", user.ID) + logger.GetLogger().Error().Err(err).Msgf("Failed to create transfer record for user %d", user.ID) return err } } diff --git a/backend/app/settle_usage.go b/backend/app/settle_usage.go index c1dc81606..38d638cf1 100644 --- a/backend/app/settle_usage.go +++ b/backend/app/settle_usage.go @@ -2,12 +2,11 @@ package app import ( "kubecloud/internal" + "kubecloud/internal/logger" "math" "strconv" "sync" "time" - - "github.com/rs/zerolog/log" ) // Map to store the last calculation time for each user @@ -30,19 +29,19 @@ func (h *Handler) DeductBalanceBasedOnUsage() { for range usageDeductionTicker.C { users, err := h.db.ListAllUsers() if err != nil { - log.Error().Err(err).Msg("Failed to list users") + logger.GetLogger().Error().Err(err).Msg("Failed to list users") continue } for _, user := range users { usageInUSDMillicent, err := h.getUserDailyUsageInUSD(user.ID) if err != nil { - log.Error().Err(err).Msgf("Failed to get usage for user %d", user.ID) + logger.GetLogger().Error().Err(err).Msgf("Failed to get usage for user %d", user.ID) continue } if err := h.db.DeductUserBalance(&user, usageInUSDMillicent); err != nil { - log.Error().Err(err).Msgf("Failed to deduct balance for user %d", user.ID) + logger.GetLogger().Error().Err(err).Msgf("Failed to deduct balance for user %d", user.ID) } } } diff --git a/backend/app/user_handler.go b/backend/app/user_handler.go index c8052c6b2..8ef7dfa43 100644 --- a/backend/app/user_handler.go +++ b/backend/app/user_handler.go @@ -14,7 +14,6 @@ import ( "time" "github.com/mattn/go-sqlite3" - "github.com/rs/zerolog/log" "github.com/stripe/stripe-go/v82" "github.com/stripe/stripe-go/v82/paymentmethod" substrate "github.com/threefoldtech/tfchain/clients/tfchain-client-go" @@ -674,7 +673,7 @@ func (h *Handler) ChargeBalance(c *gin.Context) { TFTAmount: tftAmount, Operation: models.DepositOperation, }); err != nil { - log.Error().Err(err).Send() + logger.GetLogger().Error().Err(err).Send() InternalServerError(c) return } @@ -782,7 +781,7 @@ func (h *Handler) RedeemVoucherHandler(c *gin.Context) { millicentAmount := internal.FromUSDToUSDMillicent(voucher.Value) user.CreditedBalance += millicentAmount if err := h.db.UpdateUserByID(&user); err != nil { - log.Error().Err(err).Send() + logger.GetLogger().Error().Err(err).Send() InternalServerError(c) return } @@ -800,7 +799,7 @@ func (h *Handler) RedeemVoucherHandler(c *gin.Context) { TFTAmount: tftAmount, Operation: models.DepositOperation, }); err != nil { - log.Error().Err(err).Send() + logger.GetLogger().Error().Err(err).Send() InternalServerError(c) return diff --git a/backend/app/user_handler_test.go b/backend/app/user_handler_test.go index 30fe9f756..4b9a8fae3 100644 --- a/backend/app/user_handler_test.go +++ b/backend/app/user_handler_test.go @@ -633,7 +633,7 @@ func TestRedeemVoucherHandler(t *testing.T) { req.Header.Set("Authorization", "Bearer "+token) resp := httptest.NewRecorder() router.ServeHTTP(resp, req) - assert.Equal(t, http.StatusAccepted, resp.Code) + assert.Equal(t, http.StatusOK, resp.Code) var result map[string]interface{} err = json.Unmarshal(resp.Body.Bytes(), &result) assert.NoError(t, err) From fbfc8afaee06dd8fc25af83401cae64930b0d258 Mon Sep 17 00:00:00 2001 From: rawdaGastan Date: Wed, 24 Sep 2025 11:52:56 +0300 Subject: [PATCH 09/16] - only send min TFTs amount in charging usds - vouchers redeeming - admin crediting - apply 50% discount for rented nodes (extra) - check case of 0 discount not returning 0 - add one extra day in discount to make sure it will be applied - add name contracts to usage calculations - handle certified nodes in calculations --- backend/app/admin_handler.go | 9 +-- backend/app/balance_monitor.go | 88 ++++++++++++++++++++++++--- backend/app/deployment_handler.go | 2 +- backend/app/node_handler.go | 2 +- backend/app/settle_usage.go | 18 +++--- backend/app/user_handler.go | 17 +----- backend/internal/chain_account.go | 3 +- backend/internal/contracts_billing.go | 4 +- 8 files changed, 100 insertions(+), 43 deletions(-) diff --git a/backend/app/admin_handler.go b/backend/app/admin_handler.go index a394d5cab..d31e821cb 100644 --- a/backend/app/admin_handler.go +++ b/backend/app/admin_handler.go @@ -344,17 +344,10 @@ func (h *Handler) CreditUserHandler(c *gin.Context) { return } - tftAmount, err := internal.FromUSDMillicentToTFT(h.substrateClient, millicentAmount) - if err != nil { - logger.GetLogger().Error().Err(err).Msg("Failed to convert USD millicent to TFT") - InternalServerError(c) - return - } - if err := h.db.CreateTransferRecord(&models.TransferRecord{ UserID: id, Username: user.Username, - TFTAmount: tftAmount, + TFTAmount: uint64(h.config.MinimumTFTAmountInWallet), Operation: models.DepositOperation, }); err != nil { logger.GetLogger().Error().Err(err).Send() diff --git a/backend/app/balance_monitor.go b/backend/app/balance_monitor.go index aef20fbd3..8b9e1a221 100644 --- a/backend/app/balance_monitor.go +++ b/backend/app/balance_monitor.go @@ -12,10 +12,17 @@ import ( "github.com/pkg/errors" substrate "github.com/threefoldtech/tfchain/clients/tfchain-client-go" "github.com/threefoldtech/tfgrid-sdk-go/grid-client/calculator" + "github.com/threefoldtech/tfgrid-sdk-go/grid-client/graphql" "github.com/threefoldtech/tfgrid-sdk-go/grid-proxy/pkg/types" ) -const zeroTFTBalanceValue = 0.05 * 1e7 // 0.05 TFT +const ( + // UnitFactor represents the smallest unit conversion factor for both USD and TFT + TFTUnitFactor = 1e7 + + zeroTFTBalanceValue = 0.05 * TFTUnitFactor // 0.05 TFT + defaultPricingPolicyID = uint32(1) +) func (h *Handler) MonitorSystemBalanceAndHandleSettlement(ctx context.Context) { settleTransfersTicker := time.NewTicker(time.Duration(h.config.SettleTransferRecordsIntervalInMinutes) * time.Minute) @@ -83,7 +90,7 @@ func (h *Handler) MonitorSystemBalanceAndHandleSettlement(ctx context.Context) { if err := h.db.CreateTransferRecord(&models.TransferRecord{ UserID: user.ID, Username: user.Username, - TFTAmount: uint64(h.config.MinimumTFTAmountInWallet) * 1e7, + TFTAmount: uint64(h.config.MinimumTFTAmountInWallet) * TFTUnitFactor, Operation: models.DepositOperation, }); err != nil { logger.GetLogger().Error().Err(err).Msgf("Failed to create transfer record for user %d", user.ID) @@ -111,7 +118,7 @@ func (h *Handler) resetUsersTFTsWithNoUSDBalance(users []models.User) error { continue } - if userTFTBalance <= uint64(h.config.MinimumTFTAmountInWallet)*1e7 { + if userTFTBalance <= uint64(h.config.MinimumTFTAmountInWallet)*TFTUnitFactor { continue } @@ -215,7 +222,7 @@ func (h *Handler) fundUsersToClaimDiscount(ctx context.Context, user models.User return err } - dailyUsageInUSDMillicent, err := h.calculateResourcesUsageInUSDApplyingDiscount(user.ID, user.Mnemonic, rentedNodes, []kubedeployer.Node{}, configuredDiscount) + dailyUsageInUSDMillicent, err := h.calculateResourcesUsageInUSDApplyingDiscount(ctx, user.ID, user.Mnemonic, rentedNodes, []kubedeployer.Node{}, configuredDiscount) if err != nil { logger.GetLogger().Error().Err(err).Msgf("Failed to calculate resources usage in USD for user %d", user.ID) return err @@ -258,6 +265,7 @@ func (h *Handler) fundUsersToClaimDiscount(ctx context.Context, user models.User } func (h *Handler) calculateResourcesUsageInUSDApplyingDiscount( + ctx context.Context, userID int, userMnemonic string, rentedNodes []types.Node, @@ -272,6 +280,8 @@ func (h *Handler) calculateResourcesUsageInUSDApplyingDiscount( calculator := calculator.NewCalculator(h.gridClient.SubstrateConn, userIdentity) var totalResourcesCostMillicent uint64 + + // Calculate rented nodes for _, node := range rentedNodes { resourcesCost, err := calculator.CalculateCost( node.TotalResources.CRU, @@ -286,17 +296,24 @@ func (h *Handler) calculateResourcesUsageInUSDApplyingDiscount( } // resources cost per month - totalResourcesCostMillicent += internal.FromUSDToUSDMillicent(resourcesCost) + // apply 50% discount for rented nodes + totalResourcesCostMillicent += internal.FromUSDToUSDMillicent(resourcesCost / 2) } + // Calculate shared nodes for _, node := range sharedNodes { + proxyNode, err := h.proxyClient.Node(ctx, node.NodeID) + if err != nil { + return 0, err + } + resourcesCost, err := calculator.CalculateCost( uint64(node.CPU), node.Memory, 0, node.DiskSize+node.RootSize, false, - false, + proxyNode.CertificationType != "", ) if err != nil { return 0, err @@ -306,7 +323,25 @@ func (h *Handler) calculateResourcesUsageInUSDApplyingDiscount( totalResourcesCostMillicent += internal.FromUSDToUSDMillicent(resourcesCost) } - return uint64(float64(totalResourcesCostMillicent) * getDiscountPackage(configuredDiscount).DurationInMonth), nil + // Calculate name contracts + nameContracts, err := h.listNameContractsForUser(userIdentity) + if err != nil { + return 0, err + } + + nameContractMonthlyCostInUSD, err := h.calculateUniqueNameMonthlyCost() + if err != nil { + return 0, err + } + + totalResourcesCostMillicent += internal.FromUSDToUSDMillicent(float64(len(nameContracts)) * nameContractMonthlyCostInUSD) + + discount := getDiscountPackage(configuredDiscount).DurationInMonth + if discount == 0 { + return totalResourcesCostMillicent, nil + } + + return uint64(float64(totalResourcesCostMillicent) * discount), nil } func (h *Handler) notifyAdminWithPendingRecords(records []models.TransferRecord) error { @@ -327,3 +362,42 @@ func (h *Handler) notifyAdminWithPendingRecords(records []models.TransferRecord) return nil } + +func (h *Handler) listNameContractsForUser(userIdentity substrate.Identity) ([]graphql.Contract, error) { + graphQl, err := graphql.NewGraphQl(h.config.GraphqlURL) + if err != nil { + return nil, errors.Wrapf(err, "could not create a new graphql with url: %s", h.config.GraphqlURL) + } + + twinID, err := h.substrateClient.GetTwinByPubKey(userIdentity.PublicKey()) + if err != nil { + return nil, err + } + + contractGetter := graphql.NewContractsGetter( + twinID, + graphQl, + h.gridClient.SubstrateConn, + h.gridClient.NcPool, + ) + + contractsList, err := contractGetter.ListContractsByTwinID([]string{"Created, GracePeriod"}) + if err != nil { + return nil, err + } + + return contractsList.NameContracts, nil +} + +func (h *Handler) calculateUniqueNameMonthlyCost() (float64, error) { + pricingPolicy, err := h.substrateClient.GetPricingPolicy(defaultPricingPolicyID) + if err != nil { + return 0, err + } + + // cost in unit-USD + monthlyCost := float64(pricingPolicy.UniqueName.Value) * 24 * 30 + + costInUSD := monthlyCost / TFTUnitFactor + return costInUSD, nil +} diff --git a/backend/app/deployment_handler.go b/backend/app/deployment_handler.go index fad6bba8e..7fced8208 100644 --- a/backend/app/deployment_handler.go +++ b/backend/app/deployment_handler.go @@ -323,7 +323,7 @@ func (h *Handler) HandleDeployCluster(c *gin.Context) { } // calculate resources usage in USD applying discount - dailyUsageInUSDMillicent, err := h.calculateResourcesUsageInUSDApplyingDiscount(user.ID, user.Mnemonic, []types.Node{}, cluster.Nodes, discount(h.config.AppliedDiscount)) + dailyUsageInUSDMillicent, err := h.calculateResourcesUsageInUSDApplyingDiscount(c.Request.Context(), user.ID, user.Mnemonic, []types.Node{}, cluster.Nodes, discount(h.config.AppliedDiscount)) if err != nil { logger.GetLogger().Error().Err(err).Send() InternalServerError(c) diff --git a/backend/app/node_handler.go b/backend/app/node_handler.go index 315b1046e..e83719370 100644 --- a/backend/app/node_handler.go +++ b/backend/app/node_handler.go @@ -277,7 +277,7 @@ func (h *Handler) ReserveNodeHandler(c *gin.Context) { rentedNodes = append(rentedNodes, node) // calculate resources usage in USD applying discount - dailyUsageInUSDMillicent, err := h.calculateResourcesUsageInUSDApplyingDiscount(userID, user.Mnemonic, rentedNodes, []kubedeployer.Node{}, discount(h.config.AppliedDiscount)) + dailyUsageInUSDMillicent, err := h.calculateResourcesUsageInUSDApplyingDiscount(c.Request.Context(), userID, user.Mnemonic, rentedNodes, []kubedeployer.Node{}, discount(h.config.AppliedDiscount)) if err != nil { logger.GetLogger().Error().Err(err).Send() InternalServerError(c) diff --git a/backend/app/settle_usage.go b/backend/app/settle_usage.go index 38d638cf1..eb4288eea 100644 --- a/backend/app/settle_usage.go +++ b/backend/app/settle_usage.go @@ -120,42 +120,44 @@ func (h *Handler) fromTFTtoUSDMillicent(amount uint64, report internal.Report) ( return 0, err } - usdMillicentBalance := uint64(math.Round((float64(amount) / 1e7) * float64(price))) + usdMillicentBalance := uint64(math.Round((float64(amount) / TFTUnitFactor) * float64(price))) return usdMillicentBalance, nil } func removeDiscountFromReport(report *internal.Report) (uint64, error) { - discountPackage := getDiscountPackage(discount(report.DiscountRecieved)) + discountPackage := getDiscountPackage(discount(report.DiscountReceived)) amountBilled, err := strconv.ParseInt(report.AmountBilled, 10, 64) if err != nil { return 0, err } - amountBilledWithNoDsiscount := float64(amountBilled) / float64(1-discountPackage.Discount/100) - return uint64(amountBilledWithNoDsiscount), nil + amountBilledWithNoDiscount := float64(amountBilled) / float64(1-discountPackage.Discount/100) + return uint64(amountBilledWithNoDiscount), nil } func getDiscountPackage(discountInput discount) DiscountPackage { + oneDayMargin := 1.0 / 30.0 + discountPackages := map[discount]DiscountPackage{ "none": { DurationInMonth: 0, Discount: 0, }, "default": { - DurationInMonth: 1.5, + DurationInMonth: 1.5 + oneDayMargin, Discount: 20, }, "bronze": { - DurationInMonth: 3, + DurationInMonth: 3 + oneDayMargin, Discount: 30, }, "silver": { - DurationInMonth: 6, + DurationInMonth: 6 + oneDayMargin, Discount: 40, }, "gold": { - DurationInMonth: 10, + DurationInMonth: 10 + oneDayMargin, Discount: 60, }, } diff --git a/backend/app/user_handler.go b/backend/app/user_handler.go index 8ef7dfa43..23e51e9f0 100644 --- a/backend/app/user_handler.go +++ b/backend/app/user_handler.go @@ -660,17 +660,11 @@ func (h *Handler) ChargeBalance(c *gin.Context) { } millicentAmount := internal.FromUSDToUSDMillicent(request.Amount) - tftAmount, err := internal.FromUSDMillicentToTFT(h.substrateClient, millicentAmount) - if err != nil { - logger.GetLogger().Error().Err(err).Send() - InternalServerError(c) - return - } if err := h.db.CreateTransferRecord(&models.TransferRecord{ UserID: userID, Username: user.Username, - TFTAmount: tftAmount, + TFTAmount: uint64(h.config.MinimumTFTAmountInWallet), Operation: models.DepositOperation, }); err != nil { logger.GetLogger().Error().Err(err).Send() @@ -786,17 +780,10 @@ func (h *Handler) RedeemVoucherHandler(c *gin.Context) { return } - tftAmount, err := internal.FromUSDMillicentToTFT(h.substrateClient, millicentAmount) - if err != nil { - logger.GetLogger().Error().Err(err).Send() - InternalServerError(c) - return - } - if err := h.db.CreateTransferRecord(&models.TransferRecord{ UserID: userID, Username: user.Username, - TFTAmount: tftAmount, + TFTAmount: uint64(h.config.MinimumTFTAmountInWallet), Operation: models.DepositOperation, }); err != nil { logger.GetLogger().Error().Err(err).Send() diff --git a/backend/internal/chain_account.go b/backend/internal/chain_account.go index 0065cbfa2..75c1ddec6 100644 --- a/backend/internal/chain_account.go +++ b/backend/internal/chain_account.go @@ -11,8 +11,9 @@ import ( substrate "github.com/threefoldtech/tfchain/clients/tfchain-client-go" - "github.com/tyler-smith/go-bip39" "kubecloud/internal/logger" + + "github.com/tyler-smith/go-bip39" ) // SetupUserOnTFChain performs all TFChain setup steps and returns mnemonic, identity, twin ID diff --git a/backend/internal/contracts_billing.go b/backend/internal/contracts_billing.go index b0f991c1e..ca62c7bd0 100644 --- a/backend/internal/contracts_billing.go +++ b/backend/internal/contracts_billing.go @@ -20,7 +20,7 @@ type Report struct { ContractID string `json:"contractID"` Timestamp string `json:"timestamp"` AmountBilled string `json:"amountBilled"` - DiscountRecieved string `json:"discountRecieved"` + DiscountReceived string `json:"discountReceived"` } // ListContractBillReports returns bill reports for contract ID month ago @@ -35,7 +35,7 @@ func ListContractBillReports(graphqlClient graphql.GraphQl, contractID uint64, s contractID timestamp amountBilled - discountRecieved + discountReceived } }`, contractID, endTime.Unix(), startTime.Unix()), map[string]interface{}{ From 3b25901c7edae36a7cfe357549c4c5bbd162d1d1 Mon Sep 17 00:00:00 2001 From: rawdaGastan Date: Sun, 28 Sep 2025 16:13:58 +0300 Subject: [PATCH 10/16] - use discount from pricing policy - handle trnasfer fees in reset user balance (it is not needed as it checks for minimum balance but added) - fix checking certified node - calculate all user rented and shared nodes when calculating daily usage" - check TFT wallet balance before funding user to match a dicount - save last date for usage calculation in DB not in memory --- backend/app/balance_monitor.go | 74 ++++++++++++++++++------ backend/app/deployment_handler.go | 10 +++- backend/app/node_handler.go | 17 +++--- backend/app/settle_usage.go | 27 ++++----- backend/models/db.go | 4 ++ backend/models/gorm.go | 39 +++++++++++++ backend/models/usage_calculation_time.go | 11 ++++ 7 files changed, 139 insertions(+), 43 deletions(-) create mode 100644 backend/models/usage_calculation_time.go diff --git a/backend/app/balance_monitor.go b/backend/app/balance_monitor.go index 8b9e1a221..cdd2c9581 100644 --- a/backend/app/balance_monitor.go +++ b/backend/app/balance_monitor.go @@ -19,6 +19,8 @@ import ( const ( // UnitFactor represents the smallest unit conversion factor for both USD and TFT TFTUnitFactor = 1e7 + transferFees = 0.01 * TFTUnitFactor // 0.01 TFT + nodeCertified = "Certified" zeroTFTBalanceValue = 0.05 * TFTUnitFactor // 0.05 TFT defaultPricingPolicyID = uint32(1) @@ -122,6 +124,10 @@ func (h *Handler) resetUsersTFTsWithNoUSDBalance(users []models.User) error { continue } + if userTFTBalance <= transferFees { + continue + } + transferRecord := models.TransferRecord{ UserID: user.ID, Username: user.Username, @@ -217,12 +223,7 @@ func (h *Handler) withdrawTFTsFromUser(userID int, userMnemonic string, amountTo } func (h *Handler) fundUsersToClaimDiscount(ctx context.Context, user models.User, configuredDiscount discount) error { - rentedNodes, _, err := h.getRentedNodesForUser(ctx, user.ID, true) - if err != nil { - return err - } - - dailyUsageInUSDMillicent, err := h.calculateResourcesUsageInUSDApplyingDiscount(ctx, user.ID, user.Mnemonic, rentedNodes, []kubedeployer.Node{}, configuredDiscount) + dailyUsageInUSDMillicent, err := h.calculateResourcesUsageInUSDApplyingDiscount(ctx, user.ID, user.Mnemonic, []types.Node{}, []kubedeployer.Node{}, configuredDiscount) if err != nil { logger.GetLogger().Error().Err(err).Msgf("Failed to calculate resources usage in USD for user %d", user.ID) return err @@ -240,20 +241,21 @@ func (h *Handler) fundUsersToClaimDiscount(ctx context.Context, user models.User return err } + userTFTBalance, err := internal.GetUserTFTBalance(h.substrateClient, user.Mnemonic) + if err != nil { + logger.GetLogger().Error().Err(err).Msgf("Failed to get user TFT balance for user %d", user.ID) + return err + } + // make sure no old payments will fund more than needed if totalPendingTFTAmount < dailyUsageInTFT && - dailyUsageInUSDMillicent > 0 && + userTFTBalance < dailyUsageInTFT && + dailyUsageInTFT > 0 && user.CreditCardBalance+user.CreditedBalance-user.Debt < dailyUsageInUSDMillicent { - tftAmount, err := internal.FromUSDMillicentToTFT(h.substrateClient, dailyUsageInUSDMillicent) - if err != nil { - logger.GetLogger().Error().Err(err).Msgf("Failed to convert USD to TFTs for user %d", user.ID) - return err - } - if err := h.db.CreateTransferRecord(&models.TransferRecord{ UserID: user.ID, Username: user.Username, - TFTAmount: tftAmount, + TFTAmount: dailyUsageInTFT - userTFTBalance, Operation: models.DepositOperation, }); err != nil { logger.GetLogger().Error().Err(err).Msgf("Failed to create transfer record for user %d", user.ID) @@ -268,8 +270,8 @@ func (h *Handler) calculateResourcesUsageInUSDApplyingDiscount( ctx context.Context, userID int, userMnemonic string, - rentedNodes []types.Node, - sharedNodes []kubedeployer.Node, + addedRentedNodes []types.Node, + addedSharedNodes []kubedeployer.Node, configuredDiscount discount, ) (uint64, error) { userIdentity, err := substrate.NewIdentityFromSr25519Phrase(userMnemonic) @@ -281,6 +283,12 @@ func (h *Handler) calculateResourcesUsageInUSDApplyingDiscount( var totalResourcesCostMillicent uint64 + rentedNodes, _, err := h.getRentedNodesForUser(ctx, userID, true) + if err != nil { + return 0, err + } + rentedNodes = append(rentedNodes, addedRentedNodes...) + // Calculate rented nodes for _, node := range rentedNodes { resourcesCost, err := calculator.CalculateCost( @@ -296,10 +304,20 @@ func (h *Handler) calculateResourcesUsageInUSDApplyingDiscount( } // resources cost per month - // apply 50% discount for rented nodes - totalResourcesCostMillicent += internal.FromUSDToUSDMillicent(resourcesCost / 2) + pricingPolicy, err := h.substrateClient.GetPricingPolicy(defaultPricingPolicyID) + if err != nil { + return 0, err + } + dedicatedDiscountPercentage := float64(pricingPolicy.DedicatedNodesDiscount / 100) + totalResourcesCostMillicent += internal.FromUSDToUSDMillicent(resourcesCost * dedicatedDiscountPercentage) } + sharedNodes, err := h.getUserNodes(userID) + if err != nil { + return 0, err + } + sharedNodes = append(sharedNodes, addedSharedNodes...) + // Calculate shared nodes for _, node := range sharedNodes { proxyNode, err := h.proxyClient.Node(ctx, node.NodeID) @@ -313,7 +331,7 @@ func (h *Handler) calculateResourcesUsageInUSDApplyingDiscount( 0, node.DiskSize+node.RootSize, false, - proxyNode.CertificationType != "", + proxyNode.CertificationType == nodeCertified, ) if err != nil { return 0, err @@ -401,3 +419,21 @@ func (h *Handler) calculateUniqueNameMonthlyCost() (float64, error) { costInUSD := monthlyCost / TFTUnitFactor return costInUSD, nil } + +func (h *Handler) getUserNodes(userID int) ([]kubedeployer.Node, error) { + userClusters, err := h.db.ListUserClusters(userID) + if err != nil { + return nil, err + } + + var sharedNodes []kubedeployer.Node + for _, cluster := range userClusters { + clusterResult, err := cluster.GetClusterResult() + if err != nil { + return nil, err + } + sharedNodes = append(sharedNodes, clusterResult.Nodes...) + } + + return sharedNodes, nil +} diff --git a/backend/app/deployment_handler.go b/backend/app/deployment_handler.go index 7fced8208..566b6eb00 100644 --- a/backend/app/deployment_handler.go +++ b/backend/app/deployment_handler.go @@ -344,15 +344,23 @@ func (h *Handler) HandleDeployCluster(c *gin.Context) { return } + userTFTBalance, err := internal.GetUserTFTBalance(h.substrateClient, user.Mnemonic) + if err != nil { + logger.GetLogger().Error().Err(err).Send() + InternalServerError(c) + return + } + // fund user to fulfill discount // make sure no old payments will fund more than needed if totalPendingTFTAmount < dailyUsageInTFT && + userTFTBalance < dailyUsageInTFT && dailyUsageInUSDMillicent > 0 && user.CreditCardBalance+user.CreditedBalance-user.Debt < dailyUsageInUSDMillicent { if err := h.db.CreateTransferRecord(&models.TransferRecord{ UserID: user.ID, Username: user.Username, - TFTAmount: dailyUsageInTFT, + TFTAmount: dailyUsageInTFT - userTFTBalance, Operation: models.DepositOperation, }); err != nil { logger.GetLogger().Error().Err(err).Send() diff --git a/backend/app/node_handler.go b/backend/app/node_handler.go index e83719370..a65f2f0fa 100644 --- a/backend/app/node_handler.go +++ b/backend/app/node_handler.go @@ -298,22 +298,23 @@ func (h *Handler) ReserveNodeHandler(c *gin.Context) { return } + userTFTBalance, err := internal.GetUserTFTBalance(h.substrateClient, user.Mnemonic) + if err != nil { + logger.GetLogger().Error().Err(err).Send() + InternalServerError(c) + return + } + // fund user to fulfill discount // make sure no old payments will fund more than needed if totalPendingTFTAmount < dailyUsageInTFT && + userTFTBalance < dailyUsageInTFT && dailyUsageInUSDMillicent > 0 && user.CreditCardBalance+user.CreditedBalance-user.Debt < dailyUsageInUSDMillicent { - tftAmount, err := internal.FromUSDMillicentToTFT(h.substrateClient, dailyUsageInUSDMillicent) - if err != nil { - logger.GetLogger().Error().Err(err).Send() - InternalServerError(c) - return - } - if err := h.db.CreateTransferRecord(&models.TransferRecord{ UserID: userID, Username: user.Username, - TFTAmount: tftAmount, + TFTAmount: dailyUsageInTFT - userTFTBalance, Operation: models.DepositOperation, }); err != nil { logger.GetLogger().Error().Err(err).Send() diff --git a/backend/app/settle_usage.go b/backend/app/settle_usage.go index eb4288eea..38fa3c52b 100644 --- a/backend/app/settle_usage.go +++ b/backend/app/settle_usage.go @@ -5,14 +5,9 @@ import ( "kubecloud/internal/logger" "math" "strconv" - "sync" "time" ) -// Map to store the last calculation time for each user -var lastCalculationTimeByUser = make(map[int]time.Time) -var lastCalculationTimeByUserMutex = sync.RWMutex{} - type discount string type DiscountPackage struct { @@ -61,12 +56,14 @@ func (h *Handler) getUserDailyUsageInUSD(userID int) (uint64, error) { // Define the end of the day (next day at 00:00) endOfDay := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.Local) - // Get the last calculation time for this user, or use a default if not available - lastCalculationTimeByUserMutex.RLock() - lastCalcTime, exists := lastCalculationTimeByUser[userID] - lastCalculationTimeByUserMutex.RUnlock() - if !exists { - // If this is the first time, use the start of the day as default + // Get the last calculation time for this user from the database, or use a default if not available + lastCalcTime, err := h.db.GetUserLastCalcTime(userID) + if err != nil { + return 0, err + } + + // If this is the first time or no record exists, use the start of the day as default + if lastCalcTime.IsZero() { lastCalcTime = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local) } @@ -87,10 +84,10 @@ func (h *Handler) getUserDailyUsageInUSD(userID int) (uint64, error) { totalDailyUsageInUSDMillicent += totalAmountBilledInUSDMillicent } - // Update the last calculation time for this user - lastCalculationTimeByUserMutex.Lock() - lastCalculationTimeByUser[userID] = now - lastCalculationTimeByUserMutex.Unlock() + // Update the last calculation time for this user in the database + if err := h.db.UpdateUserLastCalcTime(userID, now); err != nil { + logger.GetLogger().Error().Err(err).Msgf("Failed to update last calculation time for user %d", userID) + } return totalDailyUsageInUSDMillicent, nil } diff --git a/backend/models/db.go b/backend/models/db.go index 8cd29a545..968221a82 100644 --- a/backend/models/db.go +++ b/backend/models/db.go @@ -2,6 +2,7 @@ package models import ( "context" + "time" "gorm.io/gorm" ) @@ -35,6 +36,9 @@ type DB interface { ListUserNodes(userID int) ([]UserNodes, error) DeleteUserNode(contractID uint32) error GetUserNodeByContractID(contractID uint64) (UserNodes, error) + // Usage calculation time methods + GetUserLastCalcTime(userID int) (time.Time, error) + UpdateUserLastCalcTime(userID int, lastCalcTime time.Time) error // SSH Key methods CreateSSHKey(sshKey *SSHKey) error ListUserSSHKeys(userID int) ([]SSHKey, error) diff --git a/backend/models/gorm.go b/backend/models/gorm.go index 5de71e05b..f7eb36719 100644 --- a/backend/models/gorm.go +++ b/backend/models/gorm.go @@ -35,6 +35,7 @@ func NewGormStorage(dialector gorm.Dialector) (DB, error) { &SSHKey{}, &Cluster{}, &TransferRecord{}, + &UserUsageCalculationTime{}, ) if err != nil { return nil, err @@ -442,6 +443,44 @@ func (s *GormDB) GetUserNodeByContractID(contractID uint64) (UserNodes, error) { return userNode, s.db.Where("contract_id = ?", contractID).First(&userNode).Error } +// GetUserLastCalcTime returns the last calculation time for a user +func (s *GormDB) GetUserLastCalcTime(userID int) (time.Time, error) { + var calcTime UserUsageCalculationTime + err := s.db.Where("user_id = ?", userID).First(&calcTime).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + // If no record exists, return zero time + return time.Time{}, nil + } + return time.Time{}, err + } + return calcTime.LastCalcTime, nil +} + +// UpdateUserLastCalcTime updates the last calculation time for a user +func (s *GormDB) UpdateUserLastCalcTime(userID int, lastCalcTime time.Time) error { + var calcTime UserUsageCalculationTime + err := s.db.Where("user_id = ?", userID).First(&calcTime).Error + + if err != nil { + if err == gorm.ErrRecordNotFound { + // Create a new record if one doesn't exist + calcTime = UserUsageCalculationTime{ + UserID: userID, + LastCalcTime: lastCalcTime, + UpdatedAt: time.Now(), + } + return s.db.Create(&calcTime).Error + } + return err + } + + // Update existing record + calcTime.LastCalcTime = lastCalcTime + calcTime.UpdatedAt = time.Now() + return s.db.Save(&calcTime).Error +} + func migrateNotifications(db *gorm.DB) error { m := db.Migrator() if !m.HasTable(&Notification{}) { diff --git a/backend/models/usage_calculation_time.go b/backend/models/usage_calculation_time.go new file mode 100644 index 000000000..524859d00 --- /dev/null +++ b/backend/models/usage_calculation_time.go @@ -0,0 +1,11 @@ +package models + +import "time" + +// UserUsageCalculationTime represents the last time a user's usage was calculated +type UserUsageCalculationTime struct { + ID int `gorm:"primaryKey;autoIncrement;column:id"` + UserID int `gorm:"user_id;index:idx_user_id,unique" binding:"required"` + LastCalcTime time.Time `json:"last_calc_time"` + UpdatedAt time.Time `json:"updated_at"` +} From 9267a3a39167e16046c812e91d42fdc31586f3e0 Mon Sep 17 00:00:00 2001 From: rawdaGastan Date: Sun, 5 Oct 2025 19:13:18 +0300 Subject: [PATCH 11/16] handle all contracts through invoices, settleing money and calculating debt --- backend/app/debt_tracker.go | 14 +- backend/app/health_trackers.go | 6 +- backend/app/invoice_handler.go | 10 +- backend/app/settle_usage.go | 20 +-- backend/cmd/fix_rentables/main.go | 4 +- .../internal/activities/node_activities.go | 5 +- backend/models/db.go | 14 +- backend/models/gorm.go | 121 ++++++++++++++---- backend/models/gorm_test.go | 115 +++++++++++++++++ backend/models/user.go | 23 +++- 10 files changed, 270 insertions(+), 62 deletions(-) create mode 100644 backend/models/gorm_test.go diff --git a/backend/app/debt_tracker.go b/backend/app/debt_tracker.go index c6661bedb..1bc17b2de 100644 --- a/backend/app/debt_tracker.go +++ b/backend/app/debt_tracker.go @@ -4,14 +4,17 @@ import ( "kubecloud/internal" "time" + "kubecloud/internal/logger" + substrate "github.com/threefoldtech/tfchain/clients/tfchain-client-go" "github.com/threefoldtech/tfgrid-sdk-go/grid-client/calculator" "github.com/threefoldtech/tfgrid-sdk-go/grid-client/deployer" - "kubecloud/internal/logger" ) +const TrackingDeptPeriod = time.Hour + func (h *Handler) TrackUserDebt(gridClient deployer.TFPluginClient) { - ticker := time.NewTicker(1 * time.Hour) + ticker := time.NewTicker(TrackingDeptPeriod) defer ticker.Stop() for range ticker.C { @@ -28,11 +31,12 @@ func (h *Handler) updateUserDebt(gridClient deployer.TFPluginClient) error { } for _, user := range users { - userNodes, err := h.db.ListUserNodes(user.ID) + userContracts, err := h.db.ListAllContractsInPeriod(user.ID, time.Now().Add(-TrackingDeptPeriod), time.Now()) if err != nil { logger.GetLogger().Error().Err(err).Send() continue } + // Create identity from mnemonic identity, err := substrate.NewIdentityFromSr25519Phrase(user.Mnemonic) if err != nil { @@ -41,9 +45,9 @@ func (h *Handler) updateUserDebt(gridClient deployer.TFPluginClient) error { } var totalDebt int64 - for _, node := range userNodes { + for _, contract := range userContracts { calculatorClient := calculator.NewCalculator(gridClient.SubstrateConn, identity) - debt, err := calculatorClient.CalculateContractOverdue(node.ContractID, time.Hour) + debt, err := calculatorClient.CalculateContractOverdue(contract.ContractID, time.Hour) if err != nil { logger.GetLogger().Error().Err(err).Send() continue diff --git a/backend/app/health_trackers.go b/backend/app/health_trackers.go index 65bbe51c4..c6c6933fc 100644 --- a/backend/app/health_trackers.go +++ b/backend/app/health_trackers.go @@ -101,7 +101,7 @@ func (h *Handler) TrackReservedNodeHealth(notificationService *notification.Noti } // checkNodesWithWorkerPool uses a worker pool to check node health concurrently -func (h *Handler) checkNodesWithWorkerPool(reservedNodes []models.UserNodes, grid proxy.Client, notificationService *notification.NotificationService) { +func (h *Handler) checkNodesWithWorkerPool(reservedNodes []models.UserContractData, grid proxy.Client, notificationService *notification.NotificationService) { timeout := time.Duration(h.config.ReservedNodeHealthCheckTimeoutInMinutes) * time.Minute ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() @@ -111,7 +111,7 @@ func (h *Handler) checkNodesWithWorkerPool(reservedNodes []models.UserNodes, gri workerCount = len(reservedNodes) } - jobs := make(chan models.UserNodes, len(reservedNodes)) + jobs := make(chan models.UserContractData, len(reservedNodes)) results := make(chan NodeHealthResult, len(reservedNodes)) var wg sync.WaitGroup @@ -187,7 +187,7 @@ func (h *Handler) checkNodesWithWorkerPool(reservedNodes []models.UserNodes, gri } } -func (h *Handler) healthCheckWorker(ctx context.Context, wg *sync.WaitGroup, jobs <-chan models.UserNodes, results chan<- NodeHealthResult, grid proxy.Client) { +func (h *Handler) healthCheckWorker(ctx context.Context, wg *sync.WaitGroup, jobs <-chan models.UserContractData, results chan<- NodeHealthResult, grid proxy.Client) { defer wg.Done() for userNode := range jobs { diff --git a/backend/app/invoice_handler.go b/backend/app/invoice_handler.go index d8e917c98..b8451c766 100644 --- a/backend/app/invoice_handler.go +++ b/backend/app/invoice_handler.go @@ -174,21 +174,21 @@ func (h *Handler) DownloadInvoiceHandler(c *gin.Context) { } func (h *Handler) createUserInvoice(user models.User) error { - records, err := h.db.ListUserNodes(user.ID) + now := time.Now() + + contracts, err := h.db.ListAllContractsInPeriod(user.ID, now.AddDate(0, -1, 0), now) if err != nil { return err } - if len(records) == 0 { + if len(contracts) == 0 { return nil } - now := time.Now() - var nodeItems []models.NodeItem var totalInvoiceCostUSD float64 - for _, record := range records { + for _, record := range contracts { // get bill reports for the last month billReports, err := internal.ListContractBillReports(h.graphqlClient, record.ContractID, now.AddDate(0, -1, 0), now) if err != nil { diff --git a/backend/app/settle_usage.go b/backend/app/settle_usage.go index 3aec5974f..22eb474ff 100644 --- a/backend/app/settle_usage.go +++ b/backend/app/settle_usage.go @@ -43,15 +43,6 @@ func (h *Handler) DeductBalanceBasedOnUsage() { } func (h *Handler) getUserDailyUsageInUSD(userID int) (uint64, error) { - records, err := h.db.ListUserNodes(userID) - if err != nil { - return 0, err - } - - if len(records) == 0 { - return 0, nil - } - now := time.Now() // Define the end of the day (next day at 00:00) endOfDay := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.Local) @@ -67,9 +58,18 @@ func (h *Handler) getUserDailyUsageInUSD(userID int) (uint64, error) { lastCalcTime = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local) } + contracts, err := h.db.ListAllContractsInPeriod(userID, lastCalcTime, endOfDay) + if err != nil { + return 0, err + } + + if len(contracts) == 0 { + return 0, nil + } + var totalDailyUsageInUSDMillicent uint64 - for _, record := range records { + for _, record := range contracts { // Get bill reports from the last calculation time to the end of day billReports, err := internal.ListContractBillReports(h.graphqlClient, record.ContractID, lastCalcTime, endOfDay) if err != nil { diff --git a/backend/cmd/fix_rentables/main.go b/backend/cmd/fix_rentables/main.go index 4ce96ab49..4e344009f 100644 --- a/backend/cmd/fix_rentables/main.go +++ b/backend/cmd/fix_rentables/main.go @@ -53,8 +53,8 @@ func main() { defer substrateClient.Close() // Get all user_nodes records - var allRecords []models.UserNodes - if err := db.GetDB().Order("created_at DESC, id DESC").Find(&allRecords).Error; err != nil { + allRecords, err := db.ListAllReservedNodes() + if err != nil { log.Error().Err(err).Msg("Failed to get user_nodes records") return } diff --git a/backend/internal/activities/node_activities.go b/backend/internal/activities/node_activities.go index e073fb080..da70bae80 100644 --- a/backend/internal/activities/node_activities.go +++ b/backend/internal/activities/node_activities.go @@ -53,10 +53,11 @@ func ReserveNodeStep(db models.DB, substrateClient *substrate.Substrate) ewf.Ste return fmt.Errorf("failed to create rent contract: %w", err) } - err = db.CreateUserNode(&models.UserNodes{ + err = db.CreateUserContractData(&models.UserContractData{ UserID: userID, ContractID: contractID, NodeID: nodeID, + Type: models.ContractTypeRented, CreatedAt: time.Now(), }) if err != nil { @@ -91,7 +92,7 @@ func UnreserveNodeStep(db models.DB, substrateClient *substrate.Substrate) ewf.S return fmt.Errorf("failed to cancel contract: %w", err) } - err = db.DeleteUserNode(contractIDUint64) + err = db.DeleteUserContract(contractIDUint64) if err != nil { return fmt.Errorf("failed to delete user node: %w", err) } diff --git a/backend/models/db.go b/backend/models/db.go index 13315e827..3b9564940 100644 --- a/backend/models/db.go +++ b/backend/models/db.go @@ -32,12 +32,14 @@ type DB interface { ListUserInvoices(userID int) ([]Invoice, error) ListInvoices() ([]Invoice, error) UpdateInvoicePDF(id int, data []byte) error - CreateUserNode(userNode *UserNodes) error - DeleteUserNode(contractID uint64) error - ListUserNodes(userID int) ([]UserNodes, error) - GetUserNodeByNodeID(nodeID uint64) (UserNodes, error) - GetUserNodeByContractID(contractID uint64) (UserNodes, error) - ListAllReservedNodes() ([]UserNodes, error) + // Contract methods + CreateUserContractData(contractData *UserContractData) error + DeleteUserContract(contractID uint64) error + ListUserRentedNodes(userID int) ([]UserContractData, error) + GetUserNodeByNodeID(nodeID uint64) (UserContractData, error) + GetUserNodeByContractID(contractID uint64) (UserContractData, error) + ListAllReservedNodes() ([]UserContractData, error) + ListAllContractsInPeriod(userID int, start, end time.Time) ([]UserContractData, error) // Usage calculation time methods GetUserLastCalcTime(userID int) (time.Time, error) UpdateUserLastCalcTime(userID int, lastCalcTime time.Time) error diff --git a/backend/models/gorm.go b/backend/models/gorm.go index 6cda215f2..09319745d 100644 --- a/backend/models/gorm.go +++ b/backend/models/gorm.go @@ -30,7 +30,7 @@ func NewGormStorage(dialector gorm.Dialector) (DB, error) { Transaction{}, Invoice{}, NodeItem{}, - UserNodes{}, + UserContractData{}, &Notification{}, &SSHKey{}, &Cluster{}, @@ -299,31 +299,59 @@ func (s *GormDB) UpdateInvoicePDF(id int, data []byte) error { return s.db.Model(&Invoice{}).Where("id = ?", id).Updates(map[string]interface{}{"file_data": data}).Error } -// CreateUserNode creates new node record for user -func (s *GormDB) CreateUserNode(userNode *UserNodes) error { - return s.db.Create(&userNode).Error +// CreateUserContractData creates new contract record for user +func (s *GormDB) CreateUserContractData(contractData *UserContractData) error { + return s.db.Create(&contractData).Error } -// DeleteUserNode deletes a node record for user by its contract ID -func (s *GormDB) DeleteUserNode(contractID uint64) error { - return s.db.Where("contract_id = ?", contractID).Delete(&UserNodes{}).Error +// DeleteUserContract updates deleted time of a contract record for user by its contract ID +func (s *GormDB) DeleteUserContract(contractID uint64) error { + return s.db.Where("contract_id = ?", contractID).Update("deleted_at", time.Now()).Error } -// ListUserNodes returns all nodes records for user by its ID -func (s *GormDB) ListUserNodes(userID int) ([]UserNodes, error) { - var userNodes []UserNodes - return userNodes, s.db.Where("user_id = ?", userID).Find(&userNodes).Error +// ListUserRentedNodes returns all nodes records for user by its ID +func (s *GormDB) ListUserRentedNodes(userID int) ([]UserContractData, error) { + var userNodes []UserContractData + return userNodes, s.db.Where("user_id = ? and type = ? and deleted_at = ?", userID, ContractTypeRented, time.Time{}).Find(&userNodes).Error +} + +// ListAllContractsInPeriod returns all contracts that existed during the specified time period. +// This includes: +// 1. Contracts created before or during the period end date +// 2. AND either not deleted (deleted_at is zero time) OR deleted after the period start date +// If userID is provided (non-zero), it will only return contracts for that specific user. +// If userID is 0, it will return contracts for all users. +func (s *GormDB) ListAllContractsInPeriod(userID int, start, end time.Time) ([]UserContractData, error) { + var userNodes []UserContractData + + // Query for contracts that: + // - Were created on or before the end date of the period + // - AND are either not deleted (deleted_at is zero) OR were deleted after the start of the period + query := s.db.Where("created_at <= ?", end). + Where("(deleted_at = ? OR deleted_at >= ?)", time.Time{}, start) + + // If userID is provided (non-zero), filter by that user + if userID > 0 { + query = query.Where("user_id = ?", userID) + } + + return userNodes, query.Find(&userNodes).Error } // ListAllReservedNodes returns all reserved nodes from all users -func (s *GormDB) ListAllReservedNodes() ([]UserNodes, error) { - var userNodes []UserNodes - return userNodes, s.db.Find(&userNodes).Error +func (s *GormDB) ListAllReservedNodes() ([]UserContractData, error) { + var userNodes []UserContractData + return userNodes, s.db.Where("type = ? and deleted_at = ?", ContractTypeRented, time.Time{}).Find(&userNodes).Error } -func (s *GormDB) GetUserNodeByNodeID(nodeID uint64) (UserNodes, error) { - var userNode UserNodes - return userNode, s.db.Where("node_id = ?", nodeID).First(&userNode).Error +func (s *GormDB) GetUserNodeByNodeID(nodeID uint64) (UserContractData, error) { + var userNode UserContractData + return userNode, s.db.Where("node_id = ? and deleted_at = ?", nodeID, time.Time{}).First(&userNode).Error +} + +func (s *GormDB) GetUserNodeByContractID(contractID uint64) (UserContractData, error) { + var userNode UserContractData + return userNode, s.db.Where("contract_id = ? and deleted_at = ?", contractID, time.Time{}).First(&userNode).Error } // CreateNotification creates a new notification @@ -368,6 +396,26 @@ func (s *GormDB) CreateCluster(userID int, cluster *Cluster) error { cluster.CreatedAt = time.Now() cluster.UpdatedAt = time.Now() cluster.UserID = userID + + clusterDate, err := cluster.GetClusterResult() + if err != nil { + return err + } + + for _, node := range clusterDate.Nodes { + if err := s.CreateUserContractData( + &UserContractData{ + UserID: userID, + ContractID: node.ContractID, + NodeID: node.NodeID, + Type: ContractTypeDeployed, + CreatedAt: time.Now(), + }, + ); err != nil { + return err + } + } + return s.db.Create(cluster).Error } @@ -395,11 +443,45 @@ func (s *GormDB) UpdateCluster(cluster *Cluster) error { // DeleteCluster deletes a cluster by name for a specific user func (s *GormDB) DeleteCluster(userID int, projectName string) error { + cluster, err := s.GetClusterByName(userID, projectName) + if err != nil { + return err + } + + clusterData, err := cluster.GetClusterResult() + if err != nil { + return err + } + + for _, node := range clusterData.Nodes { + if err := s.DeleteUserContract(node.ContractID); err != nil { + return err + } + } + return s.db.Where("user_id = ? AND project_name = ?", userID, projectName).Delete(&Cluster{}).Error } // DeleteAllUserClusters deletes all clusters for a specific user func (s *GormDB) DeleteAllUserClusters(userID int) error { + clusters, err := s.ListUserClusters(userID) + if err != nil { + return err + } + + for _, cluster := range clusters { + clusterData, err := cluster.GetClusterResult() + if err != nil { + return err + } + + for _, node := range clusterData.Nodes { + if err := s.DeleteUserContract(node.ContractID); err != nil { + return err + } + } + } + return s.db.Where("user_id = ?", userID).Delete(&Cluster{}).Error } @@ -459,11 +541,6 @@ func (s *GormDB) ListAllClusters() ([]Cluster, error) { return clusters, s.db.Find(&clusters).Error } -func (s *GormDB) GetUserNodeByContractID(contractID uint64) (UserNodes, error) { - var userNode UserNodes - return userNode, s.db.Where("contract_id = ?", contractID).First(&userNode).Error -} - // GetUserLastCalcTime returns the last calculation time for a user func (s *GormDB) GetUserLastCalcTime(userID int) (time.Time, error) { var calcTime UserUsageCalculationTime diff --git a/backend/models/gorm_test.go b/backend/models/gorm_test.go new file mode 100644 index 000000000..f35fb5357 --- /dev/null +++ b/backend/models/gorm_test.go @@ -0,0 +1,115 @@ +package models + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "gorm.io/driver/sqlite" +) + +func TestListAllContractsInPeriod(t *testing.T) { + // Setup in-memory SQLite database for testing + db, err := NewGormStorage(sqlite.Open("file::memory:?cache=shared")) + assert.NoError(t, err) + + // Clean up after test + defer func() { + sqlDB, _ := db.GetDB().DB() + sqlDB.Close() + }() + + gormDB := db.(*GormDB) + + // Define test time periods + now := time.Now() + yesterday := now.Add(-24 * time.Hour) + twoDaysAgo := now.Add(-48 * time.Hour) + tomorrow := now.Add(24 * time.Hour) + + // Create test contracts with different creation and deletion times + + // Contract 1: Created two days ago, not deleted (should be included) + contract1 := &UserContractData{ + UserID: 1, + ContractID: 1001, + NodeID: 1, + Type: ContractTypeRented, + CreatedAt: twoDaysAgo, + } + assert.NoError(t, gormDB.CreateUserContractData(contract1)) + + // Contract 2: Created yesterday, deleted today (should be included) + contract2 := &UserContractData{ + UserID: 1, + ContractID: 1002, + NodeID: 2, + Type: ContractTypeRented, + CreatedAt: yesterday, + } + // Manually update the DeletedAt field since CreateUserContractData doesn't set it + assert.NoError(t, gormDB.CreateUserContractData(contract2)) + assert.NoError(t, gormDB.GetDB().Model(&UserContractData{}). + Where("contract_id = ?", 1002). + Update("deleted_at", now).Error) + + // Contract 3: Created two days ago, deleted yesterday (should be included) + contract3 := &UserContractData{ + UserID: 2, + ContractID: 1003, + NodeID: 3, + Type: ContractTypeDeployed, + CreatedAt: twoDaysAgo, + } + assert.NoError(t, gormDB.CreateUserContractData(contract3)) + assert.NoError(t, gormDB.GetDB().Model(&UserContractData{}). + Where("contract_id = ?", 1003). + Update("deleted_at", yesterday).Error) + + // Contract 4: Will be created tomorrow (should NOT be included) + contract4 := &UserContractData{ + UserID: 2, + ContractID: 1004, + NodeID: 4, + Type: ContractTypeDeployed, + CreatedAt: tomorrow, + } + assert.NoError(t, gormDB.CreateUserContractData(contract4)) + + // Test period: from yesterday to today + periodStart := yesterday + periodEnd := now + + // Test with userID = 0 (should return all contracts in the period) + contracts, err := gormDB.ListAllContractsInPeriod(0, periodStart, periodEnd) + assert.NoError(t, err) + + // Should include contracts 1, 2, and 3, but not 4 + assert.Equal(t, 3, len(contracts), "Expected 3 contracts in the period") + + // Verify contract IDs + contractIDs := make([]uint64, len(contracts)) + for i, c := range contracts { + contractIDs[i] = c.ContractID + } + + assert.Contains(t, contractIDs, uint64(1001), "Contract 1001 should be in the period") + assert.Contains(t, contractIDs, uint64(1002), "Contract 1002 should be in the period") + assert.Contains(t, contractIDs, uint64(1003), "Contract 1003 should be in the period") + + // Test with userID = 2 (should only return contracts for user 2) + contractsUser2, err := gormDB.ListAllContractsInPeriod(2, periodStart, periodEnd) + assert.NoError(t, err) + + // Should only include contract 3, not 1, 2, or 4 + assert.Equal(t, 1, len(contractsUser2), "Expected 1 contract for user 2 in the period") + + // Verify contract IDs for user 2 + contractIDsUser2 := make([]uint64, len(contractsUser2)) + for i, c := range contractsUser2 { + contractIDsUser2[i] = c.ContractID + } + + assert.Contains(t, contractIDsUser2, uint64(1003), "Contract 1003 should be in the period for user 2") + assert.NotContains(t, contractIDs, uint64(1004), "Contract 1004 should not be in the period") +} diff --git a/backend/models/user.go b/backend/models/user.go index f60b5e5d3..166bbf414 100644 --- a/backend/models/user.go +++ b/backend/models/user.go @@ -2,6 +2,13 @@ package models import "time" +type ContractType string + +const ( + ContractTypeRented ContractType = "rented" + ContractTypeDeployed ContractType = "deployed" +) + // User represents a user in the system type User struct { ID int `gorm:"primaryKey;autoIncrement;column:id"` @@ -32,11 +39,13 @@ type SSHKey struct { UpdatedAt time.Time `json:"updated_at"` } -// UserNodes model holds info of reserved nodes of user -type UserNodes struct { - ID int `gorm:"primaryKey;autoIncrement;column:id"` - UserID int `gorm:"user_id" binding:"required"` - ContractID uint64 `gorm:"contract_id" binding:"required"` - NodeID uint32 `gorm:"node_id;index:idx_user_node_id,unique" binding:"required"` - CreatedAt time.Time `json:"created_at"` +// UserContractData model holds info of contracts of user +type UserContractData struct { + ID int `gorm:"primaryKey;autoIncrement;column:id"` + UserID int `gorm:"user_id" binding:"required"` + ContractID uint64 `gorm:"contract_id" binding:"required"` + NodeID uint32 `gorm:"node_id;index:idx_user_node_id,unique" binding:"required"` + Type ContractType `gorm:"type" binding:"required"` + CreatedAt time.Time `json:"created_at"` + DeletedAt time.Time `json:"deleted_at"` } From 891862ebd0aa76e5a5a7e07fd5cab51fb4976373 Mon Sep 17 00:00:00 2001 From: rawdaGastan Date: Wed, 15 Oct 2025 13:37:29 +0300 Subject: [PATCH 12/16] retry calculate overdue - add cancelled ctx case - remove unique constraint in contract records on node ID - skip rented from calculation because they are already caluclated - improve fulfill user balance to match a discount logic and add it to add node - add failed records in settling pending payments - add transactions for cluster handling contract records --- backend/app/app.go | 2 +- backend/app/balance_monitor.go | 51 ++++++++++----- backend/app/debt_tracker.go | 42 +++++++++--- backend/app/deployment_handler.go | 57 ++++------------- backend/app/node_handler.go | 44 +------------ backend/app/user_handler.go | 2 +- backend/models/db.go | 1 + backend/models/gorm.go | 102 +++++++++++++++++++++++++----- backend/models/user.go | 2 +- 9 files changed, 173 insertions(+), 130 deletions(-) diff --git a/backend/app/app.go b/backend/app/app.go index 600f63dbd..0c67703b5 100644 --- a/backend/app/app.go +++ b/backend/app/app.go @@ -361,7 +361,7 @@ func (app *App) registerHandlers() { func (app *App) StartBackgroundWorkers(ctx context.Context) { go app.handlers.MonthlyInvoicesHandler() - go app.handlers.TrackUserDebt(app.gridClient) + go app.handlers.TrackUserDebt(ctx, app.gridClient) go app.handlers.MonitorSystemBalanceAndHandleSettlement(ctx) go app.handlers.DeductBalanceBasedOnUsage() go app.handlers.TrackClusterHealth() diff --git a/backend/app/balance_monitor.go b/backend/app/balance_monitor.go index cdd2c9581..4ba597ff3 100644 --- a/backend/app/balance_monitor.go +++ b/backend/app/balance_monitor.go @@ -51,6 +51,13 @@ func (h *Handler) MonitorSystemBalanceAndHandleSettlement(ctx context.Context) { continue } + failedRecords, err := h.db.ListFailedTransferRecords() + if err != nil { + continue + } + + records = append(records, failedRecords...) + if err := h.settlePendingPayments(records); err != nil { logger.GetLogger().Error().Err(err).Send() } @@ -85,7 +92,13 @@ func (h *Handler) MonitorSystemBalanceAndHandleSettlement(ctx context.Context) { continue } - if user.CreditedBalance+user.CreditCardBalance-user.Debt > zeroTFTBalanceValue { + zeroUSDMillicentBalanceValue, err := internal.FromTFTtoUSDMillicent(h.substrateClient, zeroTFTBalanceValue) + if err != nil { + logger.GetLogger().Error().Err(err).Msgf("failed to convert TFT to USD millicent") + continue + } + + if user.CreditedBalance+user.CreditCardBalance-user.Debt > zeroUSDMillicentBalanceValue { continue } @@ -101,7 +114,7 @@ func (h *Handler) MonitorSystemBalanceAndHandleSettlement(ctx context.Context) { case <-fundUserTFTBalanceTicker.C: for _, user := range users { - if err = h.fundUsersToClaimDiscount(ctx, user, discount(h.config.AppliedDiscount)); err != nil { + if err := h.fundUserToFulfillDiscount(ctx, user, []types.Node{}, []kubedeployer.Node{}, discount(h.config.AppliedDiscount)); err != nil { logger.GetLogger().Error().Err(err).Msgf("Failed to fund user %d to claim discount", user.ID) } } @@ -222,43 +235,39 @@ func (h *Handler) withdrawTFTsFromUser(userID int, userMnemonic string, amountTo return nil } -func (h *Handler) fundUsersToClaimDiscount(ctx context.Context, user models.User, configuredDiscount discount) error { - dailyUsageInUSDMillicent, err := h.calculateResourcesUsageInUSDApplyingDiscount(ctx, user.ID, user.Mnemonic, []types.Node{}, []kubedeployer.Node{}, configuredDiscount) +func (h *Handler) fundUserToFulfillDiscount(ctx context.Context, user models.User, addedRentedNodes []types.Node, addedSharedNodes []kubedeployer.Node, discount discount) error { + // calculate resources usage in USD applying discount + // I took the cluster nodes since only the new node is in cluster.Nodes + dailyUsageInUSDMillicent, err := h.calculateResourcesUsageInUSDApplyingDiscount(ctx, user.ID, user.Mnemonic, addedRentedNodes, addedSharedNodes, discount) if err != nil { - logger.GetLogger().Error().Err(err).Msgf("Failed to calculate resources usage in USD for user %d", user.ID) return err } dailyUsageInTFT, err := internal.FromUSDMillicentToTFT(h.substrateClient, dailyUsageInUSDMillicent) if err != nil { - logger.GetLogger().Error().Err(err).Send() return err } totalPendingTFTAmount, err := h.db.CalculateTotalPendingTFTAmountPerUser(user.ID) if err != nil { - logger.GetLogger().Error().Err(err).Send() return err } userTFTBalance, err := internal.GetUserTFTBalance(h.substrateClient, user.Mnemonic) if err != nil { - logger.GetLogger().Error().Err(err).Msgf("Failed to get user TFT balance for user %d", user.ID) return err } + // fund user to fulfill discount // make sure no old payments will fund more than needed - if totalPendingTFTAmount < dailyUsageInTFT && - userTFTBalance < dailyUsageInTFT && - dailyUsageInTFT > 0 && - user.CreditCardBalance+user.CreditedBalance-user.Debt < dailyUsageInUSDMillicent { + if totalPendingTFTAmount+userTFTBalance < dailyUsageInTFT && + dailyUsageInTFT > 0 { if err := h.db.CreateTransferRecord(&models.TransferRecord{ UserID: user.ID, Username: user.Username, - TFTAmount: dailyUsageInTFT - userTFTBalance, + TFTAmount: dailyUsageInTFT - userTFTBalance - totalPendingTFTAmount, Operation: models.DepositOperation, }); err != nil { - logger.GetLogger().Error().Err(err).Msgf("Failed to create transfer record for user %d", user.ID) return err } } @@ -297,7 +306,7 @@ func (h *Handler) calculateResourcesUsageInUSDApplyingDiscount( uint64(node.TotalResources.HRU), uint64(node.TotalResources.SRU), len(node.PublicConfig.Ipv4) > 0, - len(node.CertificationType) > 0, + node.CertificationType == nodeCertified, ) if err != nil { return 0, err @@ -325,6 +334,18 @@ func (h *Handler) calculateResourcesUsageInUSDApplyingDiscount( return 0, err } + if proxyNode.Rented { + twinID, err := h.substrateClient.GetTwinByPubKey(userIdentity.PublicKey()) + if err != nil { + return 0, err + } + + if proxyNode.RentedByTwinID == uint(twinID) { + // skip rented nodes as they are already calculated + continue + } + } + resourcesCost, err := calculator.CalculateCost( uint64(node.CPU), node.Memory, diff --git a/backend/app/debt_tracker.go b/backend/app/debt_tracker.go index 1bc17b2de..24a22d2cf 100644 --- a/backend/app/debt_tracker.go +++ b/backend/app/debt_tracker.go @@ -1,25 +1,36 @@ package app import ( + "context" "kubecloud/internal" "time" "kubecloud/internal/logger" + "github.com/cenkalti/backoff/v4" substrate "github.com/threefoldtech/tfchain/clients/tfchain-client-go" "github.com/threefoldtech/tfgrid-sdk-go/grid-client/calculator" "github.com/threefoldtech/tfgrid-sdk-go/grid-client/deployer" ) -const TrackingDeptPeriod = time.Hour +const ( + TrackingDebtPeriod = time.Hour + reties = 3 +) -func (h *Handler) TrackUserDebt(gridClient deployer.TFPluginClient) { - ticker := time.NewTicker(TrackingDeptPeriod) +func (h *Handler) TrackUserDebt(ctx context.Context, gridClient deployer.TFPluginClient) { + ticker := time.NewTicker(TrackingDebtPeriod) defer ticker.Stop() - for range ticker.C { - if err := h.updateUserDebt(gridClient); err != nil { - logger.GetLogger().Error().Err(err).Send() + for { + select { + case <-ctx.Done(): + logger.GetLogger().Info().Msg("Debt tracker stopping due to context cancellation") + return + case <-ticker.C: + if err := h.updateUserDebt(gridClient); err != nil { + logger.GetLogger().Error().Err(err).Send() + } } } } @@ -31,7 +42,7 @@ func (h *Handler) updateUserDebt(gridClient deployer.TFPluginClient) error { } for _, user := range users { - userContracts, err := h.db.ListAllContractsInPeriod(user.ID, time.Now().Add(-TrackingDeptPeriod), time.Now()) + userContracts, err := h.db.ListAllContractsInPeriod(user.ID, time.Now().Add(-TrackingDebtPeriod), time.Now()) if err != nil { logger.GetLogger().Error().Err(err).Send() continue @@ -47,11 +58,24 @@ func (h *Handler) updateUserDebt(gridClient deployer.TFPluginClient) error { var totalDebt int64 for _, contract := range userContracts { calculatorClient := calculator.NewCalculator(gridClient.SubstrateConn, identity) - debt, err := calculatorClient.CalculateContractOverdue(contract.ContractID, time.Hour) + + var debt int64 + err = backoff.Retry(func() error { + debt, err = calculatorClient.CalculateContractOverdue(contract.ContractID, time.Hour) + return err + }, backoff.WithMaxRetries( + backoff.NewExponentialBackOff(), + reties, + )) if err != nil { - logger.GetLogger().Error().Err(err).Send() + logger.GetLogger().Error(). + Uint64("contract_id", contract.ContractID). + Int("max_retries", reties). + Err(err). + Msg("Failed to calculate contract overdue after maximum retries") continue } + totalDebt += debt } diff --git a/backend/app/deployment_handler.go b/backend/app/deployment_handler.go index e1681648a..9bba52d5e 100644 --- a/backend/app/deployment_handler.go +++ b/backend/app/deployment_handler.go @@ -8,7 +8,6 @@ import ( "kubecloud/internal/constants" "kubecloud/internal/statemanager" "kubecloud/kubedeployer" - "kubecloud/models" "net/http" "os" @@ -322,53 +321,12 @@ func (h *Handler) HandleDeployCluster(c *gin.Context) { return } - // calculate resources usage in USD applying discount - dailyUsageInUSDMillicent, err := h.calculateResourcesUsageInUSDApplyingDiscount(c.Request.Context(), user.ID, user.Mnemonic, []types.Node{}, cluster.Nodes, discount(h.config.AppliedDiscount)) - if err != nil { - logger.GetLogger().Error().Err(err).Send() - InternalServerError(c) - return - } - - dailyUsageInTFT, err := internal.FromUSDMillicentToTFT(h.substrateClient, dailyUsageInUSDMillicent) - if err != nil { - logger.GetLogger().Error().Err(err).Send() - InternalServerError(c) - return - } - - totalPendingTFTAmount, err := h.db.CalculateTotalPendingTFTAmountPerUser(user.ID) - if err != nil { + if err := h.fundUserToFulfillDiscount(c.Request.Context(), user, []types.Node{}, cluster.Nodes, discount(h.config.AppliedDiscount)); err != nil { logger.GetLogger().Error().Err(err).Send() InternalServerError(c) return } - userTFTBalance, err := internal.GetUserTFTBalance(h.substrateClient, user.Mnemonic) - if err != nil { - logger.GetLogger().Error().Err(err).Send() - InternalServerError(c) - return - } - - // fund user to fulfill discount - // make sure no old payments will fund more than needed - if totalPendingTFTAmount < dailyUsageInTFT && - userTFTBalance < dailyUsageInTFT && - dailyUsageInUSDMillicent > 0 && - user.CreditCardBalance+user.CreditedBalance-user.Debt < dailyUsageInUSDMillicent { - if err := h.db.CreateTransferRecord(&models.TransferRecord{ - UserID: user.ID, - Username: user.Username, - TFTAmount: dailyUsageInTFT - userTFTBalance, - Operation: models.DepositOperation, - }); err != nil { - logger.GetLogger().Error().Err(err).Send() - InternalServerError(c) - return - } - } - projectName := kubedeployer.GetProjectName(config.UserID, cluster.Name) _, err = h.db.GetClusterByName(config.UserID, projectName) if err == nil { @@ -566,6 +524,19 @@ func (h *Handler) HandleAddNode(c *gin.Context) { } } + user, err := h.db.GetUserByID(config.UserID) + if err != nil { + logger.GetLogger().Error().Err(err).Send() + InternalServerError(c) + return + } + + if err := h.fundUserToFulfillDiscount(c.Request.Context(), user, []types.Node{}, cluster.Nodes, discount(h.config.AppliedDiscount)); err != nil { + logger.GetLogger().Error().Err(err).Send() + InternalServerError(c) + return + } + wf, err := h.ewfEngine.NewWorkflow(constants.WorkflowAddNode) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create workflow"}) diff --git a/backend/app/node_handler.go b/backend/app/node_handler.go index 58bf9501a..2e645dade 100644 --- a/backend/app/node_handler.go +++ b/backend/app/node_handler.go @@ -6,7 +6,6 @@ import ( "fmt" "kubecloud/internal" "kubecloud/kubedeployer" - "kubecloud/models" "net/http" "net/url" "reflect" @@ -292,53 +291,12 @@ func (h *Handler) ReserveNodeHandler(c *gin.Context) { // add newly rented node rentedNodes = append(rentedNodes, node) - // calculate resources usage in USD applying discount - dailyUsageInUSDMillicent, err := h.calculateResourcesUsageInUSDApplyingDiscount(c.Request.Context(), userID, user.Mnemonic, rentedNodes, []kubedeployer.Node{}, discount(h.config.AppliedDiscount)) - if err != nil { - logger.GetLogger().Error().Err(err).Send() - InternalServerError(c) - return - } - - dailyUsageInTFT, err := internal.FromUSDMillicentToTFT(h.substrateClient, dailyUsageInUSDMillicent) - if err != nil { - logger.GetLogger().Error().Err(err).Send() - InternalServerError(c) - return - } - - totalPendingTFTAmount, err := h.db.CalculateTotalPendingTFTAmountPerUser(user.ID) - if err != nil { - logger.GetLogger().Error().Err(err).Send() - InternalServerError(c) - return - } - - userTFTBalance, err := internal.GetUserTFTBalance(h.substrateClient, user.Mnemonic) - if err != nil { + if err := h.fundUserToFulfillDiscount(c.Request.Context(), user, rentedNodes, []kubedeployer.Node{}, discount(h.config.AppliedDiscount)); err != nil { logger.GetLogger().Error().Err(err).Send() InternalServerError(c) return } - // fund user to fulfill discount - // make sure no old payments will fund more than needed - if totalPendingTFTAmount < dailyUsageInTFT && - userTFTBalance < dailyUsageInTFT && - dailyUsageInUSDMillicent > 0 && - user.CreditCardBalance+user.CreditedBalance-user.Debt < dailyUsageInUSDMillicent { - if err := h.db.CreateTransferRecord(&models.TransferRecord{ - UserID: userID, - Username: user.Username, - TFTAmount: dailyUsageInTFT - userTFTBalance, - Operation: models.DepositOperation, - }); err != nil { - logger.GetLogger().Error().Err(err).Send() - InternalServerError(c) - return - } - } - wf, err := h.ewfEngine.NewWorkflow(constants.WorkflowReserveNode) if err != nil { logger.GetLogger().Error().Err(err).Send() diff --git a/backend/app/user_handler.go b/backend/app/user_handler.go index cd9917c1f..91f5c2f3c 100644 --- a/backend/app/user_handler.go +++ b/backend/app/user_handler.go @@ -712,7 +712,7 @@ func (h *Handler) ChargeBalance(c *gin.Context) { // @Tags users // @ID get-user // @Produce json -// @Success 200 {object} models.User "User is retrieved successfully" +// @Success 200 {object} APIResponse{data=models.User} "User is retrieved successfully" // @Failure 404 {object} APIResponse "User is not found" // @Failure 500 {object} APIResponse // @Router /user [get] diff --git a/backend/models/db.go b/backend/models/db.go index 1a91777ba..86ed05a57 100644 --- a/backend/models/db.go +++ b/backend/models/db.go @@ -73,6 +73,7 @@ type DB interface { ListTransferRecords() ([]TransferRecord, error) ListUserTransferRecords(userID int) ([]TransferRecord, error) ListPendingTransferRecords() ([]TransferRecord, error) + ListFailedTransferRecords() ([]TransferRecord, error) UpdateTransferRecordState(recordID int, state state, failure string) error CalculateTotalPendingTFTAmountPerUser(userID int) (uint64, error) // stats methods diff --git a/backend/models/gorm.go b/backend/models/gorm.go index 1647aa96e..fe58d3807 100644 --- a/backend/models/gorm.go +++ b/backend/models/gorm.go @@ -3,10 +3,11 @@ package models import ( "context" "fmt" - "gorm.io/gorm" "strings" "sync" "time" + + "gorm.io/gorm" ) // GormDB struct implements db interface with gorm @@ -80,6 +81,10 @@ func (s *GormDB) Ping(ctx context.Context) error { // RegisterUser registers a new user to the system func (s *GormDB) RegisterUser(user *User) error { + if err := s.UpdateUserLastCalcTime(user.ID, time.Now()); err != nil { + return err + } + return s.db.Create(user).Error } @@ -113,7 +118,7 @@ func (s *GormDB) DeductUserBalance(user *User, amount uint64) error { Error } - if user.CreditCardBalance >= amount-user.CreditedBalance { + if user.CreditedBalance > 0 && user.CreditCardBalance >= amount-user.CreditedBalance { return s.db.Model(&User{}). Where("id = ?", user.ID). UpdateColumn("credited_balance", gorm.Expr("credited_balance - ?", user.CreditedBalance)). @@ -396,26 +401,28 @@ func (s *GormDB) CreateCluster(userID int, cluster *Cluster) error { cluster.UpdatedAt = time.Now() cluster.UserID = userID - clusterDate, err := cluster.GetClusterResult() + clusterData, err := cluster.GetClusterResult() if err != nil { return err } - for _, node := range clusterDate.Nodes { - if err := s.CreateUserContractData( - &UserContractData{ + return s.db.Transaction(func(tx *gorm.DB) error { + for _, node := range clusterData.Nodes { + contractData := &UserContractData{ UserID: userID, ContractID: node.ContractID, NodeID: node.NodeID, Type: ContractTypeDeployed, CreatedAt: time.Now(), - }, - ); err != nil { - return err + } + + if err := tx.Create(contractData).Error; err != nil { + return fmt.Errorf("failed to create contract data for node %d: %w", node.NodeID, err) + } } - } - return s.db.Create(cluster).Error + return tx.Create(cluster).Error + }) } // ListUserClusters returns all clusters for a specific user @@ -434,6 +441,57 @@ func (s *GormDB) GetClusterByName(userID int, projectName string) (Cluster, erro // UpdateCluster updates an existing cluster func (s *GormDB) UpdateCluster(cluster *Cluster) error { + existingCluster, err := s.GetClusterByName(cluster.UserID, cluster.ProjectName) + if err != nil { + return fmt.Errorf("failed to get existing cluster: %w", err) + } + + existingClusterData, err := existingCluster.GetClusterResult() + if err != nil { + return fmt.Errorf("failed to parse existing cluster data: %w", err) + } + + newClusterData, err := cluster.GetClusterResult() + if err != nil { + return fmt.Errorf("failed to parse new cluster data: %w", err) + } + + existingNodes := make(map[uint64]struct{}) + for _, node := range existingClusterData.Nodes { + if node.ContractID != 0 { + existingNodes[node.ContractID] = struct{}{} + } + } + + for _, node := range newClusterData.Nodes { + if node.ContractID != 0 { + if _, exists := existingNodes[node.ContractID]; !exists { + // This is a new node, create a contract for it + if err := s.CreateUserContractData( + &UserContractData{ + UserID: cluster.UserID, + ContractID: node.ContractID, + NodeID: node.NodeID, + Type: ContractTypeDeployed, + CreatedAt: time.Now(), + }, + ); err != nil { + return fmt.Errorf("failed to create contract for new node: %w", err) + } + } + // Remove from existing nodes map to track what is processed + delete(existingNodes, node.ContractID) + } + } + + // Handle removed nodes - delete contracts for nodes that exist in old but not in new + for contractID := range existingNodes { + if err := s.DeleteUserContract(contractID); err != nil { + return fmt.Errorf("failed to delete contract for removed node: %w", err) + } + } + + // Update the cluster record cluster.UpdatedAt = time.Now() return s.db.Model(&Cluster{}). Where("user_id = ? AND project_name = ?", cluster.UserID, cluster.ProjectName). @@ -452,13 +510,18 @@ func (s *GormDB) DeleteCluster(userID int, projectName string) error { return err } - for _, node := range clusterData.Nodes { - if err := s.DeleteUserContract(node.ContractID); err != nil { - return err + return s.db.Transaction(func(tx *gorm.DB) error { + for _, node := range clusterData.Nodes { + if err := tx.Model(&UserContractData{}). + Where("contract_id = ?", node.ContractID). + Update("deleted_at", time.Now()).Error; err != nil { + return fmt.Errorf("failed to delete contract for node %d: %w", node.NodeID, err) + } } - } - return s.db.Where("user_id = ? AND project_name = ?", userID, projectName).Delete(&Cluster{}).Error + return tx.Where("user_id = ? AND project_name = ?", userID, projectName). + Delete(&Cluster{}).Error + }) } // DeleteAllUserClusters deletes all clusters for a specific user @@ -498,7 +561,7 @@ func (s *GormDB) CalculateTotalPendingTFTAmountPerUser(userID int) (uint64, erro var totalAmount uint64 err := s.db.Model(&TransferRecord{}). Select("SUM(tft_amount)"). - Where("user_id = ? AND state = ? AND state = ?", userID, PendingState, PendingState). + Where("user_id = ? AND state = ?", userID, PendingState). Scan(&totalAmount).Error if err != nil { return 0, err @@ -516,6 +579,11 @@ func (s *GormDB) ListPendingTransferRecords() ([]TransferRecord, error) { return TransferRecords, s.db.Where("state = ?", PendingState).Find(&TransferRecords).Error } +func (s *GormDB) ListFailedTransferRecords() ([]TransferRecord, error) { + var TransferRecords []TransferRecord + return TransferRecords, s.db.Where("state = ?", FailedState).Find(&TransferRecords).Error +} + func (s *GormDB) UpdateTransferRecordState(recordID int, state state, failure string) error { return s.db.Model(&TransferRecord{}).Where("id = ?", recordID).Updates( map[string]interface{}{"state": state, "failure": failure, "updated_at": time.Now()}).Error diff --git a/backend/models/user.go b/backend/models/user.go index 166bbf414..36693ea79 100644 --- a/backend/models/user.go +++ b/backend/models/user.go @@ -44,7 +44,7 @@ type UserContractData struct { ID int `gorm:"primaryKey;autoIncrement;column:id"` UserID int `gorm:"user_id" binding:"required"` ContractID uint64 `gorm:"contract_id" binding:"required"` - NodeID uint32 `gorm:"node_id;index:idx_user_node_id,unique" binding:"required"` + NodeID uint32 `gorm:"node_id" binding:"required"` Type ContractType `gorm:"type" binding:"required"` CreatedAt time.Time `json:"created_at"` DeletedAt time.Time `json:"deleted_at"` From 9511c99c0cdca857c2fdb2e9263c2a3a9bb3c1a4 Mon Sep 17 00:00:00 2001 From: rawdaGastan Date: Thu, 16 Oct 2025 11:47:03 +0300 Subject: [PATCH 13/16] add 3 days margin for none pkg - check user usd balance before applying discount fund - add ctx to routines - settle and fund user after charging balance --- backend/app/admin_handler.go | 31 ++++++-- backend/app/app.go | 4 +- backend/app/balance_monitor.go | 25 +++--- backend/app/invoice_handler.go | 57 +++++++------- backend/app/node_handler.go | 13 +--- backend/app/settle_usage.go | 42 ++++++---- backend/app/user_handler.go | 78 ++++++++++++++----- .../internal/activities/user_activities.go | 40 ---------- backend/models/gorm.go | 6 +- 9 files changed, 154 insertions(+), 142 deletions(-) diff --git a/backend/app/admin_handler.go b/backend/app/admin_handler.go index d31e821cb..d77a1eb99 100644 --- a/backend/app/admin_handler.go +++ b/backend/app/admin_handler.go @@ -14,6 +14,7 @@ import ( "sync" "time" + "kubecloud/internal/activities" "kubecloud/internal/logger" "kubecloud/internal/notification" @@ -344,17 +345,35 @@ func (h *Handler) CreditUserHandler(c *gin.Context) { return } - if err := h.db.CreateTransferRecord(&models.TransferRecord{ - UserID: id, - Username: user.Username, - TFTAmount: uint64(h.config.MinimumTFTAmountInWallet), - Operation: models.DepositOperation, - }); err != nil { + if err := h.createTransferRecordToChargeUserWithMinTFTAmount(user.ID, user.Username, user.Mnemonic); err != nil { logger.GetLogger().Error().Err(err).Send() InternalServerError(c) return } + if err := h.settleAndFundUserAfterChargedUSDBalance(c.Request.Context(), &user); err != nil { + logger.GetLogger().Error().Err(err).Msg("error settling and funding user after charging USD balance") + InternalServerError(c) + return + } + + notificationPayload := notification.MergePayload(notification.CommonPayload{ + Message: fmt.Sprintf("Admin %s has credited your account with %v$ successfully", user.Username, request.AmountUSD), + Subject: "Admin Credited Your Account", + Status: "succeeded", + }, map[string]string{ + "workflow_name": "Admin Credit Your Account", + "timestamp": time.Now().Local().Format(activities.TimestampFormat), + "amount": fmt.Sprintf("%.2f", request.AmountUSD), + "updated_balance": fmt.Sprintf("%v", user.CreditedBalance), + }) + notificationObj := models.NewNotification(user.ID, models.NotificationTypeBilling, notificationPayload, models.WithSeverity(models.NotificationSeveritySuccess)) + if err := h.notificationService.Send(c.Request.Context(), notificationObj); err != nil { + logger.GetLogger().Error().Err(err).Msg("failed to send UI notification for user credit") + InternalServerError(c) + return + } + Success(c, http.StatusCreated, fmt.Sprintf("User is credited with %v$ successfully", request.AmountUSD), CreditUserResponse{ User: user.Email, AmountUSD: request.AmountUSD, diff --git a/backend/app/app.go b/backend/app/app.go index 0c67703b5..908743dcb 100644 --- a/backend/app/app.go +++ b/backend/app/app.go @@ -360,10 +360,10 @@ func (app *App) registerHandlers() { } func (app *App) StartBackgroundWorkers(ctx context.Context) { - go app.handlers.MonthlyInvoicesHandler() + go app.handlers.MonthlyInvoicesHandler(ctx) go app.handlers.TrackUserDebt(ctx, app.gridClient) go app.handlers.MonitorSystemBalanceAndHandleSettlement(ctx) - go app.handlers.DeductBalanceBasedOnUsage() + go app.handlers.DeductUSDBalanceBasedOnUsage(ctx) go app.handlers.TrackClusterHealth() // Start command socket go app.startCommandSocket() diff --git a/backend/app/balance_monitor.go b/backend/app/balance_monitor.go index 4ba597ff3..3556358fb 100644 --- a/backend/app/balance_monitor.go +++ b/backend/app/balance_monitor.go @@ -92,22 +92,11 @@ func (h *Handler) MonitorSystemBalanceAndHandleSettlement(ctx context.Context) { continue } - zeroUSDMillicentBalanceValue, err := internal.FromTFTtoUSDMillicent(h.substrateClient, zeroTFTBalanceValue) - if err != nil { - logger.GetLogger().Error().Err(err).Msgf("failed to convert TFT to USD millicent") - continue - } - - if user.CreditedBalance+user.CreditCardBalance-user.Debt > zeroUSDMillicentBalanceValue { + if user.CreditedBalance+user.CreditCardBalance-user.Debt <= 0 { continue } - if err := h.db.CreateTransferRecord(&models.TransferRecord{ - UserID: user.ID, - Username: user.Username, - TFTAmount: uint64(h.config.MinimumTFTAmountInWallet) * TFTUnitFactor, - Operation: models.DepositOperation, - }); err != nil { + if err := h.createTransferRecordToChargeUserWithMinTFTAmount(user.ID, user.Username, user.Mnemonic); err != nil { logger.GetLogger().Error().Err(err).Msgf("Failed to create transfer record for user %d", user.ID) } } @@ -137,14 +126,14 @@ func (h *Handler) resetUsersTFTsWithNoUSDBalance(users []models.User) error { continue } - if userTFTBalance <= transferFees { + if userTFTBalance <= uint64(h.config.MinimumTFTAmountInWallet)*TFTUnitFactor+transferFees { continue } transferRecord := models.TransferRecord{ UserID: user.ID, Username: user.Username, - TFTAmount: userTFTBalance, + TFTAmount: userTFTBalance - transferFees - uint64(h.config.MinimumTFTAmountInWallet)*TFTUnitFactor, Operation: models.WithdrawOperation, State: models.SuccessState, } @@ -152,7 +141,6 @@ func (h *Handler) resetUsersTFTsWithNoUSDBalance(users []models.User) error { if err = h.withdrawTFTsFromUser(user.ID, user.Mnemonic, userTFTBalance); err != nil { logger.GetLogger().Error().Err(err).Msgf("Failed to withdraw all TFTs for user %d", user.ID) - // TODO: handle retries transferRecord.State = models.FailedState transferRecord.Failure = err.Error() } @@ -236,6 +224,11 @@ func (h *Handler) withdrawTFTsFromUser(userID int, userMnemonic string, amountTo } func (h *Handler) fundUserToFulfillDiscount(ctx context.Context, user models.User, addedRentedNodes []types.Node, addedSharedNodes []kubedeployer.Node, discount discount) error { + if user.CreditCardBalance+user.CreditedBalance-user.Debt <= 0 { + // user has no USD balance, skip + return nil + } + // calculate resources usage in USD applying discount // I took the cluster nodes since only the new node is in cluster.Nodes dailyUsageInUSDMillicent, err := h.calculateResourcesUsageInUSDApplyingDiscount(ctx, user.ID, user.Mnemonic, addedRentedNodes, addedSharedNodes, discount) diff --git a/backend/app/invoice_handler.go b/backend/app/invoice_handler.go index b8451c766..4946b8da9 100644 --- a/backend/app/invoice_handler.go +++ b/backend/app/invoice_handler.go @@ -1,6 +1,7 @@ package app import ( + "context" "errors" "fmt" "kubecloud/internal" @@ -65,41 +66,45 @@ func (h *Handler) ListUserInvoicesHandler(c *gin.Context) { }) } -func (h *Handler) MonthlyInvoicesHandler() { +func (h *Handler) MonthlyInvoicesHandler(ctx context.Context) { var lastProcessedMonth time.Month var lastProcessedYear int for { - now := time.Now() - monthLastDay := time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, now.Location()).AddDate(0, 0, -1) - if now.Day() != monthLastDay.Day() { - // sleep till last day of month - time.Sleep(monthLastDay.Sub(now)) - } + select { + case <-ctx.Done(): + return + default: + now := time.Now() + monthLastDay := time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, now.Location()).AddDate(0, 0, -1) + if now.Day() != monthLastDay.Day() { + // sleep till last day of month + time.Sleep(monthLastDay.Sub(now)) + } - // Check if invoices for the current month have already been created - if now.Month() == lastProcessedMonth && now.Year() == lastProcessedYear { - // Sleep until the first day of the next month to avoid running multiple times on the last day - nextMonth := time.Date(now.Year(), now.Month()+1, 1, 0, 5, 0, 0, now.Location()) - sleepDuration := nextMonth.Sub(now) - time.Sleep(sleepDuration) - continue - } + // Check if invoices for the current month have already been created + if now.Month() == lastProcessedMonth && now.Year() == lastProcessedYear { + // Sleep until the first day of the next month to avoid running multiple times on the last day + nextMonth := time.Date(now.Year(), now.Month()+1, 1, 0, 5, 0, 0, now.Location()) + sleepDuration := nextMonth.Sub(now) + time.Sleep(sleepDuration) + continue + } - users, err := h.db.ListAllUsers() - if err != nil { - logger.GetLogger().Error().Err(err).Send() - } - for _, user := range users { - if err = h.createUserInvoice(user); err != nil { + users, err := h.db.ListAllUsers() + if err != nil { logger.GetLogger().Error().Err(err).Send() } - } - - // Update the last processed month and year - lastProcessedMonth = now.Month() - lastProcessedYear = now.Year() + for _, user := range users { + if err = h.createUserInvoice(user); err != nil { + logger.GetLogger().Error().Err(err).Send() + } + } + // Update the last processed month and year + lastProcessedMonth = now.Month() + lastProcessedYear = now.Year() + } } } diff --git a/backend/app/node_handler.go b/backend/app/node_handler.go index 2e645dade..397659946 100644 --- a/backend/app/node_handler.go +++ b/backend/app/node_handler.go @@ -280,18 +280,7 @@ func (h *Handler) ReserveNodeHandler(c *gin.Context) { return } - // fund user to fulfill discount - rentedNodes, _, err := h.getRentedNodesForUser(c.Request.Context(), userID, true) - if err != nil { - logger.GetLogger().Error().Err(err).Send() - InternalServerError(c) - return - } - - // add newly rented node - rentedNodes = append(rentedNodes, node) - - if err := h.fundUserToFulfillDiscount(c.Request.Context(), user, rentedNodes, []kubedeployer.Node{}, discount(h.config.AppliedDiscount)); err != nil { + if err := h.fundUserToFulfillDiscount(c.Request.Context(), user, []proxyTypes.Node{node}, []kubedeployer.Node{}, discount(h.config.AppliedDiscount)); err != nil { logger.GetLogger().Error().Err(err).Send() InternalServerError(c) return diff --git a/backend/app/settle_usage.go b/backend/app/settle_usage.go index 22eb474ff..0e22920ee 100644 --- a/backend/app/settle_usage.go +++ b/backend/app/settle_usage.go @@ -1,8 +1,10 @@ package app import ( + "context" "kubecloud/internal" "kubecloud/internal/logger" + "kubecloud/models" "math" "strconv" "time" @@ -15,34 +17,42 @@ type DiscountPackage struct { Discount int } -// DeductBalanceBasedOnUsage deducts the user balance based on the usage +// DeductUSDBalanceBasedOnUsage deducts the user balance based on the usage // This function is called every 24 hours -func (h *Handler) DeductBalanceBasedOnUsage() { +func (h *Handler) DeductUSDBalanceBasedOnUsage(ctx context.Context) { usageDeductionTicker := time.NewTicker(24 * time.Hour) defer usageDeductionTicker.Stop() - for range usageDeductionTicker.C { - users, err := h.db.ListAllUsers() - if err != nil { - logger.GetLogger().Error().Err(err).Msg("Failed to list users") - continue - } - - for _, user := range users { - usageInUSDMillicent, err := h.getUserDailyUsageInUSD(user.ID) + for { + select { + case <-ctx.Done(): + return + case <-usageDeductionTicker.C: + users, err := h.db.ListAllUsers() if err != nil { - logger.GetLogger().Error().Err(err).Msgf("Failed to get usage for user %d", user.ID) + logger.GetLogger().Error().Err(err).Msg("Failed to list users") continue } - if err := h.db.DeductUserBalance(&user, usageInUSDMillicent); err != nil { - logger.GetLogger().Error().Err(err).Msgf("Failed to deduct balance for user %d", user.ID) + for _, user := range users { + if err := h.settleUserUsage(&user); err != nil { + logger.GetLogger().Error().Err(err).Msgf("Failed to settle daily usage for user %d", user.ID) + } } } } } -func (h *Handler) getUserDailyUsageInUSD(userID int) (uint64, error) { +func (h *Handler) settleUserUsage(user *models.User) error { + usageInUSDMillicent, err := h.getUserLatestUsageInUSD(user.ID) + if err != nil { + return err + } + + return h.db.DeductUserBalance(user, usageInUSDMillicent) +} + +func (h *Handler) getUserLatestUsageInUSD(userID int) (uint64, error) { now := time.Now() // Define the end of the day (next day at 00:00) endOfDay := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.Local) @@ -138,7 +148,7 @@ func getDiscountPackage(discountInput discount) DiscountPackage { discountPackages := map[discount]DiscountPackage{ "none": { - DurationInMonth: 0, + DurationInMonth: oneDayMargin * 3, Discount: 0, }, "default": { diff --git a/backend/app/user_handler.go b/backend/app/user_handler.go index 91f5c2f3c..e1987c784 100644 --- a/backend/app/user_handler.go +++ b/backend/app/user_handler.go @@ -6,6 +6,7 @@ import ( "kubecloud/internal" "kubecloud/internal/metrics" "kubecloud/internal/notification" + "kubecloud/kubedeployer" "kubecloud/models" "net/http" "strconv" @@ -22,6 +23,7 @@ import ( "github.com/threefoldtech/tfgrid-sdk-go/grid-client/deployer" "github.com/threefoldtech/tfgrid-sdk-go/grid-client/graphql" proxy "github.com/threefoldtech/tfgrid-sdk-go/grid-proxy/pkg/client" + "github.com/threefoldtech/tfgrid-sdk-go/grid-proxy/pkg/types" "github.com/xmonader/ewf" "kubecloud/internal/constants" @@ -672,19 +674,6 @@ func (h *Handler) ChargeBalance(c *gin.Context) { return } - millicentAmount := internal.FromUSDToUSDMillicent(request.Amount) - - if err := h.db.CreateTransferRecord(&models.TransferRecord{ - UserID: userID, - Username: user.Username, - TFTAmount: uint64(h.config.MinimumTFTAmountInWallet), - Operation: models.DepositOperation, - }); err != nil { - logger.GetLogger().Error().Err(err).Send() - InternalServerError(c) - return - } - wf, err := h.ewfEngine.NewWorkflow(constants.WorkflowChargeBalance) if err != nil { logger.GetLogger().Error().Err(err).Send() @@ -696,10 +685,26 @@ func (h *Handler) ChargeBalance(c *gin.Context) { "user_id": userID, "stripe_customer_id": user.StripeCustomerID, "payment_method_id": paymentMethod.ID, - "amount": millicentAmount, + "amount": internal.FromUSDToUSDMillicent(request.Amount), } - h.ewfEngine.RunAsync(context.Background(), wf) + if err := h.ewfEngine.RunSync(context.Background(), wf); err != nil { + logger.GetLogger().Error().Err(err).Send() + InternalServerError(c) + return + } + + if err := h.createTransferRecordToChargeUserWithMinTFTAmount(user.ID, user.Username, user.Mnemonic); err != nil { + logger.GetLogger().Error().Err(err).Send() + InternalServerError(c) + return + } + + if err := h.settleAndFundUserAfterChargedUSDBalance(c.Request.Context(), &user); err != nil { + logger.GetLogger().Error().Err(err).Send() + InternalServerError(c) + return + } Success(c, http.StatusAccepted, "Charge in progress. You can check its status using the workflow id.", ChargeBalanceResponse{ WorkflowID: wf.UUID, @@ -793,16 +798,16 @@ func (h *Handler) RedeemVoucherHandler(c *gin.Context) { return } - if err := h.db.CreateTransferRecord(&models.TransferRecord{ - UserID: userID, - Username: user.Username, - TFTAmount: uint64(h.config.MinimumTFTAmountInWallet), - Operation: models.DepositOperation, - }); err != nil { + if err := h.createTransferRecordToChargeUserWithMinTFTAmount(user.ID, user.Username, user.Mnemonic); err != nil { logger.GetLogger().Error().Err(err).Send() InternalServerError(c) return + } + if err := h.settleAndFundUserAfterChargedUSDBalance(c.Request.Context(), &user); err != nil { + logger.GetLogger().Error().Err(err).Msg("error settling and funding user after charging USD balance") + InternalServerError(c) + return } Success(c, http.StatusOK, fmt.Sprintf("Voucher with value %v$ is redeemed successfully.", voucher.Value), RedeemVoucherResponse{ @@ -1034,3 +1039,34 @@ func isUniqueViolation(err error) bool { } return false } + +func (h *Handler) settleAndFundUserAfterChargedUSDBalance(ctx context.Context, user *models.User) error { + if err := h.settleUserUsage(user); err != nil { + return err + } + + return h.fundUserToFulfillDiscount(ctx, *user, []types.Node{}, []kubedeployer.Node{}, discount(h.config.AppliedDiscount)) +} + +func (h *Handler) createTransferRecordToChargeUserWithMinTFTAmount(userID int, username, userMnemonic string) error { + userTFTBalance, err := internal.GetUserTFTBalance(h.substrateClient, userMnemonic) + if err != nil { + return err + } + + totalPendingTFTAmount, err := h.db.CalculateTotalPendingTFTAmountPerUser(userID) + if err != nil { + return err + } + + if userTFTBalance+totalPendingTFTAmount >= zeroTFTBalanceValue { + return nil + } + + return h.db.CreateTransferRecord(&models.TransferRecord{ + UserID: userID, + Username: username, + TFTAmount: uint64(h.config.MinimumTFTAmountInWallet) * TFTUnitFactor, + Operation: models.DepositOperation, + }) +} diff --git a/backend/internal/activities/user_activities.go b/backend/internal/activities/user_activities.go index d264a6638..f1911808a 100644 --- a/backend/internal/activities/user_activities.go +++ b/backend/internal/activities/user_activities.go @@ -400,43 +400,3 @@ func UpdateCreditCardBalanceStep(db models.DB, notificationService *notification return nil } } - -func UpdateCreditedBalanceStep(db models.DB) ewf.StepFn { - return func(ctx context.Context, state ewf.State) error { - userIDVal, ok := state["user_id"] - if !ok { - return fmt.Errorf("missing 'user_id' in state") - } - userID, ok := userIDVal.(int) - if !ok { - return fmt.Errorf("'user_id' in state is not an int") - } - - amountVal, ok := state["amount"] - if !ok { - return fmt.Errorf("missing 'amount' in state") - } - amount, ok := amountVal.(uint64) - if !ok { - return fmt.Errorf("'amount' in state is not a uint64") - } - - user, err := db.GetUserByID(userID) - if err != nil { - return fmt.Errorf("user is not found: %w", err) - } - - user.CreditedBalance += amount - if err := db.UpdateUserByID(&user); err != nil { - return fmt.Errorf("error updating user: %w", err) - } - - netBalance := int64(user.CreditCardBalance) + int64(user.CreditedBalance) - int64(user.Debt) - if netBalance < 0 { - netBalance = 0 - } - state["net_balance"] = uint64(netBalance) - - return nil - } -} diff --git a/backend/models/gorm.go b/backend/models/gorm.go index fe58d3807..c759d881f 100644 --- a/backend/models/gorm.go +++ b/backend/models/gorm.go @@ -81,11 +81,11 @@ func (s *GormDB) Ping(ctx context.Context) error { // RegisterUser registers a new user to the system func (s *GormDB) RegisterUser(user *User) error { - if err := s.UpdateUserLastCalcTime(user.ID, time.Now()); err != nil { + if err := s.db.Create(user).Error; err != nil { return err } - return s.db.Create(user).Error + return s.UpdateUserLastCalcTime(user.ID, time.Now()) } // GetUserByEmail returns user by its email if found @@ -560,7 +560,7 @@ func (s *GormDB) ListTransferRecords() ([]TransferRecord, error) { func (s *GormDB) CalculateTotalPendingTFTAmountPerUser(userID int) (uint64, error) { var totalAmount uint64 err := s.db.Model(&TransferRecord{}). - Select("SUM(tft_amount)"). + Select("COALESCE(SUM(tft_amount), 0)"). Where("user_id = ? AND state = ?", userID, PendingState). Scan(&totalAmount).Error if err != nil { From e3604e16a463b55dbe3c03fb7d5deab5d047afd6 Mon Sep 17 00:00:00 2001 From: rawdaGastan Date: Sun, 19 Oct 2025 10:50:13 +0300 Subject: [PATCH 14/16] swagger updates --- backend/docs/docs.go | 542 +++++++++++++++--------------- backend/docs/swagger.json | 689 ++++++++++++++++++-------------------- backend/docs/swagger.yaml | 268 +++++++-------- 3 files changed, 715 insertions(+), 784 deletions(-) diff --git a/backend/docs/docs.go b/backend/docs/docs.go index f29e9a870..73bb90232 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -480,6 +480,74 @@ const docTemplate = `{ } } }, + "/nodes": { + "get": { + "description": "List all nodes from the grid proxy (no user-specific filtering)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "nodes" + ], + "summary": "List all grid nodes", + "operationId": "list-all-grid-nodes", + "parameters": [ + { + "type": "boolean", + "description": "Filter by healthy nodes (default: false)", + "name": "healthy", + "in": "query" + }, + { + "type": "integer", + "description": "Limit the number of nodes returned (default: 50)", + "name": "size", + "in": "query" + }, + { + "type": "integer", + "description": "page number (default: 1)", + "name": "page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "All grid nodes retrieved successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/app.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/app.ListNodesResponse" + } + } + } + ] + } + }, + "400": { + "description": "Invalid filter parameters", + "schema": { + "$ref": "#/definitions/app.APIResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/app.APIResponse" + } + } + } + } + }, "/nodes/{node_id}/storage-pool": { "get": { "description": "Returns node storage pool", @@ -1001,112 +1069,6 @@ const docTemplate = `{ } } }, - "/nodes": { - "get": { - "description": "List all nodes from the grid proxy (no user-specific filtering)", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "nodes" - ], - "summary": "List all grid nodes", - "operationId": "list-all-grid-nodes", - "parameters": [ - { - "type": "boolean", - "description": "Filter by healthy nodes (default: false)", - "name": "healthy", - "in": "query" - }, - { - "type": "integer", - "description": "Limit the number of nodes returned (default: 50)", - "name": "size", - "in": "query" - }, - { - "type": "integer", - "description": "page number (default: 1)", - "name": "page", - "in": "query" - } - ], - "responses": { - "200": { - "description": "All grid nodes retrieved successfully", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/app.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/app.ListNodesResponse" - } - } - } - ] - } - }, - "400": { - "description": "Invalid filter parameters", - "schema": { - "$ref": "#/definitions/app.APIResponse" - } - }, - "500": { - "description": "Internal server error", - "schema": { - "$ref": "#/definitions/app.APIResponse" - } - } - } - } - }, - "/pending-records": { - "get": { - "security": [ - { - "AdminMiddleware": [] - } - ], - "description": "Returns all pending records in the system", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "List pending records", - "operationId": "list-pending-records", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/app.PendingRecordsResponse" - } - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/app.APIResponse" - } - } - } - } - }, "/stats": { "get": { "security": [ @@ -1221,6 +1183,47 @@ const docTemplate = `{ } } }, + "/transfer-records": { + "get": { + "security": [ + { + "AdminMiddleware": [] + } + ], + "description": "Returns all transfer records in the system", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "List transfer records", + "operationId": "list-transfer-records", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.TransferRecord" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/app.APIResponse" + } + } + } + } + }, "/twins/{twin_id}/account": { "get": { "description": "Retrieve the account ID associated with a specific twin ID", @@ -1304,40 +1307,19 @@ const docTemplate = `{ "200": { "description": "User is retrieved successfully", "schema": { - "$ref": "#/definitions/app.GetUserResponse" - } - }, - "404": { - "description": "User is not found", - "schema": { - "$ref": "#/definitions/app.APIResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/app.APIResponse" - } - } - } - } - }, - "/user/balance": { - "get": { - "description": "Retrieves the user's balance in USD", - "produces": [ - "application/json" - ], - "tags": [ - "users" - ], - "summary": "Get user balance", - "operationId": "get-user-balance", - "responses": { - "200": { - "description": "Balance fetched successfully", - "schema": { - "$ref": "#/definitions/app.UserBalanceResponse" + "allOf": [ + { + "$ref": "#/definitions/app.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.User" + } + } + } + ] } }, "404": { @@ -1970,44 +1952,6 @@ const docTemplate = `{ } } }, - "/user/pending-records": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Returns user pending records in the system", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "users" - ], - "summary": "List user pending records", - "operationId": "list-user-pending-records", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/app.PendingRecordsResponse" - } - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/app.APIResponse" - } - } - } - } - }, "/user/redeem/{voucher_code}": { "put": { "description": "Redeems a voucher for the user", @@ -2931,70 +2875,6 @@ const docTemplate = `{ } } }, - "app.GetUserResponse": { - "type": "object", - "required": [ - "email", - "password", - "username" - ], - "properties": { - "account_address": { - "type": "string" - }, - "admin": { - "type": "boolean" - }, - "code": { - "type": "integer" - }, - "credit_card_balance": { - "description": "millicent, money from credit card", - "type": "integer" - }, - "credited_balance": { - "description": "millicent, manually added by admin or from vouchers", - "type": "integer" - }, - "debt": { - "description": "millicent", - "type": "integer" - }, - "email": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "password": { - "type": "array", - "items": { - "type": "integer" - } - }, - "pending_balance_usd": { - "type": "number" - }, - "sponsored": { - "type": "boolean" - }, - "ssh_key": { - "type": "string" - }, - "stripe_customer_id": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "username": { - "type": "string" - }, - "verified": { - "type": "boolean" - } - } - }, "app.KubeconfigResponse": { "type": "object", "properties": { @@ -3251,39 +3131,36 @@ const docTemplate = `{ } } }, - "app.PendingRecordsResponse": { + "app.NotificationResponse": { + "description": "A notification response", "type": "object", "properties": { "created_at": { "type": "string" }, "id": { - "type": "integer" - }, - "tft_amount": { - "description": "TFTs are multiplied by 1e7", - "type": "integer" - }, - "transfer_mode": { "type": "string" }, - "transferred_tft_amount": { - "type": "integer" - }, - "transferred_usd_amount": { - "type": "number" + "payload": { + "type": "object", + "additionalProperties": { + "type": "string" + } }, - "updated_at": { + "read_at": { "type": "string" }, - "usd_amount": { - "type": "number" + "severity": { + "$ref": "#/definitions/models.NotificationSeverity" }, - "user_id": { - "type": "integer" + "status": { + "$ref": "#/definitions/models.NotificationStatus" }, - "username": { + "task_id": { "type": "string" + }, + "type": { + "$ref": "#/definitions/models.NotificationType" } } }, @@ -3505,20 +3382,6 @@ const docTemplate = `{ } } }, - "app.UserBalanceResponse": { - "type": "object", - "properties": { - "balance_usd": { - "type": "number" - }, - "debt_usd": { - "type": "number" - }, - "pending_balance_usd": { - "type": "number" - } - } - }, "app.UserResponse": { "type": "object", "required": [ @@ -3618,7 +3481,6 @@ const docTemplate = `{ }, "gridtypes.Unit": { "type": "integer", - "format": "int64", "enum": [ 1024, 1048576, @@ -3662,7 +3524,6 @@ const docTemplate = `{ } }, "tax": { - "description": "TODO:", "type": "number" }, "total": { @@ -3731,13 +3592,15 @@ const docTemplate = `{ "deployment", "billing", "user", - "connected" + "connected", + "node" ], "x-enum-varnames": [ "NotificationTypeDeployment", "NotificationTypeBilling", "NotificationTypeUser", - "NotificationTypeConnected" + "NotificationTypeConnected", + "NotificationTypeNode" ] }, "models.SSHKey": { @@ -3772,6 +3635,100 @@ const docTemplate = `{ } } }, + "models.TransferRecord": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "failure": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "operation": { + "$ref": "#/definitions/models.operation" + }, + "state": { + "$ref": "#/definitions/models.state" + }, + "tft_amount": { + "description": "TFTs are multiplied by 1e7", + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "integer" + }, + "username": { + "type": "string" + } + } + }, + "models.User": { + "type": "object", + "required": [ + "email", + "password", + "username" + ], + "properties": { + "account_address": { + "type": "string" + }, + "admin": { + "type": "boolean" + }, + "code": { + "type": "integer" + }, + "credit_card_balance": { + "description": "millicent, money from credit card", + "type": "integer" + }, + "credited_balance": { + "description": "millicent, manually added by admin or from vouchers", + "type": "integer" + }, + "debt": { + "description": "millicent", + "type": "integer" + }, + "email": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "password": { + "type": "array", + "items": { + "type": "integer" + } + }, + "sponsored": { + "type": "boolean" + }, + "ssh_key": { + "type": "string" + }, + "stripe_customer_id": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "username": { + "type": "string" + }, + "verified": { + "type": "boolean" + } + } + }, "models.Voucher": { "type": "object", "required": [ @@ -3801,6 +3758,30 @@ const docTemplate = `{ } } }, + "models.operation": { + "type": "string", + "enum": [ + "withdraw", + "deposit" + ], + "x-enum-varnames": [ + "WithdrawOperation", + "DepositOperation" + ] + }, + "models.state": { + "type": "string", + "enum": [ + "failed", + "success", + "pending" + ], + "x-enum-varnames": [ + "FailedState", + "SuccessState", + "PendingState" + ] + }, "types.BIOS": { "type": "object", "properties": { @@ -4062,6 +4043,9 @@ const docTemplate = `{ }, "vendor": { "type": "string" + }, + "vram": { + "type": "integer" } } }, diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 78e272f51..e84c87abc 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -473,6 +473,137 @@ } } }, + "/nodes": { + "get": { + "description": "List all nodes from the grid proxy (no user-specific filtering)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "nodes" + ], + "summary": "List all grid nodes", + "operationId": "list-all-grid-nodes", + "parameters": [ + { + "type": "boolean", + "description": "Filter by healthy nodes (default: false)", + "name": "healthy", + "in": "query" + }, + { + "type": "integer", + "description": "Limit the number of nodes returned (default: 50)", + "name": "size", + "in": "query" + }, + { + "type": "integer", + "description": "page number (default: 1)", + "name": "page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "All grid nodes retrieved successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/app.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/app.ListNodesResponse" + } + } + } + ] + } + }, + "400": { + "description": "Invalid filter parameters", + "schema": { + "$ref": "#/definitions/app.APIResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/app.APIResponse" + } + } + } + } + }, + "/nodes/{node_id}/storage-pool": { + "get": { + "description": "Returns node storage pool", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "nodes" + ], + "summary": "Get node storage pool", + "operationId": "get-node-storage-pool", + "parameters": [ + { + "type": "string", + "description": "Node ID", + "name": "node_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Node storage pool is retrieved successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/app.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/app.NodeStoragePoolResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request or Invalid params", + "schema": { + "$ref": "#/definitions/app.APIResponse" + } + }, + "404": { + "description": "Node not found", + "schema": { + "$ref": "#/definitions/app.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/app.APIResponse" + } + } + } + } + }, "/notifications": { "get": { "description": "Retrieves all user notifications with pagination", @@ -931,175 +1062,6 @@ } } }, - "/nodes": { - "get": { - "description": "List all nodes from the grid proxy (no user-specific filtering)", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "nodes" - ], - "summary": "List all grid nodes", - "operationId": "list-all-grid-nodes", - "parameters": [ - { - "type": "boolean", - "description": "Filter by healthy nodes (default: false)", - "name": "healthy", - "in": "query" - }, - { - "type": "integer", - "description": "Limit the number of nodes returned (default: 50)", - "name": "size", - "in": "query" - }, - { - "type": "integer", - "description": "page number (default: 1)", - "name": "page", - "in": "query" - } - ], - "responses": { - "200": { - "description": "All grid nodes retrieved successfully", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/app.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/app.ListNodesResponse" - } - } - } - ] - } - }, - "400": { - "description": "Invalid filter parameters", - "schema": { - "$ref": "#/definitions/app.APIResponse" - } - }, - "500": { - "description": "Internal server error", - "schema": { - "$ref": "#/definitions/app.APIResponse" - } - } - } - } - }, - "/nodes/{node_id}/storage-pool": { - "get": { - "description": "Returns node storage pool", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "nodes" - ], - "summary": "Get node storage pool", - "operationId": "get-node-storage-pool", - "parameters": [ - { - "type": "string", - "description": "Node ID", - "name": "node_id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "Node storage pool is retrieved successfully", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/app.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/app.NodeStoragePoolResponse" - } - } - } - ] - } - }, - "400": { - "description": "Bad Request or Invalid params", - "schema": { - "$ref": "#/definitions/app.APIResponse" - } - }, - "404": { - "description": "Node not found", - "schema": { - "$ref": "#/definitions/app.APIResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/app.APIResponse" - } - } - } - } - }, - "/pending-records": { - "get": { - "security": [ - { - "AdminMiddleware": [] - } - ], - "description": "Returns all pending records in the system", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "List pending records", - "operationId": "list-pending-records", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/app.PendingRecordsResponse" - } - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/app.APIResponse" - } - } - } - } - }, "/stats": { "get": { "security": [ @@ -1214,6 +1176,47 @@ } } }, + "/transfer-records": { + "get": { + "security": [ + { + "AdminMiddleware": [] + } + ], + "description": "Returns all transfer records in the system", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "List transfer records", + "operationId": "list-transfer-records", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.TransferRecord" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/app.APIResponse" + } + } + } + } + }, "/twins/{twin_id}/account": { "get": { "description": "Retrieve the account ID associated with a specific twin ID", @@ -1253,55 +1256,22 @@ "name": "filterParam", "in": "query" } - ], - "responses": { - "200": { - "description": "Account ID is retrieved successfully", - "schema": { - "$ref": "#/definitions/app.TwinResponse" - } - }, - "400": { - "description": "Bad Request or Invalid params", - "schema": { - "$ref": "#/definitions/app.APIResponse" - } - }, - "404": { - "description": "Twin ID not found", - "schema": { - "$ref": "#/definitions/app.APIResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/app.APIResponse" - } - } - } - } - }, - "/user": { - "get": { - "description": "Retrieves all data of the user", - "produces": [ - "application/json" - ], - "tags": [ - "users" - ], - "summary": "Get user details", - "operationId": "get-user", + ], "responses": { "200": { - "description": "User is retrieved successfully", + "description": "Account ID is retrieved successfully", + "schema": { + "$ref": "#/definitions/app.TwinResponse" + } + }, + "400": { + "description": "Bad Request or Invalid params", "schema": { - "$ref": "#/definitions/app.GetUserResponse" + "$ref": "#/definitions/app.APIResponse" } }, "404": { - "description": "User is not found", + "description": "Twin ID not found", "schema": { "$ref": "#/definitions/app.APIResponse" } @@ -1315,22 +1285,34 @@ } } }, - "/user/balance": { + "/user": { "get": { - "description": "Retrieves the user's balance in USD", + "description": "Retrieves all data of the user", "produces": [ "application/json" ], "tags": [ "users" ], - "summary": "Get user balance", - "operationId": "get-user-balance", + "summary": "Get user details", + "operationId": "get-user", "responses": { "200": { - "description": "Balance fetched successfully", + "description": "User is retrieved successfully", "schema": { - "$ref": "#/definitions/app.UserBalanceResponse" + "allOf": [ + { + "$ref": "#/definitions/app.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.User" + } + } + } + ] } }, "404": { @@ -1963,44 +1945,6 @@ } } }, - "/user/pending-records": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Returns user pending records in the system", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "users" - ], - "summary": "List user pending records", - "operationId": "list-user-pending-records", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/app.PendingRecordsResponse" - } - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/app.APIResponse" - } - } - } - } - }, "/user/redeem/{voucher_code}": { "put": { "description": "Redeems a voucher for the user", @@ -2924,70 +2868,6 @@ } } }, - "app.GetUserResponse": { - "type": "object", - "required": [ - "email", - "password", - "username" - ], - "properties": { - "account_address": { - "type": "string" - }, - "admin": { - "type": "boolean" - }, - "code": { - "type": "integer" - }, - "credit_card_balance": { - "description": "millicent, money from credit card", - "type": "integer" - }, - "credited_balance": { - "description": "millicent, manually added by admin or from vouchers", - "type": "integer" - }, - "debt": { - "description": "millicent", - "type": "integer" - }, - "email": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "password": { - "type": "array", - "items": { - "type": "integer" - } - }, - "pending_balance_usd": { - "type": "number" - }, - "sponsored": { - "type": "boolean" - }, - "ssh_key": { - "type": "string" - }, - "stripe_customer_id": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "username": { - "type": "string" - }, - "verified": { - "type": "boolean" - } - } - }, "app.KubeconfigResponse": { "type": "object", "properties": { @@ -3244,39 +3124,36 @@ } } }, - "app.PendingRecordsResponse": { + "app.NotificationResponse": { + "description": "A notification response", "type": "object", "properties": { "created_at": { "type": "string" }, "id": { - "type": "integer" - }, - "tft_amount": { - "description": "TFTs are multiplied by 1e7", - "type": "integer" - }, - "transfer_mode": { "type": "string" }, - "transferred_tft_amount": { - "type": "integer" - }, - "transferred_usd_amount": { - "type": "number" + "payload": { + "type": "object", + "additionalProperties": { + "type": "string" + } }, - "updated_at": { + "read_at": { "type": "string" }, - "usd_amount": { - "type": "number" + "severity": { + "$ref": "#/definitions/models.NotificationSeverity" }, - "user_id": { - "type": "integer" + "status": { + "$ref": "#/definitions/models.NotificationStatus" }, - "username": { + "task_id": { "type": "string" + }, + "type": { + "$ref": "#/definitions/models.NotificationType" } } }, @@ -3498,20 +3375,6 @@ } } }, - "app.UserBalanceResponse": { - "type": "object", - "properties": { - "balance_usd": { - "type": "number" - }, - "debt_usd": { - "type": "number" - }, - "pending_balance_usd": { - "type": "number" - } - } - }, "app.UserResponse": { "type": "object", "required": [ @@ -3611,7 +3474,6 @@ }, "gridtypes.Unit": { "type": "integer", - "format": "int64", "enum": [ 1024, 1048576, @@ -3655,7 +3517,6 @@ } }, "tax": { - "description": "TODO:", "type": "number" }, "total": { @@ -3767,6 +3628,100 @@ } } }, + "models.TransferRecord": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "failure": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "operation": { + "$ref": "#/definitions/models.operation" + }, + "state": { + "$ref": "#/definitions/models.state" + }, + "tft_amount": { + "description": "TFTs are multiplied by 1e7", + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "integer" + }, + "username": { + "type": "string" + } + } + }, + "models.User": { + "type": "object", + "required": [ + "email", + "password", + "username" + ], + "properties": { + "account_address": { + "type": "string" + }, + "admin": { + "type": "boolean" + }, + "code": { + "type": "integer" + }, + "credit_card_balance": { + "description": "millicent, money from credit card", + "type": "integer" + }, + "credited_balance": { + "description": "millicent, manually added by admin or from vouchers", + "type": "integer" + }, + "debt": { + "description": "millicent", + "type": "integer" + }, + "email": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "password": { + "type": "array", + "items": { + "type": "integer" + } + }, + "sponsored": { + "type": "boolean" + }, + "ssh_key": { + "type": "string" + }, + "stripe_customer_id": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "username": { + "type": "string" + }, + "verified": { + "type": "boolean" + } + } + }, "models.Voucher": { "type": "object", "required": [ @@ -3796,6 +3751,30 @@ } } }, + "models.operation": { + "type": "string", + "enum": [ + "withdraw", + "deposit" + ], + "x-enum-varnames": [ + "WithdrawOperation", + "DepositOperation" + ] + }, + "models.state": { + "type": "string", + "enum": [ + "failed", + "success", + "pending" + ], + "x-enum-varnames": [ + "FailedState", + "SuccessState", + "PendingState" + ] + }, "types.BIOS": { "type": "object", "properties": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index fc75020cb..55955bb8d 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -121,50 +121,6 @@ definitions: - expire_after_days - value type: object - app.GetUserResponse: - properties: - account_address: - type: string - admin: - type: boolean - code: - type: integer - credit_card_balance: - description: millicent, money from credit card - type: integer - credited_balance: - description: millicent, manually added by admin or from vouchers - type: integer - debt: - description: millicent - type: integer - email: - type: string - id: - type: integer - password: - items: - type: integer - type: array - pending_balance_usd: - type: number - sponsored: - type: boolean - ssh_key: - type: string - stripe_customer_id: - type: string - updated_at: - type: string - username: - type: string - verified: - type: boolean - required: - - email - - password - - username - type: object app.KubeconfigResponse: properties: kubeconfig: @@ -360,30 +316,6 @@ definitions: type: $ref: '#/definitions/models.NotificationType' type: object - app.PendingRecordsResponse: - properties: - created_at: - type: string - id: - type: integer - tft_amount: - description: TFTs are multiplied by 1e7 - type: integer - transfer_mode: - type: string - transferred_tft_amount: - type: integer - transferred_usd_amount: - type: number - updated_at: - type: string - usd_amount: - type: number - user_id: - type: integer - username: - type: string - type: object app.Pool: properties: free: @@ -528,15 +460,6 @@ definitions: workflow_id: type: string type: object - app.UserBalanceResponse: - properties: - balance_usd: - type: number - debt_usd: - type: number - pending_balance_usd: - type: number - type: object app.UserResponse: properties: account_address: @@ -609,7 +532,6 @@ definitions: - 1048576 - 1073741824 - 1099511627776 - format: int64 type: integer x-enum-varnames: - Kilobyte @@ -634,7 +556,6 @@ definitions: $ref: '#/definitions/models.NodeItem' type: array tax: - description: 'TODO:' type: number total: type: number @@ -686,12 +607,14 @@ definitions: - billing - user - connected + - node type: string x-enum-varnames: - NotificationTypeDeployment - NotificationTypeBilling - NotificationTypeUser - NotificationTypeConnected + - NotificationTypeNode models.SSHKey: properties: created_at: @@ -715,6 +638,70 @@ definitions: - public_key - userID type: object + models.TransferRecord: + properties: + created_at: + type: string + failure: + type: string + id: + type: integer + operation: + $ref: '#/definitions/models.operation' + state: + $ref: '#/definitions/models.state' + tft_amount: + description: TFTs are multiplied by 1e7 + type: integer + updated_at: + type: string + user_id: + type: integer + username: + type: string + type: object + models.User: + properties: + account_address: + type: string + admin: + type: boolean + code: + type: integer + credit_card_balance: + description: millicent, money from credit card + type: integer + credited_balance: + description: millicent, manually added by admin or from vouchers + type: integer + debt: + description: millicent + type: integer + email: + type: string + id: + type: integer + password: + items: + type: integer + type: array + sponsored: + type: boolean + ssh_key: + type: string + stripe_customer_id: + type: string + updated_at: + type: string + username: + type: string + verified: + type: boolean + required: + - email + - password + - username + type: object models.Voucher: properties: code: @@ -735,6 +722,24 @@ definitions: - expires_at - value type: object + models.operation: + enum: + - withdraw + - deposit + type: string + x-enum-varnames: + - WithdrawOperation + - DepositOperation + models.state: + enum: + - failed + - success + - pending + type: string + x-enum-varnames: + - FailedState + - SuccessState + - PendingState types.BIOS: properties: vendor: @@ -907,6 +912,8 @@ definitions: type: integer vendor: type: string + vram: + type: integer type: object types.NodePower: properties: @@ -1633,30 +1640,6 @@ paths: summary: Get unread notifications tags: - notifications - /pending-records: - get: - consumes: - - application/json - description: Returns all pending records in the system - operationId: list-pending-records - produces: - - application/json - responses: - "200": - description: OK - schema: - items: - $ref: '#/definitions/app.PendingRecordsResponse' - type: array - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/app.APIResponse' - security: - - AdminMiddleware: [] - summary: List pending records - tags: - - admin /stats: get: consumes: @@ -1729,6 +1712,32 @@ paths: summary: Set maintenance mode tags: - admin + /transfer-records: + get: + consumes: + - application/json + description: Returns all transfer records in the system + operationId: list-transfer-records + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + items: + $ref: '#/definitions/models.TransferRecord' + type: array + type: array + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/app.APIResponse' + security: + - AdminMiddleware: [] + summary: List transfer records + tags: + - admin /twins/{twin_id}/account: get: consumes: @@ -1784,7 +1793,12 @@ paths: "200": description: User is retrieved successfully schema: - $ref: '#/definitions/app.GetUserResponse' + allOf: + - $ref: '#/definitions/app.APIResponse' + - properties: + data: + $ref: '#/definitions/models.User' + type: object "404": description: User is not found schema: @@ -1796,28 +1810,6 @@ paths: summary: Get user details tags: - users - /user/balance: - get: - description: Retrieves the user's balance in USD - operationId: get-user-balance - produces: - - application/json - responses: - "200": - description: Balance fetched successfully - schema: - $ref: '#/definitions/app.UserBalanceResponse' - "404": - description: User is not found - schema: - $ref: '#/definitions/app.APIResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/app.APIResponse' - summary: Get user balance - tags: - - users /user/balance/charge: post: consumes: @@ -2214,30 +2206,6 @@ paths: summary: Unreserve node tags: - nodes - /user/pending-records: - get: - consumes: - - application/json - description: Returns user pending records in the system - operationId: list-user-pending-records - produces: - - application/json - responses: - "200": - description: OK - schema: - items: - $ref: '#/definitions/app.PendingRecordsResponse' - type: array - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/app.APIResponse' - security: - - BearerAuth: [] - summary: List user pending records - tags: - - users /user/redeem/{voucher_code}: put: description: Redeems a voucher for the user From 7e16f95c50ab487a985f1fc5e2d091aaed668d0b Mon Sep 17 00:00:00 2001 From: rawdaGastan Date: Sun, 19 Oct 2025 11:02:02 +0300 Subject: [PATCH 15/16] update migration file --- backend/models/migrate.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/models/migrate.go b/backend/models/migrate.go index 4ec14681e..1fd79af1a 100644 --- a/backend/models/migrate.go +++ b/backend/models/migrate.go @@ -26,14 +26,14 @@ func MigrateAll(ctx context.Context, src DB, dst DB) error { if err := migrateNodeItems(ctx, src.GetDB(), dst.GetDB()); err != nil { return fmt.Errorf("node_items: %w", err) } - if err := migrateUserNodes(ctx, src.GetDB(), dst.GetDB()); err != nil { - return fmt.Errorf("user_nodes: %w", err) + if err := migrateUserContractData(ctx, src.GetDB(), dst.GetDB()); err != nil { + return fmt.Errorf("user_contract_data: %w", err) } if err := migrateClusters(ctx, src.GetDB(), dst.GetDB()); err != nil { return fmt.Errorf("clusters: %w", err) } - if err := migratePendingRecords(ctx, src.GetDB(), dst.GetDB()); err != nil { - return fmt.Errorf("pending_records: %w", err) + if err := migrateTransferRecords(ctx, src.GetDB(), dst.GetDB()); err != nil { + return fmt.Errorf("transfer_records: %w", err) } if err := migrateNotificationsToDst(ctx, src.GetDB(), dst.GetDB()); err != nil { return fmt.Errorf("notifications: %w", err) @@ -96,8 +96,8 @@ func migrateNodeItems(ctx context.Context, src *gorm.DB, dst *gorm.DB) error { return insertOnConflictReturnError(ctx, dst, rows) } -func migrateUserNodes(ctx context.Context, src *gorm.DB, dst *gorm.DB) error { - var rows []UserNodes +func migrateUserContractData(ctx context.Context, src *gorm.DB, dst *gorm.DB) error { + var rows []UserContractData if err := src.WithContext(ctx).Find(&rows).Error; err != nil { return err } @@ -112,8 +112,8 @@ func migrateClusters(ctx context.Context, src *gorm.DB, dst *gorm.DB) error { return insertOnConflictReturnError(ctx, dst, rows) } -func migratePendingRecords(ctx context.Context, src *gorm.DB, dst *gorm.DB) error { - var rows []PendingRecord +func migrateTransferRecords(ctx context.Context, src *gorm.DB, dst *gorm.DB) error { + var rows []TransferRecord if err := src.WithContext(ctx).Find(&rows).Error; err != nil { return err } From 4c75e99daf7a8a5a183bde89e52586b12ab27057 Mon Sep 17 00:00:00 2001 From: rawdaGastan Date: Mon, 20 Oct 2025 12:16:21 +0300 Subject: [PATCH 16/16] return user balances in USD --- backend/app/admin_handler.go | 38 +++-- backend/app/user_handler.go | 17 ++- backend/config-example.json | 3 +- backend/docs/docs.go | 284 ++++++++++++++--------------------- backend/docs/swagger.json | 284 ++++++++++++++--------------------- backend/docs/swagger.yaml | 194 ++++++++++-------------- 6 files changed, 340 insertions(+), 480 deletions(-) diff --git a/backend/app/admin_handler.go b/backend/app/admin_handler.go index d77a1eb99..f92aea0c9 100644 --- a/backend/app/admin_handler.go +++ b/backend/app/admin_handler.go @@ -23,11 +23,6 @@ import ( "gorm.io/gorm" ) -type UserResponse struct { - models.User - Balance float64 `json:"balance"` // USD balance -} - // GenerateVouchersInput holds all data needed when creating vouchers type GenerateVouchersInput struct { Count int `json:"count" binding:"required,gt=0"` @@ -66,13 +61,18 @@ type MaintenanceModeStatus struct { Enabled bool `json:"enabled"` } +type TransferRecordsResponse struct { + models.TransferRecord + TFTAmountInWholeUnit float32 `json:"tft_amount_in_whole_unit"` +} + // @Summary Get all users // @Description Returns a list of all users // @Tags admin // @ID get-all-users // @Accept json // @Produce json -// @Success 200 {array} UserResponse +// @Success 200 {array} GetUserResponse // @Failure 500 {object} APIResponse // @Security AdminMiddleware // @Router /users [get] @@ -84,7 +84,7 @@ func (h *Handler) ListUsersHandler(c *gin.Context) { InternalServerError(c) return } - var usersWithBalance []UserResponse + var usersWithBalance []GetUserResponse const maxConcurrentBalanceFetches = 20 balanceConcurrencyLimiter := make(chan struct{}, maxConcurrentBalanceFetches) @@ -102,7 +102,7 @@ func (h *Handler) ListUsersHandler(c *gin.Context) { defer wg.Done() defer func() { <-balanceConcurrencyLimiter }() - balance, err := internal.GetUserBalanceUSDMillicent(h.substrateClient, user.Mnemonic) + balanceInTFTUnit, err := internal.GetUserTFTBalance(h.substrateClient, user.Mnemonic) if err != nil { logger.GetLogger().Error().Err(err).Int("user_id", user.ID).Msg("failed to get user balance") mu.Lock() @@ -111,11 +111,13 @@ func (h *Handler) ListUsersHandler(c *gin.Context) { return } - balanceUSD := internal.FromUSDMilliCentToUSD(balance) mu.Lock() - usersWithBalance = append(usersWithBalance, UserResponse{ - User: user, - Balance: balanceUSD, + usersWithBalance = append(usersWithBalance, GetUserResponse{ + User: user, + CreditCardBalanceInUSD: internal.FromUSDMilliCentToUSD(user.CreditCardBalance), + CreditedBalanceInUSD: internal.FromUSDMilliCentToUSD(user.CreditedBalance), + DebtInUSD: internal.FromUSDMilliCentToUSD(user.Debt), + BalanceInTFT: float64(balanceInTFTUnit) / TFTUnitFactor, }) mu.Unlock() }(user) @@ -387,7 +389,7 @@ func (h *Handler) CreditUserHandler(c *gin.Context) { // @ID list-transfer-records // @Accept json // @Produce json -// @Success 200 {array} []models.TransferRecord +// @Success 200 {array} []TransferRecordsResponse // @Failure 500 {object} APIResponse // @Security AdminMiddleware // @Router /transfer-records [get] @@ -400,8 +402,16 @@ func (h *Handler) ListTransferRecordsHandler(c *gin.Context) { return } + var transferRecordsResponse []TransferRecordsResponse + for _, transferRecord := range transferRecords { + transferRecordsResponse = append(transferRecordsResponse, TransferRecordsResponse{ + TransferRecord: transferRecord, + TFTAmountInWholeUnit: float32(transferRecord.TFTAmount) / TFTUnitFactor, + }) + } + Success(c, http.StatusOK, "Transfer records are retrieved successfully", map[string]any{ - "transfer_records": transferRecords, + "transfer_records": transferRecordsResponse, }) } diff --git a/backend/app/user_handler.go b/backend/app/user_handler.go index e1987c784..a4fbe91ec 100644 --- a/backend/app/user_handler.go +++ b/backend/app/user_handler.go @@ -172,6 +172,14 @@ type VerifyRegisterUserResponse struct { *internal.TokenPair } +type GetUserResponse struct { + models.User + CreditCardBalanceInUSD float64 `json:"credit_card_balance_in_usd"` + CreditedBalanceInUSD float64 `json:"credited_balance_in_usd"` + DebtInUSD float64 `json:"debt_in_usd"` + BalanceInTFT float64 `json:"balance_in_tft,omitempty"` +} + // RedeemVoucherResponse holds the response for redeeming a voucher type RedeemVoucherResponse struct { WorkflowID string `json:"workflow_id"` @@ -717,7 +725,7 @@ func (h *Handler) ChargeBalance(c *gin.Context) { // @Tags users // @ID get-user // @Produce json -// @Success 200 {object} APIResponse{data=models.User} "User is retrieved successfully" +// @Success 200 {object} GetUserResponse "User is retrieved successfully" // @Failure 404 {object} APIResponse "User is not found" // @Failure 500 {object} APIResponse // @Router /user [get] @@ -733,7 +741,12 @@ func (h *Handler) GetUserHandler(c *gin.Context) { } Success(c, http.StatusOK, "User is retrieved successfully", gin.H{ - "user": user, + "user": GetUserResponse{ + User: user, + CreditCardBalanceInUSD: internal.FromUSDMilliCentToUSD(user.CreditCardBalance), + CreditedBalanceInUSD: internal.FromUSDMilliCentToUSD(user.CreditedBalance), + DebtInUSD: internal.FromUSDMilliCentToUSD(user.Debt), + }, }) } diff --git a/backend/config-example.json b/backend/config-example.json index 43c9e15ef..2b6f3a772 100644 --- a/backend/config-example.json +++ b/backend/config-example.json @@ -74,5 +74,6 @@ "host": "" } }, - "notification_config_path": "./notification-config-example.json" + "notification_config_path": "./notification-config-example.json", + "applied_discount": "gold" } diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 73bb90232..4ddafaa90 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -1210,7 +1210,7 @@ const docTemplate = `{ "items": { "type": "array", "items": { - "$ref": "#/definitions/models.TransferRecord" + "$ref": "#/definitions/app.TransferRecordsResponse" } } } @@ -1307,19 +1307,7 @@ const docTemplate = `{ "200": { "description": "User is retrieved successfully", "schema": { - "allOf": [ - { - "$ref": "#/definitions/app.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/models.User" - } - } - } - ] + "$ref": "#/definitions/app.GetUserResponse" } }, "404": { @@ -2346,7 +2334,7 @@ const docTemplate = `{ "schema": { "type": "array", "items": { - "$ref": "#/definitions/app.UserResponse" + "$ref": "#/definitions/app.GetUserResponse" } } }, @@ -2875,6 +2863,79 @@ const docTemplate = `{ } } }, + "app.GetUserResponse": { + "type": "object", + "required": [ + "email", + "password", + "username" + ], + "properties": { + "account_address": { + "type": "string" + }, + "admin": { + "type": "boolean" + }, + "balance_in_tft": { + "type": "number" + }, + "code": { + "type": "integer" + }, + "credit_card_balance": { + "description": "millicent, money from credit card", + "type": "integer" + }, + "credit_card_balance_in_usd": { + "type": "number" + }, + "credited_balance": { + "description": "millicent, manually added by admin or from vouchers", + "type": "integer" + }, + "credited_balance_in_usd": { + "type": "number" + }, + "debt": { + "description": "millicent", + "type": "integer" + }, + "debt_in_usd": { + "type": "number" + }, + "email": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "password": { + "type": "array", + "items": { + "type": "integer" + } + }, + "sponsored": { + "type": "boolean" + }, + "ssh_key": { + "type": "string" + }, + "stripe_customer_id": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "username": { + "type": "string" + }, + "verified": { + "type": "boolean" + } + } + }, "app.KubeconfigResponse": { "type": "object", "properties": { @@ -3351,99 +3412,70 @@ const docTemplate = `{ } } }, - "app.TwinResponse": { + "app.TransferRecordsResponse": { "type": "object", "properties": { - "account_id": { + "created_at": { "type": "string" }, - "public_key": { + "failure": { "type": "string" }, - "relay": { + "id": { + "type": "integer" + }, + "operation": { + "$ref": "#/definitions/models.operation" + }, + "state": { + "$ref": "#/definitions/models.state" + }, + "tft_amount": { + "description": "TFTs are multiplied by 1e7", + "type": "integer" + }, + "tft_amount_in_whole_unit": { + "type": "number" + }, + "updated_at": { "type": "string" }, - "twin_id": { + "user_id": { "type": "integer" + }, + "username": { + "type": "string" } } }, - "app.UnreserveNodeResponse": { + "app.TwinResponse": { "type": "object", "properties": { - "contract_id": { - "type": "integer" + "account_id": { + "type": "string" }, - "email": { + "public_key": { "type": "string" }, - "workflow_id": { + "relay": { "type": "string" + }, + "twin_id": { + "type": "integer" } } }, - "app.UserResponse": { + "app.UnreserveNodeResponse": { "type": "object", - "required": [ - "email", - "password", - "username" - ], "properties": { - "account_address": { - "type": "string" - }, - "admin": { - "type": "boolean" - }, - "balance": { - "description": "USD balance", - "type": "number" - }, - "code": { - "type": "integer" - }, - "credit_card_balance": { - "description": "millicent, money from credit card", - "type": "integer" - }, - "credited_balance": { - "description": "millicent, manually added by admin or from vouchers", - "type": "integer" - }, - "debt": { - "description": "millicent", + "contract_id": { "type": "integer" }, "email": { "type": "string" }, - "id": { - "type": "integer" - }, - "password": { - "type": "array", - "items": { - "type": "integer" - } - }, - "sponsored": { - "type": "boolean" - }, - "ssh_key": { - "type": "string" - }, - "stripe_customer_id": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "username": { + "workflow_id": { "type": "string" - }, - "verified": { - "type": "boolean" } } }, @@ -3635,100 +3667,6 @@ const docTemplate = `{ } } }, - "models.TransferRecord": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "failure": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "operation": { - "$ref": "#/definitions/models.operation" - }, - "state": { - "$ref": "#/definitions/models.state" - }, - "tft_amount": { - "description": "TFTs are multiplied by 1e7", - "type": "integer" - }, - "updated_at": { - "type": "string" - }, - "user_id": { - "type": "integer" - }, - "username": { - "type": "string" - } - } - }, - "models.User": { - "type": "object", - "required": [ - "email", - "password", - "username" - ], - "properties": { - "account_address": { - "type": "string" - }, - "admin": { - "type": "boolean" - }, - "code": { - "type": "integer" - }, - "credit_card_balance": { - "description": "millicent, money from credit card", - "type": "integer" - }, - "credited_balance": { - "description": "millicent, manually added by admin or from vouchers", - "type": "integer" - }, - "debt": { - "description": "millicent", - "type": "integer" - }, - "email": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "password": { - "type": "array", - "items": { - "type": "integer" - } - }, - "sponsored": { - "type": "boolean" - }, - "ssh_key": { - "type": "string" - }, - "stripe_customer_id": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "username": { - "type": "string" - }, - "verified": { - "type": "boolean" - } - } - }, "models.Voucher": { "type": "object", "required": [ diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index e84c87abc..dab4d1935 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -1203,7 +1203,7 @@ "items": { "type": "array", "items": { - "$ref": "#/definitions/models.TransferRecord" + "$ref": "#/definitions/app.TransferRecordsResponse" } } } @@ -1300,19 +1300,7 @@ "200": { "description": "User is retrieved successfully", "schema": { - "allOf": [ - { - "$ref": "#/definitions/app.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/models.User" - } - } - } - ] + "$ref": "#/definitions/app.GetUserResponse" } }, "404": { @@ -2339,7 +2327,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/definitions/app.UserResponse" + "$ref": "#/definitions/app.GetUserResponse" } } }, @@ -2868,6 +2856,79 @@ } } }, + "app.GetUserResponse": { + "type": "object", + "required": [ + "email", + "password", + "username" + ], + "properties": { + "account_address": { + "type": "string" + }, + "admin": { + "type": "boolean" + }, + "balance_in_tft": { + "type": "number" + }, + "code": { + "type": "integer" + }, + "credit_card_balance": { + "description": "millicent, money from credit card", + "type": "integer" + }, + "credit_card_balance_in_usd": { + "type": "number" + }, + "credited_balance": { + "description": "millicent, manually added by admin or from vouchers", + "type": "integer" + }, + "credited_balance_in_usd": { + "type": "number" + }, + "debt": { + "description": "millicent", + "type": "integer" + }, + "debt_in_usd": { + "type": "number" + }, + "email": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "password": { + "type": "array", + "items": { + "type": "integer" + } + }, + "sponsored": { + "type": "boolean" + }, + "ssh_key": { + "type": "string" + }, + "stripe_customer_id": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "username": { + "type": "string" + }, + "verified": { + "type": "boolean" + } + } + }, "app.KubeconfigResponse": { "type": "object", "properties": { @@ -3344,99 +3405,70 @@ } } }, - "app.TwinResponse": { + "app.TransferRecordsResponse": { "type": "object", "properties": { - "account_id": { + "created_at": { "type": "string" }, - "public_key": { + "failure": { "type": "string" }, - "relay": { + "id": { + "type": "integer" + }, + "operation": { + "$ref": "#/definitions/models.operation" + }, + "state": { + "$ref": "#/definitions/models.state" + }, + "tft_amount": { + "description": "TFTs are multiplied by 1e7", + "type": "integer" + }, + "tft_amount_in_whole_unit": { + "type": "number" + }, + "updated_at": { "type": "string" }, - "twin_id": { + "user_id": { "type": "integer" + }, + "username": { + "type": "string" } } }, - "app.UnreserveNodeResponse": { + "app.TwinResponse": { "type": "object", "properties": { - "contract_id": { - "type": "integer" + "account_id": { + "type": "string" }, - "email": { + "public_key": { "type": "string" }, - "workflow_id": { + "relay": { "type": "string" + }, + "twin_id": { + "type": "integer" } } }, - "app.UserResponse": { + "app.UnreserveNodeResponse": { "type": "object", - "required": [ - "email", - "password", - "username" - ], "properties": { - "account_address": { - "type": "string" - }, - "admin": { - "type": "boolean" - }, - "balance": { - "description": "USD balance", - "type": "number" - }, - "code": { - "type": "integer" - }, - "credit_card_balance": { - "description": "millicent, money from credit card", - "type": "integer" - }, - "credited_balance": { - "description": "millicent, manually added by admin or from vouchers", - "type": "integer" - }, - "debt": { - "description": "millicent", + "contract_id": { "type": "integer" }, "email": { "type": "string" }, - "id": { - "type": "integer" - }, - "password": { - "type": "array", - "items": { - "type": "integer" - } - }, - "sponsored": { - "type": "boolean" - }, - "ssh_key": { - "type": "string" - }, - "stripe_customer_id": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "username": { + "workflow_id": { "type": "string" - }, - "verified": { - "type": "boolean" } } }, @@ -3628,100 +3660,6 @@ } } }, - "models.TransferRecord": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "failure": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "operation": { - "$ref": "#/definitions/models.operation" - }, - "state": { - "$ref": "#/definitions/models.state" - }, - "tft_amount": { - "description": "TFTs are multiplied by 1e7", - "type": "integer" - }, - "updated_at": { - "type": "string" - }, - "user_id": { - "type": "integer" - }, - "username": { - "type": "string" - } - } - }, - "models.User": { - "type": "object", - "required": [ - "email", - "password", - "username" - ], - "properties": { - "account_address": { - "type": "string" - }, - "admin": { - "type": "boolean" - }, - "code": { - "type": "integer" - }, - "credit_card_balance": { - "description": "millicent, money from credit card", - "type": "integer" - }, - "credited_balance": { - "description": "millicent, manually added by admin or from vouchers", - "type": "integer" - }, - "debt": { - "description": "millicent", - "type": "integer" - }, - "email": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "password": { - "type": "array", - "items": { - "type": "integer" - } - }, - "sponsored": { - "type": "boolean" - }, - "ssh_key": { - "type": "string" - }, - "stripe_customer_id": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "username": { - "type": "string" - }, - "verified": { - "type": "boolean" - } - } - }, "models.Voucher": { "type": "object", "required": [ diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 55955bb8d..b8e4690e1 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -121,6 +121,56 @@ definitions: - expire_after_days - value type: object + app.GetUserResponse: + properties: + account_address: + type: string + admin: + type: boolean + balance_in_tft: + type: number + code: + type: integer + credit_card_balance: + description: millicent, money from credit card + type: integer + credit_card_balance_in_usd: + type: number + credited_balance: + description: millicent, manually added by admin or from vouchers + type: integer + credited_balance_in_usd: + type: number + debt: + description: millicent + type: integer + debt_in_usd: + type: number + email: + type: string + id: + type: integer + password: + items: + type: integer + type: array + sponsored: + type: boolean + ssh_key: + type: string + stripe_customer_id: + type: string + updated_at: + type: string + username: + type: string + verified: + type: boolean + required: + - email + - password + - username + type: object app.KubeconfigResponse: properties: kubeconfig: @@ -440,6 +490,30 @@ definitions: up_nodes: type: integer type: object + app.TransferRecordsResponse: + properties: + created_at: + type: string + failure: + type: string + id: + type: integer + operation: + $ref: '#/definitions/models.operation' + state: + $ref: '#/definitions/models.state' + tft_amount: + description: TFTs are multiplied by 1e7 + type: integer + tft_amount_in_whole_unit: + type: number + updated_at: + type: string + user_id: + type: integer + username: + type: string + type: object app.TwinResponse: properties: account_id: @@ -460,51 +534,6 @@ definitions: workflow_id: type: string type: object - app.UserResponse: - properties: - account_address: - type: string - admin: - type: boolean - balance: - description: USD balance - type: number - code: - type: integer - credit_card_balance: - description: millicent, money from credit card - type: integer - credited_balance: - description: millicent, manually added by admin or from vouchers - type: integer - debt: - description: millicent - type: integer - email: - type: string - id: - type: integer - password: - items: - type: integer - type: array - sponsored: - type: boolean - ssh_key: - type: string - stripe_customer_id: - type: string - updated_at: - type: string - username: - type: string - verified: - type: boolean - required: - - email - - password - - username - type: object app.VerifyCodeInput: properties: code: @@ -638,70 +667,6 @@ definitions: - public_key - userID type: object - models.TransferRecord: - properties: - created_at: - type: string - failure: - type: string - id: - type: integer - operation: - $ref: '#/definitions/models.operation' - state: - $ref: '#/definitions/models.state' - tft_amount: - description: TFTs are multiplied by 1e7 - type: integer - updated_at: - type: string - user_id: - type: integer - username: - type: string - type: object - models.User: - properties: - account_address: - type: string - admin: - type: boolean - code: - type: integer - credit_card_balance: - description: millicent, money from credit card - type: integer - credited_balance: - description: millicent, manually added by admin or from vouchers - type: integer - debt: - description: millicent - type: integer - email: - type: string - id: - type: integer - password: - items: - type: integer - type: array - sponsored: - type: boolean - ssh_key: - type: string - stripe_customer_id: - type: string - updated_at: - type: string - username: - type: string - verified: - type: boolean - required: - - email - - password - - username - type: object models.Voucher: properties: code: @@ -1726,7 +1691,7 @@ paths: schema: items: items: - $ref: '#/definitions/models.TransferRecord' + $ref: '#/definitions/app.TransferRecordsResponse' type: array type: array "500": @@ -1793,12 +1758,7 @@ paths: "200": description: User is retrieved successfully schema: - allOf: - - $ref: '#/definitions/app.APIResponse' - - properties: - data: - $ref: '#/definitions/models.User' - type: object + $ref: '#/definitions/app.GetUserResponse' "404": description: User is not found schema: @@ -2462,7 +2422,7 @@ paths: description: OK schema: items: - $ref: '#/definitions/app.UserResponse' + $ref: '#/definitions/app.GetUserResponse' type: array "500": description: Internal Server Error