-
-
Notifications
You must be signed in to change notification settings - Fork 0
Open
Labels
Description
Overview
Create Django REST Framework serializers to support subscription CRUD operations via API.
Dependencies
- Issue Update test_models.py #1 (Subscription models) must be completed first
Proposed Serializers
1. SubscriptionPlanSerializer
class SubscriptionPlanListSerializer(serializers.ModelSerializer):
"""Simplified serializer for listing subscription plans"""
billing_cycle_display = serializers.CharField(
source='get_billing_cycle_display',
read_only=True
)
class Meta:
model = SubscriptionPlan
fields = [
'id', 'name', 'slug', 'description',
'amount', 'currency', 'billing_cycle',
'billing_cycle_display', 'trial_days', 'is_active'
]
read_only_fields = '__all__'
class SubscriptionPlanDetailSerializer(serializers.ModelSerializer):
"""Detailed subscription plan information"""
billing_cycle_display = serializers.CharField(
source='get_billing_cycle_display',
read_only=True
)
active_subscriptions_count = serializers.SerializerMethodField()
class Meta:
model = SubscriptionPlan
fields = [
'id', 'name', 'slug', 'description',
'amount', 'currency', 'billing_cycle',
'billing_cycle_display', 'cycles', 'trial_days',
'features', 'is_active', 'active_subscriptions_count',
'created_at', 'updated_at'
]
read_only_fields = ['id', 'created_at', 'updated_at']
def get_active_subscriptions_count(self, obj):
"""Get count of active subscriptions for this plan"""
return obj.subscriptions.filter(
status__in=['trial', 'active']
).count()
class SubscriptionPlanCreateSerializer(serializers.ModelSerializer):
"""Serializer for creating subscription plans"""
class Meta:
model = SubscriptionPlan
fields = [
'name', 'slug', 'description', 'amount',
'currency', 'billing_cycle', 'cycles',
'trial_days', 'features', 'is_active'
]
def validate_amount(self, value):
"""Validate amount is positive"""
if value <= 0:
raise serializers.ValidationError(
"Amount must be greater than zero"
)
return value
def validate_cycles(self, value):
"""Validate cycles is non-negative"""
if value < 0:
raise serializers.ValidationError(
"Cycles cannot be negative. Use 0 for unlimited."
)
return value
def validate_trial_days(self, value):
"""Validate trial days is non-negative"""
if value < 0:
raise serializers.ValidationError(
"Trial days cannot be negative"
)
return value2. Subscription Serializers
class SubscriptionListSerializer(serializers.ModelSerializer):
"""Simplified serializer for listing subscriptions"""
plan_name = serializers.CharField(source='plan.name', read_only=True)
user_email = serializers.EmailField(source='user.email', read_only=True)
status_display = serializers.CharField(
source='get_status_display',
read_only=True
)
days_until_renewal = serializers.SerializerMethodField()
class Meta:
model = Subscription
fields = [
'id', 'user', 'user_email', 'plan', 'plan_name',
'status', 'status_display', 'next_billing_date',
'days_until_renewal', 'created_at'
]
read_only_fields = '__all__'
def get_days_until_renewal(self, obj):
"""Calculate days until next renewal"""
return obj.days_until_renewal()
class SubscriptionDetailSerializer(serializers.ModelSerializer):
"""Detailed subscription information"""
plan = SubscriptionPlanDetailSerializer(read_only=True)
payments = serializers.SerializerMethodField()
status_display = serializers.CharField(
source='get_status_display',
read_only=True
)
days_until_renewal = serializers.SerializerMethodField()
is_in_trial = serializers.BooleanField(read_only=True)
is_past_due = serializers.BooleanField(read_only=True)
class Meta:
model = Subscription
fields = '__all__'
read_only_fields = [
'id', 'subscription_token', 'created_at',
'updated_at', 'cancelled_at', 'ended_at'
]
def get_payments(self, obj):
"""Get recent payments"""
recent_payments = obj.payments.all()[:5]
return SubscriptionPaymentSerializer(
recent_payments,
many=True
).data
def get_days_until_renewal(self, obj):
return obj.days_until_renewal()
class SubscriptionCreateSerializer(serializers.ModelSerializer):
"""Serializer for creating subscriptions"""
payfast_form_data = serializers.SerializerMethodField()
payfast_url = serializers.SerializerMethodField()
class Meta:
model = Subscription
fields = [
'id', 'plan', 'custom_str1', 'custom_str2',
'custom_int1', 'custom_int2',
'payfast_form_data', 'payfast_url'
]
read_only_fields = ['id', 'payfast_form_data', 'payfast_url']
def validate_plan(self, value):
"""Validate plan is active"""
if not value.is_active:
raise serializers.ValidationError(
"This subscription plan is not currently available"
)
return value
def validate(self, attrs):
"""Check for duplicate active subscriptions"""
user = self.context['request'].user
plan = attrs['plan']
# Check if user already has active subscription for this plan
existing = Subscription.objects.filter(
user=user,
plan=plan,
status__in=['trial', 'active']
).exists()
if existing:
raise serializers.ValidationError(
"You already have an active subscription for this plan"
)
return attrs
def create(self, validated_data):
"""Create subscription with proper initialization"""
user = self.context['request'].user
plan = validated_data['plan']
# Generate unique subscription token
subscription_token = f"SUB_{uuid.uuid4().hex[:20].upper()}"
# Calculate dates
start_date = timezone.now().date()
if plan.trial_days > 0:
trial_end = plan.get_trial_end_date(start_date)
first_billing = trial_end + timedelta(days=1)
status = 'trial'
else:
trial_end = None
first_billing = start_date
status = 'pending' # Will be 'active' after first payment
next_billing = plan.calculate_next_billing_date(first_billing)
subscription = Subscription.objects.create(
user=user,
plan=plan,
subscription_token=subscription_token,
status=status,
trial_end_date=trial_end,
current_period_start=start_date,
current_period_end=next_billing,
next_billing_date=next_billing,
**validated_data
)
return subscription
def get_payfast_form_data(self, obj):
"""Generate PayFast subscription form data"""
from payfast.utils import generate_signature
from payfast.conf import PAYFAST_MERCHANT_ID, PAYFAST_MERCHANT_KEY
request = self.context.get('request')
data = {
'merchant_id': PAYFAST_MERCHANT_ID,
'merchant_key': PAYFAST_MERCHANT_KEY,
'subscription_type': '1', # Subscription
'billing_date': obj.next_billing_date.strftime('%Y-%m-%d'),
'recurring_amount': str(obj.plan.amount),
'frequency': self._get_frequency_code(obj.plan.billing_cycle),
'cycles': str(obj.plan.cycles),
'item_name': obj.plan.name,
'item_description': obj.plan.description,
'email_address': obj.user.email,
'custom_str1': obj.custom_str1 or '',
'custom_int1': str(obj.custom_int1) if obj.custom_int1 else '',
'notify_url': request.build_absolute_uri(
reverse('payfast:notify')
),
'return_url': request.build_absolute_uri(
reverse('payfast:subscription_success', args=[obj.id])
),
'cancel_url': request.build_absolute_uri(
reverse('payfast:subscription_cancel', args=[obj.id])
),
}
# Remove empty values
data = {k: v for k, v in data.items() if v}
# Generate signature
signature = generate_signature(data)
data['signature'] = signature
return data
def get_payfast_url(self, obj):
"""Get PayFast form submission URL"""
from payfast.conf import PAYFAST_URL
return PAYFAST_URL
def _get_frequency_code(self, billing_cycle):
"""Convert billing cycle to PayFast frequency code"""
frequency_map = {
'monthly': '3', # Monthly
'quarterly': '4', # Quarterly
'biannually': '5', # Biannually
'annually': '6', # Annually
}
return frequency_map.get(billing_cycle, '3')
class SubscriptionUpdateSerializer(serializers.ModelSerializer):
"""Serializer for updating subscription details"""
class Meta:
model = Subscription
fields = [
'custom_str1', 'custom_str2',
'custom_int1', 'custom_int2'
]
def validate(self, attrs):
"""Prevent updates to cancelled/expired subscriptions"""
if self.instance.status in ['cancelled', 'expired']:
raise serializers.ValidationError(
"Cannot update cancelled or expired subscriptions"
)
return attrs
class SubscriptionCancelSerializer(serializers.Serializer):
"""Serializer for cancelling subscriptions"""
cancel_immediately = serializers.BooleanField(default=False)
reason = serializers.CharField(
required=False,
allow_blank=True,
max_length=500
)
def validate(self, attrs):
"""Validate subscription can be cancelled"""
subscription = self.context['subscription']
if subscription.status in ['cancelled', 'expired']:
raise serializers.ValidationError(
"This subscription is already cancelled or expired"
)
return attrs
def save(self):
"""Cancel the subscription"""
subscription = self.context['subscription']
cancel_immediately = self.validated_data.get('cancel_immediately', False)
reason = self.validated_data.get('reason', '')
subscription.cancel(immediate=cancel_immediately)
# Log cancellation reason
if reason:
SubscriptionCancellation.objects.create(
subscription=subscription,
reason=reason,
cancelled_by=self.context['request'].user
)
return subscription3. Subscription Payment Serializer
class SubscriptionPaymentSerializer(serializers.ModelSerializer):
"""Serializer for subscription payment records"""
payment_details = serializers.SerializerMethodField()
class Meta:
model = SubscriptionPayment
fields = [
'id', 'subscription', 'payment',
'billing_period_start', 'billing_period_end',
'cycle_number', 'payment_details', 'created_at'
]
read_only_fields = '__all__'
def get_payment_details(self, obj):
"""Get PayFast payment details"""
return {
'amount': str(obj.payment.amount),
'status': obj.payment.status,
'pf_payment_id': obj.payment.pf_payment_id,
'completed_at': obj.payment.completed_at
}4. Statistics Serializers
class SubscriptionStatisticsSerializer(serializers.Serializer):
"""Serializer for subscription statistics"""
total_subscriptions = serializers.IntegerField()
active_subscriptions = serializers.IntegerField()
trial_subscriptions = serializers.IntegerField()
cancelled_subscriptions = serializers.IntegerField()
past_due_subscriptions = serializers.IntegerField()
total_mrr = serializers.DecimalField(max_digits=10, decimal_places=2)
total_arr = serializers.DecimalField(max_digits=10, decimal_places=2)
average_subscription_value = serializers.DecimalField(
max_digits=10,
decimal_places=2
)
churn_rate = serializers.FloatField()
retention_rate = serializers.FloatField()
class SubscriptionRevenueSerializer(serializers.Serializer):
"""Serializer for subscription revenue data"""
period = serializers.DateField()
new_revenue = serializers.DecimalField(max_digits=10, decimal_places=2)
recurring_revenue = serializers.DecimalField(max_digits=10, decimal_places=2)
total_revenue = serializers.DecimalField(max_digits=10, decimal_places=2)
new_subscriptions = serializers.IntegerField()
cancelled_subscriptions = serializers.IntegerField()5. Webhook Data Serializer
class SubscriptionWebhookDataSerializer(serializers.Serializer):
"""Serializer for validating PayFast subscription webhooks"""
# Subscription fields
token = serializers.CharField(required=True)
subscription_type = serializers.CharField(required=True)
# Payment fields (for payment notifications)
m_payment_id = serializers.CharField(required=False)
pf_payment_id = serializers.CharField(required=False)
amount_gross = serializers.DecimalField(
max_digits=10,
decimal_places=2,
required=False
)
amount_fee = serializers.DecimalField(
max_digits=10,
decimal_places=2,
required=False
)
amount_net = serializers.DecimalField(
max_digits=10,
decimal_places=2,
required=False
)
# Billing fields
billing_date = serializers.DateField(required=False)
run_date = serializers.DateField(required=False)
# Status fields
payment_status = serializers.CharField(required=False)
# Signature
signature = serializers.CharField(required=True)
def validate_subscription_type(self, value):
"""Validate subscription type"""
valid_types = [
'subscription_payment',
'subscription_cancelled',
'subscription_updated'
]
if value not in valid_types:
raise serializers.ValidationError(
f"Invalid subscription type: {value}"
)
return value
```
## Validation Rules
### Global Validation
- Validate plan exists and is active before creating subscription
- Check for duplicate active subscriptions per user/plan
- Validate date fields are logical (start < end, etc.)
- Validate amounts are positive
- Validate billing cycles are valid PayFast values
### Custom Validators
```python
def validate_no_duplicate_subscription(user, plan):
"""Validate user doesn't have active subscription for plan"""
exists = Subscription.objects.filter(
user=user,
plan=plan,
status__in=['trial', 'active']
).exists()
if exists:
raise ValidationError(
"User already has an active subscription for this plan"
)
def validate_plan_active(plan):
"""Validate subscription plan is active"""
if not plan.is_active:
raise ValidationError("Subscription plan is not active")Acceptance Criteria
- All serializers created with proper field definitions
- Validation logic implemented and tested
- PayFast form data generation working correctly
- Nested serializers properly configured
- Read-only fields enforced
- Custom validators implemented
- SerializerMethodFields return correct data
- Unit tests for all serializers:
- Field validation
- Custom validators
- Create operations
- Update operations
Reactions are currently unavailable