Initial commit: breakpilot-core - Shared Infrastructure
Docker Compose with 24+ services: - PostgreSQL (PostGIS), Valkey, MinIO, Qdrant - Vault (PKI/TLS), Nginx (Reverse Proxy) - Backend Core API, Consent Service, Billing Service - RAG Service, Embedding Service - Gitea, Woodpecker CI/CD - Night Scheduler, Health Aggregator - Jitsi (Web/XMPP/JVB/Jicofo), Mailpit Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
352
billing-service/internal/services/task_service.go
Normal file
352
billing-service/internal/services/task_service.go
Normal file
@@ -0,0 +1,352 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user