Sequence Diagram: License Renewal Flow
Purpose: Automatic subscription renewal via Stripe with invoice payment, license extension, and email notifications.
Actors:
- Stripe (subscription scheduler)
- License API (webhook handler)
- PostgreSQL (license updates)
- SendGrid (email service)
Flow: Automatic renewal → Invoice payment → License extension → Success email
Mermaid Sequence Diagram
Step-by-Step Breakdown
1. Successful Renewal (Steps 1-11)
Server-side: Handle successful invoice payment:
# Server-side: Invoice paid webhook handler
async def handle_invoice_paid(invoice):
"""
Handle successful invoice payment.
Updates license expiry and records payment.
"""
from .models import License, LicenseEvent, Payment
from datetime import timedelta
subscription_id = invoice.get('subscription')
amount_paid = invoice.get('amount_paid') # In cents
currency = invoice.get('currency')
invoice_id = invoice.get('id')
# Get subscription to determine billing period
import stripe
subscription = stripe.Subscription.retrieve(subscription_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
# Determine renewal period
plan = subscription['items']['data'][0]['plan']
interval = plan['interval'] # 'month' or 'year'
if interval == 'month':
renewal_days = 30
elif interval == 'year':
renewal_days = 365
else:
logger.error(f"Unknown interval: {interval}")
return
# Extend license expiry
from django.db import transaction
async with transaction.atomic():
# Calculate new expiry (from current expiry, not now)
new_expiry = license_obj.expires_at + timedelta(days=renewal_days)
await license_obj.aupdate(
expires_at=new_expiry,
last_renewed_at=timezone.now()
)
# Record license event
await LicenseEvent.objects.acreate(
license=license_obj,
event_type='renewed',
metadata={
'subscription_id': subscription_id,
'invoice_id': invoice_id,
'amount_paid': amount_paid / 100, # Convert to dollars
'currency': currency,
'renewal_days': renewal_days,
'new_expiry': new_expiry.isoformat()
}
)
# Record payment
await Payment.objects.acreate(
license=license_obj,
tenant=license_obj.tenant,
amount=amount_paid / 100,
currency=currency,
stripe_invoice_id=invoice_id,
stripe_payment_intent_id=invoice.get('payment_intent'),
paid_at=timezone.now(),
status='succeeded'
)
# Send success email
from .email import send_renewal_success_email
await send_renewal_success_email(
email=license_obj.tenant.billing_email,
license_key=license_obj.license_key,
amount_paid=amount_paid / 100,
currency=currency,
next_billing_date=new_expiry
)
# Update metrics
from prometheus_client import Counter, Gauge
renewal_success = Counter(
'renewal_success_total',
'Total successful renewals',
['tier', 'cycle']
)
renewal_success.labels(
tier=license_obj.tier,
cycle=license_obj.subscription_cycle
).inc()
# Update MRR
mrr = Gauge('monthly_recurring_revenue', 'MRR', ['tier'])
tier_price = amount_paid / 100
if interval == 'year':
tier_price = tier_price / 12 # Convert annual to monthly
# MRR already includes this subscription, no change needed
logger.info(
f"License renewed: {license_obj.license_key} "
f"(new expiry: {new_expiry}, amount: ${amount_paid/100:.2f})"
)
2. Failed Payment Handling (Steps 12-18)
Server-side: Handle payment failures:
# Server-side: Payment failed webhook handler
async def handle_payment_failed(invoice):
"""
Handle failed invoice payment.
Stripe retry schedule:
- Attempt 1: Immediate
- Attempt 2: +3 days
- Attempt 3: +5 days (8 days total)
- Attempt 4: +7 days (15 days total)
- Final: Cancel subscription
"""
from .models import License, PaymentFailure
subscription_id = invoice.get('subscription')
invoice_id = invoice.get('id')
attempt_count = invoice.get('attempt_count', 1)
failure_message = invoice.get('last_finalization_error', {}).get('message', 'Unknown error')
# 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
# Record payment failure
await PaymentFailure.objects.acreate(
license=license_obj,
tenant=license_obj.tenant,
stripe_invoice_id=invoice_id,
attempt_count=attempt_count,
failure_reason=failure_message,
failed_at=timezone.now()
)
# Update license status
if attempt_count == 1:
# First failure - mark as past_due
await license_obj.aupdate(payment_status='past_due')
from .email import send_payment_failed_email
await send_payment_failed_email(
email=license_obj.tenant.billing_email,
license_key=license_obj.license_key,
attempt_count=attempt_count,
retry_date=timezone.now() + timedelta(days=3)
)
elif attempt_count == 2:
# Second failure - send urgent reminder
from .email import send_payment_retry_reminder
await send_payment_retry_reminder(
email=license_obj.tenant.billing_email,
license_key=license_obj.license_key,
attempt_count=attempt_count,
retry_date=timezone.now() + timedelta(days=5)
)
elif attempt_count == 3:
# Third failure - send final warning
from .email import send_payment_final_warning
await send_payment_final_warning(
email=license_obj.tenant.billing_email,
license_key=license_obj.license_key,
cancellation_date=timezone.now() + timedelta(days=7)
)
elif attempt_count >= 4:
# Final failure - deactivate license
await deactivate_license_for_nonpayment(license_obj)
logger.warning(
f"Payment failed: {license_obj.license_key} "
f"(attempt: {attempt_count}/4, reason: {failure_message})"
)
async def deactivate_license_for_nonpayment(license: 'License'):
"""
Deactivate license after all payment attempts failed.
Downgrades to Free tier.
"""
from .models import LicenseEvent
from django.db import transaction
old_tier = license.tier
async with transaction.atomic():
# Deactivate license
await license.aupdate(
is_active=False,
tier='free',
max_seats=1,
deactivated_at=timezone.now(),
deactivation_reason='payment_failed'
)
# Record event
await LicenseEvent.objects.acreate(
license=license,
event_type='deactivated',
old_tier=old_tier,
new_tier='free',
metadata={
'reason': 'payment_failed',
'attempts': 4
}
)
# Send cancellation email
from .email import send_subscription_cancelled_email
await send_subscription_cancelled_email(
email=license.tenant.billing_email,
license_key=license.license_key,
reason='payment_failed'
)
# Update metrics
from prometheus_client import Counter
churn = Counter(
'churn_total',
'Total churned subscriptions',
['tier', 'reason']
)
churn.labels(tier=old_tier, reason='payment_failed').inc()
logger.error(
f"License deactivated for nonpayment: {license.license_key} "
f"(tier: {old_tier} → free)"
)
Email Templates
Renewal Success Email
<!-- Email template: renewal_success.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Payment Received - Subscription Renewed</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>✅ Payment Received</h1>
</div>
<div style="padding: 20px;">
<p>Hi there,</p>
<p>Thank you for your payment! Your CODITECT Pro subscription has been renewed.</p>
<h3>Payment Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr style="border-bottom: 1px solid #ddd;">
<td style="padding: 10px;"><strong>Amount:</strong></td>
<td style="padding: 10px;">{{ amount_paid }} {{ currency | upper }}</td>
</tr>
<tr style="border-bottom: 1px solid #ddd;">
<td style="padding: 10px;"><strong>Payment date:</strong></td>
<td style="padding: 10px;">{{ paid_at }}</td>
</tr>
<tr style="border-bottom: 1px solid #ddd;">
<td style="padding: 10px;"><strong>Next billing date:</strong></td>
<td style="padding: 10px;">{{ next_billing_date }}</td>
</tr>
<tr>
<td style="padding: 10px;"><strong>Invoice:</strong></td>
<td style="padding: 10px;">
<a href="{{ invoice_url }}">View invoice</a>
</td>
</tr>
</table>
<p style="margin-top: 20px;">
Your license key: <code>{{ license_key }}</code>
</p>
<div style="text-align: center; margin-top: 30px;">
<a href="https://coditect.ai/billing"
style="background-color: #4CAF50; color: white; padding: 15px 30px;
text-decoration: none; border-radius: 5px; display: inline-block;">
Manage Subscription
</a>
</div>
<p style="margin-top: 30px;">
Questions? Contact our support team at
<a href="mailto:support@coditect.ai">support@coditect.ai</a>
</p>
<p>Thanks for using CODITECT!<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</p>
</div>
</body>
</html>
Payment Failed Email
<!-- Email template: payment_failed.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Payment Failed - Action Required</title>
</head>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background-color: #f44336; color: white; padding: 20px; text-align: center;">
<h1>⚠️ Payment Failed</h1>
</div>
<div style="padding: 20px;">
<p>Hi there,</p>
<p><strong>We were unable to process your payment for CODITECT Pro.</strong></p>
<p>This could be due to:</p>
<ul>
<li>Card expired or declined</li>
<li>Insufficient funds</li>
<li>Bank declined the transaction</li>
</ul>
<p><strong>What happens next?</strong></p>
<p>We'll automatically retry the payment on <strong>{{ retry_date }}</strong>.
To avoid service interruption, please update your payment method now.</p>
<div style="background-color: #fff3cd; border-left: 4px solid #ffc107;
padding: 15px; margin: 20px 0;">
<p style="margin: 0;">
<strong>Attempt {{ attempt_count }} of 4</strong><br>
After 4 failed attempts, your subscription will be cancelled.
</p>
</div>
<div style="text-align: center; margin-top: 30px;">
<a href="https://coditect.ai/billing/payment-method"
style="background-color: #f44336; color: white; padding: 15px 30px;
text-decoration: none; border-radius: 5px; display: inline-block;">
Update Payment Method
</a>
</div>
<p style="margin-top: 30px;">
Need help? Contact us at
<a href="mailto:billing@coditect.ai">billing@coditect.ai</a>
</p>
<p>The CODITECT Team</p>
</div>
<div style="background-color: #f0f0f0; padding: 15px; text-align: center; font-size: 12px;">
<p>CODITECT by AZ1.AI INC</p>
</div>
</body>
</html>
Analytics Queries
-- Renewal success rate
SELECT
DATE_TRUNC('month', paid_at) as month,
COUNT(*) as total_renewals,
SUM(CASE WHEN status = 'succeeded' THEN 1 ELSE 0 END) as successful,
(SUM(CASE WHEN status = 'succeeded' THEN 1 ELSE 0 END)::float / COUNT(*) * 100) as success_rate
FROM payments
WHERE paid_at >= NOW() - INTERVAL '12 months'
GROUP BY DATE_TRUNC('month', paid_at)
ORDER BY month;
-- Payment failure analysis
SELECT
failure_reason,
COUNT(*) as failure_count,
AVG(attempt_count) as avg_attempts
FROM payment_failures
WHERE failed_at >= NOW() - INTERVAL '30 days'
GROUP BY failure_reason
ORDER BY failure_count DESC;
-- Churn due to payment failures
SELECT
DATE(deactivated_at) as date,
COUNT(*) as churned_licenses,
SUM(CASE WHEN deactivation_reason = 'payment_failed' THEN 1 ELSE 0 END) as payment_failures
FROM licenses
WHERE deactivated_at >= NOW() - INTERVAL '90 days'
GROUP BY DATE(deactivated_at)
ORDER BY date;
Related Documentation
- ADR-012: License Expiration and Renewal
- ADR-013: Stripe Integration for Billing
- 06-stripe-checkout-flow.md: Initial subscription
- 09-subscription-cancellation-flow.md: User-initiated cancellation
Last Updated: 2025-11-30 Diagram Type: Sequence (Mermaid) Scope: Billing - License renewal