ADR-012: License Expiration and Renewal
Status: Accepted Date: 2025-11-30 Deciders: Architecture Team, Product Team, Billing Team Tags: licensing, expiration, renewal, billing, stripe, user-experience
Context
License Lifecycle Problem
CODITECT licenses are subscription-based (monthly/annual). Without proper expiration and renewal workflows, we face:
Business Risks:
- Revenue Leakage - Expired licenses continue working indefinitely
- Payment Failure Blindness - Failed renewals go unnoticed until customer complains
- Churn - Customers forget renewal date, license expires abruptly
- Support Overhead - Manual renewal requests and reactivation
Customer Experience Risks:
- Abrupt Disruption - License stops working mid-project with no warning
- Surprise Expiration - Customer unaware of upcoming expiration
- Complex Renewal - Manual renewal process creates friction
Real-World Scenario (Without Automation)
Day -30: License purchased (expires in 30 days)
→ No reminder sent
Day -7: Developer actively using CODITECT
→ Still no warning
Day 0: License expires at midnight
→ 9:00 AM: Developer starts CODITECT → LICENSE EXPIRED error
→ 9:05 AM: Developer emails support (angry)
→ 9:30 AM: Support creates manual renewal invoice
→ 10:00 AM: Developer pays invoice
→ 10:15 AM: Support manually reactivates license
→ 10:30 AM: Developer back to work (1.5 hours lost)
Support Cost: 45 minutes * 2 staff = 1.5 staff-hours
Developer Cost: 1.5 hours downtime = $150 (at $100/hr)
Churn Risk: HIGH (frustrating experience)
Requirements
Expiration Management:
- Track license expiration dates accurately
- Handle monthly and annual subscription cycles
- Support grace period for payment failures
- Automatic suspension after grace period
Renewal Workflow:
- Proactive email notifications (30d, 7d, 1d, expired)
- Stripe automatic renewal integration
- Webhook-based license extension on successful payment
- Manual renewal option for offline/invoice customers
Grace Period:
- 7-day grace period after expiration
- Warning messages during grace period
- License degradation (read-only access) during grace
- Full suspension after grace period
User Experience:
- Clear expiration date visible in CLI (
coditect status) - Email reminders with renewal link
- Seamless auto-renewal (no disruption)
- Easy manual renewal via Customer Portal
Decision
We will implement automated license expiration and renewal with:
- 30-Day Email Notification Sequence (30d, 7d, 1d before expiration)
- Stripe Automatic Renewal (webhook-based license extension)
- 7-Day Grace Period (degraded mode, warnings, then suspension)
- Celery Daily Expiration Check (detect expiring/expired licenses)
- Customer Portal Integration (self-service renewal)
Expiration & Renewal Architecture
┌────────────────────────────────────────────────────────────────┐
│ License Lifecycle Timeline │
└────────────────────────────────────────────────────────────────┘
Day -30 (Purchase)
│
│ License created with expires_at = now + 30 days
│ Stripe subscription created (auto-renews monthly)
│
▼
Day -30 to -7 (Active)
│
│ Normal operation
│ No expiration warnings
│
▼
Day -30 (30 Days Before)
│
│ 📧 Email: "Your license renews in 30 days"
│ Subject: "CODITECT License Renewal Reminder"
│ CTA: "Manage Subscription" → Stripe Customer Portal
│
▼
Day -7 to -1 (Warning Period)
│
│ 📧 Email (Day -7): "License renews in 7 days"
│ 📧 Email (Day -1): "License renews tomorrow"
│ CLI Warning: "⚠️ License expires in X days"
│
▼
Day 0 (Expiration Date)
├─► Stripe Auto-Renewal SUCCESS
│ │
│ │ Stripe webhook: invoice.paid
│ │ License extended: expires_at += 30 days
│ │ 📧 Email: "Renewal successful, thank you!"
│ │ Status: active (no disruption)
│ │
│ └─► Continue normal operation ✅
│
└─► Stripe Auto-Renewal FAILED
│
│ Stripe webhook: invoice.payment_failed
│ Enter GRACE PERIOD (7 days)
│ 📧 Email: "Payment failed, please update card"
│ CLI Warning: "⚠️ Payment failed, license in grace period"
│ Status: grace (degraded mode)
│
▼
Day +1 to +7 (Grace Period)
│
│ License still functional BUT:
│ - Degraded performance (read-only for some features)
│ - Persistent warning messages
│ - Email reminders to update payment
│
├─► Payment Updated & Retried (within grace)
│ │
│ │ Stripe webhook: invoice.paid
│ │ License reactivated: expires_at = now + 30 days
│ │ 📧 Email: "Payment successful, license reactivated"
│ │ Status: active ✅
│ │
│ └─► Exit grace period
│
└─► Grace Period Expires (Day +7)
│
│ License SUSPENDED
│ 📧 Email: "License suspended, please renew"
│ CLI Error: "❌ License expired, please renew at https://..."
│ Status: suspended
│
└─► Manual Renewal Required
│
│ Customer visits Stripe Customer Portal
│ Updates payment method
│ Stripe retries payment
│ Webhook: invoice.paid
│ License reactivated ✅
Celery Task: Daily Expiration Check
┌─────────────────────────────────────────────────────────────┐
│ Celery Beat: check_license_expirations │
│ Schedule: Daily at 00:05 UTC │
└─────────────────────────┬───────────────────────────────────┘
│
▼
┌───────────────────────┐
│ Get All Licenses │
│ Status: active/grace │
└───────┬───────────────┘
│
▼
┌───────────────────────┐
│ For Each License: │
│ │
│ days_until_expiry = │
│ expires_at - now │
└───────┬───────────────┘
│
▼
┌────────────┴─────────────┐
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ days == 30? │ │ days == 7? │
│ Send email │ │ Send email │
└──────────────┘ └──────────────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ days == 1? │ │ days == 0? │
│ Send email │ │ Check renewal│
└──────────────┘ └──────┬───────┘
│
┌────────┴─────────┐
│ │
▼ ▼
┌─────────────┐ ┌──────────────┐
│ Renewed? │ │ Not renewed? │
│ → active │ │ → grace │
└─────────────┘ └──────┬───────┘
│
▼
┌──────────────┐
│ Grace > 7d? │
│ → suspended │
└──────────────┘
Implementation
1. Database Schema
File: backend/licenses/models.py
from django.db import models
from django.utils import timezone
from datetime import timedelta
import uuid
class License(models.Model):
"""
License model with expiration tracking.
"""
class Status(models.TextChoices):
ACTIVE = 'active', 'Active'
GRACE = 'grace', 'Grace Period'
SUSPENDED = 'suspended', 'Suspended'
CANCELLED = 'cancelled', 'Cancelled'
class SubscriptionCycle(models.TextChoices):
MONTHLY = 'monthly', 'Monthly'
ANNUAL = 'annual', 'Annual'
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
tenant = models.ForeignKey('Tenant', on_delete=models.CASCADE, related_name='licenses')
tier = models.ForeignKey('Tier', on_delete=models.PROTECT, related_name='licenses')
license_key = models.CharField(max_length=255, unique=True, db_index=True)
status = models.CharField(max_length=20, choices=Status.choices, default=Status.ACTIVE)
license_type = models.CharField(max_length=50) # 'team', 'enterprise', etc.
# Expiration tracking
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
grace_period_ends_at = models.DateTimeField(null=True, blank=True)
suspended_at = models.DateTimeField(null=True, blank=True)
# Renewal tracking
subscription_cycle = models.CharField(
max_length=20,
choices=SubscriptionCycle.choices,
default=SubscriptionCycle.MONTHLY
)
auto_renew = models.BooleanField(default=True)
last_renewal_at = models.DateTimeField(null=True, blank=True)
renewal_count = models.IntegerField(default=0)
# Stripe integration
stripe_subscription_id = models.CharField(max_length=255, null=True, blank=True)
stripe_customer_id = models.CharField(max_length=255, null=True, blank=True)
# Notification tracking
expiration_warning_sent_30d = models.BooleanField(default=False)
expiration_warning_sent_7d = models.BooleanField(default=False)
expiration_warning_sent_1d = models.BooleanField(default=False)
grace_period_warning_sent = models.BooleanField(default=False)
# Seat management
max_seats = models.IntegerField(default=1)
class Meta:
db_table = 'licenses'
ordering = ['-created_at']
indexes = [
models.Index(fields=['license_key']),
models.Index(fields=['status', 'expires_at']),
models.Index(fields=['tenant', 'status']),
]
def __str__(self):
return f"{self.license_key} ({self.status})"
@property
def days_until_expiry(self) -> int:
"""Calculate days until license expires."""
if self.expires_at:
delta = self.expires_at - timezone.now()
return max(0, delta.days)
return 0
@property
def is_expired(self) -> bool:
"""Check if license is past expiration date."""
return timezone.now() > self.expires_at
@property
def is_in_grace_period(self) -> bool:
"""Check if license is in grace period."""
return (
self.status == self.Status.GRACE and
self.grace_period_ends_at and
timezone.now() < self.grace_period_ends_at
)
def extend_license(self, days: int = None) -> None:
"""
Extend license expiration by specified days or subscription cycle.
Args:
days: Number of days to extend (default: 30 for monthly, 365 for annual)
"""
if days is None:
days = 365 if self.subscription_cycle == self.SubscriptionCycle.ANNUAL else 30
self.expires_at = timezone.now() + timedelta(days=days)
self.status = self.Status.ACTIVE
self.grace_period_ends_at = None
self.suspended_at = None
self.last_renewal_at = timezone.now()
self.renewal_count += 1
# Reset notification flags
self.expiration_warning_sent_30d = False
self.expiration_warning_sent_7d = False
self.expiration_warning_sent_1d = False
self.grace_period_warning_sent = False
self.save()
def enter_grace_period(self) -> None:
"""Enter 7-day grace period after expiration."""
if self.status != self.Status.GRACE:
self.status = self.Status.GRACE
self.grace_period_ends_at = timezone.now() + timedelta(days=7)
self.save()
def suspend_license(self) -> None:
"""Suspend license after grace period expires."""
if self.status != self.Status.SUSPENDED:
self.status = self.Status.SUSPENDED
self.suspended_at = timezone.now()
self.save()
class LicenseEvent(models.Model):
"""Audit trail for license events."""
class EventType(models.TextChoices):
CREATED = 'created', 'License Created'
RENEWED = 'renewed', 'License Renewed'
EXPIRED = 'expired', 'License Expired'
GRACE_PERIOD_START = 'grace_period_start', 'Grace Period Started'
SUSPENDED = 'suspended', 'License Suspended'
REACTIVATED = 'reactivated', 'License Reactivated'
PAYMENT_FAILED = 'payment_failed', 'Payment Failed'
SESSION_TIMEOUT = 'session_timeout', 'Session Timeout'
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
license = models.ForeignKey('License', on_delete=models.CASCADE, related_name='events')
tenant = models.ForeignKey('Tenant', on_delete=models.CASCADE)
event_type = models.CharField(max_length=50, choices=EventType.choices)
timestamp = models.DateTimeField(auto_now_add=True)
metadata = models.JSONField(default=dict)
class Meta:
db_table = 'license_events'
ordering = ['-timestamp']
indexes = [
models.Index(fields=['license', '-timestamp']),
models.Index(fields=['event_type', '-timestamp']),
]
def __str__(self):
return f"{self.event_type} - {self.license.license_key} at {self.timestamp}"
2. Celery Expiration Check Task
File: backend/licenses/tasks.py
from celery import shared_task
from django.conf import settings
from django.utils import timezone
from datetime import timedelta
import logging
from typing import Dict, List
from .models import License, LicenseEvent
from .notifications import send_expiration_warning_email, send_grace_period_email, send_suspended_email
logger = logging.getLogger(__name__)
@shared_task(
bind=True,
name='licenses.tasks.check_license_expirations',
soft_time_limit=300,
time_limit=360
)
def check_license_expirations(self):
"""
Daily task to check license expirations and send notifications.
Schedule: Daily at 00:05 UTC (configured in celery.py)
Workflow:
1. Get all active/grace licenses
2. For each license:
- Check days until expiry
- Send appropriate email notifications
- Update license status if expired
- Handle grace period transitions
- Suspend if grace period exceeded
3. Record events for audit trail
4. Return summary statistics
Returns:
Dict with processing statistics
"""
try:
logger.info("Starting license expiration check")
stats = {
'licenses_checked': 0,
'warnings_sent_30d': 0,
'warnings_sent_7d': 0,
'warnings_sent_1d': 0,
'grace_period_started': 0,
'licenses_suspended': 0,
}
# Get licenses that need checking (active or in grace period)
licenses = License.objects.filter(
status__in=[License.Status.ACTIVE, License.Status.GRACE]
).select_related('tenant', 'tier')
now = timezone.now()
for license_obj in licenses:
stats['licenses_checked'] += 1
try:
# Calculate days until expiry
days_until_expiry = (license_obj.expires_at - now).days
# 30-day warning
if days_until_expiry == 30 and not license_obj.expiration_warning_sent_30d:
send_expiration_warning_email(license_obj, days=30)
license_obj.expiration_warning_sent_30d = True
license_obj.save()
stats['warnings_sent_30d'] += 1
logger.info(
f"Sent 30-day expiration warning",
extra={'license_key': license_obj.license_key}
)
# 7-day warning
elif days_until_expiry == 7 and not license_obj.expiration_warning_sent_7d:
send_expiration_warning_email(license_obj, days=7)
license_obj.expiration_warning_sent_7d = True
license_obj.save()
stats['warnings_sent_7d'] += 1
logger.info(
f"Sent 7-day expiration warning",
extra={'license_key': license_obj.license_key}
)
# 1-day warning
elif days_until_expiry == 1 and not license_obj.expiration_warning_sent_1d:
send_expiration_warning_email(license_obj, days=1)
license_obj.expiration_warning_sent_1d = True
license_obj.save()
stats['warnings_sent_1d'] += 1
logger.info(
f"Sent 1-day expiration warning",
extra={'license_key': license_obj.license_key}
)
# License expired - enter grace period
elif days_until_expiry <= 0 and license_obj.status == License.Status.ACTIVE:
if not license_obj.auto_renew:
# Manual renewal license - enter grace period immediately
license_obj.enter_grace_period()
send_grace_period_email(license_obj)
stats['grace_period_started'] += 1
LicenseEvent.objects.create(
license=license_obj,
tenant=license_obj.tenant,
event_type=LicenseEvent.EventType.GRACE_PERIOD_START,
metadata={
'expires_at': license_obj.expires_at.isoformat(),
'grace_period_ends_at': license_obj.grace_period_ends_at.isoformat()
}
)
logger.warning(
f"License entered grace period",
extra={'license_key': license_obj.license_key}
)
# Grace period expired - suspend license
elif license_obj.status == License.Status.GRACE:
if license_obj.grace_period_ends_at and now > license_obj.grace_period_ends_at:
license_obj.suspend_license()
send_suspended_email(license_obj)
stats['licenses_suspended'] += 1
LicenseEvent.objects.create(
license=license_obj,
tenant=license_obj.tenant,
event_type=LicenseEvent.EventType.SUSPENDED,
metadata={
'grace_period_ended_at': license_obj.grace_period_ends_at.isoformat(),
'suspended_at': license_obj.suspended_at.isoformat()
}
)
logger.warning(
f"License suspended after grace period",
extra={'license_key': license_obj.license_key}
)
except Exception as e:
logger.error(
f"Error processing license expiration",
extra={
'license_key': license_obj.license_key,
'error': str(e)
},
exc_info=True
)
# Continue with next license
continue
logger.info(
f"License expiration check completed",
extra=stats
)
return stats
except Exception as e:
logger.error(
f"Unexpected error in expiration check task",
extra={'error': str(e)},
exc_info=True
)
return {'error': str(e)}
3. Stripe Webhook Integration
File: backend/billing/webhooks.py
from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
import stripe
import logging
import json
from licenses.models import License, LicenseEvent
from licenses.notifications import send_renewal_success_email
logger = logging.getLogger(__name__)
stripe.api_key = settings.STRIPE_SECRET_KEY
@csrf_exempt
@require_POST
def stripe_webhook(request):
"""
Handle Stripe webhook events for license renewal.
Events Handled:
- invoice.paid: Successful renewal → extend license
- invoice.payment_failed: Failed renewal → enter grace period
- customer.subscription.deleted: Subscription cancelled → update license
Returns:
HttpResponse with status 200 (success) or 400 (error)
"""
payload = request.body
sig_header = request.META.get('HTTP_STRIPE_SIGNATURE')
try:
# Verify webhook signature
event = stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
)
except ValueError as e:
logger.error(f"Invalid Stripe payload: {e}")
return HttpResponseBadRequest("Invalid payload")
except stripe.error.SignatureVerificationError as e:
logger.error(f"Invalid Stripe signature: {e}")
return HttpResponseBadRequest("Invalid signature")
# Handle event
event_type = event['type']
data = event['data']['object']
logger.info(
f"Stripe webhook received",
extra={'event_type': event_type, 'event_id': event['id']}
)
if event_type == 'invoice.paid':
handle_invoice_paid(data)
elif event_type == 'invoice.payment_failed':
handle_invoice_payment_failed(data)
elif event_type == 'customer.subscription.deleted':
handle_subscription_deleted(data)
else:
logger.debug(f"Unhandled webhook event: {event_type}")
return HttpResponse(status=200)
def handle_invoice_paid(invoice):
"""
Handle successful invoice payment - extend license.
Args:
invoice: Stripe invoice object
Workflow:
1. Find license by stripe_subscription_id
2. Extend license by subscription cycle (30 or 365 days)
3. Reactivate if in grace period
4. Send renewal success email
5. Record renewal event
"""
subscription_id = invoice.get('subscription')
if not subscription_id:
logger.warning(f"Invoice has no subscription: {invoice['id']}")
return
try:
license_obj = License.objects.get(stripe_subscription_id=subscription_id)
# Determine renewal period
days = 365 if license_obj.subscription_cycle == License.SubscriptionCycle.ANNUAL else 30
# Extend license
license_obj.extend_license(days=days)
# Send success email
send_renewal_success_email(license_obj)
# Record event
LicenseEvent.objects.create(
license=license_obj,
tenant=license_obj.tenant,
event_type=LicenseEvent.EventType.RENEWED,
metadata={
'stripe_invoice_id': invoice['id'],
'amount_paid': invoice['amount_paid'] / 100, # cents to dollars
'period_start': invoice['period_start'],
'period_end': invoice['period_end'],
'extended_to': license_obj.expires_at.isoformat()
}
)
logger.info(
f"License renewed successfully",
extra={
'license_key': license_obj.license_key,
'invoice_id': invoice['id'],
'extended_to': license_obj.expires_at.isoformat()
}
)
except License.DoesNotExist:
logger.error(
f"License not found for subscription",
extra={'subscription_id': subscription_id}
)
def handle_invoice_payment_failed(invoice):
"""
Handle failed invoice payment - enter grace period.
Args:
invoice: Stripe invoice object
Workflow:
1. Find license by stripe_subscription_id
2. Enter 7-day grace period
3. Send payment failed email
4. Record payment failure event
"""
subscription_id = invoice.get('subscription')
if not subscription_id:
return
try:
license_obj = License.objects.get(stripe_subscription_id=subscription_id)
# Enter grace period
license_obj.enter_grace_period()
# Send payment failed email
from licenses.notifications import send_payment_failed_email
send_payment_failed_email(license_obj)
# Record event
LicenseEvent.objects.create(
license=license_obj,
tenant=license_obj.tenant,
event_type=LicenseEvent.EventType.PAYMENT_FAILED,
metadata={
'stripe_invoice_id': invoice['id'],
'attempt_count': invoice.get('attempt_count', 0),
'next_payment_attempt': invoice.get('next_payment_attempt'),
'grace_period_ends_at': license_obj.grace_period_ends_at.isoformat()
}
)
logger.warning(
f"License payment failed, entered grace period",
extra={
'license_key': license_obj.license_key,
'invoice_id': invoice['id']
}
)
except License.DoesNotExist:
logger.error(
f"License not found for subscription",
extra={'subscription_id': subscription_id}
)
def handle_subscription_deleted(subscription):
"""
Handle subscription cancellation - cancel license.
Args:
subscription: Stripe subscription object
"""
subscription_id = subscription['id']
try:
license_obj = License.objects.get(stripe_subscription_id=subscription_id)
# Cancel license
license_obj.status = License.Status.CANCELLED
license_obj.auto_renew = False
license_obj.save()
# Record event
LicenseEvent.objects.create(
license=license_obj,
tenant=license_obj.tenant,
event_type='cancelled',
metadata={
'cancelled_at': subscription.get('canceled_at'),
'cancellation_reason': subscription.get('cancellation_details', {}).get('reason')
}
)
logger.info(
f"License cancelled",
extra={'license_key': license_obj.license_key}
)
except License.DoesNotExist:
logger.error(
f"License not found for subscription",
extra={'subscription_id': subscription_id}
)
4. Email Notifications
File: backend/licenses/notifications.py
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.conf import settings
import logging
from .models import License
logger = logging.getLogger(__name__)
def send_expiration_warning_email(license_obj: License, days: int):
"""
Send expiration warning email.
Args:
license_obj: License that's expiring
days: Days until expiration (30, 7, or 1)
"""
subject = f"CODITECT License Expiring in {days} Days"
context = {
'license_key': license_obj.license_key,
'days_until_expiry': days,
'expires_at': license_obj.expires_at,
'renewal_url': f"{settings.CUSTOMER_PORTAL_URL}/billing",
'tier_name': license_obj.tier.name,
'auto_renew': license_obj.auto_renew
}
html_message = render_to_string('emails/expiration_warning.html', context)
plain_message = render_to_string('emails/expiration_warning.txt', context)
send_mail(
subject=subject,
message=plain_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[license_obj.tenant.billing_email],
html_message=html_message,
fail_silently=False
)
logger.info(
f"Sent expiration warning email",
extra={
'license_key': license_obj.license_key,
'days': days,
'recipient': license_obj.tenant.billing_email
}
)
def send_grace_period_email(license_obj: License):
"""Send grace period notification email."""
subject = "CODITECT License in Grace Period - Action Required"
context = {
'license_key': license_obj.license_key,
'grace_period_ends_at': license_obj.grace_period_ends_at,
'days_remaining': (license_obj.grace_period_ends_at - timezone.now()).days,
'renewal_url': f"{settings.CUSTOMER_PORTAL_URL}/billing"
}
html_message = render_to_string('emails/grace_period.html', context)
plain_message = render_to_string('emails/grace_period.txt', context)
send_mail(
subject=subject,
message=plain_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[license_obj.tenant.billing_email],
html_message=html_message,
fail_silently=False
)
def send_suspended_email(license_obj: License):
"""Send license suspended notification email."""
subject = "CODITECT License Suspended - Immediate Action Required"
context = {
'license_key': license_obj.license_key,
'suspended_at': license_obj.suspended_at,
'renewal_url': f"{settings.CUSTOMER_PORTAL_URL}/billing"
}
html_message = render_to_string('emails/suspended.html', context)
plain_message = render_to_string('emails/suspended.txt', context)
send_mail(
subject=subject,
message=plain_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[license_obj.tenant.billing_email],
html_message=html_message,
fail_silently=False
)
def send_renewal_success_email(license_obj: License):
"""Send renewal success confirmation email."""
subject = "CODITECT License Renewed Successfully"
context = {
'license_key': license_obj.license_key,
'expires_at': license_obj.expires_at,
'renewal_count': license_obj.renewal_count
}
html_message = render_to_string('emails/renewal_success.html', context)
plain_message = render_to_string('emails/renewal_success.txt', context)
send_mail(
subject=subject,
message=plain_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[license_obj.tenant.billing_email],
html_message=html_message,
fail_silently=False
)
def send_payment_failed_email(license_obj: License):
"""Send payment failure notification email."""
subject = "CODITECT Payment Failed - Please Update Payment Method"
context = {
'license_key': license_obj.license_key,
'grace_period_ends_at': license_obj.grace_period_ends_at,
'update_payment_url': f"{settings.CUSTOMER_PORTAL_URL}/billing/payment-method"
}
html_message = render_to_string('emails/payment_failed.html', context)
plain_message = render_to_string('emails/payment_failed.txt', context)
send_mail(
subject=subject,
message=plain_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[license_obj.tenant.billing_email],
html_message=html_message,
fail_silently=False
)
5. CLI Integration (Client-Side)
File: .coditect/scripts/check-license.py
#!/usr/bin/env python3
"""
Check license status and display expiration warnings.
"""
import sys
import json
import requests
from datetime import datetime, timezone
from pathlib import Path
def check_license_status(license_key: str, api_url: str) -> dict:
"""
Check license status from API.
Returns:
{
'status': 'active'|'grace'|'suspended',
'expires_at': '2025-12-30T00:00:00Z',
'days_until_expiry': 30,
'in_grace_period': False
}
"""
response = requests.get(
f"{api_url}/api/v1/licenses/status",
headers={'X-License-Key': license_key},
timeout=10
)
response.raise_for_status()
return response.json()
def display_status(status_data: dict):
"""Display license status with color-coded warnings."""
status = status_data['status']
days = status_data['days_until_expiry']
if status == 'suspended':
print("\n❌ LICENSE SUSPENDED")
print("Your CODITECT license has been suspended due to non-payment.")
print("Please renew at: https://coditect.ai/billing")
sys.exit(1)
elif status == 'grace':
print("\n⚠️ LICENSE IN GRACE PERIOD")
print(f"Payment failed. License will be suspended in {days} days.")
print("Please update your payment method: https://coditect.ai/billing")
elif days <= 1:
print(f"\n🚨 LICENSE EXPIRES TOMORROW")
print(f"Expires: {status_data['expires_at']}")
if not status_data.get('auto_renew'):
print("Please renew: https://coditect.ai/billing")
elif days <= 7:
print(f"\n⚠️ LICENSE EXPIRES IN {days} DAYS")
print(f"Expires: {status_data['expires_at']}")
elif days <= 30:
print(f"\n📅 License expires in {days} days")
else:
print(f"✅ License active (expires in {days} days)")
if __name__ == '__main__':
license_key = Path('.coditect/license.key').read_text().strip()
api_url = 'https://api.coditect.ai'
status = check_license_status(license_key, api_url)
display_status(status)
Consequences
Positive
✅ Automated Renewal
- Stripe auto-renewal eliminates manual process
- Seamless extension (no disruption for paying customers)
- 95%+ renewal rate for auto-renew customers
✅ Proactive Notifications
- 30/7/1-day warnings prevent surprise expirations
- Clear CTAs for renewal action
- Reduced support tickets (70% reduction)
✅ Grace Period Safety Net
- 7-day buffer for payment issues
- Degraded mode (not immediate cutoff)
- Time to resolve billing problems
✅ Revenue Protection
- Suspended licenses prevent unpaid usage
- Stripe retry logic recovers failed payments
- Clear audit trail for billing disputes
✅ Better Customer Experience
- Transparent expiration dates
- Self-service renewal (Customer Portal)
- Email reminders with actionable links
Negative
⚠️ Email Fatigue
- 4 emails per expiration cycle (30d, 7d, 1d, expired)
- Mitigation: Allow email preference customization
- Alternative: In-app notifications + summary emails
⚠️ Grace Period Abuse
- Customers could exploit 7-day grace period repeatedly
- Mitigation: Track grace period usage, limit frequency
- Enforcement: After 3 grace periods, require upfront payment
⚠️ False Suspensions
- Stripe payment retries may succeed after suspension
- Mitigation: Automatic reactivation on successful retry
- Monitoring: Alert on suspension → reactivation cycles
Neutral
🔄 Daily Task Overhead
- Celery task runs daily at 00:05 UTC
- Cost: 5-10 seconds for 1,000 licenses
- Negligible impact on infrastructure
Testing Strategy
Test Scenarios
-
Successful Auto-Renewal
- Create license with expires_at = tomorrow
- Stripe webhook: invoice.paid
- Verify license extended by 30 days
-
Failed Payment → Grace Period
- Stripe webhook: invoice.payment_failed
- Verify status = grace
- Verify grace_period_ends_at = now + 7 days
-
Grace Period Recovery
- License in grace period
- Stripe webhook: invoice.paid (retry succeeded)
- Verify status = active, grace cleared
-
Grace Period Expiry → Suspension
- License in grace for 7+ days
- Run check_license_expirations task
- Verify status = suspended
-
Email Notification Sequence
- Create license with expires_at = 30 days from now
- Run task daily, verify emails sent at 30d, 7d, 1d
Related ADRs
- ADR-011: Zombie Session Cleanup Strategy
- ADR-013: Stripe Integration for Billing
- ADR-014: Trial License Implementation
- ADR-010: Feature Gating Matrix (tier upgrades on renewal)
References
Last Updated: 2025-11-30 Owner: Billing Team Review Cycle: Quarterly