A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
353 lines
9.7 KiB
Go
353 lines
9.7 KiB
Go
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
|
|
}
|