Initial commit - clean project structure
This commit is contained in:
0
subscription/__init__.py
Normal file
0
subscription/__init__.py
Normal file
14
subscription/admin.py
Normal file
14
subscription/admin.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Plan, Subscription
|
||||
|
||||
|
||||
@admin.register(Plan)
|
||||
class PlanAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'title', 'price', 'currency', 'discount_percent', 'popular')
|
||||
|
||||
|
||||
@admin.register(Subscription)
|
||||
class SubscriptionAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'user_id', 'plan', 'status', 'started_at', 'expires_at', 'order_id')
|
||||
list_filter = ('status',)
|
||||
6
subscription/apps.py
Normal file
6
subscription/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SubscriptionConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'subscription'
|
||||
38
subscription/migrations/0001_initial.py
Normal file
38
subscription/migrations/0001_initial.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 5.2.17 on 2026-08-10 12:00
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Plan',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=50)),
|
||||
('price', models.PositiveIntegerField()),
|
||||
('currency', models.CharField(default='IRT', max_length=10)),
|
||||
('discount_percent', models.PositiveSmallIntegerField(default=0)),
|
||||
('popular', models.BooleanField(default=False)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Subscription',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('user_id', models.IntegerField(db_index=True, unique=True)),
|
||||
('status', models.CharField(choices=[('pending', 'در انتظار پرداخت'), ('active', 'فعال'), ('expired', 'منقضی'), ('canceled', 'لغوشده')], default='pending', max_length=15)),
|
||||
('started_at', models.DateTimeField(auto_now_add=True)),
|
||||
('expires_at', models.DateTimeField(blank=True, null=True)),
|
||||
('order_id', models.IntegerField(blank=True, null=True)),
|
||||
('plan', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='subscriptions', to='subscription.plan')),
|
||||
],
|
||||
),
|
||||
]
|
||||
18
subscription/migrations/0002_plan_duration_days.py
Normal file
18
subscription/migrations/0002_plan_duration_days.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.17 on 2026-08-10 17:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('subscription', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='duration_days',
|
||||
field=models.PositiveIntegerField(default=30),
|
||||
),
|
||||
]
|
||||
0
subscription/migrations/__init__.py
Normal file
0
subscription/migrations/__init__.py
Normal file
55
subscription/models.py
Normal file
55
subscription/models.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Plan(models.Model):
|
||||
"""
|
||||
⚠️ اصلاح نسبت به سند اولیه: نمونهی پاسخ GET /api/subscription/plans در
|
||||
سند Endpointهای API یک فیلد boolean به اسم `popular` داره («ماهانه» →
|
||||
false، «سالانه» → true) که در دیاگرام مدلها (سند معماری داده) برای
|
||||
Plan اصلاً ذکر نشده. چون بدون این فیلد نمیشه دقیقاً همون response
|
||||
schema رو پیادهسازی کرد، اینجا اضافه شده — دقیقاً مثل فیکس is_correct
|
||||
روی content.Choice در فاز ۱.
|
||||
"""
|
||||
title = models.CharField(max_length=50)
|
||||
price = models.PositiveIntegerField()
|
||||
currency = models.CharField(max_length=10, default='IRT')
|
||||
discount_percent = models.PositiveSmallIntegerField(default=0)
|
||||
popular = models.BooleanField(default=False) # <-- اصلاح/افزوده شده
|
||||
# ⚠️ اصلاح دیگه: سند طراحی هیچ فیلد durationای برای Plan نداره، در
|
||||
# نتیجهی این نبود، activate_subscription قبلاً مدت اشتراک رو با یک
|
||||
# قاعدهی stub (چک کردن کلمهی «سالانه»/yearly در عنوان) حدس میزد که
|
||||
# شکننده و وابسته به متن title بود. duration_days اینجا بهعنوان منبع
|
||||
# صریح و قابلاتکای مدت اشتراک اضافه شده؛ پیشفرض ۳۰ روز.
|
||||
duration_days = models.PositiveIntegerField(default=30) # <-- اصلاح/افزوده شده
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class Subscription(models.Model):
|
||||
"""
|
||||
⚠️ اصلاح نسبت به سند اولیه: STATUS_CHOICES در سند فقط شامل
|
||||
active/expired/canceled بود. اما در جریان POST /api/subscription/purchase
|
||||
یک Subscription قبل از تایید پرداخت (که با payment webhook میاد) ساخته
|
||||
میشه؛ بدون یک وضعیت میانی، راهی برای نمایش «سفارش ثبت شده ولی هنوز
|
||||
پرداخت تایید نشده» وجود نداره. برای همین وضعیت pending اضافه و بهعنوان
|
||||
پیشفرض ساخت (بهجای active در سند) تنظیم شده؛ فعالسازی واقعی توسط
|
||||
subscription.services.activate_subscription بعد از webhook پرداخت انجام
|
||||
میشه.
|
||||
"""
|
||||
STATUS_CHOICES = [
|
||||
('pending', 'در انتظار پرداخت'), # <-- اصلاح/افزوده شده
|
||||
('active', 'فعال'),
|
||||
('expired', 'منقضی'),
|
||||
('canceled', 'لغوشده'),
|
||||
]
|
||||
|
||||
user_id = models.IntegerField(unique=True, db_index=True) # ارجاع سبک به accounts.User
|
||||
plan = models.ForeignKey(Plan, on_delete=models.SET_NULL, null=True, related_name='subscriptions')
|
||||
status = models.CharField(max_length=15, choices=STATUS_CHOICES, default='pending')
|
||||
started_at = models.DateTimeField(auto_now_add=True)
|
||||
expires_at = models.DateTimeField(null=True, blank=True)
|
||||
order_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به payment.Order
|
||||
|
||||
def __str__(self):
|
||||
return f"sub-user{self.user_id}-{self.status}"
|
||||
33
subscription/serializers.py
Normal file
33
subscription/serializers.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class PlanSerializer(serializers.Serializer):
|
||||
id = serializers.CharField()
|
||||
title = serializers.CharField()
|
||||
price = serializers.IntegerField()
|
||||
currency = serializers.CharField(required=False)
|
||||
popular = serializers.BooleanField()
|
||||
discountPercent = serializers.IntegerField(required=False)
|
||||
durationDays = serializers.IntegerField(required=False)
|
||||
|
||||
|
||||
class PlansListResponseSerializer(serializers.Serializer):
|
||||
plans = PlanSerializer(many=True)
|
||||
|
||||
|
||||
class PurchaseRequestSerializer(serializers.Serializer):
|
||||
# هدر Idempotency-Key هم روی همین درخواست لازمه (بخش ۴.۳ سند API)؛
|
||||
# از request.headers خونده میشه، نه از بدنه.
|
||||
planId = serializers.CharField()
|
||||
paymentMethod = serializers.CharField(required=False, default='zarinpal')
|
||||
|
||||
|
||||
class PurchaseResponseSerializer(serializers.Serializer):
|
||||
orderId = serializers.CharField()
|
||||
paymentUrl = serializers.CharField()
|
||||
|
||||
|
||||
class SubscriptionStatusResponseSerializer(serializers.Serializer):
|
||||
active = serializers.BooleanField()
|
||||
planId = serializers.CharField(allow_null=True)
|
||||
expiresAt = serializers.DateTimeField(allow_null=True)
|
||||
59
subscription/services.py
Normal file
59
subscription/services.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from django.utils import timezone
|
||||
|
||||
from statuslog.signals import status_changed
|
||||
|
||||
from .models import Subscription
|
||||
|
||||
|
||||
def activate_subscription(subscription_id: int):
|
||||
"""
|
||||
بعد از تایید پرداخت (payment webhook) صدا زده میشه؛ اشتراک رو active
|
||||
میکنه و expires_at رو بر اساس Plan.duration_days محاسبه میکنه.
|
||||
(قبلاً چون Plan فیلد durationای نداشت، این مقدار با یک قاعدهی stub
|
||||
بر اساس متن title حدس زده میشد؛ الان duration_days مستقیماً از مدل
|
||||
Plan خونده میشه.)
|
||||
"""
|
||||
try:
|
||||
subscription = Subscription.objects.get(pk=subscription_id)
|
||||
except Subscription.DoesNotExist:
|
||||
return None
|
||||
|
||||
old_status = subscription.status
|
||||
duration_days = subscription.plan.duration_days if subscription.plan else 30
|
||||
subscription.status = 'active'
|
||||
subscription.expires_at = timezone.now() + timezone.timedelta(days=duration_days)
|
||||
subscription.save(update_fields=['status', 'expires_at'])
|
||||
|
||||
status_changed.send(
|
||||
sender='subscription.Subscription', object_id=subscription.id,
|
||||
old_status=old_status, new_status='active',
|
||||
event='payment_confirmed', user_id=subscription.user_id,
|
||||
)
|
||||
return subscription
|
||||
|
||||
|
||||
def grant_free_subscription(user_id: int, *, duration_days: int | None = None, event: str = 'offer_applied'):
|
||||
"""
|
||||
فعالسازی اشتراک بدون هیچ Order/پرداختی — برای Offer (آفر رایگان
|
||||
محدود/کامل). duration_days=None یعنی نامحدود (free_full)؛ عدد یعنی
|
||||
trial با همون تعداد روز.
|
||||
|
||||
از discount/services.py (apply_offer) صدا زده میشه؛ مثل بقیهی
|
||||
ارتباطات بیناپیِ این پروژه، یک import مستقیم به تابع سرویس، نه FK
|
||||
واقعی و نه HTTP call به خودمون.
|
||||
"""
|
||||
subscription, _ = Subscription.objects.get_or_create(user_id=user_id)
|
||||
old_status = subscription.status
|
||||
|
||||
subscription.status = 'active'
|
||||
subscription.expires_at = (
|
||||
timezone.now() + timezone.timedelta(days=duration_days) if duration_days else None
|
||||
)
|
||||
subscription.save(update_fields=['status', 'expires_at'])
|
||||
|
||||
status_changed.send(
|
||||
sender='subscription.Subscription', object_id=subscription.id,
|
||||
old_status=old_status, new_status='active',
|
||||
event=event, user_id=user_id,
|
||||
)
|
||||
return subscription
|
||||
3
subscription/tests.py
Normal file
3
subscription/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
9
subscription/urls.py
Normal file
9
subscription/urls.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import PlansListView, PurchaseView, SubscriptionStatusView
|
||||
|
||||
urlpatterns = [
|
||||
path('subscription/plans', PlansListView.as_view(), name='subscription-plans'),
|
||||
path('subscription/purchase', PurchaseView.as_view(), name='subscription-purchase'),
|
||||
path('subscription/status', SubscriptionStatusView.as_view(), name='subscription-status'),
|
||||
]
|
||||
115
subscription/views.py
Normal file
115
subscription/views.py
Normal file
@@ -0,0 +1,115 @@
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from payment.services import create_order
|
||||
|
||||
from .models import Plan, Subscription
|
||||
from .serializers import (
|
||||
PlansListResponseSerializer,
|
||||
PurchaseRequestSerializer,
|
||||
PurchaseResponseSerializer,
|
||||
SubscriptionStatusResponseSerializer,
|
||||
)
|
||||
|
||||
|
||||
class PlansListView(APIView):
|
||||
"""GET /api/subscription/plans — لیست پلنها"""
|
||||
|
||||
@extend_schema(responses=PlansListResponseSerializer, tags=['Subscription (subscription)'])
|
||||
def get(self, request):
|
||||
plans = Plan.objects.all()
|
||||
data = {
|
||||
'plans': [
|
||||
{
|
||||
'id': str(p.id),
|
||||
'title': p.title,
|
||||
'price': p.price,
|
||||
'currency': p.currency,
|
||||
'popular': p.popular,
|
||||
'discountPercent': p.discount_percent,
|
||||
'durationDays': p.duration_days,
|
||||
}
|
||||
for p in plans
|
||||
]
|
||||
}
|
||||
return Response(PlansListResponseSerializer(data).data)
|
||||
|
||||
|
||||
class PurchaseView(APIView):
|
||||
"""
|
||||
POST /api/subscription/purchase — شروع خرید (با هدر Idempotency-Key)
|
||||
یک Subscription در وضعیت pending میسازه/بهروز میکنه، بعد مستقیماً
|
||||
تابع سرویس اپ Payment رو صدا میزنه تا Order بسازه (همون «فراخوانی
|
||||
سرور-به-سرور» سند API که در این مونولیت به شکل import مستقیم پیادهسازی
|
||||
شده، نه یک HTTP call جدا به خودمون).
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
# ⚠️ افزوده شده: مستندسازی هدر Idempotency-Key که قبلاً در Swagger UI
|
||||
# قابل تنظیم/تست نبود.
|
||||
@extend_schema(
|
||||
request=PurchaseRequestSerializer,
|
||||
responses=PurchaseResponseSerializer,
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False,
|
||||
description=(
|
||||
'کلید یکتای idempotency برای جلوگیری از ساخت سفارش تکراری هنگام retry. '
|
||||
'در صورت عدم ارسال، یک کلید تصادفی ساخته میشود.'
|
||||
),
|
||||
),
|
||||
],
|
||||
tags=['Subscription (subscription)'],
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = PurchaseRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
v = serializer.validated_data
|
||||
user = request.user
|
||||
|
||||
plan = get_object_or_404(Plan, pk=v['planId'])
|
||||
|
||||
# ⚠️ اصلاح: قبلاً اگه create_order (مثلاً بهخاطر IdempotencyKeyConflict)
|
||||
# خطا میداد، Subscription که یک خط بالاتر update_or_create شده بود
|
||||
# نیمهکاره (status='pending' ولی بدون order_id معتبر) در دیتابیس
|
||||
# باقی میموند. حالا کل عملیات در یک تراکنش اتمیک انجام میشه.
|
||||
with transaction.atomic():
|
||||
subscription, _created = Subscription.objects.update_or_create(
|
||||
user_id=user.id,
|
||||
defaults={'plan': plan, 'status': 'pending'},
|
||||
)
|
||||
|
||||
order, payment_url = create_order(
|
||||
user_id=user.id,
|
||||
amount=plan.price,
|
||||
currency=plan.currency,
|
||||
subscription_id=subscription.id,
|
||||
idempotency_key=request.headers.get('Idempotency-Key'),
|
||||
)
|
||||
subscription.order_id = order.id
|
||||
subscription.save(update_fields=['order_id'])
|
||||
|
||||
data = {'orderId': str(order.id), 'paymentUrl': payment_url}
|
||||
return Response(PurchaseResponseSerializer(data).data)
|
||||
|
||||
|
||||
class SubscriptionStatusView(APIView):
|
||||
"""GET /api/subscription/status — وضعیت فعلی اشتراک"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(responses=SubscriptionStatusResponseSerializer, tags=['Subscription (subscription)'])
|
||||
def get(self, request):
|
||||
subscription = Subscription.objects.filter(user_id=request.user.id).first()
|
||||
data = {
|
||||
'active': bool(subscription and subscription.status == 'active'),
|
||||
'planId': str(subscription.plan_id) if subscription and subscription.plan_id else None,
|
||||
'expiresAt': subscription.expires_at if subscription else None,
|
||||
}
|
||||
return Response(SubscriptionStatusResponseSerializer(data).data)
|
||||
Reference in New Issue
Block a user