Introduction
Subscription billing is complex, involving recurring charges, failed payment recovery, proration, and compliance. This guide covers building production subscription systems with robust dunning management, retry logic, and revenue optimization.
Key Statistics:
- Subscription economy growing 30% annually
- Failed payments cost 5-10% of revenue
- Dunning recovery: 30-50% of failed payments
- Billing errors cost $100k+ annually
Core Concepts
1. Subscription
Recurring billing arrangement with customer.
2. Billing Cycle
Period between charges (monthly, annual, etc.).
3. Dunning
Process of recovering failed payments.
4. Proration
Adjusting charges for mid-cycle changes.
5. Retry Logic
Attempting failed payments multiple times.
6. Churn
Customer cancellation rate.
7. MRR
Monthly Recurring Revenue.
8. LTV
Customer Lifetime Value.
9. CAC
Customer Acquisition Cost.
10. Reconciliation
Matching charges with payments.
Subscription Management
from datetime import datetime, timedelta
from enum import Enum
class SubscriptionStatus(Enum):
ACTIVE = "active"
PAUSED = "paused"
CANCELLED = "cancelled"
PAST_DUE = "past_due"
class SubscriptionBillingSystem:
"""Manage subscription billing"""
def __init__(self, db_connection, payment_processor):
self.db = db_connection
self.payment = payment_processor
def create_subscription(self, customer_id: str, plan_id: str,
billing_cycle: str = "monthly") -> dict:
"""Create new subscription"""
plan = self.db.query("SELECT * FROM plans WHERE id = ?", (plan_id,))[0]
subscription = {
'id': f"sub_{datetime.now().timestamp()}",
'customer_id': customer_id,
'plan_id': plan_id,
'status': SubscriptionStatus.ACTIVE.value,
'billing_cycle': billing_cycle,
'amount': plan['price'],
'created_at': datetime.now(),
'next_billing_date': datetime.now() + timedelta(days=30),
'failed_attempts': 0
}
self.db.insert('subscriptions', subscription)
# Charge immediately
self.charge_subscription(subscription['id'])
return subscription
def charge_subscription(self, subscription_id: str) -> dict:
"""Charge subscription"""
subscription = self.db.query(
"SELECT * FROM subscriptions WHERE id = ?",
(subscription_id,)
)[0]
customer = self.db.query(
"SELECT * FROM customers WHERE id = ?",
(subscription['customer_id'],)
)[0]
# Attempt charge
try:
charge = self.payment.charge(
customer['payment_method_id'],
subscription['amount'],
description=f"Subscription {subscription_id}"
)
# Record successful charge
self.db.insert('charges', {
'subscription_id': subscription_id,
'amount': subscription['amount'],
'status': 'succeeded',
'charge_id': charge['id'],
'charged_at': datetime.now()
})
# Update subscription
self.db.update('subscriptions', subscription_id, {
'status': SubscriptionStatus.ACTIVE.value,
'next_billing_date': datetime.now() + timedelta(days=30),
'failed_attempts': 0
})
return {'success': True, 'charge_id': charge['id']}
except Exception as e:
# Record failed charge
self.db.insert('charges', {
'subscription_id': subscription_id,
'amount': subscription['amount'],
'status': 'failed',
'error': str(e),
'charged_at': datetime.now()
})
# Increment failed attempts
failed_attempts = subscription['failed_attempts'] + 1
self.db.update('subscriptions', subscription_id, {
'failed_attempts': failed_attempts,
'status': SubscriptionStatus.PAST_DUE.value if failed_attempts > 0 else SubscriptionStatus.ACTIVE.value
})
return {'success': False, 'error': str(e)}
def cancel_subscription(self, subscription_id: str,
reason: str = None) -> dict:
"""Cancel subscription"""
self.db.update('subscriptions', subscription_id, {
'status': SubscriptionStatus.CANCELLED.value,
'cancelled_at': datetime.now(),
'cancellation_reason': reason
})
return {'success': True}
# Usage
billing = SubscriptionBillingSystem(db, payment_processor)
# Create subscription
sub = billing.create_subscription('cust_123', 'plan_pro')
# Charge subscription
result = billing.charge_subscription(sub['id'])
Dunning Management
class DunningManager:
"""Manage failed payment recovery"""
RETRY_SCHEDULE = [
{'days': 0, 'description': 'Initial charge'},
{'days': 3, 'description': 'First retry'},
{'days': 5, 'description': 'Second retry'},
{'days': 7, 'description': 'Final retry'},
]
def __init__(self, db_connection, payment_processor):
self.db = db_connection
self.payment = payment_processor
def process_failed_payment(self, subscription_id: str):
"""Process failed payment with dunning"""
subscription = self.db.query(
"SELECT * FROM subscriptions WHERE id = ?",
(subscription_id,)
)[0]
failed_attempts = subscription['failed_attempts']
if failed_attempts >= len(self.RETRY_SCHEDULE):
# Max retries reached
self._cancel_subscription(subscription_id)
return
# Schedule next retry
retry_info = self.RETRY_SCHEDULE[failed_attempts]
next_retry = datetime.now() + timedelta(days=retry_info['days'])
self.db.insert('dunning_attempts', {
'subscription_id': subscription_id,
'attempt_number': failed_attempts,
'scheduled_at': next_retry,
'status': 'scheduled'
})
def _cancel_subscription(self, subscription_id: str):
"""Cancel subscription after max retries"""
self.db.update('subscriptions', subscription_id, {
'status': SubscriptionStatus.CANCELLED.value,
'cancellation_reason': 'Payment failed - max retries exceeded'
})
# Usage
dunning = DunningManager(db, payment_processor)
dunning.process_failed_payment('sub_123')
Best Practices
- Retry Logic: Implement smart retry schedules
- Dunning: Recover 30-50% of failed payments
- Proration: Handle mid-cycle changes correctly
- Reconciliation: Daily reconciliation with payment processor
- Monitoring: Alert on billing failures
- Testing: Test with real payment scenarios
- Compliance: Follow PCI-DSS and local regulations
- Communication: Notify customers of billing issues
- Analytics: Track MRR, churn, LTV
- Optimization: Continuously optimize recovery
Conclusion
Building robust subscription billing requires careful handling of recurring charges, failed payment recovery, and revenue optimization. By implementing the patterns in this guide, you can maximize revenue while maintaining customer satisfaction.
Next Steps:
- Implement subscription management
- Add dunning logic
- Set up monitoring
- Optimize recovery
- Analyze metrics
Comments