Initial commit - clean project structure

This commit is contained in:
2026-08-18 16:48:15 +03:30
commit 331148037e
147 changed files with 5543 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', 'provider', 'created_at')
list_filter = ('status', 'provider', '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'

105
payment/bazaar.py Normal file
View File

@@ -0,0 +1,105 @@
"""
کلاینت سرور-به-سرورِ پرداخت درون‌برنامه‌ای بازار (REST API v2)، طبق داکیومنت
رسمی: developers.cafebazaar.ir/fa/guidelines/in-app-billing
جریان رسمی:
۱) یک‌بار (دستی، از پنل devapi توسعه‌دهنده) با CLIENT_ID/CLIENT_SECRET یک
authorization code گرفته می‌شه و با اون REFRESH_TOKEN ساخته می‌شه —
این مرحله در این فایل نیست چون یک‌بار و دستی‌ست، نه بخشی از API عمومی.
۲) بک‌اند با همون REFRESH_TOKEN، هر بار (یا هر بار که access_token منقضی
شده) یک ACCESS_TOKEN تازه می‌گیره (POST /auth/token با
grant_type=refresh_token).
۳) با همون access_token، وضعیت خرید/اشتراک از endpoint purchase/subscription
خونده می‌شه (GET .../applications/{package}/inapp/{sku}/purchases/{token}/).
⚠️ کافه‌بازار روش صحت‌سنجی رو تغییر داده (نگاه کنید به
developers.cafebazaar.ir/fa/guidelines/in-app-billing/api/postman-and-bazaar-api-new)؛
قبل از اتصال واقعی به production، دقیق‌ترین مسیر endpoint را از همون صفحه یا
پنل devapi خودتون تایید کنید — این فایل فقط اسکلتِ همون جریان رسمی رو
پیاده‌سازی کرده تا بدون کلید واقعی هم (مثل call_payment_gateway در
services.py) از طریق Swagger قابل تست باشه.
"""
from dataclasses import dataclass
import requests
from django.conf import settings
from django.core.cache import cache
ACCESS_TOKEN_CACHE_KEY = 'bazaar_iap_access_token'
@dataclass
class BazaarPurchaseResult:
is_valid: bool
purchase_state: int | None = None # ۰=خریداری‌شده، ۱=لغوشده (طبق سند بازار)
consumption_state: int | None = None
developer_payload: str = ''
raw: dict | None = None
def _get_access_token() -> str:
"""
ACCESS_TOKEN رو کش می‌کنه (توی cache framework جنگو) تا هر verify یک
درخواست OAuth جدا نزنه؛ اگه کش نداشت یا منقضی شده بود، با REFRESH_TOKEN
یکی تازه می‌گیره.
"""
cached = cache.get(ACCESS_TOKEN_CACHE_KEY)
if cached:
return cached
if not settings.BAZAAR_REFRESH_TOKEN:
# بدون کلید واقعی (مثلاً محیط توسعه)، استاب: دقیقاً مثل
# call_payment_gateway در payment/services.py، تا جریان کامل بدون
# کلید واقعی بازار هم قابل تست باشه.
return 'stub-access-token'
resp = requests.post(
f'{settings.BAZAAR_API_BASE}/auth/token/',
data={
'grant_type': 'refresh_token',
'client_id': settings.BAZAAR_CLIENT_ID,
'client_secret': settings.BAZAAR_CLIENT_SECRET,
'refresh_token': settings.BAZAAR_REFRESH_TOKEN,
},
timeout=10,
)
resp.raise_for_status()
data = resp.json()
token = data['access_token']
# طبق سند بازار access_token معمولاً ~۱ ساعت اعتباره؛ کمی زودتر منقضی
# می‌کنیم تا لبه‌ی زمانی مشکل نسازه.
cache.set(ACCESS_TOKEN_CACHE_KEY, token, timeout=int(data.get('expires_in', 3600)) - 60)
return token
def verify_purchase(*, product_sku: str, purchase_token: str, is_subscription: bool = False) -> BazaarPurchaseResult:
"""
صحت‌سنجی purchaseToken که کلاینت اندروید بعد از خرید از SDK بازار گرفته.
is_subscription=True برای اشتراک‌های دوره‌ای بازار (endpoint جدا از خرید
تک‌محصولی)، طبق سند رسمی.
"""
if not settings.BAZAAR_REFRESH_TOKEN:
# استاب توسعه: هر token غیرخالی معتبر فرض می‌شه تا کل جریان
# (ساخت سفارش bazaar → verify → فعال‌سازی اشتراک) بدون کلید واقعی
# از طریق Swagger قابل تست باشه.
return BazaarPurchaseResult(is_valid=bool(purchase_token), purchase_state=0)
access_token = _get_access_token()
kind = 'subscriptions' if is_subscription else 'purchases'
url = (
f'{settings.BAZAAR_API_BASE}/api/applications/'
f'{settings.BAZAAR_PACKAGE_NAME}/{kind}/{product_sku}/{purchase_token}/'
)
resp = requests.get(url, headers={'Authorization': f'Bearer {access_token}'}, timeout=10)
if resp.status_code != 200:
return BazaarPurchaseResult(is_valid=False, raw={'status_code': resp.status_code})
data = resp.json()
purchase_state = data.get('purchaseState')
return BazaarPurchaseResult(
is_valid=purchase_state == 0, # ۰ یعنی خریداری‌شده و معتبر
purchase_state=purchase_state,
consumption_state=data.get('consumptionState'),
developer_payload=data.get('developerPayload', ''),
raw=data,
)

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

@@ -0,0 +1,28 @@
# Generated by Django 6.1 on 2026-08-18 02:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('payment', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='order',
name='bazaar_product_sku',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='order',
name='bazaar_purchase_token',
field=models.CharField(blank=True, max_length=300),
),
migrations.AddField(
model_name='order',
name='provider',
field=models.CharField(choices=[('gateway', 'درگاه پرداخت'), ('bazaar', 'پرداخت درون\u200cبرنامه\u200cای بازار')], default='gateway', max_length=10),
),
]

View File

52
payment/models.py Normal file
View File

@@ -0,0 +1,52 @@
from django.db import models
class Order(models.Model):
"""
تنها اپی که واقعاً پول جابه‌جا می‌کنه. عمداً از Subscription جدا شده
چون منطق و ریسکش فرق داره (درگاه، retry، idempotency).
"""
STATUS_CHOICES = [('pending', 'در انتظار'), ('paid', 'پرداخت‌شده'), ('failed', 'ناموفق')]
# دو مدل پرداخت طبق درخواست سینیور: 'gateway' (زرین‌پال/زیبال و مشابه) و
# 'bazaar' (پرداخت درون‌برنامه‌ای اپلیکیشن بازار). این دو منطق پرداخت و
# صحت‌سنجی کاملاً متفاوتی دارن (webhook درگاه در برابر verify سرور-به-
# سرور با access_token بازار)؛ برای همین provider جداست، نه فقط یک مقدار
# دیگه در Transaction.gateway (که فقط برای گزارش‌گیریِ درگاه واقعی زرین‌پال/
# زیبال باقی می‌مونه).
PROVIDER_CHOICES = [('gateway', 'درگاه پرداخت'), ('bazaar', 'پرداخت درون‌برنامه‌ای بازار')]
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')
provider = models.CharField(max_length=10, choices=PROVIDER_CHOICES, default='gateway')
# فقط وقتی provider == 'bazaar' پر می‌شن: SKU محصول درون‌برنامه‌ای تعریف‌شده
# در پنل بازار، و purchase token‌ای که کلاینت اندروید بعد از خرید از SDK
# بازار می‌گیره و برای verify به بک‌اند می‌فرسته.
bazaar_product_sku = models.CharField(max_length=100, blank=True)
bazaar_purchase_token = models.CharField(max_length=300, blank=True)
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

67
payment/serializers.py Normal file
View File

@@ -0,0 +1,67 @@
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)
# provider='bazaar' وقتی کاربر از همون ابتدا پرداخت درون‌برنامه‌ای بازار
# رو انتخاب کرده؛ در این حالت amount باید دقیقاً برابر قیمتِ محصولِ
# تعریف‌شده در پنل بازار برای همون bazaarProductSku باشه (بازار خودش
# قیمت رو کنترل می‌کنه، نه ما).
provider = serializers.ChoiceField(choices=['gateway', 'bazaar'], required=False, default='gateway')
bazaarProductSku = serializers.CharField(required=False, allow_blank=True)
class BazaarVerifyRequestSerializer(serializers.Serializer):
orderId = serializers.CharField()
productSku = serializers.CharField(required=False, allow_blank=True)
purchaseToken = serializers.CharField()
class BazaarVerifyResponseSerializer(serializers.Serializer):
orderId = serializers.CharField()
status = serializers.CharField()
valid = serializers.BooleanField()
class CreateOrderResponseSerializer(serializers.Serializer):
orderId = serializers.CharField()
status = serializers.CharField()
# provider='bazaar' یعنی paymentUrl نداریم (کلاینت با SDK بازار خودش
# خرید می‌کنه، نه با ریدایرکت به یک URL).
paymentUrl = serializers.CharField(allow_null=True)
provider = 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)

166
payment/services.py Normal file
View File

@@ -0,0 +1,166 @@
"""
منطق مشترک اپ Payment که هم از views.py همین اپ و هم به‌صورت سرور-به-سرور
از اپ‌های دیگه (مثل subscription) صدا زده می‌شه. چون این پروژه یک مونولیت
جنگویی‌ه (نه میکروسرویس واقعی)، «فراخوانی سرور-به-سرور» با یک import مستقیم
پیاده‌سازی شده، نه یک HTTP call به خودمون.
"""
from django.db import IntegrityError
from django.db import transaction as db_transaction
from django.utils import timezone
from django.utils.crypto import get_random_string
from rest_framework.exceptions import APIException
from statuslog.signals import status_changed
from .models import Order, Transaction
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,
provider: str = 'gateway', bazaar_product_sku: str = ''):
"""
ساخت سفارش با پشتیبانی از 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 = None if existing.provider == 'bazaar' else 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',
provider=provider,
bazaar_product_sku=bazaar_product_sku,
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,
)
# برای provider='bazaar' هیچ paymentUrlی وجود نداره: کلاینت اندروید خودش
# مستقیماً با SDK بازار (IInAppBillingService) خرید رو انجام می‌ده و بعدش
# purchaseToken رو به /api/payment/bazaar/verify می‌فرسته؛ ما اینجا فقط
# سفارش رو از قبل با bazaarProductSku ثبت می‌کنیم تا verify بتونه ازش
# امنش (amount/user) رو تطبیق بده.
payment_url = None if order.provider == 'bazaar' else call_payment_gateway(order)
return order, payment_url
def apply_order_payment_result(*, order: Order, gateway: str, gateway_ref_id: str,
new_status: str, event: str) -> bool:
"""
منطق مشترکِ «نتیجه‌ی پرداخت رسید»، هم برای PaymentWebhookView (درگاه
معمولی) و هم BazaarVerifyPurchaseView (پرداخت درون‌برنامه‌ای بازار).
قبلاً این منطق فقط داخل PaymentWebhookView تکرار می‌شد؛ چون بازار جریان
verify کاملاً متفاوتی داره (بدون webhook، با فراخوانی سرور-به-سرور از
کلاینت)، اما اثرات نهایی (Transaction، status، فعال‌سازی اشتراک، notify،
ثبت در statuslog) دقیقاً یکی‌ان، این تابع مشترک شده تا دو بار پیاده‌سازی
و احتمال ناهماهنگی نداشته باشیم.
idempotent: اگه Transaction مشابه (order/status/gateway_ref_id) از قبل
موجود باشه، دوباره اثر جانبی اجرا نمی‌شه. خروجی: True اگه این فراخوانی
واقعاً باعث paid شدن تازه شده باشه.
"""
already_paid = order.status == 'paid'
old_status = order.status
is_duplicate = Transaction.objects.filter(
order=order, status=new_status, gateway_ref_id=gateway_ref_id,
).exists()
if not is_duplicate:
order.status = new_status
order.save(update_fields=['status'])
Transaction.objects.create(
order=order,
gateway=gateway,
gateway_ref_id=gateway_ref_id,
status=new_status,
paid_at=timezone.now() if new_status == 'paid' else None,
)
status_changed.send(
sender='payment.Order', object_id=order.id,
old_status=old_status, new_status=new_status,
event=event, user_id=order.user_id,
)
newly_paid = new_status == 'paid' and not already_paid and not is_duplicate
if newly_paid:
if order.subscription_id:
from subscription.services import activate_subscription
activate_subscription(order.subscription_id)
from notifications.services import notify
notify(
user_id=order.user_id,
type='order_paid',
title='پرداخت با موفقیت انجام شد',
related_id=order.id,
)
return newly_paid

3
payment/tests.py Normal file
View File

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

15
payment/urls.py Normal file
View File

@@ -0,0 +1,15 @@
from django.urls import path
from .views import (
BazaarVerifyPurchaseView,
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/bazaar/verify', BazaarVerifyPurchaseView.as_view(), name='payment-bazaar-verify'),
path('payment/orders/<int:order_id>', OrderDetailView.as_view(), name='payment-order-detail'),
]

201
payment/views.py Normal file
View File

@@ -0,0 +1,201 @@
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 . import bazaar
from .models import Order, Transaction
from .permissions import HasInternalServiceKey, HasWebhookSecret
from .serializers import (
BazaarVerifyRequestSerializer,
BazaarVerifyResponseSerializer,
CreateOrderRequestSerializer,
CreateOrderResponseSerializer,
OrderDetailResponseSerializer,
WebhookRequestSerializer,
WebhookResponseSerializer,
)
from .services import apply_order_payment_result, 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'),
provider=v.get('provider') or 'gateway',
bazaar_product_sku=v.get('bazaarProductSku') or '',
)
data = {
'orderId': str(order.id), 'status': order.status,
'paymentUrl': payment_url, 'provider': order.provider,
}
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'])
# ⚠️ اصلاح امنیتی/صحت داده (قبلی): هر بار فراخوانی webhook (حتی
# تکراری از سمت درگاه) یک Transaction جدید می‌ساخت. حالا این
# idempotency + اثرات جانبی (فعال‌سازی اشتراک/notify/statuslog)
# داخل apply_order_payment_result مشترکه (هم اینجا هم بازار).
apply_order_payment_result(
order=order,
gateway=v.get('gateway') or 'zarinpal',
gateway_ref_id=gateway_ref_id,
new_status=v['status'],
event='gateway_webhook',
)
return Response(WebhookResponseSerializer({'received': True}).data)
class BazaarVerifyPurchaseView(APIView):
"""
POST /api/payment/bazaar/verify — تایید پرداخت درون‌برنامه‌ای بازار
برخلاف درگاه معمولی که با webhook خودِ درگاه تایید می‌شه، در پرداخت
درون‌برنامه‌ای بازار هیچ webhookی نیست: کلاینت اندروید بعد از خرید از
SDK بازار (IInAppBillingService)، یک purchaseToken می‌گیره و باید همون
رو برای این endpoint بفرسته تا بک‌اند با فراخوانی سرور-به-سرور به API
رسمی بازار (bazaar.verify_purchase)، صحتش رو تایید کنه — مشابه دقیقاً
همون چیزی که سند رسمی بازار توصیه می‌کنه: «هیچ‌وقت فقط به گفته‌ی کلاینت
اعتماد نکنید، حتماً سمت سرور verify کنید».
"""
permission_classes = [IsAuthenticated]
@extend_schema(
request=BazaarVerifyRequestSerializer,
responses=BazaarVerifyResponseSerializer,
tags=['Payment (payment)'],
)
def post(self, request):
serializer = BazaarVerifyRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
with transaction.atomic():
order = get_object_or_404(
Order.objects.select_for_update(),
pk=v['orderId'], user_id=request.user.id, provider='bazaar',
)
result = bazaar.verify_purchase(
product_sku=order.bazaar_product_sku or v['productSku'],
purchase_token=v['purchaseToken'],
)
new_status = 'paid' if result.is_valid else 'failed'
order.bazaar_purchase_token = v['purchaseToken']
order.save(update_fields=['bazaar_purchase_token'])
apply_order_payment_result(
order=order,
gateway='bazaar',
gateway_ref_id=v['purchaseToken'],
new_status=new_status,
event='bazaar_verify',
)
data = {'orderId': str(order.id), 'status': order.status, 'valid': result.is_valid}
return Response(BazaarVerifyResponseSerializer(data).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)