Skip to content

Add DRF serializers for subscription API #73

@Carrington-dev

Description

@Carrington-dev

Overview

Create Django REST Framework serializers to support subscription CRUD operations via API.

Dependencies

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 value

2. 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 subscription

3. 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

Metadata

Metadata

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions