package services import ( "context" "fmt" "time" "github.com/NdoleStudio/httpsms/pkg/cache" "github.com/NdoleStudio/httpsms/pkg/emails" "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/telemetry" "github.com/google/uuid" "github.com/palantir/stacktrace" ) // BillingService is responsible for tracking usages and billing users type BillingService struct { service logger telemetry.Logger tracer telemetry.Tracer cache cache.Cache emailFactory emails.UserEmailFactory mailer emails.Mailer userRepository repositories.UserRepository billingUsageRepository repositories.BillingUsageRepository } // NewBillingService creates a new BillingService func NewBillingService( logger telemetry.Logger, tracer telemetry.Tracer, cache cache.Cache, mailer emails.Mailer, emailFactory emails.UserEmailFactory, usageRepository repositories.BillingUsageRepository, userRepository repositories.UserRepository, ) (s *BillingService) { return &BillingService{ logger: logger.WithService(fmt.Sprintf("%T", s)), tracer: tracer, cache: cache, emailFactory: emailFactory, mailer: mailer, userRepository: userRepository, billingUsageRepository: usageRepository, } } // IsEntitledWithCount checks if a user can send or receive and SMS message func (service *BillingService) IsEntitledWithCount(ctx context.Context, userID entities.UserID, count uint) *string { ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() user, err := service.userRepository.Load(ctx, userID) if err != nil { msg := fmt.Sprintf("cannot load user with ID [%s], entitlement successfull", userID) ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))) return nil } usage, err := service.billingUsageRepository.GetCurrent(ctx, userID) if err != nil { msg := fmt.Sprintf("cannot load billing usage for user with ID [%s], entitlement successfull", userID) ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))) return nil } if !usage.IsEntitled(count, user.SubscriptionName.Limit()) { return service.handleLimitExceeded(ctx, user) } return nil } // IsEntitled checks if a user can send or receive and SMS message func (service *BillingService) IsEntitled(ctx context.Context, userID entities.UserID) *string { return service.IsEntitledWithCount(ctx, userID, 1) } func (service *BillingService) handleLimitExceeded(ctx context.Context, user *entities.User) *string { ctx, span := service.tracer.Start(ctx) defer span.End() service.sendLimitExceededEmail(ctx, user) message := fmt.Sprintf( "You have exceeded your limit of [%d] messages on your [%s] plan. Upgrade to send more messages on https://httpsms.com/billing", user.SubscriptionName.Limit(), user.SubscriptionName, ) return &message } func (service *BillingService) sendLimitExceededEmail(ctx context.Context, user *entities.User) { ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() key := fmt.Sprintf("user.limit.exceeded.%s", user.ID) if _, err := service.cache.Get(ctx, key); err == nil { return } email, err := service.emailFactory.UsageLimitExceeded(user) if err != nil { ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot create usage limit email for user [%s]", user.ID))) return } if err = service.mailer.Send(ctx, email); err != nil { msg := fmt.Sprintf("canot send usage limit exceeded notification to user [%s]", user.ID) ctxLogger.Error(stacktrace.Propagate(err, msg)) return } ctxLogger.Info(fmt.Sprintf("usage limit exceeded email sent to user [%s]", user.ID)) if err = service.cache.Set(ctx, key, "", time.Hour*12); err != nil { ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot set item in redis with key [%s]", key))) } } // GetCurrentUsage gets the current billing usage for a user func (service *BillingService) GetCurrentUsage(ctx context.Context, userID entities.UserID) (*entities.BillingUsage, error) { ctx, span := service.tracer.Start(ctx) defer span.End() return service.billingUsageRepository.GetCurrent(ctx, userID) } // GetUsageHistory gets the billing usage history for a user func (service *BillingService) GetUsageHistory(ctx context.Context, userID entities.UserID, params repositories.IndexParams) (*[]entities.BillingUsage, error) { ctx, span := service.tracer.Start(ctx) defer span.End() return service.billingUsageRepository.GetHistory(ctx, userID, params) } // RegisterSentMessage records the billing usage for a sent message func (service *BillingService) RegisterSentMessage(ctx context.Context, messageID uuid.UUID, timestamp time.Time, userID entities.UserID) error { ctx, span := service.tracer.Start(ctx) defer span.End() ctxLogger := service.tracer.CtxLogger(service.logger, span) if err := service.billingUsageRepository.RegisterSentMessage(ctx, timestamp, userID); err != nil { msg := fmt.Sprintf("could not register [sent] message with ID [%s] for user with ID [%s]", messageID, userID) return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } ctxLogger.Info(fmt.Sprintf("registered [sent] message with ID [%s] for user [%s]", messageID, userID)) service.sendUsageAlert(ctx, userID) return nil } // RegisterReceivedMessage records the billing usage for a received message func (service *BillingService) RegisterReceivedMessage(ctx context.Context, messageID uuid.UUID, timestamp time.Time, userID entities.UserID) error { ctx, span := service.tracer.Start(ctx) defer span.End() ctxLogger := service.tracer.CtxLogger(service.logger, span) if err := service.billingUsageRepository.RegisterReceivedMessage(ctx, timestamp, userID); err != nil { msg := fmt.Sprintf("could not register [received] message with ID [%s] for user with ID [%s]", messageID, userID) return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } ctxLogger.Info(fmt.Sprintf("registered [received] message with ID [%s] for user [%s]", messageID, userID)) service.sendUsageAlert(ctx, userID) return nil } // DeleteAllForUser deletes all entities.BillingUsage for an entities.UserID. func (service *BillingService) DeleteAllForUser(ctx context.Context, userID entities.UserID) error { ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() if err := service.billingUsageRepository.DeleteAllForUser(ctx, userID); err != nil { msg := fmt.Sprintf("could not delete [entities.BillingUsage] for user with ID [%s]", userID) return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } ctxLogger.Info(fmt.Sprintf("deleted all [entities.BillingUsage] for user with ID [%s]", userID)) return nil } func (service *BillingService) sendUsageAlert(ctx context.Context, userID entities.UserID) { ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() user, err := service.userRepository.Load(ctx, userID) if err != nil { msg := fmt.Sprintf("cannot load user with ID [%s]", userID) ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))) return } billingUsage, err := service.billingUsageRepository.GetCurrent(ctx, userID) if err != nil { msg := fmt.Sprintf("cannot load billing usage for user with ID [%s]", userID) ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))) return } if !service.shouldSendAlert(user, billingUsage) { return } email, err := service.emailFactory.UsageLimitAlert(user, billingUsage) if err != nil { ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot create usage alert email for user [%s]", user.ID))) return } if err = service.mailer.Send(ctx, email); err != nil { msg := fmt.Sprintf("canot send usage alert notification to user [%s]", user.ID) ctxLogger.Error(stacktrace.Propagate(err, msg)) } ctxLogger.Info(fmt.Sprintf("usage alert email sent to user [%s]", user.ID)) } func (service *BillingService) shouldSendAlert(user *entities.User, usage *entities.BillingUsage) bool { if user.IsOnFreePlan() && (usage.TotalMessages() == 160 || usage.TotalMessages() == 180 || usage.TotalMessages() == 190) { return true } if user.IsOnProPlan() && (usage.TotalMessages() == 4000 || usage.TotalMessages() == 4500 || usage.TotalMessages() == 4750) { return true } if user.IsOnUltraPlan() && (usage.TotalMessages() == 8000 || usage.TotalMessages() == 9000 || usage.TotalMessages() == 9500) { return true } if user.IsOn20kPlan() && (usage.TotalMessages() == 16000 || usage.TotalMessages() == 18000 || usage.TotalMessages() == 19000) { return true } return false }