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',)
}),
)
@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
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
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:
Proposed Models
1. SubscriptionPlan Model
2. Subscription Model
3. SubscriptionPayment Model
4. SubscriptionNotification Model
Database Migration
subscription.statussubscription.next_billing_datesubscription.subscription_tokensubscription.useron_deletebehaviorsAdmin Integration
SubscriptionPlanAdmin
Model Methods
Acceptance Criteria
cancel(),renew(),is_active(), etc.)is_in_trial,is_past_due, etc.)__str__methods return meaningful representationscancel_subscriptions, etc.)Related Issues
Implementation Notes
timezone.now()for all datetime operationspython-dateutilfor date calculations (add to requirements)