Skip to content

Add subscription models for recurring payments #72

@Carrington-dev

Description

@Carrington-dev

Overview

Add Django models to support PayFast subscription/recurring payment functionality, enabling merchants to create and manage subscription-based products.

Motivation

PayFast supports recurring payments through subscriptions, but dj-payfast currently only handles one-time payments. Adding subscription support will enable:

  • Monthly/annual subscriptions (SaaS products, memberships)
  • Recurring donations
  • Subscription billing with trial periods
  • Automatic payment renewals

Proposed Models

1. SubscriptionPlan Model

class SubscriptionPlan(models.Model):
"""Subscription plan/product definition"""

# Plan details
name = models.CharField(max_length=255)
slug = models.SlugField(unique=True)
description = models.TextField(blank=True)

# Pricing
amount = models.DecimalField(max_digits=10, decimal_places=2)
currency = models.CharField(max_length=3, default='ZAR')

# Billing cycle
CYCLE_CHOICES = [
    ('monthly', 'Monthly'),
    ('quarterly', 'Quarterly'),
    ('biannually', 'Bi-annually'),
    ('annually', 'Annually'),
]
billing_cycle = models.CharField(max_length=20, choices=CYCLE_CHOICES)
cycles = models.IntegerField(default=0, help_text='0 = until cancelled')

# Trial period
trial_days = models.IntegerField(default=0)

# Features/metadata
features = models.JSONField(default=dict)
is_active = models.BooleanField(default=True)

# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

class Meta:
    ordering = ['amount']
    verbose_name = 'Subscription Plan'
    verbose_name_plural = 'Subscription Plans'

def __str__(self):
    return f"{self.name} - R{self.amount}/{self.billing_cycle}"

2. Subscription Model

    class Subscription(models.Model):
        """User subscription instance"""
        
        STATUS_CHOICES = [
            ('trial', 'Trial'),
            ('active', 'Active'),
            ('past_due', 'Past Due'),
            ('cancelled', 'Cancelled'),
            ('expired', 'Expired'),
        ]
        
        # Relationships
        user = models.ForeignKey(
            User, 
            on_delete=models.CASCADE,
            related_name='subscriptions'
        )
        plan = models.ForeignKey(
            SubscriptionPlan, 
            on_delete=models.PROTECT,
            related_name='subscriptions'
        )
        
        # PayFast subscription token
        subscription_token = models.CharField(
            max_length=100, 
            unique=True,
            db_index=True
        )
        
        # Status and dates
        status = models.CharField(
            max_length=20, 
            choices=STATUS_CHOICES,
            db_index=True
        )
        trial_end_date = models.DateField(null=True, blank=True)
        current_period_start = models.DateField()
        current_period_end = models.DateField()
        cancelled_at = models.DateTimeField(null=True, blank=True)
        ended_at = models.DateTimeField(null=True, blank=True)
        
        # Billing
        next_billing_date = models.DateField(db_index=True)
        cycles_completed = models.IntegerField(default=0)
        
        # Custom fields
        custom_str1 = models.CharField(max_length=255, blank=True)
        custom_str2 = models.CharField(max_length=255, blank=True)
        custom_int1 = models.IntegerField(null=True, blank=True)
        custom_int2 = models.IntegerField(null=True, blank=True)
        
        # Timestamps
        created_at = models.DateTimeField(auto_now_add=True)
        updated_at = models.DateTimeField(auto_now=True)
        
        class Meta:
            ordering = ['-created_at']
            verbose_name = 'Subscription'
            verbose_name_plural = 'Subscriptions'
            indexes = [
                models.Index(fields=['user', 'status']),
                models.Index(fields=['next_billing_date', 'status']),
            ]
        
        def __str__(self):
            return f"{self.user.email} - {self.plan.name} ({self.status})"
        
        def cancel(self, immediate=False):
            """Cancel subscription"""
            self.status = 'cancelled'
            self.cancelled_at = timezone.now()
            if immediate:
                self.ended_at = timezone.now()
            else:
                self.ended_at = self.current_period_end
            self.save()
        
        def is_active(self):
            """Check if subscription is active"""
            return self.status in ['trial', 'active']
        
        def days_until_renewal(self):
            """Calculate days until next billing"""
            if not self.next_billing_date:
                return None
            delta = self.next_billing_date - timezone.now().date()
            return delta.days

3. SubscriptionPayment Model

class SubscriptionPayment(models.Model):
"""Individual payment within a subscription"""

subscription = models.ForeignKey(
    Subscription, 
    on_delete=models.CASCADE,
    related_name='payments'
)
payment = models.OneToOneField(
    PayFastPayment,
    on_delete=models.CASCADE
)

billing_period_start = models.DateField()
billing_period_end = models.DateField()
cycle_number = models.IntegerField()

created_at = models.DateTimeField(auto_now_add=True)

class Meta:
    ordering = ['-created_at']
    verbose_name = 'Subscription Payment'
    verbose_name_plural = 'Subscription Payments'

def __str__(self):
    return f"Payment #{self.cycle_number} - {self.subscription}"

4. SubscriptionNotification Model

  class SubscriptionNotification(models.Model):
      """Log all subscription webhook notifications"""
      
      subscription = models.ForeignKey(
          Subscription, 
          on_delete=models.CASCADE,
          related_name='notifications',
          null=True,
          blank=True
      )
      
      event_type = models.CharField(max_length=50, db_index=True)
      raw_data = models.JSONField(default=dict)
      processed = models.BooleanField(default=False, db_index=True)
      processing_errors = models.TextField(blank=True)
      ip_address = models.GenericIPAddressField(null=True, blank=True)
      
      created_at = models.DateTimeField(auto_now_add=True)
      
      class Meta:
          ordering = ['-created_at']
          verbose_name = 'Subscription Notification'
          verbose_name_plural = 'Subscription Notifications'
      
      def __str__(self):
          return f"{self.event_type} - {self.created_at}"

Database Migration

  • Create migration files for new models
  • Add indexes on frequently queried fields:
  • subscription.status
  • subscription.next_billing_date
  • subscription.subscription_token
  • subscription.user
  • Add foreign key constraints with appropriate on_delete behaviors
  • Add database-level constraints (e.g., positive amounts)

Admin Integration

SubscriptionPlanAdmin

    @admin.register(SubscriptionPlan)
    class SubscriptionPlanAdmin(admin.ModelAdmin):
        list_display = [
            'name', 'amount', 'billing_cycle', 
            'trial_days', 'is_active', 'created_at'
        ]
        list_filter = ['billing_cycle', 'is_active', 'created_at']
        search_fields = ['name', 'slug', 'description']
        prepopulated_fields = {'slug': ('name',)}
        readonly_fields = ['created_at', 'updated_at']
        
        fieldsets = (
            ('Plan Details', {
                'fields': ('name', 'slug', 'description', 'is_active')
            }),
            ('Pricing', {
                'fields': ('amount', 'currency', 'billing_cycle', 'cycles')
            }),
            ('Trial Period', {
                'fields': ('trial_days',)
            }),
            ('Features', {
                'fields': ('features',),
                'classes': ('collapse',)
            }),
            ('Timestamps', {
                'fields': ('created_at', 'updated_at'),
                'classes': ('collapse',)
            }),
        )
### SubscriptionAdmin
    @admin.register(Subscription)
    class SubscriptionAdmin(admin.ModelAdmin):
        list_display = [
            'id', 'user', 'plan', 'status', 
            'next_billing_date', 'cycles_completed', 'created_at'
        ]
        list_filter = [
            'status', 'plan', 'created_at',
            'next_billing_date', 'cancelled_at'
        ]
        search_fields = [
            'user__email', 'user__username',
            'subscription_token', 'plan__name'
        ]
        readonly_fields = [
            'subscription_token', 'created_at', 
            'updated_at', 'cancelled_at', 'ended_at'
        ]
        date_hierarchy = 'created_at'
        
        actions = ['cancel_subscriptions', 'export_subscriptions']
        
        def cancel_subscriptions(self, request, queryset):
            """Bulk cancel subscriptions"""
            count = 0
            for subscription in queryset.filter(status__in=['active', 'trial']):
                subscription.cancel()
                count += 1
            self.message_user(request, f'{count} subscriptions cancelled')
        cancel_subscriptions.short_description = 'Cancel selected subscriptions'

Model Methods

Add these utility methods to models:
```python
    # SubscriptionPlan
    def get_trial_end_date(self, start_date=None):
        """Calculate trial end date"""
        if not self.trial_days:
            return None
        start = start_date or timezone.now().date()
        return start + timedelta(days=self.trial_days)
    
    def calculate_next_billing_date(self, from_date=None):
        """Calculate next billing date based on cycle"""
        from_date = from_date or timezone.now().date()
        
        if self.billing_cycle == 'monthly':
            return from_date + relativedelta(months=1)
        elif self.billing_cycle == 'quarterly':
            return from_date + relativedelta(months=3)
        elif self.billing_cycle == 'biannually':
            return from_date + relativedelta(months=6)
        elif self.billing_cycle == 'annually':
            return from_date + relativedelta(years=1)
        
        return from_date
    
    # Subscription
    @property
    def is_in_trial(self):
        """Check if subscription is in trial period"""
        if not self.trial_end_date:
            return False
        return timezone.now().date() <= self.trial_end_date
    
    @property
    def is_past_due(self):
        """Check if subscription is past due"""
        return self.status == 'past_due'
    
    def renew(self):
        """Renew subscription for next billing period"""
        self.current_period_start = self.next_billing_date
        self.current_period_end = self.plan.calculate_next_billing_date(
            self.next_billing_date
        )
        self.next_billing_date = self.plan.calculate_next_billing_date(
            self.current_period_end
        )
        self.cycles_completed += 1
        self.status = 'active'
        self.save()
```

Acceptance Criteria

  • All models created with fields defined above
  • Migrations generated and tested
  • Models registered in Django admin with custom configuration
  • Model methods implemented (cancel(), renew(), is_active(), etc.)
  • Model properties added (is_in_trial, is_past_due, etc.)
  • Database indexes created for performance
  • Model __str__ methods return meaningful representations
  • Documentation updated with model schema
  • Unit tests for:
  • Model creation
  • Model updates
  • Model methods
  • Model properties
  • Model validation
  • Relationship integrity
  • Admin actions tested (cancel_subscriptions, etc.)

Related Issues

Implementation Notes

  • Use timezone.now() for all datetime operations
  • Use python-dateutil for date calculations (add to requirements)
  • Consider adding soft delete functionality
  • Add signal handlers for subscription state changes
  • Consider adding subscription history/audit trail

Metadata

Metadata

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions