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.