Initial commit

This commit is contained in:
2026-08-11 16:43:12 +03:30
commit e1d830f6a9
127 changed files with 4454 additions and 0 deletions

0
payment/__init__.py Normal file
View File

21
payment/admin.py Normal file
View File

@@ -0,0 +1,21 @@
from django.contrib import admin
from .models import Order, Transaction
class TransactionInline(admin.TabularInline):
model = Transaction
extra = 0
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ('id', 'user_id', 'subscription_id', 'amount', 'currency', 'status', 'created_at')
list_filter = ('status', 'currency')
inlines = [TransactionInline]
@admin.register(Transaction)
class TransactionAdmin(admin.ModelAdmin):
list_display = ('id', 'order', 'gateway', 'status', 'paid_at')
list_filter = ('gateway', 'status')

6
payment/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class PaymentConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'payment'

View File

@@ -0,0 +1,46 @@
# 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='Order',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_id', models.IntegerField(db_index=True)),
('subscription_id', models.IntegerField(blank=True, null=True)),
('amount', models.PositiveIntegerField()),
('currency', models.CharField(default='IRT', max_length=10)),
('status', models.CharField(choices=[('pending', 'در انتظار'), ('paid', 'پرداخت‌شده'), ('failed', 'ناموفق')], default='pending', max_length=10)),
('idempotency_key', models.CharField(max_length=100, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Transaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('gateway', models.CharField(default='zarinpal', max_length=30)),
('gateway_ref_id', models.CharField(blank=True, max_length=100)),
('status', models.CharField(max_length=30)),
('paid_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to='payment.order')),
],
options={
'ordering': ['created_at'],
},
),
]

View File

38
payment/models.py Normal file
View File

@@ -0,0 +1,38 @@
from django.db import models
class Order(models.Model):
"""
تنها اپی که واقعاً پول جابه‌جا می‌کنه. عمداً از Subscription جدا شده
چون منطق و ریسکش فرق داره (درگاه، retry، idempotency).
"""
STATUS_CHOICES = [('pending', 'در انتظار'), ('paid', 'پرداخت‌شده'), ('failed', 'ناموفق')]
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
subscription_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به subscription.Subscription
amount = models.PositiveIntegerField()
currency = models.CharField(max_length=10, default='IRT')
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending')
idempotency_key = models.CharField(max_length=100, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f"order-{self.pk}-user{self.user_id}"
class Transaction(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='transactions')
gateway = models.CharField(max_length=30, default='zarinpal')
gateway_ref_id = models.CharField(max_length=100, blank=True)
status = models.CharField(max_length=30)
paid_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['created_at']
def __str__(self):
return f"txn-{self.pk}-order{self.order_id}"

31
payment/permissions.py Normal file
View File

@@ -0,0 +1,31 @@
from django.conf import settings
from rest_framework.permissions import BasePermission
class HasInternalServiceKey(BasePermission):
"""
برای endpointهایی مثل POST /api/payment/orders که طبق سند فقط قراره
سرور-به-سرور (از اپ Subscription) صدا زده بشن، نه از یک کلاینت دلخواه.
درخواست باید هدر X-Internal-Key رو برابر settings.INTERNAL_SERVICE_KEY
بفرسته؛ در غیر این صورت 403 برمی‌گرده.
"""
message = 'این endpoint فقط برای فراخوانی داخلی/سرور-به-سرور است.'
def has_permission(self, request, view):
provided = request.headers.get('X-Internal-Key', '')
return bool(provided) and provided == settings.INTERNAL_SERVICE_KEY
class HasWebhookSecret(BasePermission):
"""
برای POST /api/payment/webhook: باید از سمت درگاه پرداخت بیاد، نه یک
کلاینت دلخواه. تا زمان اتصال درگاه واقعی (که خودش امضای HMAC داره)،
یک shared-secret ساده از طریق هدر X-Webhook-Secret بررسی می‌شه.
"""
message = 'امضای وب‌هوک نامعتبر است.'
def has_permission(self, request, view):
provided = request.headers.get('X-Webhook-Secret', '')
return bool(provided) and provided == settings.PAYMENT_WEBHOOK_SECRET

46
payment/serializers.py Normal file
View File

@@ -0,0 +1,46 @@
from rest_framework import serializers
class CreateOrderRequestSerializer(serializers.Serializer):
# این فراخوانی سرور-به-سرور از اپ Subscription‌ه، نه از کلاینت؛ به همین
# دلیل userId اینجا مستقیماً در بدنه ارسال می‌شه (نه از توکن استخراج).
userId = serializers.CharField()
amount = serializers.IntegerField()
currency = serializers.CharField(required=False, default='IRT')
subscriptionId = serializers.CharField(required=False, allow_null=True, allow_blank=True)
class CreateOrderResponseSerializer(serializers.Serializer):
orderId = serializers.CharField()
status = serializers.CharField()
paymentUrl = serializers.CharField()
class WebhookRequestSerializer(serializers.Serializer):
# کال‌بک از سمت درگاه پرداخت میاد، نه از اپ
orderId = serializers.CharField()
gateway = serializers.CharField(required=False, default='zarinpal')
gatewayRefId = serializers.CharField(required=False, allow_blank=True)
# ⚠️ اصلاح امنیتی: قبلاً status یک CharField آزاد بود و هر مقداری
# (حتی مقادیر پشتیبانی‌نشده/اشتباه) رو مستقیم روی Order ذخیره می‌کرد.
# حالا فقط دو وضعیت نهاییِ معتبرِ کال‌بک درگاه پذیرفته می‌شه؛ 'pending'
# وضعیت اولیه‌ی داخلیه و نباید از بیرون توسط webhook قابل‌تنظیم باشه.
status = serializers.ChoiceField(choices=['paid', 'failed'])
class WebhookResponseSerializer(serializers.Serializer):
received = serializers.BooleanField()
class TransactionSerializer(serializers.Serializer):
id = serializers.CharField()
gateway = serializers.CharField()
status = serializers.CharField()
paidAt = serializers.DateTimeField(allow_null=True)
class OrderDetailResponseSerializer(serializers.Serializer):
orderId = serializers.CharField()
status = serializers.CharField()
amount = serializers.IntegerField()
transactions = TransactionSerializer(many=True)

98
payment/services.py Normal file
View File

@@ -0,0 +1,98 @@
"""
منطق مشترک اپ Payment که هم از views.py همین اپ و هم به‌صورت سرور-به-سرور
از اپ‌های دیگه (مثل subscription) صدا زده می‌شه. چون این پروژه یک مونولیت
جنگویی‌ه (نه میکروسرویس واقعی)، «فراخوانی سرور-به-سرور» با یک import مستقیم
پیاده‌سازی شده، نه یک HTTP call به خودمون.
"""
from django.db import IntegrityError
from django.db import transaction as db_transaction
from django.utils.crypto import get_random_string
from rest_framework.exceptions import APIException
from .models import Order
class IdempotencyKeyConflict(APIException):
"""
⚠️ اصلاح امنیتی: قبلاً create_order فقط با idempotency_key سفارش رو
get_or_create می‌کرد، بدون بررسی اینکه صاحب سفارش موجود همون کاربرِ
درخواست‌دهنده‌ست یا پارامترها (amount/currency/subscription) یکی‌ان.
این یعنی یک کاربر می‌تونست با حدس زدن/استفاده‌ی مجدد از یک
Idempotency-Key، سفارشِ کاربر دیگه‌ای رو برگردونه/استفاده کنه، یا با
همون کلید ولی amount متفاوت، رفتار غیرمنتظره بگیره. حالا این حالت‌ها
409 Conflict برمی‌گردونن.
"""
status_code = 409
default_detail = 'این Idempotency-Key قبلاً برای درخواست دیگری استفاده شده است.'
default_code = 'idempotency_key_conflict'
def _ensure_idempotent_match(order: Order, *, user_id: int, amount: int, currency: str,
subscription_id: int | None) -> None:
if order.user_id != user_id:
raise IdempotencyKeyConflict(
'این Idempotency-Key متعلق به کاربر دیگری است و نمی‌تواند استفاده شود.'
)
if (order.amount != amount or order.currency != currency
or order.subscription_id != subscription_id):
raise IdempotencyKeyConflict(
'این Idempotency-Key قبلاً با پارامترهای متفاوتی استفاده شده است.'
)
def call_payment_gateway(order: Order) -> str:
"""
استاب فراخوانی درگاه پرداخت واقعی (zarinpal و غیره) — دقیقاً مثل الگوی
generate_tutor_answer در ai_tutor/views.py. فعلاً فقط یک paymentUrl
قطعی و ساختگی برمی‌گردونه تا کل جریان (ساخت سفارش → پرداخت → webhook)
بدون نیاز به کلید API واقعی درگاه، از طریق Swagger قابل تست باشه.
برای اتصال واقعی، این تابع را با فراخوانی HTTP به API درگاه (مثلاً
zarinpal.com/pg/rest/WebGate/PaymentRequest.json) جایگزین کنید.
"""
return f'https://payment.gateway/pay/{order.id}'
def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
subscription_id: int | None = None, idempotency_key: str | None = None):
"""
ساخت سفارش با پشتیبانی از Idempotency-Key (بخش ۴.۳ سند API): اگه سفارشی
با همین کلید قبلاً ساخته شده باشه، همون رکورد برگردونده می‌شه، نه یک
رکورد تکراری (مثلاً وقتی کلاینت درخواست رو به‌خاطر timeout دوباره می‌فرسته)
— اما فقط وقتی سفارشِ موجود واقعاً متعلق به همین کاربر و همین پارامترهاست
(وگرنه IdempotencyKeyConflict می‌گیره؛ نگاه کنید به توضیح بالای این فایل).
"""
if not idempotency_key:
idempotency_key = get_random_string(32)
existing = Order.objects.filter(idempotency_key=idempotency_key).first()
if existing is not None:
_ensure_idempotent_match(
existing, user_id=user_id, amount=amount, currency=currency,
subscription_id=subscription_id,
)
payment_url = call_payment_gateway(existing)
return existing, payment_url
try:
with db_transaction.atomic():
order = Order.objects.create(
user_id=user_id,
subscription_id=subscription_id,
amount=amount,
currency=currency,
status='pending',
idempotency_key=idempotency_key,
)
except IntegrityError:
# race condition: درخواست هم‌زمان دیگه‌ای با همین کلید زودتر ساخت.
order = Order.objects.filter(idempotency_key=idempotency_key).first()
if order is None:
raise
_ensure_idempotent_match(
order, user_id=user_id, amount=amount, currency=currency,
subscription_id=subscription_id,
)
payment_url = call_payment_gateway(order)
return order, payment_url

3
payment/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

9
payment/urls.py Normal file
View File

@@ -0,0 +1,9 @@
from django.urls import path
from .views import CreateOrderView, OrderDetailView, PaymentWebhookView
urlpatterns = [
path('payment/orders', CreateOrderView.as_view(), name='payment-create-order'),
path('payment/webhook', PaymentWebhookView.as_view(), name='payment-webhook'),
path('payment/orders/<int:order_id>', OrderDetailView.as_view(), name='payment-order-detail'),
]

174
payment/views.py Normal file
View File

@@ -0,0 +1,174 @@
from django.db import transaction
from django.shortcuts import get_object_or_404
from django.utils import timezone
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 .models import Order, Transaction
from .permissions import HasInternalServiceKey, HasWebhookSecret
from .serializers import (
CreateOrderRequestSerializer,
CreateOrderResponseSerializer,
OrderDetailResponseSerializer,
WebhookRequestSerializer,
WebhookResponseSerializer,
)
from .services import create_order
class CreateOrderView(APIView):
"""
POST /api/payment/orders — ساخت سفارش (فراخوانی‌شده از اپ Subscription)
طبق سند API، این یک فراخوانی سرور-به-سرور از اپ Subscription‌ه، نه از
کلاینت. در عمل subscription/views.py مستقیماً تابع create_order رو
import و صدا می‌زنه، پس این endpoint برای جریان عادی از بیرون صدا زده
نمی‌شه؛ برای همین به‌جای AllowAny یک shared-secret داخلی
(HasInternalServiceKey، هدر X-Internal-Key) لازمه، وگرنه هر کلاینتی
می‌تونست با یک userId دلخواه سفارش بسازه.
"""
permission_classes = [HasInternalServiceKey]
# ⚠️ افزوده شده: هدرهای موردنیاز (X-Internal-Key, Idempotency-Key) قبلاً
# مستند نشده بودن و از Swagger UI اصلاً قابل ست‌کردن/تست‌کردن نبودن.
@extend_schema(
request=CreateOrderRequestSerializer,
responses=CreateOrderResponseSerializer,
parameters=[
OpenApiParameter(
'X-Internal-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=True,
description='کلید مخفیِ فراخوانی داخلی/سرور-به-سرور (settings.INTERNAL_SERVICE_KEY).',
),
OpenApiParameter(
'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False,
description=(
'کلید یکتای idempotency برای جلوگیری از ساخت سفارش تکراری هنگام retry. '
'در صورت عدم ارسال، یک کلید تصادفی ساخته می‌شود.'
),
),
],
tags=['Payment (payment)'],
)
def post(self, request):
serializer = CreateOrderRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
order, payment_url = create_order(
user_id=int(v['userId']),
amount=v['amount'],
currency=v.get('currency') or 'IRT',
subscription_id=int(v['subscriptionId']) if v.get('subscriptionId') else None,
idempotency_key=request.headers.get('Idempotency-Key'),
)
data = {'orderId': str(order.id), 'status': order.status, 'paymentUrl': payment_url}
return Response(CreateOrderResponseSerializer(data).data)
class PaymentWebhookView(APIView):
"""
POST /api/payment/webhook — کال‌بک درگاه پرداخت (از سمت درگاه میاد، نه کلاینت)
محافظت‌شده با HasWebhookSecret (هدر X-Webhook-Secret) تا هر کلاینتی
نتونه با یک orderId معتبر و status=paid، بدون پرداخت واقعی، اشتراک
فعال کنه. همچنین idempotent شده: اگه سفارش از قبل paid باشه، دوباره
Transaction/notify تکراری نمی‌سازه (مثلاً وقتی درگاه callback رو دوباره
می‌فرسته).
"""
permission_classes = [HasWebhookSecret]
# ⚠️ افزوده شده: مستندسازی هدر X-Webhook-Secret که قبلاً در Swagger UI
# قابل تنظیم/تست نبود.
@extend_schema(
request=WebhookRequestSerializer,
responses=WebhookResponseSerializer,
parameters=[
OpenApiParameter(
'X-Webhook-Secret', OpenApiTypes.STR, OpenApiParameter.HEADER, required=True,
description='کلید مخفیِ وب‌هوک درگاه پرداخت (settings.PAYMENT_WEBHOOK_SECRET).',
),
],
tags=['Payment (payment)'],
)
def post(self, request):
serializer = WebhookRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
gateway_ref_id = v.get('gatewayRefId', '')
with transaction.atomic():
order = get_object_or_404(Order.objects.select_for_update(), pk=v['orderId'])
already_paid = order.status == 'paid'
# ⚠️ اصلاح امنیتی/صحت داده: قبلاً هر بار فراخوانی webhook (حتی
# اگه درگاه به‌خاطر عدم دریافت ۲۰۰ یا هر دلیل دیگه‌ای همون
# کال‌بک رو دوباره می‌فرستاد) یک رکورد Transaction جدید می‌ساخت.
# حالا اگه رکوردی با همین order/status/gatewayRefId از قبل
# موجود باشه، تکراری در نظر گرفته می‌شه و نه Transaction جدید
# ساخته می‌شه و نه اثرات جانبی (فعال‌سازی اشتراک/notify) دوباره
# اجرا می‌شن.
is_duplicate = Transaction.objects.filter(
order=order, status=v['status'], gateway_ref_id=gateway_ref_id,
).exists()
if not is_duplicate:
order.status = v['status']
order.save(update_fields=['status'])
Transaction.objects.create(
order=order,
gateway=v.get('gateway') or 'zarinpal',
gateway_ref_id=gateway_ref_id,
status=v['status'],
paid_at=timezone.now() if v['status'] == 'paid' else None,
)
if v['status'] == 'paid' and not already_paid and not is_duplicate:
if order.subscription_id:
# فعال‌سازی اشتراک بعد از پرداخت موفق. ارتباط با اپ
# subscription هم مثل create_order یک import مستقیم به
# تابع سرویسه (رفرنس سبک با id، نه FK واقعی و نه HTTP
# call به خودمون).
from subscription.services import activate_subscription
activate_subscription(order.subscription_id)
# رویداد order_paid طبق سند notifications باید ساطع بشه؛
# قبلاً این فراخوانی جا افتاده بود (فقط اشتراک فعال می‌شد).
from notifications.services import notify
notify(
user_id=order.user_id,
type='order_paid',
title='پرداخت با موفقیت انجام شد',
related_id=order.id,
)
return Response(WebhookResponseSerializer({'received': True}).data)
class OrderDetailView(APIView):
"""GET /api/payment/orders/{id} — وضعیت سفارش"""
permission_classes = [IsAuthenticated]
@extend_schema(responses=OrderDetailResponseSerializer, tags=['Payment (payment)'])
def get(self, request, order_id):
order = get_object_or_404(Order, pk=order_id, user_id=request.user.id)
data = {
'orderId': str(order.id),
'status': order.status,
'amount': order.amount,
'transactions': [
{
'id': str(t.id),
'gateway': t.gateway,
'status': t.status,
'paidAt': t.paid_at,
}
for t in order.transactions.all()
],
}
return Response(OrderDetailResponseSerializer(data).data)