Skip to content

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 history
  • POST /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:

POST /api/aso/android/app-history/
{
  "app_id": "com.example.app",
  "start_date": "2024-01-01"
}

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.