Sequence Diagram: Subscription Cancellation Flow
Purpose: User-initiated subscription cancellation with immediate effect or end-of-period options, retention offers, and downgrade handling.
Actors:
- User (customer cancelling subscription)
- Web Dashboard (React frontend)
- License API (Django on GKE)
- Stripe (subscription management)
- PostgreSQL (license updates)
- SendGrid (email service)
Flow: Cancel request → Retention offer → Stripe cancellation → License downgrade → Confirmation email
Mermaid Sequence Diagram
Step-by-Step Breakdown
1. Cancellation Request (Steps 1-4)
Client-side: Cancellation UI:
// Client-side: Cancellation component
import React, { useState } from 'react';
import axios from 'axios';
interface CancellationDialogProps {
currentPlan: string;
periodEnd: string;
}
const CancellationDialog: React.FC<CancellationDialogProps> = ({
currentPlan,
periodEnd
}) => {
const [cancelType, setCancelType] = useState<'immediate' | 'at_period_end'>('at_period_end');
const [reason, setReason] = useState('');
const [feedback, setFeedback] = useState('');
const handleCancel = async () => {
try {
const token = localStorage.getItem('jwt_token');
const response = await axios.post(
'https://api.coditect.ai/api/v1/billing/cancel',
{
cancel_type: cancelType,
reason,
feedback
},
{
headers: { 'Authorization': `Bearer ${token}` }
}
);
// Check for retention offer
if (response.data.retention_offer) {
const offer = response.data.retention_offer;
const accept = window.confirm(
`Wait! We'd like to offer you ${offer.discount}% off for ${offer.duration} months.\n` +
`Accept this offer?`
);
if (accept) {
await applyRetentionOffer(offer.coupon);
alert('Discount applied! Your subscription continues with 20% off.');
return;
}
}
// Cancellation confirmed
alert(`Subscription cancelled. Access continues until ${periodEnd}`);
window.location.href = '/billing';
} catch (error) {
console.error('Cancellation failed:', error);
alert('Failed to cancel subscription. Please try again.');
}
};
return (
<div className="cancellation-dialog">
<h2>Cancel {currentPlan} Subscription</h2>
<div className="cancel-options">
<label>
<input
type="radio"
value="at_period_end"
checked={cancelType === 'at_period_end'}
onChange={(e) => setCancelType('at_period_end')}
/>
Cancel at period end (keep access until {periodEnd})
</label>
<label>
<input
type="radio"
value="immediate"
checked={cancelType === 'immediate'}
onChange={(e) => setCancelType('immediate')}
/>
Cancel immediately (lose access now)
</label>
</div>
<div className="feedback-section">
<label>
Why are you cancelling?
<select value={reason} onChange={(e) => setReason(e.target.value)}>
<option value="">Select a reason</option>
<option value="too_expensive">Too expensive</option>
<option value="not_using">Not using enough</option>
<option value="missing_features">Missing features</option>
<option value="switching_competitor">Switching to competitor</option>
<option value="other">Other</option>
</select>
</label>
<label>
Feedback (optional):
<textarea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
placeholder="Help us improve..."
/>
</label>
</div>
<div className="actions">
<button onClick={handleCancel} className="btn-danger">
Confirm Cancellation
</button>
<button onClick={() => window.history.back()} className="btn-secondary">
Never mind
</button>
</div>
</div>
);
};
Server-side: Cancellation endpoint:
# Server-side: Cancellation endpoint
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
from apps.licenses.models import Tenant, License, CancellationFeedback, LicenseEvent
# Request/Response Serializers
class RetentionOfferSerializer(serializers.Serializer):
discount = serializers.IntegerField() # Percentage
duration = serializers.IntegerField() # Months
coupon = serializers.CharField()
class CancellationRequestSerializer(serializers.Serializer):
cancel_type = serializers.ChoiceField(choices=['immediate', 'at_period_end'])
reason = serializers.CharField()
feedback = serializers.CharField(required=False, allow_null=True)
class CancellationResponseSerializer(serializers.Serializer):
success = serializers.BooleanField(default=False)
retention_offer = RetentionOfferSerializer(required=False, allow_null=True)
cancels_at = serializers.DateTimeField(required=False, allow_null=True)
access_until = serializers.DateTimeField(required=False, allow_null=True)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def cancel_subscription(request):
"""
Cancel subscription with retention offer logic.
Process:
1. Validate active subscription
2. Evaluate retention offer eligibility
3. If eligible, return offer (don't cancel yet)
4. If declined or not eligible, proceed with cancellation
5. Update Stripe and database
6. Send confirmation email
Returns:
Cancellation confirmation or retention offer
"""
# Validate request data
serializer = CancellationRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{"detail": "Invalid request data", "errors": serializer.errors},
status=status.HTTP_400_BAD_REQUEST
)
cancel_type = serializer.validated_data['cancel_type']
reason = serializer.validated_data['reason']
feedback = serializer.validated_data.get('feedback')
user = request.user
# Get tenant and license
try:
tenant = Tenant.objects.get(owner_id=user.id)
except Tenant.DoesNotExist:
return Response(
{"detail": "Tenant not found"},
status=status.HTTP_404_NOT_FOUND
)
license_obj = License.objects.filter(
tenant=tenant,
stripe_subscription_id__isnull=False,
is_active=True
).first()
if not license_obj:
return Response(
{"detail": "No active subscription"},
status=status.HTTP_404_NOT_FOUND
)
# Evaluate retention offer
retention_offer = evaluate_retention_offer(
license=license_obj,
reason=reason
)
if retention_offer:
# Return offer without cancelling
return Response(
{'retention_offer': retention_offer},
status=status.HTTP_200_OK
)
# Proceed with cancellation
import stripe
if cancel_type == 'immediate':
# Cancel immediately
stripe.Subscription.delete(license_obj.stripe_subscription_id)
cancels_at = timezone.now()
access_until = timezone.now()
else: # at_period_end
# Cancel at period end
subscription = stripe.Subscription.modify(
license_obj.stripe_subscription_id,
cancel_at_period_end=True
)
cancels_at = datetime.fromtimestamp(subscription.cancel_at)
access_until = license_obj.expires_at
# Update license
from django.db import transaction
with transaction.atomic():
license_obj.cancellation_scheduled = True
license_obj.cancels_at = cancels_at
license_obj.cancellation_reason = 'user_requested'
license_obj.save()
# Record feedback
CancellationFeedback.objects.create(
license=license_obj,
tenant=tenant,
reason=reason,
feedback=feedback,
cancelled_at=timezone.now()
)
# Record event
LicenseEvent.objects.create(
license=license_obj,
event_type='cancellation_scheduled',
metadata={
'cancel_type': cancel_type,
'cancels_at': cancels_at.isoformat(),
'reason': reason
}
)
# Send confirmation email
from apps.core.email import send_cancellation_confirmed_email
send_cancellation_confirmed_email(
email=tenant.billing_email,
license_key=license_obj.license_key,
cancels_at=cancels_at,
access_until=access_until
)
# Update metrics
from prometheus_client import Counter
cancellations = Counter(
'cancellations_total',
'Total cancellations',
['tier', 'reason']
)
cancellations.labels(tier=license_obj.tier, reason=reason).inc()
return Response(
{
'success': True,
'cancels_at': cancels_at.isoformat(),
'access_until': access_until.isoformat()
},
status=status.HTTP_200_OK
)
def evaluate_retention_offer(
license: 'License',
reason: str
) -> Optional[RetentionOffer]:
"""
Evaluate if user is eligible for retention offer.
Eligibility criteria:
- Cancel reason is "too_expensive"
- Active for >30 days
- No previous retention offers in last 6 months
- Good payment history
Returns:
RetentionOffer if eligible, None otherwise
"""
# Check cancel reason
if reason != 'too_expensive':
return None
# Check subscription age
subscription_age = (timezone.now() - license.starts_at).days
if subscription_age < 30:
return None # Too new
# Check previous retention offers
from .models import RetentionOfferLog
previous_offer = RetentionOfferLog.objects.filter(
license=license,
offered_at__gte=timezone.now() - timedelta(days=180)
).exists()
if previous_offer:
return None # Already offered recently
# Check payment history
from .models import PaymentFailure
payment_failures = PaymentFailure.objects.filter(
license=license,
failed_at__gte=timezone.now() - timedelta(days=90)
).count()
if payment_failures > 0:
return None # Bad payment history
# Eligible for 20% off for 3 months
return RetentionOffer(
discount=20,
duration=3,
coupon='RETAIN20'
)
2. Subscription Deleted Webhook (Steps 13-17)
Server-side: Handle subscription deletion:
# Server-side: Subscription deleted webhook
async def handle_subscription_deleted(subscription):
"""
Handle subscription deletion (end of period).
Downgrades license to Free tier.
"""
from .models import License, LicenseEvent
subscription_id = subscription.get('id')
# Get license
license_obj = await License.objects.filter(
stripe_subscription_id=subscription_id
).afirst()
if not license_obj:
logger.error(f"License not found for subscription: {subscription_id}")
return
old_tier = license_obj.tier
# Downgrade to Free tier
from django.db import transaction
async with transaction.atomic():
await license_obj.aupdate(
tier='free',
max_seats=1,
is_active=True, # Still active, just Free tier
stripe_subscription_id=None,
stripe_customer_id=None,
subscription_cycle=None,
cancellation_scheduled=False,
cancels_at=None
)
# Record event
await LicenseEvent.objects.acreate(
license=license_obj,
event_type='downgraded',
old_tier=old_tier,
new_tier='free',
metadata={
'reason': 'subscription_ended',
'subscription_id': subscription_id
}
)
# Send subscription ended email
from .email import send_subscription_ended_email
await send_subscription_ended_email(
email=license_obj.tenant.billing_email,
license_key=license_obj.license_key,
old_tier=old_tier
)
# Update metrics
from prometheus_client import Counter, Gauge
churn = Counter(
'churn_total',
'Total churned subscriptions',
['tier', 'reason']
)
churn.labels(tier=old_tier, reason='user_cancelled').inc()
# Update MRR (decrease)
mrr = Gauge('monthly_recurring_revenue', 'MRR', ['tier'])
tier_prices = {'pro': 49, 'team': 199, 'enterprise': 999}
tier_price = tier_prices.get(old_tier, 0)
mrr.labels(tier=old_tier).dec(tier_price)
logger.info(
f"Subscription ended: {license_obj.license_key} "
f"({old_tier} → free)"
)
Cancellation Analytics
-- Cancellation reasons breakdown
SELECT
reason,
COUNT(*) as count,
(COUNT(*)::float / SUM(COUNT(*)) OVER () * 100) as percentage
FROM cancellation_feedback
WHERE cancelled_at >= NOW() - INTERVAL '30 days'
GROUP BY reason
ORDER BY count DESC;
-- Retention offer effectiveness
SELECT
offered,
accepted,
(accepted::float / NULLIF(offered, 0) * 100) as acceptance_rate
FROM (
SELECT
COUNT(*) as offered,
SUM(CASE WHEN accepted = TRUE THEN 1 ELSE 0 END) as accepted
FROM retention_offer_log
WHERE offered_at >= NOW() - INTERVAL '90 days'
) subquery;
-- Churn rate by tier
SELECT
l.tier,
COUNT(DISTINCT l.id) as total_subscriptions,
SUM(CASE WHEN cf.id IS NOT NULL THEN 1 ELSE 0 END) as cancelled,
(SUM(CASE WHEN cf.id IS NOT NULL THEN 1 ELSE 0 END)::float / COUNT(DISTINCT l.id) * 100) as churn_rate
FROM licenses l
LEFT JOIN cancellation_feedback cf ON l.id = cf.license_id
AND cf.cancelled_at >= NOW() - INTERVAL '30 days'
WHERE l.starts_at >= NOW() - INTERVAL '30 days'
GROUP BY l.tier;
Related Documentation
- ADR-012: License Expiration and Renewal
- ADR-013: Stripe Integration for Billing
- 08-license-renewal-flow.md: Subscription renewal
Last Updated: 2025-11-30 Diagram Type: Sequence (Mermaid) Scope: Billing - Subscription cancellation