diff --git a/admin/src/app/(dashboard)/users/[id]/client.tsx b/admin/src/app/(dashboard)/users/[id]/client.tsx index 0f77b11..917686c 100644 --- a/admin/src/app/(dashboard)/users/[id]/client.tsx +++ b/admin/src/app/(dashboard)/users/[id]/client.tsx @@ -7,12 +7,13 @@ import Link from 'next/link'; import { ArrowLeft, Edit, Trash2, Bell, Mail } from 'lucide-react'; import { toast } from 'sonner'; -import { usersApi, notificationsApi, emailsApi } from '@/lib/api'; +import { usersApi, notificationsApi, emailsApi, subscriptionsApi } from '@/lib/api'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; +import { Switch } from '@/components/ui/switch'; import { Card, CardContent, @@ -51,6 +52,26 @@ export function UserDetailClient() { enabled: !!userId, }); + const { data: subscription } = useQuery({ + queryKey: ['subscription', 'user', userId], + queryFn: () => subscriptionsApi.getByUser(userId), + enabled: !!userId, + }); + + const updateSubscriptionMutation = useMutation({ + mutationFn: (data: { is_free: boolean }) => { + if (!subscription) throw new Error('No subscription'); + return subscriptionsApi.update(subscription.id, data); + }, + onSuccess: () => { + toast.success('Subscription updated'); + queryClient.invalidateQueries({ queryKey: ['subscription', 'user', userId] }); + }, + onError: () => { + toast.error('Failed to update subscription'); + }, + }); + const deleteMutation = useMutation({ mutationFn: () => usersApi.delete(userId), onSuccess: () => { @@ -258,6 +279,71 @@ export function UserDetailClient() { + {/* Subscription */} + + + Subscription + User subscription and access settings + + + + + + Tier + + + {subscription?.tier || 'free'} + + + + + Platform + + {subscription?.platform || '-'} + + + + Auto Renew + + + {subscription?.auto_renew ? 'Yes' : 'No'} + + + + + Expires + + + {subscription?.expires_at + ? new Date(subscription.expires_at).toLocaleDateString() + : '-'} + + + + + + + + + + Free Access (No Limitations) + + + When enabled, this user bypasses all tier limitations regardless of subscription status or global settings. + + + { + updateSubscriptionMutation.mutate({ is_free: checked }); + }} + disabled={updateSubscriptionMutation.isPending} + /> + + + + {/* Residences */} {user.residences && user.residences.length > 0 && ( diff --git a/admin/src/lib/api.ts b/admin/src/lib/api.ts index c2e6c6b..b95bedf 100644 --- a/admin/src/lib/api.ts +++ b/admin/src/lib/api.ts @@ -335,6 +335,11 @@ export const subscriptionsApi = { return response.data; }, + getByUser: async (userId: number): Promise => { + const response = await api.get(`/subscriptions/user/${userId}`); + return response.data; + }, + update: async (id: number, data: UpdateSubscriptionRequest): Promise => { const response = await api.put(`/subscriptions/${id}`, data); return response.data; diff --git a/admin/src/types/models.ts b/admin/src/types/models.ts index 7b81299..475ac9a 100644 --- a/admin/src/types/models.ts +++ b/admin/src/types/models.ts @@ -472,6 +472,7 @@ export interface Subscription { tier: 'free' | 'premium' | 'pro'; platform: string; auto_renew: boolean; + is_free: boolean; subscribed_at?: string; expires_at?: string; cancelled_at?: string; @@ -491,6 +492,7 @@ export interface SubscriptionListParams extends ListParams { export interface UpdateSubscriptionRequest { tier?: string; auto_renew?: boolean; + is_free?: boolean; } export interface SubscriptionStats { diff --git a/docs/TASK_KANBAN_LOGIC.md b/docs/TASK_KANBAN_LOGIC.md new file mode 100644 index 0000000..5097c4b --- /dev/null +++ b/docs/TASK_KANBAN_LOGIC.md @@ -0,0 +1,214 @@ +# Task Kanban Board Categorization Logic + +This document describes how tasks are categorized into kanban columns for display in the Casera mobile app. + +## Overview + +Tasks are organized into 6 kanban columns based on their state and due date. The categorization logic is implemented in `internal/repositories/task_repo.go` in the `GetKanbanData` and `GetKanbanDataForMultipleResidences` functions. + +## Columns + +| Column | Name | Color | Description | +|--------|------|-------|-------------| +| 1 | **Overdue** | `#FF3B30` (Red) | Tasks past their due date | +| 2 | **Due Soon** | `#FF9500` (Orange) | Tasks due within the threshold (default 30 days) | +| 3 | **Upcoming** | `#007AFF` (Blue) | Tasks due beyond the threshold or with no due date | +| 4 | **In Progress** | `#5856D6` (Purple) | Tasks with status "In Progress" | +| 5 | **Completed** | `#34C759` (Green) | Tasks with at least one completion record | +| 6 | **Cancelled** | `#8E8E93` (Gray) | Tasks marked as cancelled | + +## Categorization Flow + +The categorization follows this priority order (first match wins): + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ START: Process Task │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────┐ + │ Is task cancelled? │ + │ (is_cancelled = true) │ + └─────────────────────────┘ + │ │ + YES NO + │ │ + ▼ ▼ + ┌──────────┐ ┌─────────────────────────┐ + │CANCELLED │ │ Has task completions? │ + │ column │ │ (len(completions) > 0) │ + └──────────┘ └─────────────────────────┘ + │ │ + YES NO + │ │ + ▼ ▼ + ┌──────────┐ ┌─────────────────────────┐ + │COMPLETED │ │ Is status "In Progress"?│ + │ column │ │ (status.name = "In...) │ + └──────────┘ └─────────────────────────┘ + │ │ + YES NO + │ │ + ▼ ▼ + ┌───────────┐ ┌─────────────────┐ + │IN PROGRESS│ │ Has due date? │ + │ column │ └─────────────────┘ + └───────────┘ │ │ + YES NO + │ │ + ▼ ▼ + ┌──────────────┐ ┌────────┐ + │Check due date│ │UPCOMING│ + └──────────────┘ │ column │ + │ └────────┘ + ┌────────────┼────────────┐ + ▼ ▼ ▼ + ┌─────────────┐ ┌──────────┐ ┌────────────┐ + │ due < now │ │due < now │ │due >= now +│ + │ │ │+ threshold│ │ threshold │ + └─────────────┘ └──────────┘ └────────────┘ + │ │ │ + ▼ ▼ ▼ + ┌─────────┐ ┌──────────┐ ┌────────┐ + │ OVERDUE │ │ DUE SOON │ │UPCOMING│ + │ column │ │ column │ │ column │ + └─────────┘ └──────────┘ └────────┘ +``` + +## Detailed Rules + +### 1. Cancelled (highest priority) +```go +if task.IsCancelled { + cancelled = append(cancelled, task) + continue +} +``` +- **Condition**: `is_cancelled = true` +- **Actions Available**: `uncancel`, `delete` + +### 2. Completed +```go +if len(task.Completions) > 0 { + completed = append(completed, task) + continue +} +``` +- **Condition**: Task has at least one `TaskCompletion` record +- **Note**: A task is considered completed based on having completion records, NOT based on status +- **Actions Available**: `view` + +### 3. In Progress +```go +if task.Status != nil && task.Status.Name == "In Progress" { + inProgress = append(inProgress, task) + continue +} +``` +- **Condition**: Task's status name is exactly `"In Progress"` +- **Actions Available**: `edit`, `complete` + +### 4. Due Date Based Categories (only for tasks not cancelled, completed, or in progress) + +#### Overdue +```go +if task.DueDate.Before(now) { + overdue = append(overdue, task) +} +``` +- **Condition**: `due_date < current_time` +- **Actions Available**: `edit`, `cancel`, `mark_in_progress` + +#### Due Soon +```go +if task.DueDate.Before(threshold) { + dueSoon = append(dueSoon, task) +} +``` +- **Condition**: `current_time <= due_date < (current_time + days_threshold)` +- **Default threshold**: 30 days +- **Actions Available**: `edit`, `complete`, `mark_in_progress` + +#### Upcoming +```go +upcoming = append(upcoming, task) +``` +- **Condition**: `due_date >= (current_time + days_threshold)` OR `due_date IS NULL` +- **Actions Available**: `edit`, `cancel` + +## Column Metadata + +Each column includes metadata for the mobile clients: + +```go +{ + Name: "overdue_tasks", // Internal identifier + DisplayName: "Overdue", // User-facing label + ButtonTypes: []string{"edit", "cancel", "mark_in_progress"}, // Available actions + Icons: map[string]string{ // Platform-specific icons + "ios": "exclamationmark.triangle", + "android": "Warning" + }, + Color: "#FF3B30", // Display color + Tasks: []Task{...}, // Tasks in this column + Count: int, // Number of tasks +} +``` + +## Days Threshold Parameter + +The `daysThreshold` parameter (default: 30) determines the boundary between "Due Soon" and "Upcoming": + +- Tasks due within `daysThreshold` days from now → **Due Soon** +- Tasks due beyond `daysThreshold` days from now → **Upcoming** + +This can be customized per request via query parameter. + +## Sorting + +Tasks within each column are sorted by: +1. `due_date ASC NULLS LAST` (earliest due date first, tasks without due dates at end) +2. `priority_id DESC` (higher priority first) +3. `created_at DESC` (newest first) + +## Excluded Tasks + +The following tasks are excluded from the kanban board entirely: +- **Archived tasks**: `is_archived = true` + +## API Response Example + +```json +{ + "columns": [ + { + "name": "overdue_tasks", + "display_name": "Overdue", + "button_types": ["edit", "cancel", "mark_in_progress"], + "icons": { + "ios": "exclamationmark.triangle", + "android": "Warning" + }, + "color": "#FF3B30", + "tasks": [...], + "count": 2 + }, + // ... other columns + ], + "days_threshold": 30, + "residence_id": "123" +} +``` + +## Code Location + +- **Repository**: `internal/repositories/task_repo.go` + - `GetKanbanData()` - Single residence + - `GetKanbanDataForMultipleResidences()` - Multiple residences (all user's properties) +- **Service**: `internal/services/task_service.go` + - `ListTasks()` - All tasks for user + - `GetTasksByResidence()` - Tasks for specific residence +- **Response DTOs**: `internal/dto/responses/task.go` + - `KanbanBoardResponse` + - `KanbanColumnResponse` diff --git a/internal/admin/dto/requests.go b/internal/admin/dto/requests.go index 03a0a21..85d1bb9 100644 --- a/internal/admin/dto/requests.go +++ b/internal/admin/dto/requests.go @@ -236,6 +236,7 @@ type SubscriptionFilters struct { type UpdateSubscriptionRequest struct { Tier *string `json:"tier" binding:"omitempty,oneof=free premium pro"` AutoRenew *bool `json:"auto_renew"` + IsFree *bool `json:"is_free"` Platform *string `json:"platform" binding:"omitempty,max=20"` SubscribedAt *string `json:"subscribed_at"` ExpiresAt *string `json:"expires_at"` diff --git a/internal/admin/dto/responses.go b/internal/admin/dto/responses.go index 64666f5..06a9a63 100644 --- a/internal/admin/dto/responses.go +++ b/internal/admin/dto/responses.go @@ -258,6 +258,7 @@ type SubscriptionResponse struct { Tier string `json:"tier"` Platform string `json:"platform"` AutoRenew bool `json:"auto_renew"` + IsFree bool `json:"is_free"` SubscribedAt *string `json:"subscribed_at,omitempty"` ExpiresAt *string `json:"expires_at,omitempty"` CancelledAt *string `json:"cancelled_at,omitempty"` diff --git a/internal/admin/handlers/subscription_handler.go b/internal/admin/handlers/subscription_handler.go index 744f125..6ad5b47 100644 --- a/internal/admin/handlers/subscription_handler.go +++ b/internal/admin/handlers/subscription_handler.go @@ -143,6 +143,9 @@ func (h *AdminSubscriptionHandler) Update(c *gin.Context) { if req.AutoRenew != nil { subscription.AutoRenew = *req.AutoRenew } + if req.IsFree != nil { + subscription.IsFree = *req.IsFree + } if err := h.db.Save(&subscription).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update subscription"}) @@ -153,6 +156,44 @@ func (h *AdminSubscriptionHandler) Update(c *gin.Context) { c.JSON(http.StatusOK, h.toSubscriptionResponse(&subscription)) } +// GetByUser handles GET /api/admin/subscriptions/user/:user_id +func (h *AdminSubscriptionHandler) GetByUser(c *gin.Context) { + userID, err := strconv.ParseUint(c.Param("user_id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"}) + return + } + + var subscription models.UserSubscription + err = h.db. + Preload("User"). + Where("user_id = ?", userID). + First(&subscription).Error + + if err != nil { + if err == gorm.ErrRecordNotFound { + // Create a default subscription for the user + subscription = models.UserSubscription{ + UserID: uint(userID), + Tier: models.TierFree, + AutoRenew: true, + IsFree: false, + } + if err := h.db.Create(&subscription).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create subscription"}) + return + } + // Reload with user + h.db.Preload("User").First(&subscription, subscription.ID) + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch subscription"}) + return + } + } + + c.JSON(http.StatusOK, h.toSubscriptionResponse(&subscription)) +} + // GetStats handles GET /api/admin/subscriptions/stats func (h *AdminSubscriptionHandler) GetStats(c *gin.Context) { var total, free, premium, pro int64 @@ -177,6 +218,7 @@ func (h *AdminSubscriptionHandler) toSubscriptionResponse(sub *models.UserSubscr Tier: string(sub.Tier), Platform: sub.Platform, AutoRenew: sub.AutoRenew, + IsFree: sub.IsFree, CreatedAt: sub.CreatedAt.Format("2006-01-02T15:04:05Z"), } diff --git a/internal/admin/routes.go b/internal/admin/routes.go index 4982078..f2433bc 100644 --- a/internal/admin/routes.go +++ b/internal/admin/routes.go @@ -142,6 +142,7 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, deps *Depe { subscriptions.GET("", subscriptionHandler.List) subscriptions.GET("/stats", subscriptionHandler.GetStats) + subscriptions.GET("/user/:user_id", subscriptionHandler.GetByUser) subscriptions.GET("/:id", subscriptionHandler.Get) subscriptions.PUT("/:id", subscriptionHandler.Update) } diff --git a/internal/integration/subscription_is_free_test.go b/internal/integration/subscription_is_free_test.go new file mode 100644 index 0000000..47d779a --- /dev/null +++ b/internal/integration/subscription_is_free_test.go @@ -0,0 +1,373 @@ +package integration + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/treytartt/casera-api/internal/config" + "github.com/treytartt/casera-api/internal/handlers" + "github.com/treytartt/casera-api/internal/middleware" + "github.com/treytartt/casera-api/internal/models" + "github.com/treytartt/casera-api/internal/repositories" + "github.com/treytartt/casera-api/internal/services" + "github.com/treytartt/casera-api/internal/testutil" + "gorm.io/gorm" +) + +// SubscriptionTestApp holds components for subscription integration testing +type SubscriptionTestApp struct { + DB *gorm.DB + Router *gin.Engine + SubscriptionService *services.SubscriptionService + SubscriptionRepo *repositories.SubscriptionRepository +} + +func setupSubscriptionTest(t *testing.T) *SubscriptionTestApp { + gin.SetMode(gin.TestMode) + + db := testutil.SetupTestDB(t) + testutil.SeedLookupData(t, db) + + // Create repositories + userRepo := repositories.NewUserRepository(db) + residenceRepo := repositories.NewResidenceRepository(db) + taskRepo := repositories.NewTaskRepository(db) + contractorRepo := repositories.NewContractorRepository(db) + documentRepo := repositories.NewDocumentRepository(db) + subscriptionRepo := repositories.NewSubscriptionRepository(db) + + // Create config + cfg := &config.Config{ + Security: config.SecurityConfig{ + SecretKey: "test-secret-key-for-subscription-tests", + PasswordResetExpiry: 15 * time.Minute, + ConfirmationExpiry: 24 * time.Hour, + MaxPasswordResetRate: 3, + }, + } + + // Create services + authService := services.NewAuthService(userRepo, cfg) + residenceService := services.NewResidenceService(residenceRepo, userRepo, cfg) + subscriptionService := services.NewSubscriptionService(subscriptionRepo, residenceRepo, taskRepo, contractorRepo, documentRepo) + + // Create handlers + authHandler := handlers.NewAuthHandler(authService, nil, nil) + residenceHandler := handlers.NewResidenceHandler(residenceService, nil, nil) + subscriptionHandler := handlers.NewSubscriptionHandler(subscriptionService) + + // Create router + router := gin.New() + + // Public routes + auth := router.Group("/api/auth") + { + auth.POST("/register", authHandler.Register) + auth.POST("/login", authHandler.Login) + } + + // Protected routes + authMiddleware := middleware.NewAuthMiddleware(db, nil) + api := router.Group("/api") + api.Use(authMiddleware.TokenAuth()) + { + api.GET("/auth/me", authHandler.CurrentUser) + + residences := api.Group("/residences") + { + residences.POST("", residenceHandler.CreateResidence) + } + + subscription := api.Group("/subscription") + { + subscription.GET("/", subscriptionHandler.GetSubscription) + subscription.GET("/status/", subscriptionHandler.GetSubscriptionStatus) + } + } + + return &SubscriptionTestApp{ + DB: db, + Router: router, + SubscriptionService: subscriptionService, + SubscriptionRepo: subscriptionRepo, + } +} + +// Helper to make authenticated requests +func (app *SubscriptionTestApp) makeAuthenticatedRequest(t *testing.T, method, path string, body interface{}, token string) *httptest.ResponseRecorder { + var reqBody []byte + var err error + if body != nil { + reqBody, err = json.Marshal(body) + require.NoError(t, err) + } + + req := httptest.NewRequest(method, path, nil) + if body != nil { + req = httptest.NewRequest(method, path, bytes.NewBuffer(reqBody)) + } + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Token "+token) + } + + w := httptest.NewRecorder() + app.Router.ServeHTTP(w, req) + return w +} + +// Helper to register and login a user, returns token and user ID +func (app *SubscriptionTestApp) registerAndLogin(t *testing.T, username, email, password string) (string, uint) { + // Register + registerBody := map[string]string{ + "username": username, + "email": email, + "password": password, + } + w := app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody, "") + require.Equal(t, http.StatusCreated, w.Code) + + // Login + loginBody := map[string]string{ + "username": username, + "password": password, + } + w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/login", loginBody, "") + require.Equal(t, http.StatusOK, w.Code) + + var loginResp map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &loginResp) + require.NoError(t, err) + + token := loginResp["token"].(string) + userMap := loginResp["user"].(map[string]interface{}) + userID := uint(userMap["id"].(float64)) + + return token, userID +} + +// TestIntegration_IsFreeBypassesLimitations tests that users with IsFree=true +// see limitations_enabled=false regardless of global settings +func TestIntegration_IsFreeBypassesLimitations(t *testing.T) { + app := setupSubscriptionTest(t) + + // Register and login a user + token, userID := app.registerAndLogin(t, "freeuser", "free@test.com", "password123") + + // Enable global limitations - first delete any existing, then create with enabled + app.DB.Where("1=1").Delete(&models.SubscriptionSettings{}) + settings := &models.SubscriptionSettings{EnableLimitations: true} + err := app.DB.Create(settings).Error + require.NoError(t, err) + + // Verify limitations are enabled globally + var verifySettings models.SubscriptionSettings + app.DB.First(&verifySettings) + require.True(t, verifySettings.EnableLimitations, "Global limitations should be enabled") + + // ========== Test 1: Normal user sees limitations_enabled=true ========== + w := app.makeAuthenticatedRequest(t, "GET", "/api/subscription/status/", nil, token) + require.Equal(t, http.StatusOK, w.Code) + + var statusResp map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &statusResp) + require.NoError(t, err) + + assert.True(t, statusResp["limitations_enabled"].(bool), + "Normal user should see limitations_enabled=true when global setting is enabled") + + // ========== Test 2: Set IsFree=true for user ========== + // Get user's subscription + sub, err := app.SubscriptionRepo.GetOrCreate(userID) + require.NoError(t, err) + + // Set IsFree=true + sub.IsFree = true + err = app.SubscriptionRepo.Update(sub) + require.NoError(t, err) + + // ========== Test 3: User with IsFree=true sees limitations_enabled=false ========== + w = app.makeAuthenticatedRequest(t, "GET", "/api/subscription/status/", nil, token) + require.Equal(t, http.StatusOK, w.Code) + + err = json.Unmarshal(w.Body.Bytes(), &statusResp) + require.NoError(t, err) + + assert.False(t, statusResp["limitations_enabled"].(bool), + "User with IsFree=true should see limitations_enabled=false regardless of global setting") +} + +// TestIntegration_IsFreeBypassesCheckLimit tests that IsFree users can create +// resources beyond their tier limits +func TestIntegration_IsFreeBypassesCheckLimit(t *testing.T) { + app := setupSubscriptionTest(t) + + // Register and login a user + _, userID := app.registerAndLogin(t, "limituser", "limit@test.com", "password123") + + // Enable global limitations + settings := &models.SubscriptionSettings{EnableLimitations: true} + app.DB.Where("1=1").Delete(&models.SubscriptionSettings{}) + app.DB.Create(settings) + + // Set free tier limit to 1 property + one := 1 + freeLimits := &models.TierLimits{ + Tier: models.TierFree, + PropertiesLimit: &one, + } + app.DB.Where("tier = ?", models.TierFree).Delete(&models.TierLimits{}) + app.DB.Create(freeLimits) + + // Get user's subscription (should be free tier) + sub, err := app.SubscriptionRepo.GetOrCreate(userID) + require.NoError(t, err) + require.Equal(t, models.TierFree, sub.Tier) + + // ========== Test 1: Normal free user hits limit ========== + // First property should succeed + err = app.SubscriptionService.CheckLimit(userID, "properties") + assert.NoError(t, err, "First property should be allowed") + + // Create a property to use up the limit + residence := &models.Residence{ + Name: "Test Property", + OwnerID: userID, + } + app.DB.Create(residence) + + // Second property should fail + err = app.SubscriptionService.CheckLimit(userID, "properties") + assert.Error(t, err, "Second property should be blocked for normal free user") + assert.Equal(t, services.ErrPropertiesLimitExceeded, err) + + // ========== Test 2: Set IsFree=true ========== + sub.IsFree = true + err = app.SubscriptionRepo.Update(sub) + require.NoError(t, err) + + // ========== Test 3: IsFree user bypasses limit ========== + err = app.SubscriptionService.CheckLimit(userID, "properties") + assert.NoError(t, err, "IsFree user should bypass property limits") + + // Should also bypass other limits + err = app.SubscriptionService.CheckLimit(userID, "tasks") + assert.NoError(t, err, "IsFree user should bypass task limits") + + err = app.SubscriptionService.CheckLimit(userID, "contractors") + assert.NoError(t, err, "IsFree user should bypass contractor limits") + + err = app.SubscriptionService.CheckLimit(userID, "documents") + assert.NoError(t, err, "IsFree user should bypass document limits") +} + +// TestIntegration_IsFreeIndependentOfTier tests that IsFree works regardless of +// the user's subscription tier +func TestIntegration_IsFreeIndependentOfTier(t *testing.T) { + app := setupSubscriptionTest(t) + + // Register and login a user + token, userID := app.registerAndLogin(t, "tieruser", "tier@test.com", "password123") + + // Enable global limitations + settings := &models.SubscriptionSettings{EnableLimitations: true} + app.DB.Where("1=1").Delete(&models.SubscriptionSettings{}) + app.DB.Create(settings) + + // Get user's subscription + sub, err := app.SubscriptionRepo.GetOrCreate(userID) + require.NoError(t, err) + + // ========== Test with Free tier + IsFree ========== + sub.Tier = models.TierFree + sub.IsFree = true + err = app.SubscriptionRepo.Update(sub) + require.NoError(t, err) + + w := app.makeAuthenticatedRequest(t, "GET", "/api/subscription/status/", nil, token) + require.Equal(t, http.StatusOK, w.Code) + + var statusResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &statusResp) + assert.False(t, statusResp["limitations_enabled"].(bool), + "Free tier user with IsFree should see limitations_enabled=false") + + // ========== Test with Pro tier + IsFree ========== + sub.Tier = models.TierPro + sub.IsFree = true + err = app.SubscriptionRepo.Update(sub) + require.NoError(t, err) + + w = app.makeAuthenticatedRequest(t, "GET", "/api/subscription/status/", nil, token) + require.Equal(t, http.StatusOK, w.Code) + + json.Unmarshal(w.Body.Bytes(), &statusResp) + assert.False(t, statusResp["limitations_enabled"].(bool), + "Pro tier user with IsFree should see limitations_enabled=false") + + // ========== Test disabling IsFree restores normal behavior ========== + sub.Tier = models.TierFree + sub.IsFree = false + err = app.SubscriptionRepo.Update(sub) + require.NoError(t, err) + + w = app.makeAuthenticatedRequest(t, "GET", "/api/subscription/status/", nil, token) + require.Equal(t, http.StatusOK, w.Code) + + json.Unmarshal(w.Body.Bytes(), &statusResp) + assert.True(t, statusResp["limitations_enabled"].(bool), + "Free tier user without IsFree should see limitations_enabled=true") +} + +// TestIntegration_IsFreeWhenGlobalLimitationsDisabled tests that IsFree has no +// effect when global limitations are already disabled +func TestIntegration_IsFreeWhenGlobalLimitationsDisabled(t *testing.T) { + app := setupSubscriptionTest(t) + + // Register and login a user + token, userID := app.registerAndLogin(t, "globaluser", "global@test.com", "password123") + + // Disable global limitations + settings := &models.SubscriptionSettings{EnableLimitations: false} + app.DB.Where("1=1").Delete(&models.SubscriptionSettings{}) + app.DB.Create(settings) + + // Get user's subscription + sub, err := app.SubscriptionRepo.GetOrCreate(userID) + require.NoError(t, err) + + // ========== Test 1: Without IsFree, limitations are disabled ========== + sub.IsFree = false + app.SubscriptionRepo.Update(sub) + + w := app.makeAuthenticatedRequest(t, "GET", "/api/subscription/status/", nil, token) + require.Equal(t, http.StatusOK, w.Code) + + var statusResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &statusResp) + assert.False(t, statusResp["limitations_enabled"].(bool), + "When global limitations are disabled, limitations_enabled should be false") + + // ========== Test 2: With IsFree, limitations are still disabled ========== + sub.IsFree = true + app.SubscriptionRepo.Update(sub) + + w = app.makeAuthenticatedRequest(t, "GET", "/api/subscription/status/", nil, token) + require.Equal(t, http.StatusOK, w.Code) + + json.Unmarshal(w.Body.Bytes(), &statusResp) + assert.False(t, statusResp["limitations_enabled"].(bool), + "With IsFree and global limitations disabled, limitations_enabled should be false") + + // Both cases result in the same outcome - no limitations + err = app.SubscriptionService.CheckLimit(userID, "properties") + assert.NoError(t, err, "Should bypass limits when global limitations are disabled") +} diff --git a/internal/models/subscription.go b/internal/models/subscription.go index 7817071..9204434 100644 --- a/internal/models/subscription.go +++ b/internal/models/subscription.go @@ -42,6 +42,9 @@ type UserSubscription struct { // Tracking CancelledAt *time.Time `gorm:"column:cancelled_at" json:"cancelled_at"` Platform string `gorm:"column:platform;size:10" json:"platform"` // ios, android + + // Admin override - bypasses all limitations regardless of global settings + IsFree bool `gorm:"column:is_free;default:false" json:"is_free"` } // TableName returns the table name for GORM diff --git a/internal/services/subscription_service.go b/internal/services/subscription_service.go index 4b8e5a4..a035e26 100644 --- a/internal/services/subscription_service.go +++ b/internal/services/subscription_service.go @@ -95,12 +95,19 @@ func (s *SubscriptionService) GetSubscriptionStatus(userID uint) (*SubscriptionS return nil, err } + // Determine if limitations are enabled for this user + // If user has IsFree flag, always return false (no limitations) + limitationsEnabled := settings.EnableLimitations + if sub.IsFree { + limitationsEnabled = false + } + // Build flattened response (KMM expects subscription fields at top level) resp := &SubscriptionStatusResponse{ AutoRenew: sub.AutoRenew, Limits: limitsMap, Usage: usage, - LimitationsEnabled: settings.EnableLimitations, + LimitationsEnabled: limitationsEnabled, } // Format dates if present @@ -161,7 +168,7 @@ func (s *SubscriptionService) CheckLimit(userID uint, limitType string) error { return err } - // If limitations are disabled, allow everything + // If limitations are disabled globally, allow everything if !settings.EnableLimitations { return nil } @@ -171,6 +178,11 @@ func (s *SubscriptionService) CheckLimit(userID uint, limitType string) error { return err } + // IsFree users bypass all limitations + if sub.IsFree { + return nil + } + // Pro users have unlimited access if sub.IsPro() { return nil diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index 81c1103..bf8c58c 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -48,9 +48,11 @@ func SetupTestDB(t *testing.T) *gorm.DB { &models.APNSDevice{}, &models.GCMDevice{}, &models.UserSubscription{}, + &models.SubscriptionSettings{}, &models.TierLimits{}, &models.FeatureBenefit{}, &models.UpgradeTrigger{}, + &models.Promotion{}, ) require.NoError(t, err) diff --git a/migrations/004_subscription_is_free.down.sql b/migrations/004_subscription_is_free.down.sql new file mode 100644 index 0000000..df2ba91 --- /dev/null +++ b/migrations/004_subscription_is_free.down.sql @@ -0,0 +1,2 @@ +-- Remove is_free column from subscription_usersubscription table +ALTER TABLE subscription_usersubscription DROP COLUMN IF EXISTS is_free; diff --git a/migrations/004_subscription_is_free.up.sql b/migrations/004_subscription_is_free.up.sql new file mode 100644 index 0000000..2a4e504 --- /dev/null +++ b/migrations/004_subscription_is_free.up.sql @@ -0,0 +1,3 @@ +-- Add is_free column to subscription_usersubscription table +-- When true, user bypasses all limitations regardless of global settings +ALTER TABLE subscription_usersubscription ADD COLUMN IF NOT EXISTS is_free BOOLEAN NOT NULL DEFAULT FALSE;
+ When enabled, this user bypasses all tier limitations regardless of subscription status or global settings. +