package services import ( "context" "errors" "fmt" "time" "github.com/breakpilot/billing-service/internal/database" "github.com/breakpilot/billing-service/internal/models" "github.com/google/uuid" ) var ( // ErrTaskLimitReached is returned when task balance is 0 ErrTaskLimitReached = errors.New("TASK_LIMIT_REACHED") // ErrNoSubscription is returned when user has no subscription ErrNoSubscription = errors.New("NO_SUBSCRIPTION") ) // TaskService handles task consumption and balance management type TaskService struct { db *database.DB subService *SubscriptionService } // NewTaskService creates a new TaskService func NewTaskService(db *database.DB, subService *SubscriptionService) *TaskService { return &TaskService{ db: db, subService: subService, } } // GetAccountUsage retrieves or creates account usage for a user func (s *TaskService) GetAccountUsage(ctx context.Context, userID uuid.UUID) (*models.AccountUsage, error) { query := ` SELECT id, account_id, plan, monthly_task_allowance, carryover_months_cap, max_task_balance, task_balance, last_renewal_at, created_at, updated_at FROM account_usage WHERE account_id = $1 ` var usage models.AccountUsage err := s.db.Pool.QueryRow(ctx, query, userID).Scan( &usage.ID, &usage.AccountID, &usage.PlanID, &usage.MonthlyTaskAllowance, &usage.CarryoverMonthsCap, &usage.MaxTaskBalance, &usage.TaskBalance, &usage.LastRenewalAt, &usage.CreatedAt, &usage.UpdatedAt, ) if err != nil { if err.Error() == "no rows in result set" { // Create new account usage based on subscription return s.createAccountUsage(ctx, userID) } return nil, err } // Check if month renewal is needed if err := s.checkAndApplyMonthRenewal(ctx, &usage); err != nil { return nil, err } return &usage, nil } // createAccountUsage creates account usage based on user's subscription func (s *TaskService) createAccountUsage(ctx context.Context, userID uuid.UUID) (*models.AccountUsage, error) { // Get subscription to determine plan sub, err := s.subService.GetByUserID(ctx, userID) if err != nil || sub == nil { return nil, ErrNoSubscription } // Get plan features plan, err := s.subService.GetPlanByID(ctx, string(sub.PlanID)) if err != nil || plan == nil { return nil, fmt.Errorf("plan not found: %s", sub.PlanID) } now := time.Now() usage := &models.AccountUsage{ AccountID: userID, PlanID: sub.PlanID, MonthlyTaskAllowance: plan.Features.MonthlyTaskAllowance, CarryoverMonthsCap: models.CarryoverMonthsCap, MaxTaskBalance: plan.Features.MaxTaskBalance, TaskBalance: plan.Features.MonthlyTaskAllowance, // Start with one month's worth LastRenewalAt: now, } query := ` INSERT INTO account_usage ( account_id, plan, monthly_task_allowance, carryover_months_cap, max_task_balance, task_balance, last_renewal_at ) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at, updated_at ` err = s.db.Pool.QueryRow(ctx, query, usage.AccountID, usage.PlanID, usage.MonthlyTaskAllowance, usage.CarryoverMonthsCap, usage.MaxTaskBalance, usage.TaskBalance, usage.LastRenewalAt, ).Scan(&usage.ID, &usage.CreatedAt, &usage.UpdatedAt) if err != nil { return nil, err } return usage, nil } // checkAndApplyMonthRenewal checks if a month has passed and adds allowance // Implements the carryover logic: tasks accumulate up to max_task_balance func (s *TaskService) checkAndApplyMonthRenewal(ctx context.Context, usage *models.AccountUsage) error { now := time.Now() // Check if at least one month has passed since last renewal monthsSinceRenewal := monthsBetween(usage.LastRenewalAt, now) if monthsSinceRenewal < 1 { return nil } // Calculate new balance with carryover // Add monthly allowance for each month that passed newBalance := usage.TaskBalance for i := 0; i < monthsSinceRenewal; i++ { newBalance += usage.MonthlyTaskAllowance // Cap at max balance if newBalance > usage.MaxTaskBalance { newBalance = usage.MaxTaskBalance break } } // Calculate new renewal date (add the number of months) newRenewalAt := usage.LastRenewalAt.AddDate(0, monthsSinceRenewal, 0) // Update in database query := ` UPDATE account_usage SET task_balance = $2, last_renewal_at = $3, updated_at = NOW() WHERE id = $1 ` _, err := s.db.Pool.Exec(ctx, query, usage.ID, newBalance, newRenewalAt) if err != nil { return err } // Update local struct usage.TaskBalance = newBalance usage.LastRenewalAt = newRenewalAt return nil } // monthsBetween calculates full months between two dates func monthsBetween(start, end time.Time) int { months := 0 for start.AddDate(0, months+1, 0).Before(end) || start.AddDate(0, months+1, 0).Equal(end) { months++ } return months } // CheckTaskAllowed checks if a task can be consumed (balance > 0) func (s *TaskService) CheckTaskAllowed(ctx context.Context, userID uuid.UUID) (*models.CheckTaskAllowedResponse, error) { usage, err := s.GetAccountUsage(ctx, userID) if err != nil { if errors.Is(err, ErrNoSubscription) { return &models.CheckTaskAllowedResponse{ Allowed: false, PlanID: "", Message: "Kein aktives Abonnement gefunden.", }, nil } return nil, err } // Premium Fair Use mode - always allow plan, _ := s.subService.GetPlanByID(ctx, string(usage.PlanID)) if plan != nil && plan.Features.FairUseMode { return &models.CheckTaskAllowedResponse{ Allowed: true, TasksAvailable: usage.TaskBalance, MaxTasks: usage.MaxTaskBalance, PlanID: usage.PlanID, }, nil } allowed := usage.TaskBalance > 0 response := &models.CheckTaskAllowedResponse{ Allowed: allowed, TasksAvailable: usage.TaskBalance, MaxTasks: usage.MaxTaskBalance, PlanID: usage.PlanID, } if !allowed { response.Message = "Dein Aufgaben-Kontingent ist aufgebraucht." } return response, nil } // ConsumeTask consumes one task from the balance // Returns error if balance is 0 func (s *TaskService) ConsumeTask(ctx context.Context, userID uuid.UUID, taskType models.TaskType) (*models.ConsumeTaskResponse, error) { // First check if allowed checkResponse, err := s.CheckTaskAllowed(ctx, userID) if err != nil { return nil, err } if !checkResponse.Allowed { return &models.ConsumeTaskResponse{ Success: false, TasksRemaining: 0, Message: checkResponse.Message, }, ErrTaskLimitReached } // Get current usage usage, err := s.GetAccountUsage(ctx, userID) if err != nil { return nil, err } // Start transaction tx, err := s.db.Pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) // Decrement balance (only if not Premium Fair Use) plan, _ := s.subService.GetPlanByID(ctx, string(usage.PlanID)) newBalance := usage.TaskBalance if plan == nil || !plan.Features.FairUseMode { newBalance = usage.TaskBalance - 1 _, err = tx.Exec(ctx, ` UPDATE account_usage SET task_balance = $2, updated_at = NOW() WHERE account_id = $1 `, userID, newBalance) if err != nil { return nil, err } } // Create task record taskID := uuid.New() _, err = tx.Exec(ctx, ` INSERT INTO tasks (id, account_id, task_type, consumed, created_at) VALUES ($1, $2, $3, true, NOW()) `, taskID, userID, taskType) if err != nil { return nil, err } // Commit transaction if err = tx.Commit(ctx); err != nil { return nil, err } return &models.ConsumeTaskResponse{ Success: true, TaskID: taskID.String(), TasksRemaining: newBalance, }, nil } // GetTaskUsageInfo returns formatted task usage info for display func (s *TaskService) GetTaskUsageInfo(ctx context.Context, userID uuid.UUID) (*models.TaskUsageInfo, error) { usage, err := s.GetAccountUsage(ctx, userID) if err != nil { return nil, err } // Check for Fair Use mode (Premium) plan, _ := s.subService.GetPlanByID(ctx, string(usage.PlanID)) if plan != nil && plan.Features.FairUseMode { return &models.TaskUsageInfo{ TasksAvailable: usage.TaskBalance, MaxTasks: usage.MaxTaskBalance, InfoText: "Unbegrenzte Aufgaben (Fair Use)", TooltipText: "Im Premium-Tarif gibt es keine praktische Begrenzung.", }, nil } return &models.TaskUsageInfo{ TasksAvailable: usage.TaskBalance, MaxTasks: usage.MaxTaskBalance, InfoText: fmt.Sprintf("Aufgaben verfuegbar: %d von max. %d", usage.TaskBalance, usage.MaxTaskBalance), TooltipText: "Aufgaben koennen sich bis zu 5 Monate ansammeln.", }, nil } // UpdatePlanForUser updates the plan and adjusts allowances func (s *TaskService) UpdatePlanForUser(ctx context.Context, userID uuid.UUID, newPlanID models.PlanID) error { plan, err := s.subService.GetPlanByID(ctx, string(newPlanID)) if err != nil || plan == nil { return fmt.Errorf("plan not found: %s", newPlanID) } // Update account usage with new plan limits query := ` UPDATE account_usage SET plan = $2, monthly_task_allowance = $3, max_task_balance = $4, updated_at = NOW() WHERE account_id = $1 ` _, err = s.db.Pool.Exec(ctx, query, userID, newPlanID, plan.Features.MonthlyTaskAllowance, plan.Features.MaxTaskBalance) return err } // GetTaskHistory returns task history for a user func (s *TaskService) GetTaskHistory(ctx context.Context, userID uuid.UUID, limit int) ([]models.Task, error) { if limit <= 0 { limit = 50 } query := ` SELECT id, account_id, task_type, created_at, consumed FROM tasks WHERE account_id = $1 ORDER BY created_at DESC LIMIT $2 ` rows, err := s.db.Pool.Query(ctx, query, userID, limit) if err != nil { return nil, err } defer rows.Close() var tasks []models.Task for rows.Next() { var task models.Task err := rows.Scan(&task.ID, &task.AccountID, &task.TaskType, &task.CreatedAt, &task.Consumed) if err != nil { return nil, err } tasks = append(tasks, task) } return tasks, nil }