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:
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
21
payment/migrations/0013_remove_question_packs.py
Normal file
21
payment/migrations/0013_remove_question_packs.py
Normal 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',
|
||||
),
|
||||
]
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
|
||||
@@ -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"')
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user