Date Validation Framework
Overview
The date validation framework enforces plan-based historical data access limits. When users request data with date parameters, the system validates that the requested dates are within their plan's limits.
Key Features:
- ✅ Automatic validation against historical_data_days feature
- ✅ Clear error messages with upgrade prompts
- ✅ Reusable across all endpoints
- ✅ Configurable via Django Admin
How It Works
Plan Limits
Each subscription plan has a historical_data_days limit:
| Plan | Historical Data Access |
|---|---|
| FREE | 7 days |
| STARTER | 30 days |
| PROFESSIONAL | 90 days |
| ENTERPRISE | 365 days |
| INTERNAL | Unlimited |
These limits are configured through the Dynamic Feature System and can be modified via Django Admin without code changes.
Validation Flow
1. User sends API request with date parameter (e.g., start_date=2024-01-01)
2. Serializer validates the date
3. DateValidationService checks against workspace's plan limit
4. If valid: Request proceeds
If invalid: Return 400 error with upgrade prompt
Using Date Validation
Method 1: Automatic Validation (Existing Endpoints)
The following endpoints automatically validate dates:
POST /api/aso/android/app-history/- Android app historyPOST /api/aso/apple/app-history/- Apple app history
No additional code needed - validation happens automatically in the serializer.
Method 2: Using DateValidationService Directly
For custom implementations:
from services.billing import DateValidationService
from datetime import date
# Validate a single date
is_valid, error_msg, context = DateValidationService.validate_single_date(
workspace=workspace,
check_date=date(2024, 1, 1)
)
if not is_valid:
return Response(context, status=400)
# Validate a date range
is_valid, error_msg, context = DateValidationService.validate_date_range(
workspace=workspace,
start_date=date(2024, 1, 1),
end_date=date(2024, 1, 31)
)
if not is_valid:
return Response(context, status=400)
Method 3: Using DateValidationMixin (For New Serializers)
Add automatic validation to any serializer with date fields:
from rest_framework import serializers
from workspaces.mixins import DateValidationMixin
class MyCustomSerializer(DateValidationMixin, serializers.Serializer):
start_date = serializers.DateField()
end_date = serializers.DateField()
# Validation happens automatically!
The mixin looks for these field names by default:
- start_date
- from_date
- date
To customize which fields are validated:
class MyCustomSerializer(DateValidationMixin, serializers.Serializer):
custom_date = serializers.DateField()
def get_date_fields_to_validate(self):
return ['custom_date'] # Override default fields
Error Responses
When User Exceeds Limit
Request:
Response (400 Bad Request):
{
"start_date": [
"Date range exceeds your plan limit. Your FREE plan allows access to the last 7 days of data. Requested date is 35 days ago."
],
"error": "Date range exceeds plan limit",
"detail": "Your FREE plan allows access to the last 7 days of data",
"requested_date": "2024-01-01",
"requested_days_ago": 35,
"max_allowed_days": 7,
"cutoff_date": "2024-10-27",
"upgrade_url": "/api/billing/plans/"
}
Error Response Fields
| Field | Description |
|---|---|
error |
Short error description |
detail |
Human-readable explanation |
requested_date |
The date that was requested |
requested_days_ago |
How many days ago the requested date is |
max_allowed_days |
Maximum days allowed for this plan |
cutoff_date |
The earliest date accessible |
upgrade_url |
Link to view available plans |
Configuration
Viewing Current Limits
# Via Django Admin
http://localhost:8000/admin/billing/featureplan/
# Or via Django shell
docker compose run --rm web uv run python manage.py shell
from billing.models import FeaturePlan, Plan, Feature
# Get historical_data_days feature
feature = Feature.objects.get(key='historical_data_days')
# View limits for all plans
for plan in Plan.objects.filter(is_active=True):
fp = FeaturePlan.objects.filter(plan=plan, feature=feature).first()
if fp:
print(f"{plan.name}: {fp.limit_value} days")
Changing Limits
Via Django Admin:
1. Go to http://localhost:8000/admin/billing/featureplan/
2. Find the historical_data_days feature for the desired plan
3. Update the Limit value field
4. Save
Via Django Shell:
from billing.models import FeaturePlan, Plan, Feature
# Update STARTER plan to 45 days
feature = Feature.objects.get(key='historical_data_days')
starter_plan = Plan.objects.get(tier='STARTER')
fp = FeaturePlan.objects.get(plan=starter_plan, feature=feature)
fp.limit_value = 45
fp.save()
print(f"Updated {starter_plan.name} to {fp.limit_value} days")
Via Script:
# update_plan_limits.py
from billing.models import FeaturePlan, Plan, Feature
feature = Feature.objects.get(key='historical_data_days')
# Update multiple plans at once
updates = {
'FREE': 14, # Change from 7 to 14 days
'STARTER': 60, # Change from 30 to 60 days
'PROFESSIONAL': 180, # Change from 90 to 180 days
}
for tier, days in updates.items():
plan = Plan.objects.get(tier=tier)
fp = FeaturePlan.objects.get(plan=plan, feature=feature)
fp.limit_value = days
fp.save()
print(f"✅ {plan.name}: {days} days")
Helper Methods
Get Available Date Ranges for UI
from services.billing import DateValidationService
# Get info about what dates the workspace can access
info = DateValidationService.get_max_date_range(workspace)
# Returns:
{
'plan_name': 'FREE',
'max_days': 7,
'cutoff_date': '2024-10-27',
'today': '2024-11-03',
'is_unlimited': False,
'available_ranges': [
{'label': '24 Hours', 'days': 1},
{'label': '7 Days', 'days': 7}
]
}
Use this to populate dropdown options in your frontend:
// Fetch available ranges
const { available_ranges } = await fetchDateRangeInfo();
// Render dropdown
<select>
{available_ranges.map(range => (
<option value={range.days}>{range.label}</option>
))}
</select>
Get Cutoff Date
from services.billing import DateValidationService
# Get the earliest date accessible
cutoff = DateValidationService.get_cutoff_date(workspace)
# Returns: date(2024, 10, 27) # 7 days ago for FREE plan
Testing
Test Script
# test_date_validation.py
from services.billing import DateValidationService
from workspaces.models import Workspace
from datetime import date, timedelta
workspace = Workspace.objects.first()
# Test dates within limit
today = date.today()
week_ago = today - timedelta(days=7)
is_valid, _, _ = DateValidationService.validate_single_date(workspace, week_ago)
print(f"7 days ago: {'✅ Valid' if is_valid else '❌ Invalid'}")
# Test dates outside limit
month_ago = today - timedelta(days=30)
is_valid, error, context = DateValidationService.validate_single_date(workspace, month_ago)
print(f"30 days ago: {'✅ Valid' if is_valid else '❌ Invalid'}")
if not is_valid:
print(f"Error: {error}")
print(f"Max allowed: {context['max_allowed_days']} days")
Manual API Testing
# Test with valid date (within limit)
curl -X POST http://localhost:8000/api/aso/android/app-history/ \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"app_id": "com.example.app",
"start_date": "2024-10-28"
}'
# Test with invalid date (exceeds limit)
curl -X POST http://localhost:8000/api/aso/android/app-history/ \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"app_id": "com.example.app",
"start_date": "2024-01-01"
}'
Frontend Integration
Show Available Date Ranges
async function loadAvailableDateRanges() {
const response = await fetch('/api/billing/date-ranges/', {
headers: { 'Authorization': `Bearer ${token}` }
});
const { available_ranges, max_days, plan_name } = await response.json();
// Update UI
document.getElementById('plan-info').textContent =
`${plan_name} plan: Access to ${max_days} days of history`;
// Populate date range selector
const selector = document.getElementById('date-range');
available_ranges.forEach(range => {
const option = document.createElement('option');
option.value = range.days;
option.textContent = range.label;
selector.appendChild(option);
});
}
Handle Date Validation Errors
async function fetchHistoricalData(startDate, endDate) {
try {
const response = await fetch('/api/aso/android/app-history/', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
app_id: 'com.example.app',
start_date: startDate,
end_date: endDate
})
});
if (!response.ok) {
const error = await response.json();
if (response.status === 400 && error.error === 'Date range exceeds plan limit') {
// Show upgrade prompt
showUpgradeModal({
message: error.detail,
currentPlan: error.detail.split(' ')[1], // Extract plan name
maxDays: error.max_allowed_days,
requestedDays: error.requested_days_ago,
upgradeUrl: error.upgrade_url
});
return null;
}
throw new Error(error.detail || 'Request failed');
}
return await response.json();
} catch (error) {
console.error('Failed to fetch data:', error);
showError(error.message);
return null;
}
}
Best Practices
1. Always Provide Context in Requests
# In your views
serializer = AndroidAppHistoryRequestSerializer(
data=request.data,
context={'request': request} # ← Required for validation!
)
2. Handle Validation Errors Gracefully
try:
serializer.is_valid(raise_exception=True)
except ValidationError as e:
if 'start_date' in e.detail and 'upgrade_url' in e.detail:
# This is a plan limit error
return Response({
'error': 'Plan limit exceeded',
'message': str(e.detail['start_date'][0]),
'upgrade_info': {
'current_limit': e.detail.get('max_allowed_days'),
'upgrade_url': e.detail.get('upgrade_url')
}
}, status=status.HTTP_402_PAYMENT_REQUIRED)
raise
3. Cache Validation Results
Date validation queries the database. For high-traffic endpoints, cache the cutoff date:
from django.core.cache import cache
def get_cached_cutoff_date(workspace_id):
cache_key = f'date_cutoff:{workspace_id}'
cutoff = cache.get(cache_key)
if cutoff is None:
workspace = Workspace.objects.get(id=workspace_id)
cutoff = DateValidationService.get_cutoff_date(workspace)
cache.set(cache_key, cutoff, 300) # Cache for 5 minutes
return cutoff
4. Provide Clear UI Feedback
- Show available date ranges in date pickers
- Display plan limits prominently
- Offer upgrade path when limits are hit
- Use visual cues (disabled dates, warnings)
Troubleshooting
Issue: Validation Not Working
Check:
1. Request context includes request object
2. User is authenticated
3. User has a workspace
4. Feature historical_data_days exists in database
5. FeaturePlan is configured for the workspace's plan
# Debug script
workspace = Workspace.objects.get(id='...')
print(f"Has subscription: {hasattr(workspace, 'subscription')}")
if hasattr(workspace, 'subscription'):
print(f"Plan: {workspace.subscription.plan.name}")
from billing.models import FeaturePlan, Feature
feature = Feature.objects.get(key='historical_data_days')
fp = FeaturePlan.objects.filter(
plan=workspace.subscription.plan,
feature=feature
).first()
print(f"Limit configured: {fp.limit_value if fp else 'NOT FOUND'}")
Issue: Wrong Cutoff Date
The cutoff is calculated as today - limit_days. Check:
- Server timezone is correct
- historical_data_days value is correct
- No custom logic overriding the calculation
Issue: Validation Too Strict
If default 30-day lookback is too restrictive, adjust serializer defaults:
# In serializer
if not data.get('start_date'):
# Change from 30 days to plan limit
max_days = DateValidationService.get_cutoff_date(workspace)
data['start_date'] = max_days
Summary
The date validation framework provides: - ✅ Automatic enforcement of plan-based limits - ✅ Clear error messages with upgrade prompts - ✅ Flexible configuration via Django Admin - ✅ Reusable across all endpoints - ✅ Easy frontend integration
For questions or issues, see the main billing documentation or feature configuration guide.