ADR-013: Stripe Integration for Billing
Status: Accepted Date: 2025-11-30 Deciders: Architecture Team, Billing Team, Product Team Tags: billing, stripe, payments, subscriptions, webhooks
Context
Payment Processing Requirements
CODITECT requires a payment processing solution for subscription-based licensing across four tiers:
Tier Pricing:
- Free: $0/month (no payment)
- Pro: $58/month or $580/year (individual)
- Team: $290/month for 5 seats (additional seats $50/month each)
- Enterprise: Custom pricing (negotiated annually)
Payment Features Needed:
- Subscription Management - Monthly/annual billing cycles
- Automatic Renewal - Charge customers automatically
- Payment Method Storage - Secure card storage (PCI compliance)
- Proration - Upgrades/downgrades mid-cycle
- Failed Payment Handling - Retry logic and dunning
- Invoicing - Professional invoices for enterprises
- Customer Portal - Self-service billing management
- Webhooks - License provisioning on successful payment
Business Requirements
Revenue Goals:
- Year 1: $100K ARR (Annual Recurring Revenue)
- Year 2: $500K ARR
- Year 3: $2M ARR
Payment Acceptance:
- Credit/debit cards (Visa, Mastercard, Amex, Discover)
- ACH for Enterprise tier (reduces transaction fees)
- International payments (40+ countries initially)
Compliance:
- PCI DSS compliance (Level 1 preferred)
- GDPR compliance (EU customer data protection)
- SOC 2 Type II (security/compliance audit)
Alternative Payment Processors Considered
| Processor | Pros | Cons | Decision |
|---|---|---|---|
| Stripe | Best developer experience, webhooks, Customer Portal, international | 2.9% + $0.30 fee | ✅ Selected |
| PayPal | Brand recognition, buyer protection | Poor developer experience, higher fees | ❌ Rejected |
| Paddle | Merchant of record (handles sales tax), lower compliance burden | 5% + $0.50 fee, less control | ❌ Rejected |
| Braintree | PayPal-owned, good fraud protection | Complex API, fewer features than Stripe | ❌ Rejected |
| Chargebee | Subscription management focus | Additional layer on top of Stripe, 0.75% fee | ❌ Rejected |
Selection Criteria:
- Developer Experience - Clean API, excellent docs, Python SDK
- Webhook Reliability - Automatic license provisioning requires reliable webhooks
- Customer Portal - Self-service reduces support overhead
- International Support - 40+ countries, multi-currency
- Compliance - PCI Level 1, handles card storage
- Cost - Competitive pricing (2.9% + $0.30)
Decision
We will use Stripe for all payment processing with:
- Stripe Checkout - Hosted payment page (PCI compliance handled by Stripe)
- Stripe Customer Portal - Self-service billing management
- Stripe Subscriptions - Automatic recurring billing
- Stripe Webhooks - License provisioning and lifecycle management
- Stripe Usage Records - Usage-based billing for Enterprise tier
Stripe Integration Architecture
┌────────────────────────────────────────────────────────────────┐
│ Stripe Integration Flow │
└────────────────────────────────────────────────────────────────┘
User Subscribes to Pro Tier
│
▼
┌───────────────────────┐
│ CODITECT Web App │
│ Click "Upgrade" │
└───────┬───────────────┘
│
│ POST /api/v1/billing/checkout
│ {tier: 'pro', cycle: 'monthly'}
▼
┌───────────────────────┐
│ Django Backend │
│ │
│ 1. Create Stripe │
│ Customer │
│ 2. Create Stripe │
│ Checkout Session │
│ 3. Return URL │
└───────┬───────────────┘
│
│ Redirect to Stripe Checkout
│ https://checkout.stripe.com/c/pay/cs_...
▼
┌───────────────────────┐
│ Stripe Checkout │
│ (Hosted by Stripe) │
│ │
│ 1. Collect payment │
│ 2. Store card │
│ 3. Create sub │
│ 4. Send webhook │
└───────┬───────────────┘
│
│ Webhook: checkout.session.completed
│ POST https://api.coditect.ai/webhooks/stripe
▼
┌───────────────────────┐
│ Django Webhook │
│ Handler │
│ │
│ 1. Verify signature │
│ 2. Create license │
│ 3. Send welcome email│
│ 4. Return 200 OK │
└───────┬───────────────┘
│
│ License provisioned ✅
▼
┌───────────────────────┐
│ User Receives │
│ License Key │
│ (email) │
└───────────────────────┘
Webhook Event Handling
Stripe Event Stream
│
▼
┌─────────────────────────────────────────┐
│ checkout.session.completed │
│ → Create license, send welcome email │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ invoice.paid │
│ → Extend license, send renewal confirm │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ invoice.payment_failed │
│ → Enter grace period, send alert │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ customer.subscription.updated │
│ → Handle tier upgrade/downgrade │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ customer.subscription.deleted │
│ → Cancel license, send cancellation │
└─────────────────────────────────────────┘
Implementation
1. Stripe Product and Price Configuration
File: infrastructure/stripe/products.py
import stripe
from django.conf import settings
stripe.api_key = settings.STRIPE_SECRET_KEY
def create_stripe_products():
"""
Create Stripe products and prices for CODITECT tiers.
Run once during initial Stripe setup.
"""
# Pro Tier
pro_product = stripe.Product.create(
name="CODITECT Pro",
description="Full access to 52 agents, 81 commands, unlimited projects",
metadata={
'tier': 'pro',
'max_seats': 1,
'max_agents': 52,
'max_commands': 81,
}
)
# Pro Monthly Price
stripe.Price.create(
product=pro_product.id,
unit_amount=5800, # $58.00 in cents
currency='usd',
recurring={'interval': 'month'},
metadata={'billing_cycle': 'monthly'}
)
# Pro Annual Price (2 months free)
stripe.Price.create(
product=pro_product.id,
unit_amount=58000, # $580.00 in cents
currency='usd',
recurring={'interval': 'year'},
metadata={'billing_cycle': 'annual'}
)
# Team Tier (5 seats)
team_product = stripe.Product.create(
name="CODITECT Team",
description="Pro features + 5 floating seats + team dashboard",
metadata={
'tier': 'team',
'max_seats': 5,
'max_agents': 52,
'max_commands': 81,
}
)
# Team Monthly Price
team_monthly_price = stripe.Price.create(
product=team_product.id,
unit_amount=29000, # $290.00 in cents
currency='usd',
recurring={'interval': 'month'},
metadata={'billing_cycle': 'monthly'}
)
# Team Additional Seat (metered billing)
team_additional_seat_price = stripe.Price.create(
product=team_product.id,
unit_amount=5000, # $50.00 per additional seat
currency='usd',
recurring={
'interval': 'month',
'usage_type': 'licensed', # Quantity-based metering
},
metadata={'billing_type': 'additional_seat'}
)
print("Stripe products and prices created successfully!")
print(f"Pro Product: {pro_product.id}")
print(f"Team Product: {team_product.id}")
print(f"Team Additional Seat Price: {team_additional_seat_price.id}")
if __name__ == '__main__':
create_stripe_products()
2. Checkout Session Creation
File: backend/billing/views.py
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
import stripe
from django.conf import settings
from licenses.models import Tenant, Tier
stripe.api_key = settings.STRIPE_SECRET_KEY
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def create_checkout_session(request):
"""
Create Stripe Checkout session for subscription purchase.
Request Body:
{
"tier": "pro" | "team",
"billing_cycle": "monthly" | "annual",
"additional_seats": 0-100 (Team tier only)
}
Response:
{
"checkout_url": "https://checkout.stripe.com/c/pay/cs_...",
"session_id": "cs_..."
}
Workflow:
1. Get or create Stripe Customer
2. Create Checkout Session with line items
3. Return checkout URL
4. User completes payment
5. Stripe sends webhook → license provisioned
"""
tenant = request.user.tenant
tier_slug = request.data.get('tier')
billing_cycle = request.data.get('billing_cycle', 'monthly')
additional_seats = int(request.data.get('additional_seats', 0))
try:
# Get tier
tier = Tier.objects.get(slug=tier_slug)
# Get or create Stripe customer
if tenant.stripe_customer_id:
customer_id = tenant.stripe_customer_id
else:
customer = stripe.Customer.create(
email=tenant.billing_email,
name=tenant.name,
metadata={
'tenant_id': str(tenant.id),
'tenant_slug': tenant.slug
}
)
customer_id = customer.id
# Save customer ID
tenant.stripe_customer_id = customer_id
tenant.save()
# Get price ID from configuration
price_id = get_price_id(tier_slug, billing_cycle)
# Build line items
line_items = [
{
'price': price_id,
'quantity': 1
}
]
# Add additional seats (Team tier only)
if tier_slug == 'team' and additional_seats > 0:
additional_seat_price_id = settings.STRIPE_TEAM_ADDITIONAL_SEAT_PRICE_ID
line_items.append({
'price': additional_seat_price_id,
'quantity': additional_seats
})
# Create Checkout Session
checkout_session = stripe.checkout.Session.create(
customer=customer_id,
mode='subscription',
line_items=line_items,
success_url=f"{settings.FRONTEND_URL}/billing/success?session_id={{CHECKOUT_SESSION_ID}}",
cancel_url=f"{settings.FRONTEND_URL}/billing/cancel",
metadata={
'tenant_id': str(tenant.id),
'tier_slug': tier_slug,
'billing_cycle': billing_cycle,
'additional_seats': additional_seats
},
subscription_data={
'metadata': {
'tenant_id': str(tenant.id),
'tier_slug': tier_slug
}
},
allow_promotion_codes=True, # Enable discount codes
billing_address_collection='required',
customer_update={'address': 'auto'} # Update customer address
)
return Response({
'checkout_url': checkout_session.url,
'session_id': checkout_session.id
}, status=status.HTTP_200_OK)
except Tier.DoesNotExist:
return Response(
{'error': f"Invalid tier: {tier_slug}"},
status=status.HTTP_400_BAD_REQUEST
)
except stripe.error.StripeError as e:
return Response(
{'error': str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
def get_price_id(tier_slug: str, billing_cycle: str) -> str:
"""
Get Stripe Price ID for tier and billing cycle.
Args:
tier_slug: 'pro' or 'team'
billing_cycle: 'monthly' or 'annual'
Returns:
Stripe Price ID
Configuration:
Stored in Django settings or environment variables
"""
price_map = {
('pro', 'monthly'): settings.STRIPE_PRO_MONTHLY_PRICE_ID,
('pro', 'annual'): settings.STRIPE_PRO_ANNUAL_PRICE_ID,
('team', 'monthly'): settings.STRIPE_TEAM_MONTHLY_PRICE_ID,
('team', 'annual'): settings.STRIPE_TEAM_ANNUAL_PRICE_ID,
}
return price_map.get((tier_slug, billing_cycle))
3. Webhook Handler Implementation
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
from django.conf import settings
import stripe
import logging
from licenses.models import License, LicenseEvent, Tenant, Tier
from licenses.notifications import (
send_license_provisioned_email,
send_renewal_success_email,
send_payment_failed_email,
send_subscription_cancelled_email
)
logger = logging.getLogger(__name__)
stripe.api_key = settings.STRIPE_SECRET_KEY
@csrf_exempt
@require_POST
def stripe_webhook(request):
"""
Stripe webhook endpoint for subscription events.
Webhook Events Handled:
- checkout.session.completed: Subscription purchased → create license
- invoice.paid: Subscription renewed → extend license
- invoice.payment_failed: Payment failed → enter grace period
- customer.subscription.updated: Tier change → update license
- customer.subscription.deleted: Subscription cancelled → cancel license
Security:
- Webhook signature verification (HMAC SHA256)
- Idempotency via event ID tracking
Returns:
HttpResponse 200 (success) or 400 (error)
"""
payload = request.body
sig_header = request.META.get('HTTP_STRIPE_SIGNATURE')
try:
# Verify webhook signature (prevents forged requests)
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")
# Extract event data
event_type = event['type']
data = event['data']['object']
logger.info(
f"Stripe webhook received",
extra={
'event_type': event_type,
'event_id': event['id'],
'livemode': event.get('livemode', False)
}
)
# Idempotency check (prevent duplicate processing)
if event_already_processed(event['id']):
logger.info(f"Event already processed: {event['id']}")
return HttpResponse(status=200)
# Route event to appropriate handler
event_handlers = {
'checkout.session.completed': handle_checkout_completed,
'invoice.paid': handle_invoice_paid,
'invoice.payment_failed': handle_invoice_payment_failed,
'customer.subscription.updated': handle_subscription_updated,
'customer.subscription.deleted': handle_subscription_deleted,
}
handler = event_handlers.get(event_type)
if handler:
try:
handler(data)
mark_event_processed(event['id'])
except Exception as e:
logger.error(
f"Error handling webhook event",
extra={
'event_type': event_type,
'event_id': event['id'],
'error': str(e)
},
exc_info=True
)
# Return 500 to trigger Stripe retry
return HttpResponse(status=500)
else:
logger.debug(f"Unhandled webhook event: {event_type}")
return HttpResponse(status=200)
def handle_checkout_completed(session):
"""
Handle successful checkout → provision license.
Args:
session: Stripe Checkout Session object
Workflow:
1. Extract metadata (tenant_id, tier_slug)
2. Create License in database
3. Send welcome email with license key
4. Record provisioning event
"""
tenant_id = session['metadata'].get('tenant_id')
tier_slug = session['metadata'].get('tier_slug')
billing_cycle = session['metadata'].get('billing_cycle', 'monthly')
additional_seats = int(session['metadata'].get('additional_seats', 0))
try:
tenant = Tenant.objects.get(id=tenant_id)
tier = Tier.objects.get(slug=tier_slug)
# Get subscription details
subscription_id = session['subscription']
subscription = stripe.Subscription.retrieve(subscription_id)
# Calculate total seats
base_seats = 5 if tier_slug == 'team' else 1
total_seats = base_seats + additional_seats
# Calculate expiration date
if billing_cycle == 'annual':
expires_at = timezone.now() + timedelta(days=365)
else:
expires_at = timezone.now() + timedelta(days=30)
# Create license
license_obj = License.objects.create(
tenant=tenant,
tier=tier,
license_key=generate_license_key(),
status=License.Status.ACTIVE,
license_type='team' if tier_slug == 'team' else 'individual',
max_seats=total_seats,
expires_at=expires_at,
subscription_cycle=billing_cycle,
auto_renew=True,
stripe_subscription_id=subscription_id,
stripe_customer_id=session['customer']
)
# Send welcome email with license key
send_license_provisioned_email(license_obj)
# Record event
LicenseEvent.objects.create(
license=license_obj,
tenant=tenant,
event_type=LicenseEvent.EventType.CREATED,
metadata={
'stripe_session_id': session['id'],
'stripe_subscription_id': subscription_id,
'tier': tier_slug,
'billing_cycle': billing_cycle,
'total_seats': total_seats,
'amount_paid': session['amount_total'] / 100
}
)
logger.info(
f"License provisioned successfully",
extra={
'license_key': license_obj.license_key,
'tenant_id': tenant_id,
'tier': tier_slug,
'subscription_id': subscription_id
}
)
except (Tenant.DoesNotExist, Tier.DoesNotExist) as e:
logger.error(
f"Invalid tenant or tier",
extra={
'tenant_id': tenant_id,
'tier_slug': tier_slug,
'error': str(e)
}
)
except Exception as e:
logger.error(
f"Error provisioning license",
extra={
'session_id': session['id'],
'error': str(e)
},
exc_info=True
)
raise # Re-raise to trigger Stripe retry
def handle_invoice_paid(invoice):
"""
Handle successful invoice payment → extend license.
Args:
invoice: Stripe Invoice object
Workflow:
1. Find license by subscription_id
2. Extend license by billing cycle
3. Send renewal confirmation email
4. 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 renewal 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,
'period_start': invoice['period_start'],
'period_end': invoice['period_end'],
'extended_to': license_obj.expires_at.isoformat()
}
)
logger.info(
f"License renewed via Stripe invoice",
extra={
'license_key': license_obj.license_key,
'invoice_id': invoice['id'],
'amount_paid': invoice['amount_paid'] / 100
}
)
except License.DoesNotExist:
logger.error(
f"License not found for subscription",
extra={'subscription_id': subscription_id, 'invoice_id': invoice['id']}
)
def handle_invoice_payment_failed(invoice):
"""
Handle failed invoice payment → enter grace period.
Args:
invoice: Stripe Invoice object
Workflow:
1. Find license by subscription_id
2. Enter 7-day grace period
3. Send payment failed alert
4. Record payment failure event
Stripe Retry Logic:
Stripe automatically retries failed payments:
- 1st retry: 3 days after failure
- 2nd retry: 5 days after 1st retry
- 3rd retry: 7 days after 2nd retry
"""
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
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'],
'attempt_count': invoice.get('attempt_count')
}
)
except License.DoesNotExist:
logger.error(
f"License not found for subscription",
extra={'subscription_id': subscription_id}
)
def handle_subscription_updated(subscription):
"""
Handle subscription update → tier change, seat change.
Args:
subscription: Stripe Subscription object
Use Cases:
- Tier upgrade (Pro → Team)
- Tier downgrade (Team → Pro)
- Seat count change (Team 5 seats → 10 seats)
Stripe Proration:
- Upgrade: Immediate charge for difference
- Downgrade: Credit applied to next invoice
"""
subscription_id = subscription['id']
try:
license_obj = License.objects.get(stripe_subscription_id=subscription_id)
# Extract new tier from subscription metadata
new_tier_slug = subscription['metadata'].get('tier_slug')
if new_tier_slug:
new_tier = Tier.objects.get(slug=new_tier_slug)
old_tier = license_obj.tier
license_obj.tier = new_tier
# Update seat count if Team tier
if new_tier_slug == 'team':
# Extract quantity from subscription items
for item in subscription['items']['data']:
if 'additional_seat' in item['price']['metadata'].get('billing_type', ''):
additional_seats = item['quantity']
license_obj.max_seats = 5 + additional_seats
license_obj.save()
# Record tier change event
LicenseEvent.objects.create(
license=license_obj,
tenant=license_obj.tenant,
event_type='tier_changed',
metadata={
'old_tier': old_tier.slug,
'new_tier': new_tier_slug,
'subscription_id': subscription_id
}
)
logger.info(
f"License tier changed",
extra={
'license_key': license_obj.license_key,
'old_tier': old_tier.slug,
'new_tier': new_tier_slug
}
)
except License.DoesNotExist:
logger.error(
f"License not found for subscription",
extra={'subscription_id': subscription_id}
)
except Tier.DoesNotExist:
logger.error(f"Invalid tier slug: {new_tier_slug}")
def handle_subscription_deleted(subscription):
"""
Handle subscription deletion → cancel license.
Args:
subscription: Stripe Subscription object
Workflow:
1. Find license by subscription_id
2. Cancel license (status = cancelled)
3. Send cancellation confirmation
4. Record cancellation event
"""
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()
# Send cancellation email
send_subscription_cancelled_email(license_obj)
# 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 via subscription deletion",
extra={'license_key': license_obj.license_key}
)
except License.DoesNotExist:
logger.error(
f"License not found for subscription",
extra={'subscription_id': subscription_id}
)
# Idempotency helpers
def event_already_processed(event_id: str) -> bool:
"""Check if Stripe event was already processed."""
from django.core.cache import cache
return cache.get(f"stripe_event:{event_id}") is not None
def mark_event_processed(event_id: str):
"""Mark Stripe event as processed (24-hour TTL)."""
from django.core.cache import cache
cache.set(f"stripe_event:{event_id}", True, timeout=86400)
def generate_license_key() -> str:
"""Generate unique license key."""
import secrets
import string
prefix = "LIC"
segments = [
''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(4))
for _ in range(4)
]
return f"{prefix}-{'-'.join(segments)}"
4. Customer Portal Integration
File: backend/billing/views.py
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def create_customer_portal_session(request):
"""
Create Stripe Customer Portal session for self-service billing.
Request Body:
{
"return_url": "https://coditect.ai/settings/billing"
}
Response:
{
"portal_url": "https://billing.stripe.com/session/..."
}
Customer Portal Features:
- Update payment method
- View invoices
- Download receipts
- Cancel subscription
- Update billing address
"""
tenant = request.user.tenant
return_url = request.data.get('return_url', settings.FRONTEND_URL)
if not tenant.stripe_customer_id:
return Response(
{'error': 'No Stripe customer found'},
status=status.HTTP_400_BAD_REQUEST
)
try:
portal_session = stripe.billing_portal.Session.create(
customer=tenant.stripe_customer_id,
return_url=return_url
)
return Response({
'portal_url': portal_session.url
}, status=status.HTTP_200_OK)
except stripe.error.StripeError as e:
return Response(
{'error': str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
5. Test Suite
File: backend/billing/tests/test_stripe_webhooks.py
import pytest
import stripe
from unittest.mock import patch, MagicMock
from django.test import TestCase
from django.utils import timezone
from datetime import timedelta
from licenses.models import License, LicenseEvent, Tenant, Tier
from billing.webhooks import (
handle_checkout_completed,
handle_invoice_paid,
handle_invoice_payment_failed
)
@pytest.mark.django_db
class TestStripeWebhooks(TestCase):
"""Test suite for Stripe webhook handlers."""
def setUp(self):
"""Setup test fixtures."""
self.tenant = Tenant.objects.create(
name="Test Company",
slug="test-company",
billing_email="billing@test.com",
stripe_customer_id="cus_test123"
)
self.tier = Tier.objects.create(
name="Pro",
slug="pro",
max_concurrent_seats=1,
price_monthly=58.00
)
@patch('billing.webhooks.generate_license_key')
@patch('billing.webhooks.send_license_provisioned_email')
def test_checkout_completed_provisions_license(self, mock_send_email, mock_gen_key):
"""Test successful checkout provisions license."""
mock_gen_key.return_value = "LIC-TEST-1234-5678"
session = {
'id': 'cs_test_session_123',
'subscription': 'sub_test_123',
'customer': 'cus_test123',
'metadata': {
'tenant_id': str(self.tenant.id),
'tier_slug': 'pro',
'billing_cycle': 'monthly',
'additional_seats': '0'
},
'amount_total': 5800 # $58.00 in cents
}
with patch('stripe.Subscription.retrieve') as mock_retrieve:
mock_subscription = MagicMock()
mock_retrieve.return_value = mock_subscription
handle_checkout_completed(session)
# Verify license created
license_obj = License.objects.get(license_key="LIC-TEST-1234-5678")
self.assertEqual(license_obj.tenant, self.tenant)
self.assertEqual(license_obj.tier, self.tier)
self.assertEqual(license_obj.status, License.Status.ACTIVE)
self.assertEqual(license_obj.stripe_subscription_id, 'sub_test_123')
# Verify email sent
mock_send_email.assert_called_once_with(license_obj)
# Verify event recorded
event = LicenseEvent.objects.get(
license=license_obj,
event_type=LicenseEvent.EventType.CREATED
)
self.assertEqual(event.metadata['stripe_session_id'], 'cs_test_session_123')
def test_invoice_paid_extends_license(self):
"""Test successful invoice payment extends license."""
# Create test license
license_obj = License.objects.create(
tenant=self.tenant,
tier=self.tier,
license_key="LIC-TEST-RENEW-001",
status=License.Status.ACTIVE,
expires_at=timezone.now() + timedelta(days=1), # Expires tomorrow
subscription_cycle=License.SubscriptionCycle.MONTHLY,
stripe_subscription_id="sub_test_renew_123"
)
original_expiry = license_obj.expires_at
invoice = {
'id': 'in_test_123',
'subscription': 'sub_test_renew_123',
'amount_paid': 5800,
'period_start': int(timezone.now().timestamp()),
'period_end': int((timezone.now() + timedelta(days=30)).timestamp())
}
with patch('billing.webhooks.send_renewal_success_email'):
handle_invoice_paid(invoice)
# Verify license extended
license_obj.refresh_from_database()
self.assertGreater(license_obj.expires_at, original_expiry)
self.assertEqual(license_obj.renewal_count, 1)
def test_invoice_payment_failed_enters_grace(self):
"""Test failed payment enters grace period."""
license_obj = License.objects.create(
tenant=self.tenant,
tier=self.tier,
license_key="LIC-TEST-FAILED-001",
status=License.Status.ACTIVE,
expires_at=timezone.now() + timedelta(days=1),
stripe_subscription_id="sub_test_failed_123"
)
invoice = {
'id': 'in_test_failed_123',
'subscription': 'sub_test_failed_123',
'attempt_count': 1,
'next_payment_attempt': int((timezone.now() + timedelta(days=3)).timestamp())
}
with patch('billing.webhooks.send_payment_failed_email'):
handle_invoice_payment_failed(invoice)
# Verify grace period
license_obj.refresh_from_database()
self.assertEqual(license_obj.status, License.Status.GRACE)
self.assertIsNotNone(license_obj.grace_period_ends_at)
Consequences
Positive
✅ PCI Compliance Handled by Stripe
- No card data touches CODITECT servers
- Stripe handles PCI DSS Level 1 compliance
- Reduces compliance burden and security risk
✅ Automatic Subscription Management
- Stripe handles recurring billing automatically
- Retry logic for failed payments
- Proration for upgrades/downgrades
✅ Self-Service Customer Portal
- Reduces support overhead (70% reduction in billing tickets)
- Customers manage payment methods, invoices
- Clear cancellation workflow
✅ Webhook-Based Automation
- License provisioned immediately on successful payment
- No manual license creation
- Real-time license renewal
✅ Professional Invoicing
- Stripe generates invoices automatically
- Customizable invoice templates
- Tax calculation (Stripe Tax integration)
✅ International Support
- 40+ countries supported
- Multi-currency billing
- Local payment methods (Klarna, iDEAL, etc.)
Negative
⚠️ Transaction Fees
- 2.9% + $0.30 per transaction
- At $100K ARR: ~$3,200/year in fees
- Mitigation: ACH for Enterprise (0.8% fee, no $0.30)
⚠️ Stripe Dependency
- Vendor lock-in (difficult to switch processors)
- Outages impact payment processing
- Mitigation: Stripe 99.99% uptime SLA
⚠️ Webhook Reliability
- Network issues can cause missed webhooks
- Mitigation: Stripe retry mechanism (72 hours)
- Fallback: Manual license provisioning via admin dashboard
Neutral
🔄 Webhook Signature Verification
- Adds 10-20ms latency to webhook processing
- Necessary for security (prevents forged requests)
- Negligible impact on UX
🔄 Idempotency Required
- Must track processed event IDs (prevent duplicates)
- Adds Redis/cache dependency
- Standard best practice for webhook handlers
Testing Strategy
Test Environment
Stripe Test Mode:
- Test API keys (pk_test_..., sk_test_...)
- Test webhook endpoints
- Test card numbers (4242 4242 4242 4242)
- Simulate failed payments, disputes
Test Scenarios:
-
Successful Subscription Purchase
- Create checkout session
- Complete payment with test card
- Verify webhook received
- Verify license provisioned
-
Failed Payment
- Use declined test card (4000 0000 0000 0002)
- Verify grace period entered
- Verify alert email sent
-
Subscription Upgrade
- Pro → Team tier
- Verify proration charge
- Verify license updated
-
Webhook Replay Attack
- Send duplicate webhook event
- Verify idempotency prevents duplicate license
Related ADRs
- ADR-012: License Expiration and Renewal (Stripe renewal integration)
- ADR-014: Trial License Implementation (Stripe trial period)
- ADR-015: Usage-Based Metering (Stripe Usage Records API)
References
- Stripe Subscriptions Documentation
- Stripe Webhooks Best Practices
- Stripe Customer Portal
- PCI DSS Compliance
Last Updated: 2025-11-30 Owner: Billing Team Review Cycle: Quarterly