Sequence Diagram: Trial License Activation Flow
Purpose: 14-day Pro trial activation workflow with validation, activation, and conversion tracking.
Actors:
- User (new CODITECT user)
- Web Dashboard (React frontend)
- License API (Django on GKE)
- PostgreSQL (license and trial tracking)
- SendGrid (email service)
Flow: Trial request → Validation → License upgrade → Welcome email → Trial tracking
Mermaid Sequence Diagram
Step-by-Step Breakdown
1. Trial Validation (Steps 3-4)
Server-side: Trial eligibility checks:
# Server-side: Trial validation
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status, serializers
from django.utils import timezone
from datetime import datetime, timedelta
from apps.licenses.models import Tenant, License, TrialRequest
# Response Serializer
class TrialStartResponseSerializer(serializers.Serializer):
success = serializers.BooleanField()
trial_ends_at = serializers.DateTimeField()
features = serializers.ListField(child=serializers.CharField())
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def start_trial(request):
"""
Start 14-day Pro trial.
Eligibility requirements:
- No previous trial
- Account >24 hours old
- Not disposable email
- No abuse flags
Returns:
Trial activation details
"""
user = request.user
# Get tenant
try:
tenant = Tenant.objects.get(owner_id=user.id)
except Tenant.DoesNotExist:
return Response(
{"detail": "Tenant not found"},
status=status.HTTP_404_NOT_FOUND
)
# Check 1: Previous trial
previous_trial = TrialRequest.objects.filter(
email=tenant.billing_email
).first()
if previous_trial:
return Response(
{"detail": "Trial already used for this email address"},
status=status.HTTP_429_TOO_MANY_REQUESTS
)
# Check 2: Account age (must be >24 hours)
account_age = timezone.now() - tenant.created_at
if account_age < timedelta(hours=24):
retry_after = tenant.created_at + timedelta(hours=24)
return Response(
{
'error': 'Account too new',
'retry_after': retry_after.isoformat()
},
status=status.HTTP_403_FORBIDDEN
)
# Check 3: Disposable email
if is_disposable_email(tenant.billing_email):
return Response(
{"detail": "Disposable email addresses not allowed"},
status=status.HTTP_403_FORBIDDEN
)
# Check 4: Abuse flags (simplified - would check fraud detection service)
# if tenant.abuse_flags > 0:
# return Response({"detail": "Account flagged"}, status=status.HTTP_403_FORBIDDEN)
# Get current license
current_license = License.objects.filter(
tenant=tenant
).order_by('-created_at').first()
if not current_license:
return Response(
{"detail": "License not found"},
status=status.HTTP_404_NOT_FOUND
)
if current_license.tier != 'free':
return Response(
{"detail": f'Already on {current_license.tier} tier'},
status=status.HTTP_400_BAD_REQUEST
)
# Activate trial
trial_ends_at = timezone.now() + timedelta(days=14)
activate_trial(
license=current_license,
tenant=tenant,
trial_ends_at=trial_ends_at
)
return Response(
{
'success': True,
'trial_ends_at': trial_ends_at.isoformat(),
'features': [
'10 concurrent seats',
'Priority support',
'Advanced features',
'Team collaboration'
]
},
status=status.HTTP_200_OK
)
def is_disposable_email(email: str) -> bool:
"""
Check if email is from disposable provider.
Uses static list + API check (simplified).
"""
disposable_domains = [
'guerrillamail.com',
'mailinator.com',
'tempmail.com',
'10minutemail.com',
'throwaway.email'
]
domain = email.split('@')[1].lower()
return domain in disposable_domains
2. Trial Activation (Steps 6-8)
Server-side: Activate trial:
# Server-side: Trial activation
from datetime import datetime, timedelta
from django.db import transaction
async def activate_trial(
license: 'License',
tenant: 'Tenant',
trial_ends_at: datetime
):
"""
Activate Pro trial for tenant.
Process:
1. Create trial request record
2. Upgrade license to Pro trial
3. Record license event
4. Send welcome email
5. Update metrics
"""
from .models import TrialRequest, LicenseEvent
async with transaction.atomic():
# Step 1: Record trial request
trial_request = await TrialRequest.objects.acreate(
tenant=tenant,
email=tenant.billing_email,
tier='pro',
requested_at=datetime.utcnow(),
ip_address=get_client_ip(), # From request context
user_agent=get_user_agent()
)
# Step 2: Upgrade license to Pro trial
await license.aupdate(
tier='pro_trial',
max_seats=10,
trial_starts_at=datetime.utcnow(),
trial_ends_at=trial_ends_at,
is_trial=True
)
# Step 3: Record license event
await LicenseEvent.objects.acreate(
license=license,
event_type='trial_started',
old_tier='free',
new_tier='pro_trial',
metadata={
'trial_duration_days': 14,
'trial_ends_at': trial_ends_at.isoformat()
}
)
# Step 4: Send welcome email
from .email import send_trial_welcome_email
await send_trial_welcome_email(
email=tenant.billing_email,
license_key=license.license_key,
trial_ends_at=trial_ends_at
)
# Step 5: Update metrics
from prometheus_client import Counter
trial_activations = Counter(
'trial_activations_total',
'Total trial activations',
['tier']
)
trial_activations.labels(tier='pro').inc()
logger.info(
f"Trial activated: {license.license_key} "
f"(tenant: {tenant.id}, expires: {trial_ends_at})"
)
3. Trial Expiration Handling (Steps 14-15)
Server-side: Celery tasks for trial lifecycle:
# Server-side: Trial expiration tasks
from celery import shared_task
from datetime import datetime, timedelta
@shared_task(name='licenses.tasks.send_trial_expiring_reminders')
def send_trial_expiring_reminders():
"""
Send reminder emails to trials expiring in 2 days.
Schedule: Daily at 9:00 AM UTC
"""
from .models import License
# Find trials expiring in 48 hours
expiring_soon = datetime.utcnow() + timedelta(hours=48)
expiring_cutoff = datetime.utcnow() + timedelta(hours=72)
trials = License.objects.filter(
is_trial=True,
trial_ends_at__gte=expiring_soon,
trial_ends_at__lt=expiring_cutoff
).select_related('tenant')
for license in trials:
# Check if reminder already sent
from .models import EmailLog
reminder_sent = EmailLog.objects.filter(
license=license,
email_type='trial_expiring',
sent_at__gte=expiring_soon - timedelta(days=1)
).exists()
if reminder_sent:
continue # Already sent
# Send reminder email
from .email import send_trial_expiring_email
send_trial_expiring_email(
email=license.tenant.billing_email,
license_key=license.license_key,
trial_ends_at=license.trial_ends_at
)
# Log email sent
EmailLog.objects.create(
license=license,
email_type='trial_expiring',
sent_at=datetime.utcnow()
)
logger.info(f"Trial expiring reminder sent: {license.license_key}")
@shared_task(name='licenses.tasks.expire_trials')
def expire_trials():
"""
Expire trials that have ended.
Schedule: Hourly
"""
from .models import License, LicenseEvent
# Find expired trials
expired_trials = License.objects.filter(
is_trial=True,
trial_ends_at__lt=datetime.utcnow()
).select_related('tenant')
for license in expired_trials:
# Downgrade to Free tier
old_tier = license.tier
license.tier = 'free'
license.max_seats = 1
license.is_trial = False
license.save()
# Record event
LicenseEvent.objects.create(
license=license,
event_type='trial_expired',
old_tier=old_tier,
new_tier='free',
metadata={
'trial_duration_days': (
license.trial_ends_at - license.trial_starts_at
).days
}
)
# Send trial expired email
from .email import send_trial_expired_email
send_trial_expired_email(
email=license.tenant.billing_email,
license_key=license.license_key
)
logger.info(f"Trial expired: {license.license_key}")
# Celery Beat schedule
CELERY_BEAT_SCHEDULE = {
'send-trial-expiring-reminders': {
'task': 'licenses.tasks.send_trial_expiring_reminders',
'schedule': crontab(hour=9, minute=0), # Daily at 9 AM UTC
},
'expire-trials': {
'task': 'licenses.tasks.expire_trials',
'schedule': crontab(minute=0), # Every hour
},
}
4. Email Templates
Trial welcome email:
<!-- Email template: trial_welcome.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Your CODITECT Pro Trial is Active!</title>
</head>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background-color: #4CAF50; color: white; padding: 20px; text-align: center;">
<h1>🎉 Welcome to CODITECT Pro!</h1>
</div>
<div style="padding: 20px;">
<p>Hi there,</p>
<p>Your 14-day Pro trial is now active! You have full access to all Pro features:</p>
<ul>
<li>✅ 10 concurrent seats (vs 1 on Free)</li>
<li>✅ Priority support (24-hour response)</li>
<li>✅ Advanced features and integrations</li>
<li>✅ Team collaboration tools</li>
</ul>
<p><strong>Trial details:</strong></p>
<ul>
<li>Starts: {{ trial_starts_at }}</li>
<li>Ends: {{ trial_ends_at }}</li>
<li>Duration: 14 days</li>
</ul>
<h3>Getting Started</h3>
<ol>
<li>Download CODITECT CLI: <a href="https://coditect.ai/download">coditect.ai/download</a></li>
<li>Authenticate with your license key: <code>{{ license_key }}</code></li>
<li>Explore Pro features: <a href="https://docs.coditect.ai/pro">docs.coditect.ai/pro</a></li>
</ol>
<p style="background-color: #f0f0f0; padding: 15px; border-radius: 5px;">
💡 <strong>Tip:</strong> No credit card required for trial. We'll remind you 2 days before expiry.
</p>
<div style="text-align: center; margin-top: 30px;">
<a href="https://coditect.ai/pricing"
style="background-color: #4CAF50; color: white; padding: 15px 30px;
text-decoration: none; border-radius: 5px; display: inline-block;">
View Pricing
</a>
</div>
<p style="margin-top: 30px;">
Questions? Reply to this email or visit our
<a href="https://support.coditect.ai">support center</a>.
</p>
<p>Happy coding!<br>The CODITECT Team</p>
</div>
<div style="background-color: #f0f0f0; padding: 15px; text-align: center; font-size: 12px;">
<p>CODITECT by AZ1.AI INC<br>
<a href="https://coditect.ai/unsubscribe">Unsubscribe</a></p>
</div>
</body>
</html>
Trial Conversion Tracking
Analytics queries:
-- Trial activation rate
SELECT
DATE(created_at) as date,
COUNT(*) as signups,
SUM(CASE WHEN has_activated_trial THEN 1 ELSE 0 END) as trial_activations,
(SUM(CASE WHEN has_activated_trial THEN 1 ELSE 0 END)::float / COUNT(*) * 100) as activation_rate
FROM tenants
WHERE created_at >= NOW() - INTERVAL '30 days'
GROUP BY DATE(created_at)
ORDER BY date;
-- Trial to paid conversion rate
SELECT
COUNT(*) as total_trials,
SUM(CASE WHEN stripe_subscription_id IS NOT NULL THEN 1 ELSE 0 END) as converted_to_paid,
(SUM(CASE WHEN stripe_subscription_id IS NOT NULL THEN 1 ELSE 0 END)::float / COUNT(*) * 100) as conversion_rate
FROM trial_requests tr
JOIN licenses l ON tr.tenant_id = l.tenant_id
WHERE tr.requested_at >= NOW() - INTERVAL '90 days';
-- Average time to conversion
SELECT
AVG(EXTRACT(EPOCH FROM (l.starts_at - tr.requested_at)) / 86400) as avg_days_to_convert
FROM trial_requests tr
JOIN licenses l ON tr.tenant_id = l.tenant_id
WHERE l.stripe_subscription_id IS NOT NULL
AND tr.requested_at >= NOW() - INTERVAL '90 days';
-- Trial drop-off points
SELECT
CASE
WHEN trial_ends_at < NOW() AND tier = 'free' THEN 'expired_no_conversion'
WHEN trial_ends_at < NOW() AND tier != 'free' THEN 'expired_converted'
WHEN trial_ends_at >= NOW() THEN 'active_trial'
END as status,
COUNT(*) as count
FROM licenses
WHERE is_trial = TRUE OR trial_ends_at IS NOT NULL
GROUP BY status;
Related Documentation
- ADR-014: Trial License Implementation
- ADR-013: Stripe Integration for Billing
- 06-stripe-checkout-flow.md: Upgrade to paid
- 08-license-renewal-flow.md: Post-conversion renewal
Last Updated: 2025-11-30 Diagram Type: Sequence (Mermaid) Scope: Billing - Trial activation