Or press ESC to close.

Recurring Billing Automation: Avoiding the Calendar Chaos in Subscription Testing

Dec 1st 2024 16 min read
medium
python3.13.0
pytest8.3.3

Managing recurring events like subscription payments is a critical aspect of modern software applications, especially for subscription-based services. Automating this process not only ensures timely billing and consistent user experience but also reduces the risk of human error. In this blog post, we'll explore how to design and implement an efficient subscription management system in Python. From handling edge cases like leap years to testing payment workflows, we'll cover all the essentials you need to master recurring payment automation.

Introduction to Recurring Event Automation

Recurring events, such as subscription renewals or scheduled payments, form the backbone of many modern businesses. Automating these processes is essential for ensuring operational efficiency and providing a seamless user experience. By eliminating the need for manual intervention, automation helps businesses manage recurring events at scale, maintain consistent service quality, and minimize errors caused by human oversight.

However, automating recurring events is not without its challenges. One significant issue is handling variations in billing cycles, such as shorter months, leap years, or timezone differences, which can lead to misaligned payment schedules. Additionally, dealing with payment failures, subscription cancellations, and edge cases - like users subscribing on the 31st of a month - requires careful planning and robust implementation. For subscription-based services, these challenges are magnified by the need to ensure accurate billing and maintain customer trust.

Designing the Subscription Service

Building a robust subscription management system begins with a well-defined structure. At the core of our solution is the Subscription class, which encapsulates all relevant details about a subscription. This includes the user's ID, the type of subscription plan, start and next billing dates, status, amount, and optional cancellation information. Using Python's @dataclass simplifies the creation and maintenance of this class by handling boilerplate code like initializers and representation methods automatically.

                
@dataclass
class Subscription:
    user_id: str
    plan_type: str
    start_date: datetime
    next_billing_date: datetime
    status: str
    amount: float
    cancelled_at: Optional[datetime] = None
                

One of the key responsibilities of a subscription management system is calculating the next billing date based on the plan type. For monthly plans, the system needs to account for the varying number of days in each month. For example, a subscription created on January 31st must correctly set its next billing date to February 28th (or 29th in a leap year). Similarly, yearly plans must handle leap years if the subscription starts on February 29th.

The _calculate_next_billing_date method encapsulates this logic:

Here's an example of this logic in action:

                
def _calculate_next_billing_date(self, current_date: datetime, plan_type: str) -> datetime:
    if plan_type == "monthly":
        if current_date.month == 12:
            next_month = 1
            next_year = current_date.year + 1
        else:
            next_month = current_date.month + 1
            next_year = current_date.year
            
        _, last_day = calendar.monthrange(next_year, next_month)
            
        next_date = datetime(
            year=next_year,
            month=next_month,
            day=min(current_date.day, last_day),
            hour=current_date.hour,
            minute=current_date.minute
        )
        return next_date
    elif plan_type == "yearly":
        next_year = current_date.year + 1
        if current_date.month == 2 and current_date.day == 29:
            if not calendar.isleap(next_year):
                return datetime(next_year, 2, 28,
                                current_date.hour, current_date.minute)
        return datetime(next_year, current_date.month, current_date.day,
                        current_date.hour, current_date.minute)
    raise ValueError(f"Unsupported plan type: {plan_type}")
                

When managing recurring billing, edge cases can significantly impact the reliability of our system. Some examples include:

By addressing these challenges in the design phase, the subscription service ensures accurate and reliable billing cycles for all users. With this foundation, we can now explore how to process recurring payments efficiently in the next section.

Building Payment Processing Workflows

The payment processing workflow is the engine that powers the recurring billing system. It must reliably handle transactions, respond to payment outcomes, and adapt to changes in a subscription's lifecycle, such as cancellations.

Payment Success and Failure Handling

To process recurring payments, the system iterates through all active subscriptions and checks whether the current date has reached or surpassed the next_billing_date. For eligible subscriptions, the payment gateway is invoked to charge the user.

In the event of a successful payment, the system calculates the next billing date using the _calculate_next_billing_date method and updates the subscription.

If the payment fails, the system transitions the subscription's status to "payment_failed". This allows for targeted follow-up actions, such as retrying the payment, notifying the user, or suspending the subscription after repeated failures.

Here's the workflow encapsulated in the process_recurring_payments method:

                
def process_recurring_payments(self):  
    current_time = self.time_provider.now()  
    for subscription in self.subscriptions.values():  
        if (subscription.status == "active" and  
                subscription.next_billing_date <= current_time):  
            try:  
                self.payment_gateway.charge(subscription.user_id, subscription.amount)  
                subscription.next_billing_date = self._calculate_next_billing_date(  
                    subscription.next_billing_date,  
                    subscription.plan_type  
                )  
            except PaymentError:  
                subscription.status = "payment_failed"
                

This approach ensures that subscriptions continue uninterrupted for successful payments while providing a clear mechanism for handling failures.

Cancellation and Its Implications on Billing

Subscription cancellations are an integral part of any recurring billing system. When a user decides to cancel, the system must immediately update the subscription's status to "cancelled" and record the cancellation timestamp.

Cancellations affect billing in two significant ways:

The cancel_subscription method manages this process:

                
def cancel_subscription(self, user_id: str):  
    if user_id in self.subscriptions:  
        self.subscriptions[user_id].status = "cancelled"  
        self.subscriptions[user_id].cancelled_at = self.time_provider.now()
                

Additionally, during the process_recurring_payments workflow, cancelled subscriptions are excluded from payment processing, as demonstrated here:

                
if subscription.status != "active":  
    continue
                

This ensures that cancelled subscriptions are properly managed without requiring special checks during payment processing.

By addressing both successful and failed payments as well as cancellations, the system creates a seamless and user-friendly experience while maintaining operational efficiency.

Testing the Automation

Thorough testing is critical to ensure that the subscription management system functions reliably. By simulating real-world scenarios and edge cases, we can validate the accuracy and resilience of the automation.

Mocking Dependencies for Robust Testing

To isolate the subscription service from external systems, such as the payment gateway and the time provider, we use mocking. Python's unittest.mock.Mock is a powerful tool for simulating these dependencies.

Here's an example of setting up these mocks in test fixtures:

                
@pytest.fixture  
def mock_payment_gateway():  
    return Mock()  
                    
@pytest.fixture  
def mock_time_provider():  
    provider = Mock()  
    provider.now.return_value = datetime(2024, 1, 1, 12, 0)  
    return provider  
                    
@pytest.fixture  
def subscription_service(mock_payment_gateway, mock_time_provider):  
    return SubscriptionService(mock_payment_gateway, mock_time_provider)
                

These mocks allow us to simulate different conditions, such as successful transactions, payment failures, and cancellations, while keeping tests deterministic and repeatable.

Test Scenarios for Billing, Failures, and Edge Cases

To ensure comprehensive coverage, tests are designed to validate key workflows, handle failures gracefully, and account for edge cases like leap years or end-of-month billing.

1. Billing Success

This test verifies that the subscription's next_billing_date updates correctly after a successful payment and that the payment gateway processes the expected charge:

                
def test_successful_recurring_payment(subscription_service, mock_payment_gateway, mock_time_provider):  
    subscription = subscription_service.create_subscription("user1", "monthly", 9.99)  
    mock_time_provider.now.return_value = subscription.next_billing_date  
                
    subscription_service.process_recurring_payments()  
                
    mock_payment_gateway.charge.assert_called_once_with("user1", 9.99)  
    assert subscription.next_billing_date == datetime(2024, 3, 1, 12, 0)
                

2. Payment Failures

This test ensures that when a payment fails, the subscription status is updated to "payment_failed". The test also simulates a failure using the mock's side_effect feature:

                
def test_failed_recurring_payment(subscription_service, mock_payment_gateway, mock_time_provider):  
    subscription = subscription_service.create_subscription("user1", "monthly", 9.99)  
    mock_payment_gateway.charge.side_effect = PaymentError()  
    mock_time_provider.now.return_value = subscription.next_billing_date  
                
    subscription_service.process_recurring_payments()  
                
    assert subscription.status == "payment_failed"
                

3. Edge Cases

Edge cases, such as leap years and months with fewer days, are tested to ensure accurate date calculations:

                
def test_edge_cases_monthly_billing(subscription_service, mock_time_provider):  
    mock_time_provider.now.return_value = datetime(2024, 1, 31, 12, 0)  
    jan_subscription = subscription_service.create_subscription("user1", "monthly", 9.99)  
    assert jan_subscription.next_billing_date == datetime(2024, 2, 29, 12, 0)  
                
    mock_time_provider.now.return_value = datetime(2024, 2, 29, 12, 0)  
    feb_subscription = subscription_service.create_subscription("user2", "monthly", 9.99)  
    assert feb_subscription.next_billing_date == datetime(2024, 3, 29, 12, 0)
                

4. Cancellation Testing

Tests ensure that cancelled subscriptions are properly handled and excluded from payment processing:

                
def test_no_charge_after_cancellation(subscription_service, mock_payment_gateway, mock_time_provider):  
    subscription = subscription_service.create_subscription("user1", "monthly", 9.99)  
    subscription_service.cancel_subscription("user1")  
    mock_time_provider.now.return_value = subscription.next_billing_date  
                
    subscription_service.process_recurring_payments()  
                
    mock_payment_gateway.charge.assert_not_called()
                

By combining mocking, focused test cases, and edge-case validation, we can build confidence in the system's ability to handle the complexities of recurring billing.

Best Practices for Recurring Event Automation

Automating recurring events, such as subscription billing, requires more than just implementing workflows - it demands robust strategies to ensure data accuracy, reliability, and system resilience. Below are best practices to streamline and safeguard the automation of recurring events.

Ensuring Data Accuracy and Reliability

Accurate data is the foundation of any recurring event system. Misaligned dates, incorrect amounts, or inconsistent statuses can erode user trust and lead to operational issues. To maintain accuracy, consider the following:

Monitoring and Alerting for Failed Payments

No recurring billing system is immune to payment failures, making monitoring and proactive resolution critical. Here are strategies to handle failed payments effectively:

Conclusion

Automating recurring events, such as subscription billing, is a powerful way to streamline operations and enhance user experience. However, success depends on designing systems that prioritize data accuracy, reliability, and proactive monitoring. By implementing best practices - like robust validation, idempotent operations, clear user notifications, and effective failure handling - we can build a resilient automation framework that minimizes errors and ensures customer satisfaction. As our systems evolve, continuously refine our processes to meet new challenges, keeping reliability and user trust at the core of our automation strategy.

The complete code for the subscription automation system is available on our GitHub page - check it out to explore the implementation in detail!