Skip to main content
โšก Calmops

Subscription Billing Systems: Recurring Payments and Dunning Management

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

  1. Retry Logic: Implement smart retry schedules
  2. Dunning: Recover 30-50% of failed payments
  3. Proration: Handle mid-cycle changes correctly
  4. Reconciliation: Daily reconciliation with payment processor
  5. Monitoring: Alert on billing failures
  6. Testing: Test with real payment scenarios
  7. Compliance: Follow PCI-DSS and local regulations
  8. Communication: Notify customers of billing issues
  9. Analytics: Track MRR, churn, LTV
  10. 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:

  1. Implement subscription management
  2. Add dunning logic
  3. Set up monitoring
  4. Optimize recovery
  5. Analyze metrics

Comments