Add IsFree subscription toggle to bypass all tier limitations

- Add IsFree boolean field to UserSubscription model
- When IsFree is true, user sees limitations_enabled=false regardless of global setting
- CheckLimit() bypasses all limit checks for IsFree users
- Add admin endpoint GET /api/admin/subscriptions/user/:user_id
- Add IsFree toggle to admin user detail page under Subscription card
- Add database migration 004_subscription_is_free
- Add integration tests for IsFree functionality
- Add task kanban categorization documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-01 18:05:41 -06:00
parent 0a708c092d
commit 0c86611a10
14 changed files with 750 additions and 3 deletions

View File

@@ -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() {
</CardContent>
</Card>
{/* Subscription */}
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Subscription</CardTitle>
<CardDescription>User subscription and access settings</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<div className="text-sm font-medium text-muted-foreground">
Tier
</div>
<Badge variant={subscription?.tier === 'pro' ? 'default' : 'secondary'}>
{subscription?.tier || 'free'}
</Badge>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">
Platform
</div>
<div>{subscription?.platform || '-'}</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">
Auto Renew
</div>
<Badge variant={subscription?.auto_renew ? 'default' : 'outline'}>
{subscription?.auto_renew ? 'Yes' : 'No'}
</Badge>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">
Expires
</div>
<div>
{subscription?.expires_at
? new Date(subscription.expires_at).toLocaleDateString()
: '-'}
</div>
</div>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="is-free" className="text-base">
Free Access (No Limitations)
</Label>
<p className="text-sm text-muted-foreground">
When enabled, this user bypasses all tier limitations regardless of subscription status or global settings.
</p>
</div>
<Switch
id="is-free"
checked={subscription?.is_free ?? false}
onCheckedChange={(checked) => {
updateSubscriptionMutation.mutate({ is_free: checked });
}}
disabled={updateSubscriptionMutation.isPending}
/>
</div>
</CardContent>
</Card>
{/* Residences */}
{user.residences && user.residences.length > 0 && (
<Card className="md:col-span-2">

View File

@@ -335,6 +335,11 @@ export const subscriptionsApi = {
return response.data;
},
getByUser: async (userId: number): Promise<Subscription> => {
const response = await api.get<Subscription>(`/subscriptions/user/${userId}`);
return response.data;
},
update: async (id: number, data: UpdateSubscriptionRequest): Promise<Subscription> => {
const response = await api.put<Subscription>(`/subscriptions/${id}`, data);
return response.data;

View File

@@ -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 {

214
docs/TASK_KANBAN_LOGIC.md Normal file
View File

@@ -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`

View File

@@ -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"`

View File

@@ -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"`

View File

@@ -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"),
}

View File

@@ -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)
}

View File

@@ -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")
}

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -0,0 +1,2 @@
-- Remove is_free column from subscription_usersubscription table
ALTER TABLE subscription_usersubscription DROP COLUMN IF EXISTS is_free;

View File

@@ -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;