Skip to main content

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:

  1. Track license expiration dates accurately
  2. Handle monthly and annual subscription cycles
  3. Support grace period for payment failures
  4. Automatic suspension after grace period

Renewal Workflow:

  1. Proactive email notifications (30d, 7d, 1d, expired)
  2. Stripe automatic renewal integration
  3. Webhook-based license extension on successful payment
  4. Manual renewal option for offline/invoice customers

Grace Period:

  1. 7-day grace period after expiration
  2. Warning messages during grace period
  3. License degradation (read-only access) during grace
  4. Full suspension after grace period

User Experience:

  1. Clear expiration date visible in CLI (coditect status)
  2. Email reminders with renewal link
  3. Seamless auto-renewal (no disruption)
  4. Easy manual renewal via Customer Portal

Decision

We will implement automated license expiration and renewal with:

  1. 30-Day Email Notification Sequence (30d, 7d, 1d before expiration)
  2. Stripe Automatic Renewal (webhook-based license extension)
  3. 7-Day Grace Period (degraded mode, warnings, then suspension)
  4. Celery Daily Expiration Check (detect expiring/expired licenses)
  5. 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

  1. Successful Auto-Renewal

    • Create license with expires_at = tomorrow
    • Stripe webhook: invoice.paid
    • Verify license extended by 30 days
  2. Failed Payment → Grace Period

    • Stripe webhook: invoice.payment_failed
    • Verify status = grace
    • Verify grace_period_ends_at = now + 7 days
  3. Grace Period Recovery

    • License in grace period
    • Stripe webhook: invoice.paid (retry succeeded)
    • Verify status = active, grace cleared
  4. Grace Period Expiry → Suspension

    • License in grace for 7+ days
    • Run check_license_expirations task
    • Verify status = suspended
  5. Email Notification Sequence

    • Create license with expires_at = 30 days from now
    • Run task daily, verify emails sent at 30d, 7d, 1d

  • 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