Skip to content

Example №3

Task Deadline
[KAN-38] CRUD for subscriptions 08/12/2024

Problem

User cannot check all available subscription plans, also user cannot check his subscription and cancel it.

Solution

Implement functionality that would allow user to see his subscription plan, cancel it and see all available subscription plans.

Acceptance Criteria

  • User can check all available subscription plans.
  • User can check his subscription details.
  • User can cancel his subscription.

Database Changes

Changes: - Change billing_interval field type to VARCHAR(5) in SubscriptionPlans table. - Change activce field name to is_active and type to BOOLEAN in SubscriptionPlans table. - Remove start_at, end_at fields in Subscriptions table.

Subscriptions Table

Column Name Column Type Nullable Default
id UUID No Generated UUID
created_at DATETIME No auto_now_add=True
updated_at DATETIME No auto_now=True
status FOREIGN KEY(SubscriptionStatus(id)) No -
plan FOREIGN KEY(SubscriptionPlans(id)) No -
user FOREIGN KEY(User(id)) No -

SubscriptionPlans Table

Column Name Column Type Nullable Default
id UUID No Generated UUID
name VARCHAR(50) No -
description TEXT Yes -
price REAL No -
currency ENUM No UAH
trial_period_days SMALLINT CHECK (column >= 0) No 0
billing_interval VARCHAR(5) No MONTH
created_at DATETIME No auto_now_add=True
updated_at DATETIME No auto_now=True
is_active BOOLEAN No False

Models

Models implementation

class EnumChoices(Enum):
    @classmethod
    def choices(cls):
        return [(member.value, member.name) for member in cls]

class SubscriptionStatus(EnumChoices):
    PENDING = "PENDING"
    SUBSCRIBED = "SUBSCRIBED"
    CANCELLED = "CANCELLED"

class SubscriptionCurrency(EnumChoices):
    GBP = "GBP"
    EUR = "EUR"
    USD = "USD"
    UAH = "UAH"

class SubscriptionBillingInterval(EnumChoices):
    WEEK = "WEEK"
    MONTH = "MONTH"
    YEAR = "YEAR"

class Subscriptions(models.Model):
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    status = models.ForeignKey('SubscriptionStatus', on_delete=models.PROTECT)
    plan = models.ForeignKey('SubscriptionPlans', on_delete=models.PROTECT)
    user = models.ForeignKey(User, on_delete=models.PROTECT)

class SubscriptionStatus(models.Model):
    name = models.CharField(max_length=20, choices=SubscriptionStatus.choices(), default=SubscriptionStatus.PENDING)

    def __str__(self):
        return f'Subscription status: {self.name}'

class SubscriptionPlans(models.Model):
    name = models.CharField(max_length=50)
    description = models.TextField(null=True, blank=True)
    price = models.DecimalField(max_digits=4, decimal_places=2)
    currency = models.CharField(max_length=3, choices=SubscriptionCurrency.choices(), default='UAH')
    billing_interval = models.CharField(max_length=5, choices=SubscriptionBillingInterval.choices(), default=SubscriptionBillingInterval.MONTH)
    trial_period_days = models.PositiveSmallIntegerField(default=0)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    is_active = models.BooleanField(default=False)

    def __str__(self):
        return f'Subscription plan: {self.name}'

Note: think about user FOREIGN KEY, probably do OneToOne relationship.

Endpoints

Method Endpoint Description Payload
GET api/v1/subscription-plans Endpoint that return all available subscription plans -
GET api/v1/me/subscription Endpoint that return user's active subscription -
POST api/v1/me/subscription/cancel Endpoint that cancelling user's subscription -

Views

Implementation view for following endpoint: [GET] api/v1/me/subscription/

class GetSubscription(APIView):
    permission_classes = [permissions.IsAuthenticated] # Fill the permission_classes

    def get(self, request, *args, **kwargs):
        # 1. Try to get acrive subscription by user_id field
        # 2.1. If no subscription record - 204 return error_message
        # 2.2. If subscription record exist - 200 return subscription info

Responses:

200 OK - return user's subscription information

{
    "result": "ok",
    "sybscription": {
        "start_at": "datetime",
        "end_at": "datetime",
        "created_at": "datetime",
        "updated_at": "datetime",
        "status": {
            "name": "SUBSCRIBED"
        },
        "plan": {
            "name": "Plan name",
            "description": "Plan description",
            "price": 120,
            "currency": "GBP",
            "billing_interval": "MONTH",
            "trial_period_days": 0,
        }
    }
}

204 No Content - return error_message

{
    "result": "bad",
    "error_message": "User doesn`t have an active subscription."
}

Implementation view for following endpoint: [POST] api/v1/me/subscription/cancel/

class CancelSubscription(APIView):
    permission_classes = [permissions.IsAuthenticated] # Fill the permission_classes

    def post(self, request, *args, **kwargs):
        # 1. Try to get subscription by user_id field.
        # 2. If no subscription record - 204 return error_message.
        # 3. Use Stripe to cancel subscription by order_id: subscription.id field.
        # 4.1. If response status = unsubscribed - return 200
        # 4.2. If response status = error/failure - 500 return with error_message and status

Responses:

200 OK - return

{
    "result": "ok",
    "message": "Subscription successfully cancelled."
}

204 No Content - return error_message

{
    "result": "bad",
    "error_message": "User doesn`t have an active subscription."
}

400 Bad Request - Payload data didn`t pass throught serializer, return error message and result

{
    "result": "error",
    "error_message": "Invalid request data."
}

500 (status is error/failure) - return status and error_message

{
    "result": "error",
    "status" : "error",
    "error_message" : "Authorization is required"
}

Implementation view for following endpoint: [GET] api/v1/subscription-plans/

class GetSubscriptionPlans(APIView):
    permission_classes = [permissions.IsAuthenticated] # Fill the permission_classes

    def get(self, request, *args, **kwargs):
        # 1. Get all available subscription plans from db.
        # 2.1. If its empty - 204 return error_message.
        # 2.2. Else - 200 return recieved subscription plans.

Responses:

200 OK - return

{
    "result": "ok",
    "subscriptions": {
        "subscription_plan_1": {
        "name": "Plan name",
        "description": "Plan description",
        "price": 499.99,
        "currency": "UAH",
        "billing_interval": "MONTH",
        "trial_period_days": 0,
        },
        "subscription_plan_2": {
            "name": "Plan name",
            "description": "Plan description",
            "price": 9.99,
            "currency": "USD",
            "billing_interval": "YEAR",
            "trial_period_days": 30,
        }
    }
}

204 No Content - return error_message

{
    "result": "bad",
    "error_message": "There are no subscription plans."
}

Note: add result field to response, that should help FE to manage response data, ok - return some data, bad - return error_message that can be send to user, error - return error_message, that describe server problem.

Note: views describe the steps they need to perform, but we should create a separate service that will use the liqpay API.


Tests

Example: test_subscription_crud.py

Name Purpose
test_subscription_get Tests get user's subscription endpoint with valid inputs
test_subscription_get_error Tests get user's subscription endpoint with no content exist
test_subscription_cancel Tests cancel user's subscription endpoint with valid inputs
test_subscription_cancel_error Tests cancel user's subscription endpoint with no content exist
test_subscription_plans_get Tests get subscription plans endpoint with valid inputs
test_subscription_plans_get_error Tests get subscription plans endpoint with no content exist

Implementation Checklist

  • Review task dependencies and confirm any blockers are resolved.
  • Follow database schema guidelines and complete migrations.
  • Ensure the models cover all nessesary payment data.
  • Make sure that API was integrated successfully.
  • Add documentation for each endpoint and update API documentation.