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:
@@ -7,12 +7,13 @@ import Link from 'next/link';
|
|||||||
import { ArrowLeft, Edit, Trash2, Bell, Mail } from 'lucide-react';
|
import { ArrowLeft, Edit, Trash2, Bell, Mail } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -51,6 +52,26 @@ export function UserDetailClient() {
|
|||||||
enabled: !!userId,
|
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({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: () => usersApi.delete(userId),
|
mutationFn: () => usersApi.delete(userId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -258,6 +279,71 @@ export function UserDetailClient() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* Residences */}
|
||||||
{user.residences && user.residences.length > 0 && (
|
{user.residences && user.residences.length > 0 && (
|
||||||
<Card className="md:col-span-2">
|
<Card className="md:col-span-2">
|
||||||
|
|||||||
@@ -335,6 +335,11 @@ export const subscriptionsApi = {
|
|||||||
return response.data;
|
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> => {
|
update: async (id: number, data: UpdateSubscriptionRequest): Promise<Subscription> => {
|
||||||
const response = await api.put<Subscription>(`/subscriptions/${id}`, data);
|
const response = await api.put<Subscription>(`/subscriptions/${id}`, data);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -472,6 +472,7 @@ export interface Subscription {
|
|||||||
tier: 'free' | 'premium' | 'pro';
|
tier: 'free' | 'premium' | 'pro';
|
||||||
platform: string;
|
platform: string;
|
||||||
auto_renew: boolean;
|
auto_renew: boolean;
|
||||||
|
is_free: boolean;
|
||||||
subscribed_at?: string;
|
subscribed_at?: string;
|
||||||
expires_at?: string;
|
expires_at?: string;
|
||||||
cancelled_at?: string;
|
cancelled_at?: string;
|
||||||
@@ -491,6 +492,7 @@ export interface SubscriptionListParams extends ListParams {
|
|||||||
export interface UpdateSubscriptionRequest {
|
export interface UpdateSubscriptionRequest {
|
||||||
tier?: string;
|
tier?: string;
|
||||||
auto_renew?: boolean;
|
auto_renew?: boolean;
|
||||||
|
is_free?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubscriptionStats {
|
export interface SubscriptionStats {
|
||||||
|
|||||||
214
docs/TASK_KANBAN_LOGIC.md
Normal file
214
docs/TASK_KANBAN_LOGIC.md
Normal 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`
|
||||||
@@ -236,6 +236,7 @@ type SubscriptionFilters struct {
|
|||||||
type UpdateSubscriptionRequest struct {
|
type UpdateSubscriptionRequest struct {
|
||||||
Tier *string `json:"tier" binding:"omitempty,oneof=free premium pro"`
|
Tier *string `json:"tier" binding:"omitempty,oneof=free premium pro"`
|
||||||
AutoRenew *bool `json:"auto_renew"`
|
AutoRenew *bool `json:"auto_renew"`
|
||||||
|
IsFree *bool `json:"is_free"`
|
||||||
Platform *string `json:"platform" binding:"omitempty,max=20"`
|
Platform *string `json:"platform" binding:"omitempty,max=20"`
|
||||||
SubscribedAt *string `json:"subscribed_at"`
|
SubscribedAt *string `json:"subscribed_at"`
|
||||||
ExpiresAt *string `json:"expires_at"`
|
ExpiresAt *string `json:"expires_at"`
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ type SubscriptionResponse struct {
|
|||||||
Tier string `json:"tier"`
|
Tier string `json:"tier"`
|
||||||
Platform string `json:"platform"`
|
Platform string `json:"platform"`
|
||||||
AutoRenew bool `json:"auto_renew"`
|
AutoRenew bool `json:"auto_renew"`
|
||||||
|
IsFree bool `json:"is_free"`
|
||||||
SubscribedAt *string `json:"subscribed_at,omitempty"`
|
SubscribedAt *string `json:"subscribed_at,omitempty"`
|
||||||
ExpiresAt *string `json:"expires_at,omitempty"`
|
ExpiresAt *string `json:"expires_at,omitempty"`
|
||||||
CancelledAt *string `json:"cancelled_at,omitempty"`
|
CancelledAt *string `json:"cancelled_at,omitempty"`
|
||||||
|
|||||||
@@ -143,6 +143,9 @@ func (h *AdminSubscriptionHandler) Update(c *gin.Context) {
|
|||||||
if req.AutoRenew != nil {
|
if req.AutoRenew != nil {
|
||||||
subscription.AutoRenew = *req.AutoRenew
|
subscription.AutoRenew = *req.AutoRenew
|
||||||
}
|
}
|
||||||
|
if req.IsFree != nil {
|
||||||
|
subscription.IsFree = *req.IsFree
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.db.Save(&subscription).Error; err != nil {
|
if err := h.db.Save(&subscription).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update subscription"})
|
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))
|
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
|
// GetStats handles GET /api/admin/subscriptions/stats
|
||||||
func (h *AdminSubscriptionHandler) GetStats(c *gin.Context) {
|
func (h *AdminSubscriptionHandler) GetStats(c *gin.Context) {
|
||||||
var total, free, premium, pro int64
|
var total, free, premium, pro int64
|
||||||
@@ -177,6 +218,7 @@ func (h *AdminSubscriptionHandler) toSubscriptionResponse(sub *models.UserSubscr
|
|||||||
Tier: string(sub.Tier),
|
Tier: string(sub.Tier),
|
||||||
Platform: sub.Platform,
|
Platform: sub.Platform,
|
||||||
AutoRenew: sub.AutoRenew,
|
AutoRenew: sub.AutoRenew,
|
||||||
|
IsFree: sub.IsFree,
|
||||||
CreatedAt: sub.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
CreatedAt: sub.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, deps *Depe
|
|||||||
{
|
{
|
||||||
subscriptions.GET("", subscriptionHandler.List)
|
subscriptions.GET("", subscriptionHandler.List)
|
||||||
subscriptions.GET("/stats", subscriptionHandler.GetStats)
|
subscriptions.GET("/stats", subscriptionHandler.GetStats)
|
||||||
|
subscriptions.GET("/user/:user_id", subscriptionHandler.GetByUser)
|
||||||
subscriptions.GET("/:id", subscriptionHandler.Get)
|
subscriptions.GET("/:id", subscriptionHandler.Get)
|
||||||
subscriptions.PUT("/:id", subscriptionHandler.Update)
|
subscriptions.PUT("/:id", subscriptionHandler.Update)
|
||||||
}
|
}
|
||||||
|
|||||||
373
internal/integration/subscription_is_free_test.go
Normal file
373
internal/integration/subscription_is_free_test.go
Normal 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")
|
||||||
|
}
|
||||||
@@ -42,6 +42,9 @@ type UserSubscription struct {
|
|||||||
// Tracking
|
// Tracking
|
||||||
CancelledAt *time.Time `gorm:"column:cancelled_at" json:"cancelled_at"`
|
CancelledAt *time.Time `gorm:"column:cancelled_at" json:"cancelled_at"`
|
||||||
Platform string `gorm:"column:platform;size:10" json:"platform"` // ios, android
|
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
|
// TableName returns the table name for GORM
|
||||||
|
|||||||
@@ -95,12 +95,19 @@ func (s *SubscriptionService) GetSubscriptionStatus(userID uint) (*SubscriptionS
|
|||||||
return nil, err
|
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)
|
// Build flattened response (KMM expects subscription fields at top level)
|
||||||
resp := &SubscriptionStatusResponse{
|
resp := &SubscriptionStatusResponse{
|
||||||
AutoRenew: sub.AutoRenew,
|
AutoRenew: sub.AutoRenew,
|
||||||
Limits: limitsMap,
|
Limits: limitsMap,
|
||||||
Usage: usage,
|
Usage: usage,
|
||||||
LimitationsEnabled: settings.EnableLimitations,
|
LimitationsEnabled: limitationsEnabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format dates if present
|
// Format dates if present
|
||||||
@@ -161,7 +168,7 @@ func (s *SubscriptionService) CheckLimit(userID uint, limitType string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// If limitations are disabled, allow everything
|
// If limitations are disabled globally, allow everything
|
||||||
if !settings.EnableLimitations {
|
if !settings.EnableLimitations {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -171,6 +178,11 @@ func (s *SubscriptionService) CheckLimit(userID uint, limitType string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsFree users bypass all limitations
|
||||||
|
if sub.IsFree {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Pro users have unlimited access
|
// Pro users have unlimited access
|
||||||
if sub.IsPro() {
|
if sub.IsPro() {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -48,9 +48,11 @@ func SetupTestDB(t *testing.T) *gorm.DB {
|
|||||||
&models.APNSDevice{},
|
&models.APNSDevice{},
|
||||||
&models.GCMDevice{},
|
&models.GCMDevice{},
|
||||||
&models.UserSubscription{},
|
&models.UserSubscription{},
|
||||||
|
&models.SubscriptionSettings{},
|
||||||
&models.TierLimits{},
|
&models.TierLimits{},
|
||||||
&models.FeatureBenefit{},
|
&models.FeatureBenefit{},
|
||||||
&models.UpgradeTrigger{},
|
&models.UpgradeTrigger{},
|
||||||
|
&models.Promotion{},
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|||||||
2
migrations/004_subscription_is_free.down.sql
Normal file
2
migrations/004_subscription_is_free.down.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Remove is_free column from subscription_usersubscription table
|
||||||
|
ALTER TABLE subscription_usersubscription DROP COLUMN IF EXISTS is_free;
|
||||||
3
migrations/004_subscription_is_free.up.sql
Normal file
3
migrations/004_subscription_is_free.up.sql
Normal 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;
|
||||||
Reference in New Issue
Block a user