diff --git a/backend/internal/api/app/app_dependencies.go b/backend/internal/api/app/app_dependencies.go index d8e87415..bd55ab69 100644 --- a/backend/internal/api/app/app_dependencies.go +++ b/backend/internal/api/app/app_dependencies.go @@ -392,27 +392,27 @@ func (app *App) createWorkers() workers.Workers { invoiceRepo := corepersistence.NewGormInvoiceRepository(app.core.db) contractsRepo := corepersistence.NewGormUserContractDataRepository(app.core.db) + billingService := services.NewBillingService( + userRepo, contractsRepo, transferRecordsRepo, clusterRepo, + app.infra.graphql, app.infra.gridClient, + uint64(app.config.MinimumTFTAmountInWallet), services.Discount(app.config.AppliedDiscount), + ) + workersService := services.NewWorkersService( app.core.appCtx, userRepo, contractsRepo, invoiceRepo, clusterRepo, transferRecordsRepo, app.communication.mailService, app.infra.gridClient, app.core.ewfEngine, app.communication.notificationDispatcher, app.infra.graphql, app.infra.firesquidClient, - app.config.Invoice, app.config.SystemAccount.Mnemonic, + billingService, app.config.Invoice, app.config.Currency, app.config.ClusterHealthCheckIntervalInHours, app.config.NodeHealthCheck.ReservedNodeHealthCheckIntervalInHours, app.config.NodeHealthCheck.ReservedNodeHealthCheckTimeoutInMinutes, app.config.NodeHealthCheck.ReservedNodeHealthCheckWorkersNum, app.config.SettleTransferRecordsIntervalInMinutes, app.config.NotifyAdminsForPendingRecordsInHours, - app.config.MinimumTFTAmountInWallet, services.Discount(app.config.AppliedDiscount), + app.config.MinimumTFTAmountInWallet, app.config.UsersBalanceCheckIntervalInHours, app.config.CheckUserDebtIntervalInHours, ) - billingService := services.NewBillingService( - userRepo, contractsRepo, transferRecordsRepo, clusterRepo, - app.infra.graphql, app.infra.gridClient, - uint64(app.config.MinimumTFTAmountInWallet), services.Discount(app.config.AppliedDiscount), - ) - return workers.NewWorkers(app.core.appCtx, workersService, billingService, app.core.metrics, app.core.db) } diff --git a/backend/internal/api/handlers/deployment_handler.go b/backend/internal/api/handlers/deployment_handler.go index 536d6f1c..1757f51f 100644 --- a/backend/internal/api/handlers/deployment_handler.go +++ b/backend/internal/api/handlers/deployment_handler.go @@ -224,23 +224,6 @@ func (h *DeploymentHandler) HandleDeployCluster(c *gin.Context) { return } - user, err := h.svc.GetUserByID(config.UserID) - if err != nil { - if errors.Is(err, models.ErrUserNotFound) { - NotFound(c, "User not found") - return - } - reqLog.Error().Err(err).Send() - InternalServerError(c) - return - } - - if err := h.billingService.FundUserToFulfillDiscount(c.Request.Context(), &user, nil, cluster.Nodes); err != nil { - reqLog.Error().Err(err).Send() - InternalServerError(c) - return - } - projectName := kubedeployer.GetProjectName(config.UserID, cluster.Name) logWithProject := reqLog.With().Str("project_name", projectName).Logger() reqLog = &logWithProject @@ -428,23 +411,6 @@ func (h *DeploymentHandler) HandleAddNode(c *gin.Context) { } } - user, err := h.svc.GetUserByID(config.UserID) - if err != nil { - if errors.Is(err, models.ErrUserNotFound) { - NotFound(c, "User not found") - return - } - reqLog.Error().Err(err).Send() - InternalServerError(c) - return - } - - if err := h.billingService.FundUserToFulfillDiscount(c.Request.Context(), &user, nil, cluster.Nodes); err != nil { - reqLog.Error().Err(err).Send() - InternalServerError(c) - return - } - wfUUID, wfStatus, err := h.svc.AsyncAddNode(config, cl, cluster.Nodes[0]) if err != nil { reqLog.Error().Err(err).Msg("failed to start add node workflow") diff --git a/backend/internal/api/handlers/node_handler.go b/backend/internal/api/handlers/node_handler.go index bb9571eb..8e699f7a 100644 --- a/backend/internal/api/handlers/node_handler.go +++ b/backend/internal/api/handlers/node_handler.go @@ -292,12 +292,6 @@ func (h *NodeHandler) ReserveNodeHandler(c *gin.Context) { return } - if err := h.billingService.FundUserToFulfillDiscount(c.Request.Context(), &user, nil, nil); err != nil { - reqLog.Error().Err(err).Send() - InternalServerError(c) - return - } - wfUUID, err := h.svc.AsyncReserveNode(userID, user.Mnemonic, nodeID) if err != nil { reqLog.Error().Err(err).Msg("failed to start workflow to reserve node") diff --git a/backend/internal/core/services/billing_service.go b/backend/internal/core/services/billing_service.go index 91644fa9..1725db04 100644 --- a/backend/internal/core/services/billing_service.go +++ b/backend/internal/core/services/billing_service.go @@ -55,6 +55,10 @@ func NewBillingService(userRepo models.UserRepository, contractsRepo models.Cont } } +func (svc *BillingService) Discount() Discount { + return svc.appliedDiscount +} + func (svc *BillingService) SettleUserUsage(user *models.User) error { usageInUSDMillicent, err := svc.getUserLatestUsageInUSD(user.ID) if err != nil { @@ -65,92 +69,70 @@ func (svc *BillingService) SettleUserUsage(user *models.User) error { } func (svc *BillingService) AfterUserGetCredit(ctx context.Context, user *models.User) error { - if err := svc.CreateTransferRecordToChargeUserWithMinTFTAmount(user.ID, user.Username, user.Mnemonic); err != nil { + if err := svc.FundUserWithTFTs(ctx, user); err != nil { return err } - if err := svc.SettleUserUsage(user); err != nil { + return svc.SettleUserUsage(user) +} + +func (svc *BillingService) FundUserWithTFTs(ctx context.Context, user *models.User) error { + userGridAccountTFTBalance, err := svc.gridClient.GetFreeBalanceTFT(user.Mnemonic) + if err != nil { return err } - return svc.FundUserToFulfillDiscount(ctx, user, nil, nil) -} - -func (svc *BillingService) CreateTransferRecordToChargeUserWithMinTFTAmount(userID int, username, userMnemonic string) error { - userTFTBalance, err := svc.gridClient.GetFreeBalanceTFT(userMnemonic) + totalPendingTFTAmount, err := svc.transferRecordsRepo.CalculateTotalPendingTFTAmountPerUser(user.ID) if err != nil { return err } - totalPendingTFTAmount, err := svc.transferRecordsRepo.CalculateTotalPendingTFTAmountPerUser(userID) + userBalanceInTFT, err := svc.gridClient.FromUSDMillicentToTFT(user.CreditedBalance + user.CreditCardBalance) if err != nil { return err } - if userTFTBalance+totalPendingTFTAmount >= zeroTFTBalanceValue { + if userGridAccountTFTBalance+totalPendingTFTAmount >= userBalanceInTFT { return nil } return svc.transferRecordsRepo.CreateTransferRecord(&models.TransferRecord{ - UserID: userID, - Username: username, - TFTAmount: svc.minimumTFTAmountInWallet * TFTUnitFactor, + UserID: user.ID, + Username: user.Username, + TFTAmount: (userBalanceInTFT - (userGridAccountTFTBalance + totalPendingTFTAmount)) * TFTUnitFactor, Operation: models.DepositOperation, }) } -func (svc *BillingService) FundUserToFulfillDiscount(ctx context.Context, user *models.User, addedRentedNodes []types.Node, addedSharedNodes []kubedeployer.Node) 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 := svc.calculateResourcesUsageInUSDApplyingDiscount(ctx, user.ID, user.Mnemonic, addedRentedNodes, addedSharedNodes, svc.appliedDiscount) - if err != nil { - return err - } - - dailyUsageInTFT, err := svc.gridClient.FromUSDMillicentToTFT(dailyUsageInUSDMillicent) - if err != nil { - return err - } - - totalPendingTFTAmount, err := svc.transferRecordsRepo.CalculateTotalPendingTFTAmountPerUser(user.ID) +func (svc *BillingService) CreateTransferRecordToChargeUserWithMinTFTAmount(userID int, username, userMnemonic string) error { + userTFTBalance, err := svc.gridClient.GetFreeBalanceTFT(userMnemonic) if err != nil { return err } - userTFTBalance, err := svc.gridClient.GetFreeBalanceTFT(user.Mnemonic) + totalPendingTFTAmount, err := svc.transferRecordsRepo.CalculateTotalPendingTFTAmountPerUser(userID) if err != nil { return err } - // fund user to fulfill discount - // make sure no old payments will fund more than needed - if totalPendingTFTAmount+userTFTBalance < dailyUsageInTFT && - dailyUsageInTFT > 0 { - if err := svc.transferRecordsRepo.CreateTransferRecord(&models.TransferRecord{ - UserID: user.ID, - Username: user.Username, - TFTAmount: dailyUsageInTFT - userTFTBalance - totalPendingTFTAmount, - Operation: models.DepositOperation, - }); err != nil { - return err - } + if userTFTBalance+totalPendingTFTAmount >= zeroTFTBalanceValue { + return nil } - return nil + return svc.transferRecordsRepo.CreateTransferRecord(&models.TransferRecord{ + UserID: userID, + Username: username, + TFTAmount: svc.minimumTFTAmountInWallet * TFTUnitFactor, + Operation: models.DepositOperation, + }) } -func (svc *BillingService) calculateResourcesUsageInUSDApplyingDiscount( +func (svc *BillingService) calculateResourcesUsageInUSD( ctx context.Context, userID int, userMnemonic string, addedRentedNodes []types.Node, addedSharedNodes []kubedeployer.Node, - configuredDiscount Discount, ) (uint64, error) { calculator, err := svc.gridClient.NewCalculator(userMnemonic) if err != nil { @@ -251,8 +233,11 @@ func (svc *BillingService) calculateResourcesUsageInUSDApplyingDiscount( } totalResourcesCostMillicent += gridclient.FromUSDToUSDMillicent(float64(len(nameContracts)) * nameContractMonthlyCostInUSD) + return totalResourcesCostMillicent, nil +} - discount := getDiscountPackage(configuredDiscount).DurationInMonth +func (svc *BillingService) ApplyDiscountOnUsage(totalResourcesCostMillicent uint64) (uint64, error) { + discount := getDiscountPackage(svc.appliedDiscount).DurationInMonth if discount == 0 { return totalResourcesCostMillicent, nil } diff --git a/backend/internal/core/services/workers_service.go b/backend/internal/core/services/workers_service.go index 0dd07104..f09dbd18 100644 --- a/backend/internal/core/services/workers_service.go +++ b/backend/internal/core/services/workers_service.go @@ -51,9 +51,9 @@ type WorkerService struct { gridClient gridclient.GridClient ewfEngine *ewf.Engine notificationDispatcher *notification.NotificationDispatcher + billingService BillingService // configs - systemMnemonic string invoiceCompanyData config.InvoiceCompanyData currency string checkUserDebtIntervalInHours int @@ -64,7 +64,6 @@ type WorkerService struct { settleTransferRecordsIntervalInMinutes int notifyAdminsForPendingRecordsInHours int minimumTFTAmountInWallet int - appliedDiscount Discount usersBalanceCheckIntervalInHours int } @@ -73,12 +72,12 @@ func NewWorkersService( invoicesRepo models.InvoiceRepository, clusterRepo models.ClusterRepository, transferRecordsRepo models.TransferRecordRepository, mailService mailservice.MailService, gridClient gridclient.GridClient, ewfEngine *ewf.Engine, notificationDispatcher *notification.NotificationDispatcher, - graphql graphql.GraphQl, firesquidClient graphql.GraphQl, - invoiceCompanyData config.InvoiceCompanyData, systemMnemonic, currency string, + graphql graphql.GraphQl, firesquidClient graphql.GraphQl, billingService BillingService, + invoiceCompanyData config.InvoiceCompanyData, currency string, clusterHealthCheckIntervalInHours, reservedNodeHealthCheckIntervalInHours, reservedNodeHealthCheckTimeoutInMinutes, reservedNodeHealthCheckWorkersNum, settleTransferRecordsIntervalInMinutes, notifyAdminsForPendingRecordsInHours, - minimumTFTAmountInWallet int, appliedDiscount Discount, + minimumTFTAmountInWallet, usersBalanceCheckIntervalInHours, checkUserDebtIntervalInHours int, ) WorkerService { @@ -96,8 +95,8 @@ func NewWorkersService( graphql: graphql, firesquidClient: firesquidClient, gridClient: gridClient, + billingService: billingService, - systemMnemonic: systemMnemonic, invoiceCompanyData: invoiceCompanyData, currency: currency, @@ -110,7 +109,6 @@ func NewWorkersService( notifyAdminsForPendingRecordsInHours: notifyAdminsForPendingRecordsInHours, minimumTFTAmountInWallet: minimumTFTAmountInWallet, - appliedDiscount: appliedDiscount, usersBalanceCheckIntervalInHours: usersBalanceCheckIntervalInHours, } } @@ -168,7 +166,7 @@ func (svc WorkerService) GetUsersBalanceCheckInterval() time.Duration { return time.Duration(svc.usersBalanceCheckIntervalInHours) * time.Hour } -func (svc WorkerService) CreateUserInvoice(BillingService BillingService, user models.User) error { +func (svc WorkerService) CreateUserInvoice(user models.User) error { now := time.Now() timeMonthAgo := now.AddDate(0, -1, 0) @@ -190,7 +188,7 @@ func (svc WorkerService) CreateUserInvoice(BillingService BillingService, user m return err } - totalAmountBilledInUSDMillicent, err := BillingService.calculateTotalUsageOfReportsInUSDMillicent(billReports.Reports) + totalAmountBilledInUSDMillicent, err := svc.billingService.calculateTotalUsageOfReportsInUSDMillicent(billReports.Reports) if err != nil { return err } @@ -435,7 +433,7 @@ func (svc WorkerService) SettlePendingPayments(records []models.TransferRecord) var transferFailure string // getting balance every time to ensure we have the latest balance - systemTFTBalance, err := svc.gridClient.GetFreeBalanceTFT(svc.systemMnemonic) + systemTFTBalance, err := svc.gridClient.GetSystemTFTBalance() if err != nil { log.Error().Err(err).Int("record_id", record.ID).Msg("Failed to get system TFT balance for pending record") continue @@ -517,7 +515,6 @@ func (svc *WorkerService) ResetUsersTFTsWithNoUSDBalance(users []models.User) er } func (svc WorkerService) NotifyAdminWithPendingRecords(records []models.TransferRecord) error { - admins, err := svc.userRepo.ListAdmins() if err != nil { return err @@ -534,6 +531,61 @@ func (svc WorkerService) NotifyAdminWithPendingRecords(records []models.Transfer return nil } +func (svc WorkerService) NotifyAdminWithInsufficientBalance() error { + currentBalance, err := svc.gridClient.GetSystemTFTBalance() + if err != nil { + return err + } + + users, err := svc.userRepo.ListAllUsers() + if err != nil { + return err + } + + var totalUserDailyUsage uint64 + + for _, user := range users { + dailyUsageInUSDMillicent, err := svc.billingService.calculateResourcesUsageInUSD(svc.ctx, user.ID, user.Mnemonic, nil, nil) + if err != nil { + return err + } + + dailyUsageInTFT, err := svc.gridClient.FromUSDMillicentToTFT(dailyUsageInUSDMillicent) + if err != nil { + return err + } + + totalUserDailyUsage += dailyUsageInTFT + } + + requiredBalance, err := svc.billingService.ApplyDiscountOnUsage(totalUserDailyUsage) + if err != nil { + return err + } + + if requiredBalance <= currentBalance { + return nil + } + + admins, err := svc.userRepo.ListAdmins() + if err != nil { + return err + } + + for _, admin := range admins { + err = svc.mailService.SendInsufficientBalanceNotificationEmail( + admin.Email, float64(currentBalance)/TFTUnitFactor, + float64(requiredBalance)/TFTUnitFactor, fmt.Sprint(svc.billingService.Discount()), + ) + if err != nil { + logger.ForOperation("balance_monitor", "send_admin_mail").Error().Err(err).Msg("Failed to send admin notification email") + continue + } + } + + return nil +} + func (svc WorkerService) AsyncTrackClusterHealth(cluster models.Cluster) error { wf, err := svc.ewfEngine.NewWorkflow(workflows.WorkflowTrackClusterHealth, ewf.WithDisplayName("Cluster Health Check")) if err != nil { diff --git a/backend/internal/core/workers/balance_monitor.go b/backend/internal/core/workers/balance_monitor.go index 9dab546a..1983b10a 100644 --- a/backend/internal/core/workers/balance_monitor.go +++ b/backend/internal/core/workers/balance_monitor.go @@ -11,12 +11,10 @@ func (w Workers) MonitorSystemBalanceAndHandleSettlement() { adminNotifyTicker := time.NewTicker(w.svc.GetNotifyAdminsForPendingRecordsInterval()) 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 { select { @@ -87,19 +85,6 @@ func (w Workers) MonitorSystemBalanceAndHandleSettlement() { logger.GetLogger().Error().Err(err).Msgf("Failed to create transfer record for user %d", user.ID) } } - - case <-fundUserTFTBalanceTicker.C: - users, err := w.svc.ListAllUsers() - if err != nil { - logger.GetLogger().Error().Err(err).Msg("Failed to list users") - continue - } - - for _, user := range users { - if err := w.billingService.FundUserToFulfillDiscount(w.ctx, &user, nil, nil); err != nil { - logger.GetLogger().Error().Err(err).Msgf("Failed to fund user %d to claim discount", user.ID) - } - } } } } diff --git a/backend/internal/core/workers/create_invoices.go b/backend/internal/core/workers/create_invoices.go index fb2988d0..327e9353 100644 --- a/backend/internal/core/workers/create_invoices.go +++ b/backend/internal/core/workers/create_invoices.go @@ -34,7 +34,7 @@ func (w Workers) MonthlyInvoicesHandler() { } for _, user := range users { - if err = w.svc.CreateUserInvoice(w.billingService, user); err != nil { + if err = w.svc.CreateUserInvoice(user); err != nil { baseLog.Error().Err(err).Int("user_id", user.ID).Msg("failed to create invoice for user") } } diff --git a/backend/internal/core/workflows/user_activities.go b/backend/internal/core/workflows/user_activities.go index 82a728f8..f6a0fa55 100644 --- a/backend/internal/core/workflows/user_activities.go +++ b/backend/internal/core/workflows/user_activities.go @@ -174,11 +174,15 @@ func SetupTFChainStep(gridClient gridclient.GridClient, userRepo models.UserRepo return nil } - mnemonic, _, err := gridClient.SetupUserOnTFChain(termsAndConditions) + mnemonic, twinID, err := gridClient.SetupUserOnTFChain(termsAndConditions) if err != nil { return err } + if err := gridClient.BondTwinAccount(twinID); err != nil { + return err + } + if err := userRepo.UpdateUserByID(&models.User{ ID: userConfig.UserID, Mnemonic: mnemonic, diff --git a/backend/internal/infrastructure/gridclient/grid_client.go b/backend/internal/infrastructure/gridclient/grid_client.go index dd737dc2..d3f3b454 100644 --- a/backend/internal/infrastructure/gridclient/grid_client.go +++ b/backend/internal/infrastructure/gridclient/grid_client.go @@ -40,6 +40,7 @@ type GridClient interface { GetTwinIDFromUserMnemonic(mnemonic string) (uint64, error) GetTwin(mnemonic string) (uint32, error) GetFreeBalanceTFT(mnemonic string) (uint64, error) + GetSystemTFTBalance() (uint64, error) GetUserAddress(mnemonic string) (string, error) AcceptTermsAndConditions(mnemonic, docLink, docHash string) error CreateTwin(mnemonic string) (uint32, error) @@ -49,6 +50,8 @@ type GridClient interface { GetPricingPolicy(policyID uint32) (pricingPolicy substrate.PricingPolicy, err error) GetTFTBillingRateAt(block uint64) (float64, error) GetCurrentHeight() (uint32, error) + BondTwinAccount(twinID uint32) error + GetTwinBondedAccount(twinID uint32) (*substrate.AccountID, error) // node methods GetNodeClient(nodeID uint32) (*client.NodeClient, error) @@ -314,6 +317,10 @@ func (s *gridClient) GetFreeBalanceTFT(mnemonic string) (uint64, error) { return balance.Free.Uint64(), nil } +func (s *gridClient) GetSystemTFTBalance() (uint64, error) { + return s.GetFreeBalanceTFT(s.systemMnemonic) +} + // GetUserAddress gets user address from user mnemonic func (s *gridClient) GetUserAddress(mnemonic string) (string, error) { identity, err := s.getIdentity(mnemonic) @@ -451,6 +458,19 @@ func (s *gridClient) GetCurrentHeight() (uint32, error) { return s.gridClient.SubstrateConn.GetCurrentHeight() } +func (s *gridClient) BondTwinAccount(twinID uint32) error { + stashIdentity, err := s.gridClient.SubstrateConn.NewIdentityFromSr25519Phrase(s.systemMnemonic) + if err != nil { + return fmt.Errorf("failed to create stash identity: %w", err) + } + + return s.gridClient.SubstrateConn.BondTwinAccount(stashIdentity, twinID) +} + +func (s *gridClient) GetTwinBondedAccount(twinID uint32) (*substrate.AccountID, error) { + return s.gridClient.SubstrateConn.GetTwinBondedAccount(twinID) +} + func (s *gridClient) Close() { s.gridClient.Close() } diff --git a/backend/internal/infrastructure/mailservice/mail_content_formatter/mail_fomatter.go b/backend/internal/infrastructure/mailservice/mail_content_formatter/mail_fomatter.go index a66761d6..f1b7c8b4 100644 --- a/backend/internal/infrastructure/mailservice/mail_content_formatter/mail_fomatter.go +++ b/backend/internal/infrastructure/mailservice/mail_content_formatter/mail_fomatter.go @@ -9,5 +9,6 @@ type MailContentFormatter interface { FormatInvoiceMailContent(invoiceTotal float64, currency string, invoiceID int) (string, string) FormatSystemAnnouncementMailBody(body string) string FormatNotifyAdminsMailContent(recordsNumber int, systemHost string) (string, string) + FormatInsufficientBalanceNotificationMailContent(currentBalance, requiredBalance float64, discount string, systemHost string) (string, string) FormatNotificationMailContent(notification models.Notification) (string, string, error) } diff --git a/backend/internal/infrastructure/mailservice/mail_content_formatter/mail_html_formatter.go b/backend/internal/infrastructure/mailservice/mail_content_formatter/mail_html_formatter.go index 2eedc65a..01e10187 100644 --- a/backend/internal/infrastructure/mailservice/mail_content_formatter/mail_html_formatter.go +++ b/backend/internal/infrastructure/mailservice/mail_content_formatter/mail_html_formatter.go @@ -27,6 +27,9 @@ var notifyPaymentRecordsMail []byte //go:embed templates/system_announcement.html var systemAnnouncementMail []byte +//go:embed templates/insufficient_balance_notification.html +var insufficientBalanceNotificationMail []byte + type MailHTMLFormatter struct { } @@ -98,6 +101,18 @@ func (f MailHTMLFormatter) FormatNotifyAdminsMailContent(recordsNumber int, syst return subject, body } +func (f MailHTMLFormatter) FormatInsufficientBalanceNotificationMailContent(currentBalance, requiredBalance float64, discount string, systemHost string) (string, string) { + subject := "⚠️ System Balance Insufficient for Discount Requirements" + body := string(insufficientBalanceNotificationMail) + + body = strings.ReplaceAll(body, "-balance-", fmt.Sprintf("%.2f", currentBalance)) + body = strings.ReplaceAll(body, "-required-", fmt.Sprintf("%.2f", requiredBalance)) + body = strings.ReplaceAll(body, "-discount-", discount) + body = strings.ReplaceAll(body, "-host-", systemHost) + + return subject, body +} + func (f MailHTMLFormatter) FormatNotificationMailContent(notification models.Notification) (string, string, error) { tplName := string(notification.Type) diff --git a/backend/internal/infrastructure/mailservice/mail_content_formatter/mail_text_formatter.go b/backend/internal/infrastructure/mailservice/mail_content_formatter/mail_text_formatter.go index fcaaef7b..d910355b 100644 --- a/backend/internal/infrastructure/mailservice/mail_content_formatter/mail_text_formatter.go +++ b/backend/internal/infrastructure/mailservice/mail_content_formatter/mail_text_formatter.go @@ -76,6 +76,16 @@ func (f MailTextFormatter) FormatNotifyAdminsMailContent(recordsNumber int, syst return subject, body } +func (f MailTextFormatter) FormatInsufficientBalanceNotificationMailContent(currentBalance, requiredBalance float64, discount string, systemHost string) (string, string) { + subject := "Insufficient Balance" + body := fmt.Sprintf( + "We hope you're well.\n\nYour account balance %f is insufficient for discount %s. Should b e %f. Please add more funds to your account.\n\nVisit %s to add funds.", + currentBalance, discount, requiredBalance, systemHost, + ) + + return subject, body +} + func (f MailTextFormatter) FormatNotificationMailContent(notification models.Notification) (string, string, error) { subject := notification.Payload["subject"] if subject == "" { diff --git a/backend/internal/infrastructure/mailservice/mail_content_formatter/templates/insufficient_balance_notification.html b/backend/internal/infrastructure/mailservice/mail_content_formatter/templates/insufficient_balance_notification.html new file mode 100644 index 00000000..91289cdc --- /dev/null +++ b/backend/internal/infrastructure/mailservice/mail_content_formatter/templates/insufficient_balance_notification.html @@ -0,0 +1,283 @@ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+
+ + + + +
+

+ ⚠️ System Balance Alert +

+
+
+ + + + + + + + + + +
+

+ Hello Admin, +

+

+ The system account balance is insufficient to fulfill the configured discount requirements. +

+
+ + + + +
+ + + + + + + + + + + + + +
Current System Balance:$-balance-
Required for Discount:$-required-
Configured Discount:-discount-
+
+
+

+ Please top up the system account to ensure users can benefit from the configured discount package. +

+

+ You can view and manage the system balance from the admin dashboard: +

+
+ + + + +
+ + + + +
+ Go to Dashboard +
+
+
+

+ Best regards,
+ Mycelium Cloud System +

+
+
+ + + + +
+

+ This is an automated notification from the Mycelium Cloud + monitoring system. +

+
+
+ + diff --git a/backend/internal/infrastructure/mailservice/mail_service.go b/backend/internal/infrastructure/mailservice/mail_service.go index 645a9ac9..c6077d20 100644 --- a/backend/internal/infrastructure/mailservice/mail_service.go +++ b/backend/internal/infrastructure/mailservice/mail_service.go @@ -80,6 +80,16 @@ func (m MailService) SendNotifyAdminsEmail(to string, recordsNumber int) error { }) } +func (m MailService) SendInsufficientBalanceNotificationEmail(to string, currentBalance, requiredBalance float64, discount string) error { + subject, body := m.mailContentFormatter.FormatInsufficientBalanceNotificationMailContent(currentBalance, requiredBalance, discount, m.config.Server.Host) + return m.mailSender.Send(mailsender.MailRequest{ + From: m.config.MailSender.Email, + To: to, + Subject: subject, + Body: body, + }) +} + func (m MailService) SendEmailNotification(to string, notification models.Notification) error { subject, body, err := m.mailContentFormatter.FormatNotificationMailContent(notification) if err != nil { diff --git a/backend/internal/infrastructure/notification/email_notifier.go b/backend/internal/infrastructure/notification/email_notifier.go index 0f82bec5..9c8ae9d9 100644 --- a/backend/internal/infrastructure/notification/email_notifier.go +++ b/backend/internal/infrastructure/notification/email_notifier.go @@ -5,7 +5,7 @@ import ( "fmt" "kubecloud/internal/core/models" "kubecloud/internal/infrastructure/logger" - mailservice "kubecloud/internal/infrastructure/mailservice" + "kubecloud/internal/infrastructure/mailservice" "github.com/xmonader/ewf" )