Initial commit - clean project structure
This commit is contained in:
0
payment/__init__.py
Normal file
0
payment/__init__.py
Normal file
21
payment/admin.py
Normal file
21
payment/admin.py
Normal 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
6
payment/apps.py
Normal 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
105
payment/bazaar.py
Normal 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,
|
||||
)
|
||||
46
payment/migrations/0001_initial.py
Normal file
46
payment/migrations/0001_initial.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
0
payment/migrations/__init__.py
Normal file
0
payment/migrations/__init__.py
Normal file
52
payment/models.py
Normal file
52
payment/models.py
Normal 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
31
payment/permissions.py
Normal 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
67
payment/serializers.py
Normal 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
166
payment/services.py
Normal 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
3
payment/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
15
payment/urls.py
Normal file
15
payment/urls.py
Normal 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
201
payment/views.py
Normal 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)
|
||||
Reference in New Issue
Block a user