Question packs removed at the root; all subscriptions 4 assistant questions a day; Bazaar is subscription-only

- subscription.0017/0018, payment.0013: pack plans deleted, QuestionCredit/HeartsPass models and Plan.kind /
  question_credits / hearts_days / Order.question_credits / hearts_days dropped. The only paid pack order was the
  company owner's test account (user 26).
- Assistant: no credit spending; quota is the daily cap only (free 2, every plan 4, gift subscription 4).
- Bazaar: verify_purchase is subscription-only (no inapp guess), start/purchase responses lose productType,
  verify refuses an order without a subscription. A paid order with no subscription now logs ERROR.
- Removed POST /api/payment/orders (internal; no caller). Its permission tests stay.
- Pack-based tests rewritten on subscriptions (refund, security, Bazaar token); pack-only test files deleted.
- docs: profit table for 4/day on both payment routes plus free users; handbook, operations, CLAUDE.md current.
949 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 20:33:38 +03:30
parent 9c3adc3e60
commit 947f946909
39 changed files with 398 additions and 1174 deletions

View File

@@ -33,7 +33,7 @@ class OrderAdmin(admin.ModelAdmin):
# همه‌ی ستون‌ها فقط‌خواندنی‌اند، نه فقط وضعیت: اجازه‌ی change برای دو action لازم است، و تا امروز
# همان اجازه فرمِ سفارش را هم باز می‌کرد. کارمندی که فقط باید لغو/بازپرداخت کند، روی سفارشِ در
# انتظارِ خودش amount را ۱۰۰۰ و question_credits را ۱۰۰٬۰۰۰ کرد و با ۱۰۰۰ تومان ۱۰۰٬۰۰۰ سوال گرفت
# انتظارِ خودش amount را ۱۰۰۰ کرد و با ۱۰۰۰ تومان خرید (بازتولید شد)
# (بازتولید شد). سفارش سندِ مالی است: از پنل ساخته یا پاک هم نمی‌شود؛ پاک شدنِ سفارشِ پرداخت‌شده
# تراکنش‌هایش را می‌برد و توکنِ خریدش برای سفارشِ دیگری دوباره پذیرفته می‌شد.
def get_readonly_fields(self, request, obj=None):
@@ -76,14 +76,8 @@ class OrderAdmin(admin.ModelAdmin):
except OrderStateError as exc:
self.message_user(request, str(exc), messages.WARNING)
continue
if r['subscription_days']:
what = f'{r["subscription_days"]} روز از اشتراک کم شد'
else:
what = f'{r["credits_taken"]} از {r["credits_granted"]} سوالِ بسته پس گرفته شد'
if r['credits_taken'] < r['credits_granted']:
what += f' (بقیه خرج شده بود)'
if r['hearts_days']:
what += f'، {r["hearts_days"]} روز قلبِ نامحدود کم شد'
what = (f'{r["subscription_days"]} روز از اشتراک کم شد' if r['subscription_days']
else 'اشتراکی برای کم کردن نبود')
self.message_user(request, f'سفارشِ {order.id}: {what}. پول را از پنلِ درگاه برگردانید.', messages.SUCCESS)

View File

@@ -10,8 +10,8 @@
۲) بک‌اند با همون 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}/).
۳) با همون access_token، وضعیتِ اشتراک خونده می‌شه
(GET .../applications/{package}/subscriptions/{sku}/purchases/{token}/، verify_purchase).
⚠️ کافه‌بازار روش صحت‌سنجی رو تغییر داده (نگاه کنید به
developers.cafebazaar.ir/fa/guidelines/in-app-billing/api/postman-and-bazaar-api-new)؛
@@ -57,11 +57,9 @@ class BazaarAPIError(APIException):
@dataclass
class BazaarPurchaseResult:
is_valid: bool
purchase_state: int | None = None # ۰=خریداری‌شده، ۱=لغوشده (طبق سند بازار)
consumption_state: int | None = None
developer_payload: str = ''
raw: dict | None = None
# فقط اشتراک: پایانِ دوره‌ی پرداخت‌شده (با هر تمدیدِ خودکار جلو می‌رود) و این‌که تمدید روشن است.
# پایانِ دوره‌ی پرداخت‌شده (با هر تمدیدِ خودکار جلو می‌رود) و این‌که تمدید روشن است.
valid_until: 'datetime | None' = None
auto_renewing: bool | None = None
@@ -256,73 +254,44 @@ def _auth_headers() -> dict:
return {'Authorization': _get_access_token()}
def _is_unknown_product(status: int, data) -> bool:
return (
status == 404 and isinstance(data, dict) and data.get('error') == 'invalid_value'
and 'product' in str(data.get('error_description', '')).lower()
)
def verify_purchase(*, product_sku: str, purchase_token: str,
is_subscription: bool | None = None) -> BazaarPurchaseResult:
def verify_purchase(*, product_sku: str, purchase_token: str) -> BazaarPurchaseResult:
"""
صحت‌سنجی purchaseToken که کلاینت اندروید بعد از خرید از SDK بازار گرفته.
صحت‌سنجیِ purchaseTokenِ یک **اشتراکِ** بازار که اپِ اندروید بعد از subscribeProduct گرفته. فقط اشتراک
فروخته می‌شود (subscription.0017)، پس آدرسِ تک‌خرید (inapp) این‌جا نیست.
آدرس‌ها از سندِ خودِ بازار (خوانده‌شده ۲۰۲۶-۰۹-۲۶ از wp-jsonِ
developers.cafebazaar.ir؛ پیش از آن هر دو غلط بودند و هیچ خریدِ واقعی‌ای
تأیید نمی‌شد):
تک‌خرید: api/validate/<pkg>/inapp/<sku>/purchases/<token>/
اشتراک: api/applications/<pkg>/subscriptions/<sku>/purchases/<token>
به گفته‌ی سند «تنها اگر error برابر not_found باشد خرید انجام نشده»؛ پس
فقط همان نامعتبر است و بقیه‌ی خطاها (کلیدِ غلط، سقفِ روزانه، قطعی) ۵۰۳ اند
تا یک خریدِ واقعی به‌خاطرِ خطای ما failed نشود.
is_subscription=None (پیش‌فرض): نوعِ محصول در پیشخانِ بازار به ما گفته نشده
(۲۰۲۶-۰۹-۳۰)، پس اول تک‌خرید پرسیده می‌شود و اگر بازار گفت «Product is not
found»، همان SKU به‌عنوانِ اشتراک. با پاسخِ واقعیِ بازار روی SKU و توکنِ
ساختگی دیده شد؛ با خریدِ واقعی هنوز آزموده نشده.
آدرس از سندِ بازار («Subscription Validation»، خوانده‌شده ۲۰۲۶-۱۰-۱۲):
api/applications/<pkg>/subscriptions/<sku>/purchases/<token>/
به گفته‌ی سند «تنها اگر error برابر not_found باشد خرید انجام نشده»؛ پس فقط همان نامعتبر است و بقیه‌ی
خطاها (کلیدِ غلط، سقفِ روزانه، قطعی) ۵۰۳ اند تا یک خریدِ واقعی به‌خاطرِ خطای ما failed نشود. پاسخ اشتراکِ
منقضی را هم برمی‌گرداند؛ معتبر یعنی validUntil بعد از اکنون. با SKU و توکنِ ساختگی روی بازارِ واقعی دیده
شد (۲۰۲۶-۱۰-۱۲)؛ با خریدِ واقعی هنوز آزموده نشده.
"""
if not is_configured():
# 🔒 fail-closed: بدونِ کلیدِ بازار هیچ‌وقت به گفته‌ی کلاینت اعتماد
# نمی‌کنیم (قبلاً هر purchaseTokenِ غیرخالی valid فرض می‌شد).
return BazaarPurchaseResult(
is_valid=False,
raw={'error': 'bazaar_credentials_not_configured'},
)
# 🔒 fail-closed: بدونِ کلیدِ بازار هیچ‌وقت به گفته‌ی کلاینت اعتماد نمی‌کنیم.
return BazaarPurchaseResult(is_valid=False, raw={'error': 'bazaar_credentials_not_configured'})
# token از کلاینت می‌آید: quote با safe='' تا `/` یا `..` در آن مسیرِ
# دیگری از API را با کلیدِ ما صدا نزند.
# token از کلاینت می‌آید: quote با safe='' تا `/` یا `..` در آن مسیرِ دیگری از API را با کلیدِ ما صدا نزند.
pkg = quote(settings.BAZAAR_PACKAGE_NAME, safe='')
sku = quote(product_sku, safe='')
token = quote(purchase_token, safe='')
def fetch(subscription: bool):
if subscription:
url = f'{settings.BAZAAR_API_BASE}/api/applications/{pkg}/subscriptions/{sku}/purchases/{token}/'
else:
url = f'{settings.BAZAAR_API_BASE}/api/validate/{pkg}/inapp/{sku}/purchases/{token}/'
try:
resp = requests.get(url, headers=_auth_headers(), timeout=10)
except requests.RequestException as exc:
# فقط نوعِ خطا: متنِ خطا آدرس را دارد و توکنِ خریدِ کاربر بخشی از آن است.
logger.error('Bazaar verify-purchase request failed: error=%s', type(exc).__name__)
raise BazaarAPIError()
try:
return resp.status_code, resp.json()
except ValueError:
return resp.status_code, None
subscription = bool(is_subscription)
status, data = fetch(subscription)
if is_subscription is None and _is_unknown_product(status, data):
subscription = True
status, data = fetch(True)
url = f'{settings.BAZAAR_API_BASE}/api/applications/{pkg}/subscriptions/{sku}/purchases/{token}/'
try:
resp = requests.get(url, headers=_auth_headers(), timeout=10)
except requests.RequestException as exc:
# فقط نوعِ خطا: متنِ خطا آدرس را دارد و توکنِ خریدِ کاربر بخشی از آن است.
logger.error('Bazaar verify-purchase request failed: error=%s', type(exc).__name__)
raise BazaarAPIError()
status = resp.status_code
try:
data = resp.json()
except ValueError:
data = None
error = data.get('error') if isinstance(data, dict) else None
if status == 404 and error == 'not_found':
return BazaarPurchaseResult(is_valid=False, raw=data)
# 404 با invalid_value («Package name is invalid» / «Product is not found»)
# یعنی تنظیمِ ما غلط است، نه خریدِ کاربر؛ مثلِ بقیه‌ی خطاها ۵۰۳.
# 404 با invalid_value («Package name is invalid» / «Product is not found») یعنی تنظیمِ ما غلط است، نه
# خریدِ کاربر؛ مثلِ بقیه‌ی خطاها ۵۰۳.
if status != 200 or not isinstance(data, dict):
if status in (401, 403):
cache.delete(ACCESS_TOKEN_CACHE_KEY)
@@ -330,30 +299,15 @@ def verify_purchase(*, product_sku: str, purchase_token: str,
error, data.get('error_description') if isinstance(data, dict) else None)
raise BazaarAPIError()
if subscription:
# پاسخِ اشتراک purchaseState ندارد و اشتراکِ منقضی را هم برمی‌گرداند؛
# سند می‌گوید validUntil را با اکنون مقایسه کنید.
valid_until = data.get('validUntilTimestampMsec') or 0
is_valid = valid_until > timezone.now().timestamp() * 1000
return BazaarPurchaseResult(
is_valid=is_valid,
developer_payload=data.get('developerPayload', ''),
raw=data,
valid_until=datetime.fromtimestamp(valid_until / 1000, tz=dt_timezone.utc) if valid_until else None,
auto_renewing=data.get('autoRenewing'),
)
else:
purchase_state = data.get('purchaseState')
is_valid = purchase_state == 0 # ۰ یعنی خریداری‌شده؛ ۱ یعنی refund
valid_until = data.get('validUntilTimestampMsec') or 0
return BazaarPurchaseResult(
is_valid=is_valid,
purchase_state=purchase_state,
consumption_state=data.get('consumptionState'),
is_valid=valid_until > timezone.now().timestamp() * 1000,
developer_payload=data.get('developerPayload', ''),
raw=data,
valid_until=datetime.fromtimestamp(valid_until / 1000, tz=dt_timezone.utc) if valid_until else None,
auto_renewing=data.get('autoRenewing'),
)
def dynamic_price_configured() -> bool:
return bool(settings.BAZAAR_DYNAMIC_PRICE_KEY)

View File

@@ -0,0 +1,21 @@
# Generated by Django 5.2.17 on 2026-10-12 16:48
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('payment', '0012_bazaar_subscription'),
]
operations = [
migrations.RemoveField(
model_name='order',
name='hearts_days',
),
migrations.RemoveField(
model_name='order',
name='question_credits',
),
]

View File

@@ -30,11 +30,8 @@ class Order(models.Model):
# پیش از ۲۰۲۶-۱۰-۰۱ این دو را ندارند (null / خالی).
list_price = models.PositiveIntegerField(null=True, blank=True)
plan_title = models.CharField(max_length=50, blank=True)
# آنچه این سفارش فروخته، در لحظه‌ی ساخت (create_order). پرداخت و بازپرداخت همین‌ها را می‌دهند و
# پس می‌گیرند، نه عددهای فعلیِ پلن: پیش‌تر اگر ادمین پلن را بینِ ساختِ سفارش و پرداخت عوض می‌کرد،
# خریدارِ بسته‌ی ۴۵ هزار تومانی ۱۵۰ سوال می‌گرفت. null یعنی سفارشِ بی‌پلن یا ساخته‌شده پیش از این ستون.
question_credits = models.PositiveIntegerField(null=True, blank=True)
hearts_days = models.PositiveIntegerField(null=True, blank=True)
# مدتِ اشتراکی که این سفارش فروخته، در لحظه‌ی ساخت (create_order). پرداخت و بازپرداخت همین را می‌دهند و
# پس می‌گیرند، نه مدتِ فعلیِ پلن. null یعنی سفارشِ بی‌پلن یا ساخته‌شده پیش از این ستون.
duration_days = models.PositiveIntegerField(null=True, blank=True)
currency = models.CharField(max_length=10, default='IRT')
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending')

View File

@@ -87,8 +87,7 @@ class BazaarProvider(PaymentProvider):
return None
def purchase_extras(self, order, plan) -> dict:
return {'bazaarSku': order.bazaar_product_sku,
'bazaarProductType': 'subscription' if plan.kind == 'subscription' else 'inapp'}
return {'bazaarSku': order.bazaar_product_sku}
def discount_blocker(self, order) -> str | None:
from . import bazaar

View File

@@ -1,36 +1,14 @@
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)
# بسته‌ی سوال (subscription.Plan با kind='pack'): بدونِ این، سفارشِ بسته بی‌plan_id ساخته می‌شد و
# پرداختش هیچ اعتباری نمی‌داد (بازبینیِ ۲۰۲۶-۱۰-۰۲).
planId = serializers.CharField(required=False, allow_null=True, allow_blank=True)
class BazaarVerifyRequestSerializer(serializers.Serializer):
orderId = serializers.CharField()
productSku = serializers.CharField(required=False, allow_blank=True)
purchaseToken = serializers.CharField()
class BazaarStartResponseSerializer(serializers.Serializer):
orderId = serializers.CharField()
sku = serializers.CharField(help_text='productIdِ PurchaseRequestِ Poolakey.')
productType = serializers.ChoiceField(
choices=['inapp', 'subscription'],
help_text='inapp: purchaseProduct و بعد از تأییدِ سرور consume. subscription: subscribeProduct، بی‌consume.')
amount = serializers.IntegerField(help_text='مبلغِ سفارش (تومان)، پس از کدِ تخفیف.')
dynamicPriceToken = serializers.CharField(
allow_null=True, help_text='JWTِ «تخفیف پویا» برای PurchaseRequest.dynamicPriceToken؛ null یعنی قیمتِ پیشخان.')
@@ -42,15 +20,6 @@ class BazaarVerifyResponseSerializer(serializers.Serializer):
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 ZarinpalCallbackResponseSerializer(serializers.Serializer):
"""فقط وقتی settings.ZARINPAL_REDIRECT_URL خالی باشه برگردونده می‌شه؛
وگرنه کاربر به همون URL ریدایرکت می‌شه، نه به این JSON."""

View File

@@ -115,8 +115,6 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
user_id=user_id,
subscription_id=subscription_id,
plan_id=plan_id,
question_credits=plan.question_credits if plan else None,
hearts_days=plan.hearts_days if plan else None,
duration_days=plan.duration_days if plan else None,
amount=amount,
list_price=amount,
@@ -227,10 +225,10 @@ def apply_order_payment_result(*, order: Order, gateway: str, gateway_ref_id: st
from subscription.services import activate_subscription
activate_subscription(order.subscription_id, plan_id=order.plan_id,
duration_days=order.duration_days)
elif order.plan_id:
# بسته‌ی سوال: اعتبار و روزهای قلبِ نامحدود، بی‌دست زدن به اشتراک.
from subscription.services import grant_pack
grant_pack(order)
else:
# هر سفارشِ فروشی از PurchaseView اشتراک دارد (فقط اشتراک فروخته می‌شود، subscription.0017)؛
# سفارشِ پرداخت‌شده‌ی بی‌اشتراک یعنی چیزی داده نشد و باید دیده شود.
logger.error('سفارشِ %s پرداخت شد ولی اشتراکی ندارد؛ چیزی فعال نشد', order.id)
# پورسانتِ معرف فقط روی پرداختِ واقعی (نه ثبت‌نام، نه مهمان).
from affiliate.services import record_paid_order
record_paid_order(order)
@@ -322,7 +320,7 @@ def sync_bazaar_renewals(user_id: int) -> int:
continue
try:
result = bazaar.verify_purchase(
product_sku=last.bazaar_product_sku, purchase_token=token, is_subscription=True)
product_sku=last.bazaar_product_sku, purchase_token=token)
except bazaar.BazaarAPIError: # خودِ bazaar لاگ کرده؛ ساعتِ بعد
continue
except Exception: # noqa: BLE001 — صفحه‌ی وضعیت نباید با خطای بازار ۵۰۰ بدهد؛ ولی دیده شود
@@ -399,21 +397,18 @@ def refund_order(order: Order, *, staff: str = '') -> dict:
**پول را برنمی‌گرداند.** برگرداندنِ پول در پنلِ زرین‌پال (یا کافه‌بازار) انجام می‌شود؛ این تابع
فقط حساب‌وکتابِ خودِ اپ را با آن یکی می‌کند. چه چیزی پس گرفته می‌شود (تصمیمِ ۲۰۲۶-۱۰-۰۲):
- بسته: اعتبارِ همین سفارش، ولی فقط آن‌قدر که هنوز خرج نشده (موجودی منفی نمی‌شود)، و روزهای
قلبِ نامحدودِ همین بسته از پایانِ کارتِ قلب.
- اشتراک: روزهای همین پلن از پایانِ اشتراک؛ اگر چیزی نماند اشتراک «لغوشده» می‌شود.
- روزهای همین پلن از پایانِ اشتراک؛ اگر چیزی نماند اشتراک «لغوشده» می‌شود.
- پورسانتِ معرف برای همین سفارش به «در انتظار» برمی‌گردد.
خروجی: خلاصه برای پیامِ ادمین.
"""
from subscription.models import HeartsPass, Plan, QuestionCredit, Subscription
from subscription.services import credit_balance
from subscription.models import Plan, Subscription
with db_transaction.atomic():
order = Order.objects.select_for_update().get(pk=order.pk)
if order.status != 'paid':
raise OrderStateError(f'سفارشِ {order.id} «{order.get_status_display()}» است؛ فقط سفارشِ پرداخت‌شده بازپرداخت می‌شود.')
now = timezone.now()
summary = {'credits_granted': 0, 'credits_taken': 0, 'hearts_days': 0, 'subscription_days': 0}
summary = {'subscription_days': 0}
plan = Plan.objects.filter(pk=order.plan_id).first() if order.plan_id else None
if order.subscription_id:
@@ -426,22 +421,6 @@ def refund_order(order: Order, *, staff: str = '') -> dict:
sub.version += 1 # PurchaseView با version تشخیص می‌دهد کسی ردیف را هم‌زمان عوض کرده
sub.save(update_fields=['expires_at', 'status', 'version'])
summary['subscription_days'] = days
else:
granted = QuestionCredit.objects.filter(order_id=order.id, reason='purchase').first()
if granted is not None:
take = min(granted.delta, max(credit_balance(order.user_id), 0))
summary['credits_granted'] = granted.delta
summary['credits_taken'] = take
if take:
QuestionCredit.objects.create(user_id=order.user_id, delta=-take, reason='refund',
order_id=order.id, note=f'بازپرداختِ سفارشِ {order.id}')
hearts_days = order.hearts_days if order.hearts_days is not None else (plan.hearts_days if plan else 0)
if hearts_days:
hp = HeartsPass.objects.select_for_update().filter(user_id=order.user_id).first()
if hp is not None:
hp.expires_at = max(hp.expires_at - timezone.timedelta(days=hearts_days), now)
hp.save(update_fields=['expires_at'])
summary['hearts_days'] = hearts_days
from affiliate.models import Conversion
Conversion.objects.filter(order_id=order.id, status='paid').update(

View File

@@ -110,7 +110,7 @@ class BazaarVerifyPurchaseFailClosedTests(TestCase):
}
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {
'purchaseState': 0, 'consumptionState': 0, 'developerPayload': '',
'validUntilTimestampMsec': (time.time() + 86400) * 1000, 'autoRenewing': True,
}
result = bazaar.verify_purchase(product_sku='sku1', purchase_token='real-token')
@@ -135,10 +135,9 @@ class BazaarVerifyPurchaseFailClosedTests(TestCase):
'access_token': 'real-access-token', 'expires_in': 3600,
}
mock_get.return_value.status_code = 200
# purchaseState=1 یعنی لغوشده (طبق سند بازار)، یعنی خودِ سرور بازار
# می‌گه این خرید معتبر نیست.
# اشتراکِ منقضی‌شده: بازار پاسخ می‌دهد ولی validUntil گذشته است.
mock_get.return_value.json.return_value = {
'purchaseState': 1, 'consumptionState': 0, 'developerPayload': '',
'validUntilTimestampMsec': (time.time() - 60) * 1000, 'autoRenewing': False,
}
result = bazaar.verify_purchase(product_sku='sku1', purchase_token='cancelled-token')
@@ -166,7 +165,7 @@ class BazaarVerifyPurchaseThrottleTests(TestCase):
def _make_order(self, key):
return Order.objects.create(
user_id=self.user.id, amount=10000, provider='bazaar',
bazaar_product_sku='sku_one_time', idempotency_key=key,
subscription_id=1, bazaar_product_sku='sku_one_time', idempotency_key=key,
)
def _verify(self, order_id, purchase_token, token=None, remote_addr=None):
@@ -189,7 +188,7 @@ class BazaarVerifyPurchaseThrottleTests(TestCase):
def test_requests_within_the_limit_still_succeed(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'bazaar_verify': '3/hour'}):
with patch('payment.views.bazaar.verify_purchase') as mock_verify:
mock_verify.return_value = BazaarPurchaseResult(is_valid=True, purchase_state=0)
mock_verify.return_value = BazaarPurchaseResult(is_valid=True)
for i in range(3):
order = self._make_order(f'order-bazaar-within-{i}')
response = self._verify(order.id, f'token-within-{i}', token=self.token)
@@ -198,7 +197,7 @@ class BazaarVerifyPurchaseThrottleTests(TestCase):
def test_excessive_verify_attempts_from_same_user_are_blocked(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'bazaar_verify': '3/hour'}):
with patch('payment.views.bazaar.verify_purchase') as mock_verify:
mock_verify.return_value = BazaarPurchaseResult(is_valid=True, purchase_state=0)
mock_verify.return_value = BazaarPurchaseResult(is_valid=True)
for i in range(3):
order = self._make_order(f'order-bazaar-block-{i}')
response = self._verify(order.id, f'token-block-{i}', token=self.token)
@@ -216,11 +215,11 @@ class BazaarVerifyPurchaseThrottleTests(TestCase):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'bazaar_verify': '3/hour'}):
with patch('payment.views.bazaar.verify_purchase') as mock_verify:
mock_verify.return_value = BazaarPurchaseResult(is_valid=True, purchase_state=0)
mock_verify.return_value = BazaarPurchaseResult(is_valid=True)
for i in range(3):
order = Order.objects.create(
user_id=registered_user.id, amount=10000, provider='bazaar',
bazaar_product_sku='sku_one_time', idempotency_key=f'order-bazaar-ip-{i}',
subscription_id=1, bazaar_product_sku='sku_one_time', idempotency_key=f'order-bazaar-ip-{i}',
)
response = self._verify(
order.id, f'token-ip-{i}', token=registered_token, remote_addr=f'10.4.0.{i + 1}',
@@ -229,7 +228,7 @@ class BazaarVerifyPurchaseThrottleTests(TestCase):
extra_order = Order.objects.create(
user_id=registered_user.id, amount=10000, provider='bazaar',
bazaar_product_sku='sku_one_time', idempotency_key='order-bazaar-ip-extra',
subscription_id=1, bazaar_product_sku='sku_one_time', idempotency_key='order-bazaar-ip-extra',
)
blocked_response = self._verify(
extra_order.id, 'token-ip-extra', token=registered_token, remote_addr='10.4.0.99',
@@ -239,7 +238,7 @@ class BazaarVerifyPurchaseThrottleTests(TestCase):
def test_throttled_request_does_not_call_bazaar_or_mutate_order(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'bazaar_verify': '1/hour'}):
with patch('payment.views.bazaar.verify_purchase') as mock_verify:
mock_verify.return_value = BazaarPurchaseResult(is_valid=True, purchase_state=0)
mock_verify.return_value = BazaarPurchaseResult(is_valid=True)
first_order = self._make_order('order-bazaar-mutate-1')
first_response = self._verify(first_order.id, 'token-mutate-1', token=self.token)
self.assertEqual(first_response.status_code, 200)
@@ -276,12 +275,12 @@ class BazaarVerifyPurchasePerUserThrottleTests(TestCase):
def test_registered_users_keep_independent_per_user_buckets(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'bazaar_verify': '1/hour'}):
with patch('payment.views.bazaar.verify_purchase') as mock_verify:
mock_verify.return_value = BazaarPurchaseResult(is_valid=True, purchase_state=0)
mock_verify.return_value = BazaarPurchaseResult(is_valid=True)
user_a = User.objects.create(name='', phone='+989120000761')
order_a = Order.objects.create(
user_id=user_a.id, amount=10000, provider='bazaar',
bazaar_product_sku='sku_one_time', idempotency_key='order-bazaar-reg-a',
subscription_id=1, bazaar_product_sku='sku_one_time', idempotency_key='order-bazaar-reg-a',
)
response_a = self._verify(order_a.id, 'token-reg-a', issue_access_token(user_a.id))
self.assertEqual(response_a.status_code, 200)
@@ -289,7 +288,7 @@ class BazaarVerifyPurchasePerUserThrottleTests(TestCase):
user_b = User.objects.create(name='', phone='+989120000762')
order_b = Order.objects.create(
user_id=user_b.id, amount=10000, provider='bazaar',
bazaar_product_sku='sku_one_time', idempotency_key='order-bazaar-reg-b',
subscription_id=1, bazaar_product_sku='sku_one_time', idempotency_key='order-bazaar-reg-b',
)
response_b = self._verify(order_b.id, 'token-reg-b', issue_access_token(user_b.id))

View File

@@ -118,7 +118,7 @@ class BazaarOAuthCallbackTests(TestCase):
mock_post.return_value.raise_for_status = lambda: None
mock_post.return_value.json.return_value = {'access_token': 'acc', 'expires_in': 3600}
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {'purchaseState': 0}
mock_get.return_value.json.return_value = {'validUntilTimestampMsec': timezone.now().timestamp() * 1000 + 86_400_000}
self.assertTrue(bazaar.verify_purchase(product_sku='s', purchase_token='t').is_valid)
self.assertEqual(mock_post.call_args.kwargs['data']['refresh_token'], 'db-ref')
@@ -141,21 +141,22 @@ class BazaarValidateAgainstDocsTests(TestCase):
@patch('payment.bazaar.requests.post')
@patch('payment.bazaar.requests.get')
def test_inapp_url_and_new_method_header(self, mock_get, mock_post):
self._resp(mock_get, 200, {'purchaseState': 0, 'consumptionState': 1})
self.assertTrue(bazaar.verify_purchase(product_sku='monthly', purchase_token='tok').is_valid)
def test_subscription_url_new_method_header_and_expiry(self, mock_get, mock_post):
now_ms = timezone.now().timestamp() * 1000
self._resp(mock_get, 200, {'validUntilTimestampMsec': now_ms + 86_400_000, 'autoRenewing': True})
result = bazaar.verify_purchase(product_sku='10343', purchase_token='tok')
self.assertTrue(result.is_valid)
self.assertTrue(result.auto_renewing)
self.assertEqual(
mock_get.call_args.args[0],
'https://pardakht.cafebazaar.ir/devapi/v2/api/validate/ir.example.app/inapp/monthly/purchases/tok/',
'https://pardakht.cafebazaar.ir/devapi/v2/api/applications/ir.example.app/subscriptions/10343/purchases/tok/',
)
self.assertEqual(mock_get.call_args.kwargs['headers'],
{'CAFEBAZAAR-PISHKHAN-API-SECRET': 'pishkhan-secret'})
mock_post.assert_not_called() # روشِ جدید OAuth نمی‌خواهد
@patch('payment.bazaar.requests.get')
def test_refunded_is_invalid(self, mock_get):
self._resp(mock_get, 200, {'purchaseState': 1})
self.assertFalse(bazaar.verify_purchase(product_sku='s', purchase_token='t').is_valid)
# اشتراکِ منقضی را هم بازار برمی‌گرداند؛ معتبر نیست.
self._resp(mock_get, 200, {'validUntilTimestampMsec': now_ms - 1000})
self.assertFalse(bazaar.verify_purchase(product_sku='10343', purchase_token='tok').is_valid)
@patch('payment.bazaar.requests.get')
def test_not_found_is_invalid(self, mock_get):
@@ -187,70 +188,3 @@ class BazaarValidateAgainstDocsTests(TestCase):
self._resp(mock_get, 404, {'error': 'not_found'})
bazaar.verify_purchase(product_sku='s', purchase_token='../../applications/x')
self.assertIn('/purchases/..%2F..%2Fapplications%2Fx/', mock_get.call_args.args[0])
@patch('payment.bazaar.requests.get')
def test_subscription_url_and_expiry(self, mock_get):
now_ms = timezone.now().timestamp() * 1000
self._resp(mock_get, 200, {'validUntilTimestampMsec': now_ms + 86_400_000})
self.assertTrue(bazaar.verify_purchase(product_sku='sub', purchase_token='t', is_subscription=True).is_valid)
self.assertEqual(
mock_get.call_args.args[0],
'https://pardakht.cafebazaar.ir/devapi/v2/api/applications/ir.example.app/subscriptions/sub/purchases/t/',
)
self._resp(mock_get, 200, {'validUntilTimestampMsec': now_ms - 1000})
self.assertFalse(bazaar.verify_purchase(product_sku='sub', purchase_token='t', is_subscription=True).is_valid)
@override_settings(
BAZAAR_PISHKHAN_API_SECRET='pishkhan-secret', BAZAAR_REFRESH_TOKEN='',
BAZAAR_PACKAGE_NAME='ir.example.app',
BAZAAR_API_BASE='https://pardakht.cafebazaar.ir/devapi/v2',
)
class BazaarProductTypeAutoDetectTests(TestCase):
"""نوعِ محصول (تک‌خرید/اشتراک) به ما گفته نشده؛ کد خودش امتحان می‌کند."""
def setUp(self):
cache.clear()
def _responses(self, mock_get, *pairs):
responses = []
for status, body in pairs:
r = type('R', (), {})()
r.status_code = status
r.json = (lambda b: (lambda: b))(body)
responses.append(r)
mock_get.side_effect = responses
@patch('payment.bazaar.requests.get')
def test_inapp_product_is_checked_once(self, mock_get):
self._responses(mock_get, (200, {'purchaseState': 0}))
self.assertTrue(bazaar.verify_purchase(product_sku='monthly', purchase_token='t').is_valid)
self.assertEqual(mock_get.call_count, 1)
self.assertIn('/inapp/', mock_get.call_args.args[0])
@patch('payment.bazaar.requests.get')
def test_unknown_inapp_product_falls_back_to_subscription(self, mock_get):
future = timezone.now().timestamp() * 1000 + 86_400_000
self._responses(
mock_get,
(404, {'error': 'invalid_value', 'error_description': 'Product is not found.'}),
(200, {'validUntilTimestampMsec': future}),
)
self.assertTrue(bazaar.verify_purchase(product_sku='monthly', purchase_token='t').is_valid)
self.assertIn('/subscriptions/monthly/purchases/t', mock_get.call_args.args[0])
@patch('payment.bazaar.requests.get')
def test_forged_token_on_subscription_is_invalid(self, mock_get):
self._responses(
mock_get,
(404, {'error': 'invalid_value', 'error_description': 'Product is not found.'}),
(404, {'error': 'not_found'}),
)
self.assertFalse(bazaar.verify_purchase(product_sku='monthly', purchase_token='forged').is_valid)
@patch('payment.bazaar.requests.get')
def test_wrong_package_does_not_fall_back(self, mock_get):
self._responses(mock_get, (404, {'error': 'invalid_value', 'error_description': 'Package name is invalid.'}))
with self.assertRaises(bazaar.BazaarAPIError):
bazaar.verify_purchase(product_sku='monthly', purchase_token='t')
self.assertEqual(mock_get.call_count, 1)

View File

@@ -1,175 +0,0 @@
"""
خریدِ بسته‌ی سوال از راهِ کافه‌بازار (بازبینیِ ۲۰۲۶-۱۰-۰۲).
سفارشِ بازار فقط از `POST /api/payment/orders` (کلیدِ داخلی) ساخته می‌شود. آن endpoint
`planId` نمی‌گرفت، پس سفارشِ بسته بی‌`plan_id` ساخته می‌شد و بعد از تأییدِ بازار «paid» می‌شد
بی‌آن‌که `grant_pack` چیزی بدهد: پول گرفته می‌شد و اعتباری نمی‌آمد. پاسخِ تأیید هم وضعیتِ
پیش از پرداخت («pending») را برمی‌گرداند. بازار با `bazaar.verify_purchase`ِ ساختگی آزموده
می‌شود؛ خریدِ واقعیِ بازار هنوز هیچ‌جا آزموده نشده.
"""
from unittest.mock import patch
from django.core.cache import cache
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from subscription.models import Plan
from subscription.services import credit_balance
from .bazaar import BazaarPurchaseResult
from .models import Order
@override_settings(INTERNAL_SERVICE_KEY='internal-test-key')
class BazaarPackTests(TestCase):
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
cache.clear()
self.user = User.objects.create(phone='09120007101')
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
self.pack = Plan.objects.get(kind='pack', question_credits=20)
def tearDown(self):
cache.clear()
def create_bazaar_order(self, **extra):
response = APIClient().post('/api/payment/orders', {
'userId': str(self.user.id), 'amount': self.pack.price, 'provider': 'bazaar',
'bazaarProductSku': 'pack20', **extra,
}, format='json', HTTP_X_INTERNAL_KEY='internal-test-key')
self.assertEqual(response.status_code, 200, response.content)
return response.json()['orderId']
def verify(self, order_id, valid=True):
with patch('payment.bazaar.verify_purchase',
return_value=BazaarPurchaseResult(is_valid=valid, purchase_state=0 if valid else None)):
return self.client.post('/api/payment/bazaar/verify', {
'orderId': order_id, 'productSku': 'pack20', 'purchaseToken': 'token-1'}, format='json')
def test_a_pack_bought_through_bazaar_gives_its_credits(self):
order_id = self.create_bazaar_order(planId=str(self.pack.id))
order = Order.objects.get(pk=order_id)
self.assertEqual((order.plan_id, order.plan_title, order.list_price), (self.pack.id, self.pack.title, self.pack.price))
self.verify(order_id)
self.assertEqual(credit_balance(self.user.id), 20)
def test_verify_reports_the_status_the_order_actually_has(self):
order_id = self.create_bazaar_order(planId=str(self.pack.id))
response = self.verify(order_id)
self.assertEqual(Order.objects.get(pk=order_id).status, 'paid')
self.assertEqual(response.json()['status'], 'paid')
def test_unknown_plan_is_rejected(self):
response = APIClient().post('/api/payment/orders', {
'userId': str(self.user.id), 'amount': 1, 'provider': 'bazaar', 'planId': '999999',
}, format='json', HTTP_X_INTERNAL_KEY='internal-test-key')
self.assertEqual(response.status_code, 400)
@override_settings(INTERNAL_SERVICE_KEY='internal-test-key')
class BazaarSkuComesFromTheOrderTests(TestCase):
"""تأییدِ بازار SKU را فقط از سفارش می‌گیرد، نه از کلاینت (۲۰۲۶-۱۰-۰۳)."""
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
cache.clear()
self.user = User.objects.create(phone='09120007102')
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
def test_a_bazaar_order_needs_a_sku(self):
response = APIClient().post('/api/payment/orders', {
'userId': str(self.user.id), 'amount': 1000, 'provider': 'bazaar',
}, format='json', HTTP_X_INTERNAL_KEY='internal-test-key')
self.assertEqual(response.status_code, 400)
def test_verify_never_uses_the_clients_sku(self):
order = Order.objects.create(user_id=self.user.id, amount=259000, provider='bazaar',
idempotency_key='no-sku-1')
with patch('payment.bazaar.verify_purchase') as verify:
response = self.client.post('/api/payment/bazaar/verify', {
'orderId': str(order.id), 'productSku': 'cheapest', 'purchaseToken': 't'}, format='json')
self.assertEqual(response.status_code, 400)
verify.assert_not_called()
order.refresh_from_db()
self.assertEqual(order.status, 'pending')
class BazaarPurchaseFromTheAppTests(TestCase):
"""
خریدِ بازار از مسیرِ خودِ کاربر: `POST /api/subscription/purchase` با
`paymentMethod: "bazaar"` (۲۰۲۶-۱۰-۰۳). پیش از آن تنها راهِ ساختِ سفارشِ بازار کلیدِ
داخلی بود و `paymentMethod` هر مقداری را می‌پذیرفت و نادیده می‌گرفت.
"""
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
cache.clear()
self.user = User.objects.create(phone='09120007103')
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
self.pack = Plan.objects.get(kind='pack', question_credits=20)
self.pack.bazaar_sku = 'tadrisino_pack_20'
self.pack.save()
def tearDown(self):
cache.clear()
def buy(self, plan, method='bazaar', **headers):
return self.client.post('/api/subscription/purchase',
{'planId': str(plan.id), 'paymentMethod': method}, format='json', **headers)
def test_bazaar_order_carries_the_plans_sku_and_no_payment_url(self):
response = self.buy(self.pack)
self.assertEqual(response.status_code, 200, response.content)
body = response.json()
self.assertIsNone(body['paymentUrl'])
self.assertEqual(body['bazaarSku'], 'tadrisino_pack_20')
order = Order.objects.get(pk=body['orderId'])
self.assertEqual((order.provider, order.bazaar_product_sku, order.plan_id),
('bazaar', 'tadrisino_pack_20', self.pack.id))
def test_whole_flow_gives_the_pack_credits(self):
order_id = self.buy(self.pack).json()['orderId']
with patch('payment.bazaar.verify_purchase',
return_value=BazaarPurchaseResult(is_valid=True, purchase_state=0)) as verify:
response = self.client.post('/api/payment/bazaar/verify',
{'orderId': order_id, 'purchaseToken': 'tok-app-1'}, format='json')
self.assertEqual(response.json()['status'], 'paid')
self.assertEqual(verify.call_args.kwargs['product_sku'], 'tadrisino_pack_20')
self.assertEqual(credit_balance(self.user.id), 20)
def test_pack_without_a_sku_is_refused_not_sent_to_zarinpal(self):
other = Plan.objects.get(kind='pack', question_credits=60)
response = self.buy(other)
self.assertEqual(response.status_code, 400)
self.assertFalse(Order.objects.filter(user_id=self.user.id).exists())
def test_unknown_payment_method_is_a_400(self):
self.assertEqual(self.buy(self.pack, method='paypal').status_code, 400)
def test_zarinpal_is_still_the_default(self):
response = self.client.post('/api/subscription/purchase', {'planId': str(self.pack.id)}, format='json')
self.assertEqual(response.status_code, 200)
self.assertIn('/pay', response.json()['paymentUrl'])
self.assertEqual(Order.objects.get(pk=response.json()['orderId']).provider, 'gateway')
def test_same_idempotency_key_cannot_switch_payment_method(self):
self.assertEqual(self.buy(self.pack, HTTP_IDEMPOTENCY_KEY='k-1').status_code, 200)
self.assertEqual(self.buy(self.pack, method='zarinpal', HTTP_IDEMPOTENCY_KEY='k-1').status_code, 409)
def test_discount_code_is_refused_on_a_bazaar_order(self):
from discount.models import DiscountCode
DiscountCode.objects.create(code='HALF', type='percent', value=50, max_uses=10)
order_id = self.buy(self.pack).json()['orderId']
response = self.client.post('/api/discount/redeem', {'code': 'HALF', 'orderId': order_id}, format='json')
self.assertEqual(response.status_code, 400)
self.assertEqual(Order.objects.get(pk=order_id).amount, self.pack.price)
def test_plans_list_exposes_the_sku(self):
plans = self.client.get('/api/subscription/plans').json()['plans']
self.assertEqual(next(p for p in plans if p['id'] == str(self.pack.id))['bazaarSku'], 'tadrisino_pack_20')

View File

@@ -35,7 +35,7 @@ class BazaarSubscriptionTests(TestCase):
self.user = User.objects.create(phone='09120007301')
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
self.three = Plan.objects.get(kind='subscription', on_sale=True, duration_days=90)
self.three = Plan.objects.get(on_sale=True, duration_days=90)
def tearDown(self):
cache.clear()
@@ -52,43 +52,50 @@ class BazaarSubscriptionTests(TestCase):
def test_migration_put_the_panel_skus_on_three_and_six_months(self):
self.assertEqual(self.three.bazaar_sku, '10343')
self.assertEqual(Plan.objects.get(kind='subscription', on_sale=True, duration_days=180).bazaar_sku, '10344')
self.assertEqual(Plan.objects.get(kind='subscription', on_sale=True, duration_days=30).bazaar_sku, '10342')
# بسته‌ها هنوز در پیشخان نیستند.
self.assertFalse(Plan.objects.filter(kind='pack').exclude(bazaar_sku='').exists())
self.assertEqual(Plan.objects.get(on_sale=True, duration_days=180).bazaar_sku, '10344')
self.assertEqual(Plan.objects.get(on_sale=True, duration_days=30).bazaar_sku, '10342')
def test_plan_without_sku_is_refused_for_bazaar(self):
pack = Plan.objects.get(kind='pack', question_credits=20)
Plan.objects.filter(pk=pack.pk).update(on_sale=True) # بسته‌ها از subscription.0016 فروشی نیستند
self.assertEqual(self.buy(pack).status_code, 400)
Plan.objects.filter(pk=self.three.pk).update(bazaar_sku='')
self.assertEqual(self.buy(self.three).status_code, 400)
self.assertFalse(Order.objects.filter(user_id=self.user.id).exists())
def test_subscription_bought_in_bazaar_activates_for_the_plan(self):
response = self.buy(self.three)
self.assertEqual(response.status_code, 200, response.content)
body = response.json()
self.assertEqual((body['bazaarSku'], body['bazaarProductType'], body['paymentUrl']), ('10343', 'subscription', None))
self.assertEqual((body['bazaarSku'], body['paymentUrl']), ('10343', None))
order = Order.objects.get(pk=body['orderId'])
self.assertEqual((order.provider, order.plan_id), ('bazaar', self.three.id))
self.assertIsNotNone(order.subscription_id)
response, mock = self.verify(body['orderId'], _result(90))
self.assertEqual(response.json()['status'], 'paid')
self.assertTrue(mock.call_args.kwargs['is_subscription'])
sub = Subscription.objects.get(user_id=self.user.id)
self.assertEqual(sub.status, 'active')
self.assertAlmostEqual((sub.expires_at - timezone.now()).days, 89, delta=1)
self.assertIsNotNone(Order.objects.get(pk=body['orderId']).bazaar_valid_until)
def test_pack_verify_asks_for_inapp(self):
pack = Plan.objects.get(kind='pack', question_credits=20)
pack.bazaar_sku = 'pack20'
pack.on_sale = True
pack.save()
body = self.buy(pack).json()
self.assertEqual(body['bazaarProductType'], 'inapp')
_, mock = self.verify(body['orderId'], BazaarPurchaseResult(is_valid=True, purchase_state=0))
self.assertFalse(mock.call_args.kwargs['is_subscription'])
def test_verify_never_uses_the_clients_sku(self):
# سفارشی بی‌SKU (دستی ساخته‌شده): SKUِ فرستاده‌ی کلاینت هرگز سنجیده نمی‌شود.
order = Order.objects.create(user_id=self.user.id, amount=1, provider='bazaar', idempotency_key='no-sku-1')
with patch('payment.bazaar.verify_purchase') as verify:
response = self.client.post('/api/payment/bazaar/verify', {
'orderId': str(order.id), 'productSku': 'cheapest', 'purchaseToken': 't'}, format='json')
self.assertEqual(response.status_code, 400)
verify.assert_not_called()
def test_same_idempotency_key_cannot_switch_payment_method(self):
self.assertEqual(self.buy(self.three, key='k-1').status_code, 200)
response = self.client.post('/api/subscription/purchase', {'planId': str(self.three.id), 'paymentMethod': 'zarinpal'},
format='json', HTTP_IDEMPOTENCY_KEY='k-1')
self.assertEqual(response.status_code, 409)
def test_unknown_payment_method_is_a_400(self):
response = self.client.post('/api/subscription/purchase', {'planId': str(self.three.id), 'paymentMethod': 'paypal'},
format='json')
self.assertEqual(response.status_code, 400)
def _paid_subscription(self):
order_id = self.buy(self.three).json()['orderId']
@@ -105,7 +112,7 @@ class BazaarSubscriptionTests(TestCase):
self.client.get('/api/subscription/status')
cache.clear() # سقفِ ساعتی را کنار بگذار: ثبتِ دوباره هم نباید دوره‌ی دوم بسازد
self.client.get('/api/subscription/status')
self.assertTrue(mock.call_args.kwargs['is_subscription'])
self.assertTrue(mock.called)
renewals = Order.objects.filter(user_id=self.user.id, status='paid').exclude(pk=order.pk)
self.assertEqual(renewals.count(), 1)
self.assertEqual(renewals.get().amount, self.three.price)
@@ -151,7 +158,7 @@ class BazaarDynamicPriceTests(TestCase):
self.user = User.objects.create(phone='09120007302')
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
self.three = Plan.objects.get(kind='subscription', on_sale=True, duration_days=90)
self.three = Plan.objects.get(on_sale=True, duration_days=90)
DiscountCode.objects.create(code='HALF', type='percent', value=50, max_uses=10)
def tearDown(self):
@@ -173,7 +180,7 @@ class BazaarDynamicPriceTests(TestCase):
self.assertEqual(self.redeem(order_id).status_code, 400)
body = self.start(order_id).json()
self.assertIsNone(body['dynamicPriceToken'])
self.assertEqual((body['sku'], body['productType']), ('10343', 'subscription'))
self.assertEqual(body['sku'], '10343')
@override_settings(BAZAAR_DYNAMIC_PRICE_KEY='panel-key', BAZAAR_PACKAGE_NAME='com.tadrisino.math.app')
def test_discounted_price_is_signed_for_bazaar(self):
@@ -234,7 +241,7 @@ class PaymentProviderFactoryTests(TestCase):
user = User.objects.create(phone='09120007304')
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
three = Plan.objects.get(kind='subscription', on_sale=True, duration_days=90)
three = Plan.objects.get(on_sale=True, duration_days=90)
z = client.post('/api/subscription/purchase', {'planId': str(three.id)}, format='json').json()
self.assertIn('/api/payment/orders/', z['paymentUrl'])
self.assertNotIn('bazaarSku', z)

View File

@@ -1,8 +1,8 @@
"""
لغو و بازپرداختِ سفارش از پنلِ ادمین (۲۰۲۶-۱۰-۰۲).
راه‌های شکستن که امتحان می‌شوند: بازپرداختِ دوباره‌ی یک سفارش، منفی شدنِ موجودیِ کسی که بخشی از
بسته را خرج کرده، بازپرداختِ سفارشی که پرداخت نشده، کلیکِ اشتباه در ادمین (بی‌صفحه‌ی تأیید)، و
راه‌های شکستن که امتحان می‌شوند: بازپرداختِ دوباره‌ی یک سفارش، کم شدنِ روزهای خریدِ دیگر،
بازپرداختِ سفارشی که پرداخت نشده، کلیکِ اشتباه در ادمین (بی‌صفحه‌ی تأیید)، و
سفارشِ لغوشده‌ای که کاربرش واقعاً پول داده بود. خودِ برگرداندنِ پول در درگاه آزموده نمی‌شود چون این
کد آن را انجام نمی‌دهد.
"""
@@ -17,8 +17,8 @@ from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from affiliate.models import AffiliateAccount, Conversion
from subscription.models import HeartsPass, Plan, QuestionCredit, Subscription
from subscription.services import credit_balance
from subscription.models import Plan, Subscription
from subscription.services import has_active_subscription
from .models import Order, Transaction
from .services import OrderStateError, apply_order_payment_result, cancel_pending_order, refund_order
@@ -26,60 +26,53 @@ from .services import OrderStateError, apply_order_payment_result, cancel_pendin
class _Base(TestCase):
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
cache.clear()
self.user = User.objects.create(phone='09120007401')
self.client_app = APIClient()
self.client_app.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
self.pack = Plan.objects.get(kind='pack', question_credits=20)
self.plan = Plan.objects.get(on_sale=True, duration_days=90)
def tearDown(self):
cache.clear()
def paid_pack(self, ref='R1'):
response = self.client_app.post('/api/subscription/purchase', {'planId': str(self.pack.id)}, format='json')
def paid_subscription(self, ref='R1'):
response = self.client_app.post('/api/subscription/purchase', {'planId': str(self.plan.id)}, format='json',
HTTP_IDEMPOTENCY_KEY=f'key-{ref}')
order = Order.objects.get(pk=response.json()['orderId'])
apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id=ref, new_status='paid', event='t')
order.refresh_from_db()
return order
def spend(self, n):
for i in range(n):
QuestionCredit.objects.create(user_id=self.user.id, delta=-1, reason='question', message_id=10_000 + i)
def days_left(self):
sub = Subscription.objects.get(user_id=self.user.id)
return (sub.expires_at - timezone.now()).days
class RefundServiceTests(_Base):
def test_an_unused_pack_is_taken_back_whole_with_its_heart_days(self):
order = self.paid_pack()
def test_a_fresh_subscription_is_taken_back_whole(self):
order = self.paid_subscription()
summary = refund_order(order)
self.assertEqual((summary['credits_granted'], summary['credits_taken']), (20, 20))
self.assertEqual(credit_balance(self.user.id), 0)
# ۷ روزِ همین بسته از کارتِ ۷روزه کم شد، پس قلبِ نامحدود همین الان تمام است.
self.assertLessEqual(HeartsPass.objects.get(user_id=self.user.id).expires_at, timezone.now() + timedelta(seconds=5))
self.assertEqual(summary['subscription_days'], 90)
self.assertFalse(has_active_subscription(self.user.id))
self.assertEqual(Subscription.objects.get(user_id=self.user.id).status, 'canceled')
order.refresh_from_db()
self.assertEqual(order.status, 'refunded')
self.assertTrue(Transaction.objects.filter(order=order, status='refunded', gateway='admin').exists())
def test_what_was_already_spent_is_not_taken_twice_and_balance_never_goes_negative(self):
order = self.paid_pack()
self.spend(15)
summary = refund_order(order)
self.assertEqual(summary['credits_taken'], 5)
self.assertEqual(credit_balance(self.user.id), 0)
def test_only_this_orders_credits_are_taken_not_a_second_packs(self):
first = self.paid_pack(ref='R1')
self.paid_pack(ref='R2')
def test_only_this_orders_days_are_taken_not_a_second_purchases(self):
first = self.paid_subscription(ref='R1')
self.paid_subscription(ref='R2')
self.assertAlmostEqual(self.days_left(), 179, delta=1)
refund_order(first)
self.assertEqual(credit_balance(self.user.id), 20)
self.assertAlmostEqual(self.days_left(), 89, delta=1)
self.assertTrue(has_active_subscription(self.user.id))
def test_a_second_refund_of_the_same_order_is_refused(self):
order = self.paid_pack()
order = self.paid_subscription()
refund_order(order)
with self.assertRaises(OrderStateError):
refund_order(order)
self.assertEqual(credit_balance(self.user.id), 0)
self.assertEqual(QuestionCredit.objects.filter(reason='refund').count(), 1)
self.assertEqual(Transaction.objects.filter(order=order, status='refunded').count(), 1)
def test_an_unpaid_order_cannot_be_refunded(self):
order = Order.objects.create(user_id=self.user.id, amount=1000, idempotency_key='u1')
@@ -87,7 +80,7 @@ class RefundServiceTests(_Base):
refund_order(order)
def test_a_subscription_refund_takes_its_days_off(self):
plan = Plan.objects.filter(kind='subscription').first()
plan = Plan.objects.filter().first()
sub = Subscription.objects.create(user_id=self.user.id, plan=plan, status='active',
expires_at=timezone.now() + timedelta(days=plan.duration_days + 10))
order = Order.objects.create(user_id=self.user.id, amount=plan.price, subscription_id=sub.id,
@@ -98,7 +91,7 @@ class RefundServiceTests(_Base):
self.assertEqual(sub.status, 'active')
def test_a_subscription_with_nothing_left_is_canceled(self):
plan = Plan.objects.filter(kind='subscription').first()
plan = Plan.objects.filter().first()
sub = Subscription.objects.create(user_id=self.user.id, plan=plan, status='active',
expires_at=timezone.now() + timedelta(days=3))
order = Order.objects.create(user_id=self.user.id, amount=plan.price, subscription_id=sub.id,
@@ -111,14 +104,14 @@ class RefundServiceTests(_Base):
referrer = User.objects.create(phone='09120007402')
account = AffiliateAccount.objects.create(user_id=referrer.id, referral_code='REF1', commission_rate=0.1)
Conversion.objects.create(affiliate_account=account, referred_user_id=self.user.id)
order = self.paid_pack()
order = self.paid_subscription()
self.assertEqual(Conversion.objects.get().status, 'paid')
refund_order(order)
conversion = Conversion.objects.get()
self.assertEqual((conversion.status, conversion.commission_amount, conversion.order_id), ('pending', 0, None))
def test_the_invoice_shows_the_refund(self):
refund_order(self.paid_pack())
refund_order(self.paid_subscription())
invoice = self.client_app.get('/api/payment/invoices').json()['invoices'][0]
self.assertEqual(invoice['status'], 'refunded')
@@ -130,19 +123,18 @@ class CancelServiceTests(_Base):
order.refresh_from_db()
self.assertEqual(order.status, 'canceled')
with self.assertRaises(OrderStateError):
cancel_pending_order(self.paid_pack())
cancel_pending_order(self.paid_subscription())
def test_if_the_user_did_pay_a_canceled_order_the_payment_still_counts(self):
response = self.client_app.post('/api/subscription/purchase', {'planId': str(self.pack.id)}, format='json')
response = self.client_app.post('/api/subscription/purchase', {'planId': str(self.plan.id)}, format='json')
order = Order.objects.get(pk=response.json()['orderId'])
cancel_pending_order(order)
apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id='LATE', new_status='paid', event='t')
self.assertEqual(credit_balance(self.user.id), 20)
self.assertTrue(has_active_subscription(self.user.id))
class OrderAdminTests(_Base):
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
super().setUp()
staff = get_user_model().objects.create_superuser('admin', 'a@example.com', 'pw-for-tests-only')
self.admin = self.client
@@ -152,19 +144,19 @@ class OrderAdminTests(_Base):
return self.admin.post('/admin/payment/order/', {'action': name, '_selected_action': [order.pk], **extra})
def test_refund_asks_for_confirmation_first(self):
order = self.paid_pack()
order = self.paid_subscription()
page = self.action('refund', order)
self.assertEqual(page.status_code, 200)
self.assertContains(page, 'پول را برنمی‌گرداند')
order.refresh_from_db()
self.assertEqual(order.status, 'paid')
self.assertEqual(credit_balance(self.user.id), 20)
self.assertTrue(has_active_subscription(self.user.id))
done = self.action('refund', order, confirm='yes')
self.assertEqual(done.status_code, 302)
order.refresh_from_db()
self.assertEqual(order.status, 'refunded')
self.assertEqual(credit_balance(self.user.id), 0)
self.assertFalse(has_active_subscription(self.user.id))
def test_cancel_action(self):
order = Order.objects.create(user_id=self.user.id, amount=1000, idempotency_key='c2')
@@ -173,7 +165,7 @@ class OrderAdminTests(_Base):
self.assertEqual(order.status, 'canceled')
def test_status_cannot_be_edited_by_hand(self):
order = self.paid_pack()
order = self.paid_subscription()
page = self.admin.get(f'/admin/payment/order/{order.pk}/change/')
self.assertEqual(page.status_code, 200)
self.assertNotContains(page, 'name="status"')

View File

@@ -13,8 +13,10 @@ from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from discount.models import DiscountCode
from subscription.models import Plan
from subscription.services import credit_balance
from django.utils import timezone
from subscription.models import Plan, Subscription
from subscription.services import has_active_subscription
from .bazaar import BazaarPurchaseResult
from .models import Order, Transaction
@@ -29,25 +31,27 @@ def client_for(user):
class _Base(TestCase):
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
cache.clear()
self.user = User.objects.create(phone='09120009901')
self.app = client_for(self.user)
self.pack = Plan.objects.get(kind='pack', question_credits=20)
self.plan = Plan.objects.get(on_sale=True, duration_days=90)
def tearDown(self):
cache.clear()
def pack_order(self, **extra):
r = self.app.post('/api/subscription/purchase', {'planId': str(self.pack.id), **extra}, format='json')
def sub_order(self, key='sec-k', **extra):
r = self.app.post('/api/subscription/purchase', {'planId': str(self.plan.id), **extra}, format='json',
HTTP_IDEMPOTENCY_KEY=key)
return Order.objects.get(pk=r.json()['orderId'])
def days_left(self):
return (Subscription.objects.get(user_id=self.user.id).expires_at - timezone.now()).days
class SupervisorCannotRunMoneyActionsTests(_Base):
"""گروهِ Supervisor فقط view_order دارد؛ actionِ بی‌permissions برای هر بیننده‌ی فهرست اجرا می‌شد."""
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
super().setUp()
call_command('setup_supervisor_group', verbosity=0)
staff = get_user_model().objects.create_user('sup', 'sup@example.com', 'pw-for-tests-only', is_staff=True)
@@ -55,12 +59,12 @@ class SupervisorCannotRunMoneyActionsTests(_Base):
self.client.force_login(staff)
def test_refund_is_refused(self):
order = self.pack_order()
order = self.sub_order()
apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id='R1', new_status='paid', event='t')
self.client.post('/admin/payment/order/', {'action': 'refund', '_selected_action': [order.pk], 'confirm': 'yes'})
order.refresh_from_db()
self.assertEqual(order.status, 'paid')
self.assertEqual(credit_balance(self.user.id), 20)
self.assertTrue(has_active_subscription(self.user.id))
def test_cancel_is_refused(self):
order = Order.objects.create(user_id=self.user.id, amount=1000, idempotency_key='sec-c1')
@@ -72,7 +76,7 @@ class SupervisorCannotRunMoneyActionsTests(_Base):
class CallbackWithoutAuthorityTests(_Base):
def test_missing_authority_is_400_and_touches_no_order(self):
DiscountCode.objects.create(code='SEC50', type='percent', value=50, max_uses=10)
order = self.pack_order()
order = self.sub_order()
self.assertEqual(self.app.post('/api/discount/redeem', {'code': 'SEC50', 'orderId': str(order.id)},
format='json').status_code, 200)
Order.objects.create(user_id=self.user.id, amount=1000, idempotency_key='sec-b') # دومین سفارشِ بازنشده
@@ -85,23 +89,21 @@ class CallbackWithoutAuthorityTests(_Base):
class PlanEditedBetweenOrderAndPaymentTests(_Base):
def test_buyer_gets_what_the_order_sold(self):
order = self.pack_order()
Plan.objects.filter(pk=self.pack.pk).update(price=order.amount * 5, question_credits=150, hearts_days=60)
order = self.sub_order()
Plan.objects.filter(pk=self.plan.pk).update(price=order.amount * 5, duration_days=365)
apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id='P1', new_status='paid', event='t')
self.assertEqual(credit_balance(self.user.id), 20)
self.assertEqual(order.question_credits, 20)
self.assertAlmostEqual(self.days_left(), 89, delta=1)
self.assertEqual(order.duration_days, 90)
@override_settings(BAZAAR_PISHKHAN_API_SECRET='x', BAZAAR_PACKAGE_NAME='com.example')
class BazaarSecondTokenOnPaidOrderTests(_Base):
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
super().setUp()
Plan.objects.filter(pk=self.pack.pk).update(bazaar_sku='sec-pack20')
def test_second_token_is_409_and_stays_usable(self):
order = self.pack_order(paymentMethod='bazaar')
valid = BazaarPurchaseResult(is_valid=True, purchase_state=0)
order = self.sub_order(paymentMethod='bazaar')
valid = BazaarPurchaseResult(is_valid=True)
with patch('payment.bazaar.verify_purchase', return_value=valid):
first = self.app.post('/api/payment/bazaar/verify',
{'orderId': str(order.id), 'purchaseToken': 'TOKEN-1'}, format='json')
@@ -114,13 +116,13 @@ class BazaarSecondTokenOnPaidOrderTests(_Base):
self.assertEqual(second.status_code, 409)
self.assertFalse(Transaction.objects.filter(gateway_ref_id='TOKEN-2').exists())
r2 = self.app.post('/api/subscription/purchase', {'planId': str(self.pack.id), 'paymentMethod': 'bazaar'},
r2 = self.app.post('/api/subscription/purchase', {'planId': str(self.plan.id), 'paymentMethod': 'bazaar'},
format='json', HTTP_IDEMPOTENCY_KEY='sec-new')
with patch('payment.bazaar.verify_purchase', return_value=valid):
fresh = self.app.post('/api/payment/bazaar/verify',
{'orderId': r2.json()['orderId'], 'purchaseToken': 'TOKEN-2'}, format='json')
self.assertEqual(fresh.json()['status'], 'paid')
self.assertEqual(credit_balance(self.user.id), 40)
self.assertAlmostEqual(self.days_left(), 179, delta=1)
@override_settings(BAZAAR_PISHKHAN_API_SECRET='x', BAZAAR_PACKAGE_NAME='com.example')
@@ -128,11 +130,8 @@ class BazaarRejectedVerifyDoesNotBurnTokenTests(_Base):
"""ردِ بازار (not_found) توکن را روی تراکنشِ «failed» ثبت می‌کرد و قیدِ یکتا همان خرید را برای همیشه می‌سوزاند."""
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
super().setUp()
Plan.objects.filter(pk=self.pack.pk).update(bazaar_sku='sec-p20')
self.big = Plan.objects.filter(kind='pack').exclude(pk=self.pack.pk).order_by('-price').first()
Plan.objects.filter(pk=self.big.pk).update(bazaar_sku='sec-pbig')
self.big = Plan.objects.get(on_sale=True, duration_days=180)
def verify(self, order_id, token, result):
with patch('payment.bazaar.verify_purchase', return_value=result):
@@ -140,39 +139,38 @@ class BazaarRejectedVerifyDoesNotBurnTokenTests(_Base):
format='json')
def test_same_order_after_a_rejection(self):
order = self.pack_order(paymentMethod='bazaar')
order = self.sub_order(paymentMethod='bazaar')
self.verify(order.id, 'TOK-A', BazaarPurchaseResult(is_valid=False, raw={'error': 'not_found'}))
r = self.verify(order.id, 'TOK-A', BazaarPurchaseResult(is_valid=True, purchase_state=0))
r = self.verify(order.id, 'TOK-A', BazaarPurchaseResult(is_valid=True))
self.assertEqual(r.json()['status'], 'paid')
self.assertEqual(credit_balance(self.user.id), 20)
self.assertAlmostEqual(self.days_left(), 89, delta=1)
def test_token_sent_with_the_wrong_order_still_pays_the_right_one(self):
small = self.pack_order(paymentMethod='bazaar')
small = self.sub_order(paymentMethod='bazaar')
big = self.app.post('/api/subscription/purchase', {'planId': str(self.big.id), 'paymentMethod': 'bazaar'},
format='json').json()['orderId']
format='json', HTTP_IDEMPOTENCY_KEY='sec-big').json()['orderId']
self.verify(big, 'TOK-S', BazaarPurchaseResult(is_valid=False, raw={'error': 'not_found'}))
r = self.verify(small.id, 'TOK-S', BazaarPurchaseResult(is_valid=True, purchase_state=0))
r = self.verify(small.id, 'TOK-S', BazaarPurchaseResult(is_valid=True))
self.assertEqual(r.json()['status'], 'paid')
self.assertEqual(credit_balance(self.user.id), 20)
self.assertAlmostEqual(self.days_left(), 89, delta=1)
self.assertEqual(Order.objects.get(pk=big).status, 'failed')
def test_refunded_order_refuses_a_new_token(self):
from .services import refund_order
order = self.pack_order(paymentMethod='bazaar')
self.verify(order.id, 'TOK-1', BazaarPurchaseResult(is_valid=True, purchase_state=0))
order = self.sub_order(paymentMethod='bazaar')
self.verify(order.id, 'TOK-1', BazaarPurchaseResult(is_valid=True))
refund_order(Order.objects.get(pk=order.pk))
r = self.verify(order.id, 'TOK-2', BazaarPurchaseResult(is_valid=True, purchase_state=0))
r = self.verify(order.id, 'TOK-2', BazaarPurchaseResult(is_valid=True))
self.assertEqual(r.status_code, 409)
self.assertEqual(Order.objects.get(pk=order.pk).status, 'refunded')
self.assertFalse(Transaction.objects.filter(gateway_ref_id='TOK-2').exists())
class OrderAdminMoneyFieldsReadOnlyTests(_Base):
"""اجازه‌ی change (لازمِ لغو/بازپرداخت) فرمِ سفارش را هم باز می‌کرد: مبلغ و تعدادِ سوالِ سفارش ویرایش‌شدنی بود."""
"""اجازه‌ی change (لازمِ لغو/بازپرداخت) فرمِ سفارش را هم باز می‌کرد: مبلغ و مدتِ سفارش ویرایش‌شدنی بود."""
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
super().setUp()
from django.contrib.auth.models import Permission
@@ -185,19 +183,19 @@ class OrderAdminMoneyFieldsReadOnlyTests(_Base):
staff.groups.add(group)
self.client.force_login(staff)
def test_amount_and_credits_cannot_be_edited(self):
order = self.pack_order()
data = {'amount': 1000, 'question_credits': 100000, 'user_id': order.user_id,
def test_amount_and_duration_cannot_be_edited(self):
order = self.sub_order()
data = {'amount': 1000, 'duration_days': 100000, 'user_id': order.user_id,
'transactions-TOTAL_FORMS': 0, 'transactions-INITIAL_FORMS': 0,
'transactions-MIN_NUM_FORMS': 0, 'transactions-MAX_NUM_FORMS': 1000}
self.client.post(f'/admin/payment/order/{order.pk}/change/', data)
order.refresh_from_db()
self.assertEqual((order.amount, order.question_credits), (self.pack.price, 20))
self.assertEqual((order.amount, order.duration_days), (self.plan.price, 90))
page = self.client.get(f'/admin/payment/order/{order.pk}/change/')
self.assertNotContains(page, 'name="amount"')
def test_orders_and_transactions_are_not_deleted_or_added_from_the_panel(self):
order = self.pack_order()
order = self.sub_order()
apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id='DEL-1', new_status='paid', event='t')
self.client.post(f'/admin/payment/order/{order.pk}/delete/', {'post': 'yes'})
txn = Transaction.objects.get(gateway_ref_id='DEL-1')
@@ -215,7 +213,7 @@ class DiscountDuringPaymentRequestTests(_Base):
from .zarinpal import ZarinpalRequestResult
DiscountCode.objects.create(code='SECHALF', type='percent', value=50, max_uses=5)
r = self.app.post('/api/subscription/purchase', {'planId': str(self.pack.id)}, format='json')
r = self.app.post('/api/subscription/purchase', {'planId': str(self.plan.id)}, format='json')
order_id, pay_url = r.json()['orderId'], r.json()['paymentUrl']
requested = []
@@ -229,8 +227,8 @@ class DiscountDuringPaymentRequestTests(_Base):
resp = APIClient().get('/api/' + pay_url.split('/api/', 1)[1])
order = Order.objects.get(pk=order_id)
self.assertEqual(resp.status_code, 302)
self.assertEqual(order.amount, self.pack.price // 2)
self.assertEqual(requested, [self.pack.price, self.pack.price // 2])
self.assertEqual(order.amount, self.plan.price // 2)
self.assertEqual(requested, [self.plan.price, self.plan.price // 2])
self.assertEqual(order.gateway_authority, 'A-2')

View File

@@ -3,7 +3,6 @@ from django.urls import path
from .views import (
BazaarStartPurchaseView,
BazaarVerifyPurchaseView,
CreateOrderView,
InvoiceListView,
OrderDetailView,
PaymentRedirectView,
@@ -11,7 +10,6 @@ from .views import (
)
urlpatterns = [
path('payment/orders', CreateOrderView.as_view(), name='payment-create-order'),
path('payment/zarinpal/callback', ZarinpalCallbackView.as_view(), name='payment-zarinpal-callback'),
path('payment/bazaar/verify', BazaarVerifyPurchaseView.as_view(), name='payment-bazaar-verify'),
path('payment/bazaar/orders/<int:order_id>/start', BazaarStartPurchaseView.as_view(),

View File

@@ -23,8 +23,6 @@ from .serializers import (
BazaarStartResponseSerializer,
BazaarVerifyRequestSerializer,
BazaarVerifyResponseSerializer,
CreateOrderRequestSerializer,
CreateOrderResponseSerializer,
OrderDetailResponseSerializer,
ZarinpalCallbackResponseSerializer,
)
@@ -49,73 +47,6 @@ def _redirect_to_configured_url(url: str) -> HttpResponseRedirect:
return redirect_cls(url)
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
if v.get('provider') == 'bazaar' and not v.get('bazaarProductSku'):
raise ValidationError({'bazaarProductSku': 'سفارشِ بازار بدونِ SKU ساخته نمی‌شود.'})
plan = None
if v.get('planId'):
from subscription.models import Plan
plan = Plan.objects.filter(pk=v['planId']).first() if (str(v['planId']).isascii() and str(v['planId']).isdigit()) else None
if plan is None:
raise ValidationError({'planId': 'پلنی با این شناسه وجود ندارد.'})
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 '',
plan_id=plan.id if plan else None,
plan_title=plan.title if plan else '',
)
data = {
'orderId': str(order.id), 'status': order.status,
'paymentUrl': payment_url, 'provider': order.provider,
}
return Response(CreateOrderResponseSerializer(data).data)
class PaymentRedirectView(APIView):
"""
GET /api/payment/orders/{id}/pay — همان paymentUrlی که
@@ -340,12 +271,12 @@ class BazaarVerifyPurchaseView(APIView):
raise ValidationError({'orderId': 'این سفارش محصولِ بازار ندارد.'})
if self._second_purchase_on_paid_order(order_lookup, v['purchaseToken']):
return self._second_purchase_response()
# نوعِ محصول از سفارش: سفارشِ اشتراک subscription_id دارد (PurchaseView)، بسته ندارد. پیش‌تر نوع
# حدس زده می‌شد (اول تک‌خرید، بعد اشتراک).
# فقط اشتراک از بازار فروخته می‌شود (subscription.0017)؛ سفارشِ بی‌اشتراک چیزی برای فعال کردن ندارد.
if not order_lookup.subscription_id:
raise ValidationError({'orderId': 'این سفارش اشتراکی ندارد.'})
result = bazaar.verify_purchase(
product_sku=order_lookup.bazaar_product_sku,
purchase_token=v['purchaseToken'],
is_subscription=bool(order_lookup.subscription_id),
)
with transaction.atomic():
@@ -438,7 +369,6 @@ class BazaarStartPurchaseView(APIView):
data = {
'orderId': str(order.id),
'sku': order.bazaar_product_sku,
'productType': 'subscription' if order.subscription_id else 'inapp',
'amount': order.amount,
'dynamicPriceToken': token,
}