Bazaar: subscriptions (10343/10344), dynamic price (discount codes on Bazaar), auto-renewal sync
- Purchase with paymentMethod=bazaar now works for subscription plans with a bazaar_sku (not only packs);
response carries bazaarProductType. Migration subscription.0014 puts 10343/10344 on the 90/180-day plans.
- Verify asks Bazaar for the right product type (from the order) and stores validUntil (payment.0012).
- POST /api/payment/bazaar/orders/{id}/start: SKU, type, and an HS256 dynamic-price JWT when a discount
code lowered the price (BAZAAR_DYNAMIC_PRICE_KEY); discount on Bazaar orders allowed only with that key
and only before start.
- Auto-renewal: sync_bazaar_renewals re-asks Bazaar when the paid period is near its end (status page and
the paywall via has_subscription_access); each new period becomes a paid order.
- Default BAZAAR_PACKAGE_NAME = com.tadrisino.math.app; health shows bazaarDynamicPrice.
979 tests green. No real Bazaar purchase or renewal tested.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,10 +22,15 @@ developers.cafebazaar.ir/fa/guidelines/in-app-billing/api/postman-and-bazaar-api
|
||||
fail-closed عمل میکنه و invalid برمیگردونه؛ هیچوقت به purchaseToken
|
||||
ارسالیِ کلاینت، بدون صحتسنجیِ واقعی با سرور بازار، اعتماد نمیشه.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta, timezone as dt_timezone
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
import requests
|
||||
@@ -56,6 +61,9 @@ class BazaarPurchaseResult:
|
||||
consumption_state: int | None = None
|
||||
developer_payload: str = ''
|
||||
raw: dict | None = None
|
||||
# فقط اشتراک: پایانِ دورهی پرداختشده (با هر تمدیدِ خودکار جلو میرود) و اینکه تمدید روشن است.
|
||||
valid_until: 'datetime | None' = None
|
||||
auto_renewing: bool | None = None
|
||||
|
||||
|
||||
def _authorize_window() -> timedelta:
|
||||
@@ -290,7 +298,7 @@ def verify_purchase(*, product_sku: str, purchase_token: str,
|
||||
|
||||
def fetch(subscription: bool):
|
||||
if subscription:
|
||||
url = f'{settings.BAZAAR_API_BASE}/api/applications/{pkg}/subscriptions/{sku}/purchases/{token}'
|
||||
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:
|
||||
@@ -327,7 +335,13 @@ def verify_purchase(*, product_sku: str, purchase_token: str,
|
||||
# سند میگوید validUntil را با اکنون مقایسه کنید.
|
||||
valid_until = data.get('validUntilTimestampMsec') or 0
|
||||
is_valid = valid_until > timezone.now().timestamp() * 1000
|
||||
purchase_state = None
|
||||
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
|
||||
@@ -338,3 +352,36 @@ def verify_purchase(*, product_sku: str, purchase_token: str,
|
||||
developer_payload=data.get('developerPayload', ''),
|
||||
raw=data,
|
||||
)
|
||||
|
||||
|
||||
def dynamic_price_configured() -> bool:
|
||||
return bool(settings.BAZAAR_DYNAMIC_PRICE_KEY)
|
||||
|
||||
|
||||
def _b64url(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b'=').decode()
|
||||
|
||||
|
||||
def dynamic_price_token(*, sku: str, price_rial: int, ttl_seconds: int = 30 * 60) -> str:
|
||||
"""
|
||||
JWTِ «تخفیف پویا»ی بازار (سندِ dynamic-discount، خواندهشده ۲۰۲۶-۱۰-۱۲): HS256 با کلیدِ پیشخان،
|
||||
payload = price (ریال)، package_name، sku، exp، nonce. اپ آن را در PurchaseRequest.dynamicPriceToken
|
||||
میگذارد و بازار همین مبلغ را میگیرد؛ اگر از قیمتِ پیشخان بیشتر باشد خطای ۱۰ میدهد.
|
||||
یکبارمصرف است (nonce). بیکتابخانه، چون فقط HS256 لازم است.
|
||||
نسنجیده: خریدِ واقعی با این توکن (اپِ تازه هنوز ساخته نشده).
|
||||
"""
|
||||
key = settings.BAZAAR_DYNAMIC_PRICE_KEY
|
||||
if not key:
|
||||
raise ValueError('BAZAAR_DYNAMIC_PRICE_KEY is not set')
|
||||
header = {'alg': 'HS256', 'typ': 'JWT'}
|
||||
payload = {
|
||||
'price': int(price_rial),
|
||||
'package_name': settings.BAZAAR_PACKAGE_NAME,
|
||||
'sku': sku,
|
||||
'exp': int(time.time()) + ttl_seconds,
|
||||
'nonce': secrets.token_hex(12),
|
||||
}
|
||||
signing_input = '.'.join(
|
||||
_b64url(json.dumps(part, separators=(',', ':')).encode()) for part in (header, payload))
|
||||
signature = hmac.new(key.encode(), signing_input.encode(), hashlib.sha256).digest()
|
||||
return f'{signing_input}.{_b64url(signature)}'
|
||||
|
||||
23
payment/migrations/0012_bazaar_subscription.py
Normal file
23
payment/migrations/0012_bazaar_subscription.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.17 on 2026-10-12 14:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('payment', '0011_order_plan_snapshot'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='bazaar_price_rial',
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='bazaar_valid_until',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -44,6 +44,12 @@ class Order(models.Model):
|
||||
# بازار میگیره و برای verify به بکاند میفرسته.
|
||||
bazaar_product_sku = models.CharField(max_length=100, blank=True)
|
||||
bazaar_purchase_token = models.CharField(max_length=300, blank=True)
|
||||
# مبلغی (ریال) که در JWTِ «تخفیف پویا» برای همین سفارش امضا شد؛ بعد از آن کدِ تخفیف روی سفارش
|
||||
# نمینشیند (همان قاعدهی gateway_authority). null یعنی بازار قیمتِ پیشخان را گرفته/میگیرد.
|
||||
bazaar_price_rial = models.PositiveIntegerField(null=True, blank=True)
|
||||
# فقط اشتراکِ بازار: پایانِ دورهای که همین سفارش پولش را داده، از پاسخِ بازار. تمدیدِ خودکار با
|
||||
# جلو رفتنِ این عدد در بازار دیده میشود (payment.services.sync_bazaar_renewals).
|
||||
bazaar_valid_until = models.DateTimeField(null=True, blank=True)
|
||||
# فقط وقتی provider == 'gateway' (زرینپال) پر میشه: Authorityِ برگشتی از
|
||||
# PaymentRequest، وقتی کاربر واقعاً لینکِ پرداخت رو باز کنه (نه موقعِ
|
||||
# ساختِ سفارش؛ نگاه کنید به PaymentRedirectView). همون چیزیه که زرینپال
|
||||
|
||||
@@ -25,6 +25,17 @@ class BazaarVerifyRequestSerializer(serializers.Serializer):
|
||||
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 یعنی قیمتِ پیشخان.')
|
||||
|
||||
|
||||
class BazaarVerifyResponseSerializer(serializers.Serializer):
|
||||
orderId = serializers.CharField()
|
||||
status = serializers.CharField()
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
جنگوییه (نه میکروسرویس واقعی)، «فراخوانی سرور-به-سرور» با یک import مستقیم
|
||||
پیادهسازی شده، نه یک HTTP call به خودمون.
|
||||
"""
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
@@ -281,12 +282,97 @@ def reconcile_pending_order(order: Order) -> bool:
|
||||
|
||||
|
||||
def reconcile_user_orders(user_id: int) -> None:
|
||||
"""سفارشهای pendingِ اخیرِ یک کاربر؛ از صفحهی وضعیتِ اشتراک صدا زده میشود."""
|
||||
"""سفارشهای pendingِ اخیرِ یک کاربر و تمدیدِ اشتراکِ بازار؛ از صفحهی وضعیتِ اشتراک صدا زده میشود."""
|
||||
since = timezone.now() - timezone.timedelta(days=2)
|
||||
for order in Order.objects.filter(
|
||||
user_id=user_id, status='pending', provider='gateway', created_at__gte=since,
|
||||
).exclude(gateway_authority='')[:3]:
|
||||
reconcile_pending_order(order)
|
||||
sync_bazaar_renewals(user_id)
|
||||
|
||||
|
||||
# تا این فاصله پیش از پایانِ دوره، از بازار پرسیده میشود که تمدید شده یا نه. بازار روزِ آخر تمدید میکند.
|
||||
BAZAAR_RENEWAL_LOOKAHEAD = timezone.timedelta(days=1)
|
||||
|
||||
|
||||
def sync_bazaar_renewals(user_id: int) -> int:
|
||||
"""
|
||||
اشتراکِ بازار خودکار تمدید میشود و بازار به ما خبر نمیدهد (webhook ندارد). پس وقتی دورهی آخرِ
|
||||
پرداختشدهی کاربر (`bazaar_valid_until`ِ آخرین سفارشِ همان توکن) نزدیکِ پایان یا گذشته است، همان توکن
|
||||
دوباره از بازار پرسیده میشود؛ اگر `validUntil` جلو رفته، برای هر دورهی تازه یک سفارشِ «paid» ساخته و
|
||||
همان مسیرِ پرداخت (activate_subscription، اعلان، پورسانت) اجرا میشود. هر توکن ساعتی یک بار.
|
||||
|
||||
مبلغِ سفارشِ تمدید = قیمتِ فعلیِ پلن در ادمین؛ بازار تمدید را با قیمتِ پیشخان میگیرد و آن را به ما
|
||||
نمیگوید. روزها مثلِ هر خریدِ دیگر روی پایانِ فعلی اضافه میشوند، پس کاربری که چند روز بعد از تمدید
|
||||
میآید همان چند روز را بیشتر دارد. نسنجیده: تمدیدِ واقعی در بازار. خروجی: تعدادِ دورههای تازه.
|
||||
"""
|
||||
from django.core.cache import cache
|
||||
|
||||
from . import bazaar
|
||||
|
||||
now = timezone.now()
|
||||
latest_by_token = {}
|
||||
for order in Order.objects.filter(
|
||||
user_id=user_id, provider='bazaar', status='paid', subscription_id__isnull=False,
|
||||
bazaar_valid_until__isnull=False,
|
||||
).exclude(bazaar_purchase_token='').order_by('bazaar_valid_until'):
|
||||
latest_by_token[order.bazaar_purchase_token] = order
|
||||
added = 0
|
||||
for token, last in latest_by_token.items():
|
||||
if last.bazaar_valid_until > now + BAZAAR_RENEWAL_LOOKAHEAD:
|
||||
continue
|
||||
token_key = hashlib.sha256(token.encode()).hexdigest()[:32]
|
||||
if not cache.add(f'payment:bazaar-renewal:{token_key}', 1, 3600):
|
||||
continue
|
||||
try:
|
||||
result = bazaar.verify_purchase(
|
||||
product_sku=last.bazaar_product_sku, purchase_token=token, is_subscription=True)
|
||||
except bazaar.BazaarAPIError: # خودِ bazaar لاگ کرده؛ ساعتِ بعد
|
||||
continue
|
||||
except Exception: # noqa: BLE001 — صفحهی وضعیت نباید با خطای بازار ۵۰۰ بدهد؛ ولی دیده شود
|
||||
logger.exception('bazaar renewal: سنجشِ تمدیدِ سفارشِ %s شکست خورد', last.id)
|
||||
continue
|
||||
if result.valid_until is None or result.valid_until <= last.bazaar_valid_until + timezone.timedelta(hours=12):
|
||||
continue
|
||||
added += _record_bazaar_renewal(last, token, token_key, result.valid_until)
|
||||
return added
|
||||
|
||||
|
||||
def _record_bazaar_renewal(last: Order, token: str, token_key: str, valid_until) -> int:
|
||||
from subscription.models import Plan
|
||||
|
||||
plan = Plan.objects.filter(pk=last.plan_id).first() if last.plan_id else None
|
||||
days = last.duration_days or (plan.duration_days if plan else None)
|
||||
if not days:
|
||||
logger.error('bazaar renewal: سفارشِ %s مدت ندارد؛ تمدید ثبت نشد', last.id)
|
||||
return 0
|
||||
gap = (valid_until - last.bazaar_valid_until).total_seconds() / 86400
|
||||
periods = max(1, round(gap / days))
|
||||
if periods > 1:
|
||||
logger.warning('bazaar renewal: %s دورهی تمدید برای سفارشِ %s یکجا ثبت میشود', periods, last.id)
|
||||
msec = int(valid_until.timestamp() * 1000)
|
||||
added = 0
|
||||
for i in range(periods):
|
||||
period_end = valid_until - timezone.timedelta(days=days * (periods - 1 - i))
|
||||
price = plan.price if plan else last.list_price or last.amount
|
||||
order, created = Order.objects.get_or_create(
|
||||
idempotency_key=f'bazaar-renew-{token_key}-{msec}-{i}',
|
||||
defaults=dict(
|
||||
user_id=last.user_id, subscription_id=last.subscription_id, plan_id=last.plan_id,
|
||||
duration_days=days, amount=price, list_price=price,
|
||||
plan_title=(plan.title if plan else last.plan_title)[:50], currency=last.currency,
|
||||
status='pending', provider='bazaar', bazaar_product_sku=last.bazaar_product_sku,
|
||||
bazaar_purchase_token=token, bazaar_valid_until=period_end,
|
||||
),
|
||||
)
|
||||
if not created and order.status != 'pending':
|
||||
continue
|
||||
if apply_order_payment_result(
|
||||
order=order, gateway='bazaar', gateway_ref_id=f'bazaar-renew-{token_key}-{msec}-{i}', # توکن تا ۳۰۰ نویسه است؛ ref هم ۳۰۰
|
||||
new_status='paid', event='bazaar_renewal',
|
||||
):
|
||||
added += 1
|
||||
return added
|
||||
|
||||
|
||||
class OrderStateError(Exception):
|
||||
|
||||
@@ -195,7 +195,7 @@ class BazaarValidateAgainstDocsTests(TestCase):
|
||||
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',
|
||||
'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)
|
||||
|
||||
203
payment/tests_bazaar_subscription.py
Normal file
203
payment/tests_bazaar_subscription.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
اشتراکِ کافهبازار، «تخفیف پویا» و تمدیدِ خودکار (۲۰۲۶-۱۰-۱۲).
|
||||
|
||||
بازار با `bazaar.verify_purchase`ِ ساختگی آزموده میشود؛ خریدِ واقعی، تمدیدِ واقعی و پذیرفتنِ JWT
|
||||
از سوی بازار هیچکدام آزموده نشدهاند (اپِ اندرویدِ تازه هنوز ساخته نشده).
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
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, Subscription
|
||||
|
||||
from .bazaar import BazaarPurchaseResult
|
||||
from .models import Order
|
||||
|
||||
|
||||
def _result(days: float, valid=True):
|
||||
return BazaarPurchaseResult(is_valid=valid, valid_until=timezone.now() + timezone.timedelta(days=days),
|
||||
auto_renewing=True)
|
||||
|
||||
|
||||
class BazaarSubscriptionTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
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)
|
||||
|
||||
def tearDown(self):
|
||||
cache.clear()
|
||||
|
||||
def buy(self, plan, key='k1'):
|
||||
return self.client.post('/api/subscription/purchase', {'planId': str(plan.id), 'paymentMethod': 'bazaar'},
|
||||
format='json', HTTP_IDEMPOTENCY_KEY=key)
|
||||
|
||||
def verify(self, order_id, result):
|
||||
with patch('payment.bazaar.verify_purchase', return_value=result) as mock:
|
||||
response = self.client.post('/api/payment/bazaar/verify',
|
||||
{'orderId': order_id, 'purchaseToken': 'sub-token-1'}, format='json')
|
||||
return response, mock
|
||||
|
||||
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, '')
|
||||
|
||||
def test_plan_without_sku_is_refused_for_bazaar(self):
|
||||
monthly = Plan.objects.get(kind='subscription', on_sale=True, duration_days=30)
|
||||
self.assertEqual(self.buy(monthly).status_code, 400)
|
||||
|
||||
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))
|
||||
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.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 _paid_subscription(self):
|
||||
order_id = self.buy(self.three).json()['orderId']
|
||||
self.verify(order_id, _result(90))
|
||||
return Order.objects.get(pk=order_id)
|
||||
|
||||
def test_auto_renewal_in_bazaar_extends_the_subscription_once(self):
|
||||
order = self._paid_subscription()
|
||||
# دورهی پرداختشده تمام شده؛ بازار تمدید کرده و validUntil نود روز جلو رفته.
|
||||
order.bazaar_valid_until = timezone.now() - timezone.timedelta(hours=1)
|
||||
order.save(update_fields=['bazaar_valid_until'])
|
||||
before = Subscription.objects.get(user_id=self.user.id).expires_at
|
||||
with patch('payment.bazaar.verify_purchase', return_value=_result(90)) as mock:
|
||||
self.client.get('/api/subscription/status')
|
||||
cache.clear() # سقفِ ساعتی را کنار بگذار: ثبتِ دوباره هم نباید دورهی دوم بسازد
|
||||
self.client.get('/api/subscription/status')
|
||||
self.assertTrue(mock.call_args.kwargs['is_subscription'])
|
||||
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)
|
||||
after = Subscription.objects.get(user_id=self.user.id).expires_at
|
||||
self.assertAlmostEqual((after - before).days, 90, delta=1)
|
||||
|
||||
def test_paywall_picks_up_a_renewal_without_opening_the_store(self):
|
||||
from subscription.services import has_active_subscription, has_subscription_access
|
||||
order = self._paid_subscription()
|
||||
past = timezone.now() - timezone.timedelta(hours=1)
|
||||
Order.objects.filter(pk=order.pk).update(bazaar_valid_until=past)
|
||||
Subscription.objects.filter(user_id=self.user.id).update(expires_at=past)
|
||||
self.assertFalse(has_active_subscription(self.user.id))
|
||||
with patch('payment.bazaar.verify_purchase', return_value=_result(90)):
|
||||
self.assertTrue(has_subscription_access(self.user.id))
|
||||
|
||||
def test_no_renewal_when_bazaar_did_not_move_the_period(self):
|
||||
order = self._paid_subscription()
|
||||
order.bazaar_valid_until = timezone.now() - timezone.timedelta(hours=1)
|
||||
order.save(update_fields=['bazaar_valid_until'])
|
||||
with patch('payment.bazaar.verify_purchase', return_value=_result(-1 / 24, valid=False)):
|
||||
self.client.get('/api/subscription/status')
|
||||
self.assertEqual(Order.objects.filter(user_id=self.user.id).count(), 1)
|
||||
|
||||
def test_bazaar_is_not_asked_while_the_period_is_far_from_its_end(self):
|
||||
self._paid_subscription()
|
||||
with patch('payment.bazaar.verify_purchase') as mock:
|
||||
self.client.get('/api/subscription/status')
|
||||
mock.assert_not_called()
|
||||
|
||||
|
||||
def _decode(token: str, key: str) -> dict:
|
||||
head, body, sig = token.split('.')
|
||||
expected = base64.urlsafe_b64encode(
|
||||
hmac.new(key.encode(), f'{head}.{body}'.encode(), hashlib.sha256).digest()).rstrip(b'=').decode()
|
||||
assert hmac.compare_digest(sig, expected), 'bad signature'
|
||||
return json.loads(base64.urlsafe_b64decode(body + '=' * (-len(body) % 4)))
|
||||
|
||||
|
||||
class BazaarDynamicPriceTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
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)
|
||||
DiscountCode.objects.create(code='HALF', type='percent', value=50, max_uses=10)
|
||||
|
||||
def tearDown(self):
|
||||
cache.clear()
|
||||
|
||||
def order(self):
|
||||
return self.client.post('/api/subscription/purchase', {'planId': str(self.three.id), 'paymentMethod': 'bazaar'},
|
||||
format='json', HTTP_IDEMPOTENCY_KEY='dp1').json()['orderId']
|
||||
|
||||
def start(self, order_id):
|
||||
return self.client.post(f'/api/payment/bazaar/orders/{order_id}/start', format='json')
|
||||
|
||||
def redeem(self, order_id):
|
||||
return self.client.post('/api/discount/redeem', {'code': 'HALF', 'orderId': order_id}, format='json')
|
||||
|
||||
@override_settings(BAZAAR_DYNAMIC_PRICE_KEY='')
|
||||
def test_without_the_key_discount_is_refused_and_no_token(self):
|
||||
order_id = self.order()
|
||||
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'))
|
||||
|
||||
@override_settings(BAZAAR_DYNAMIC_PRICE_KEY='panel-key', BAZAAR_PACKAGE_NAME='com.tadrisino.math.app')
|
||||
def test_discounted_price_is_signed_for_bazaar(self):
|
||||
order_id = self.order()
|
||||
self.assertEqual(self.redeem(order_id).status_code, 200)
|
||||
body = self.start(order_id).json()
|
||||
claims = _decode(body['dynamicPriceToken'], 'panel-key')
|
||||
self.assertEqual(claims['price'], self.three.price // 2 * 10) # تومان → ریال
|
||||
self.assertEqual((claims['sku'], claims['package_name']), ('10343', 'com.tadrisino.math.app'))
|
||||
self.assertGreater(claims['exp'], timezone.now().timestamp())
|
||||
self.assertTrue(claims['nonce'])
|
||||
self.assertEqual(Order.objects.get(pk=order_id).bazaar_price_rial, claims['price'])
|
||||
|
||||
@override_settings(BAZAAR_DYNAMIC_PRICE_KEY='panel-key')
|
||||
def test_full_price_sends_no_token(self):
|
||||
# توکن روی بازارِ قدیمیتر از ۱۳.۳ خطا میدهد؛ بیتخفیف لازمش نداریم.
|
||||
self.assertIsNone(self.start(self.order()).json()['dynamicPriceToken'])
|
||||
|
||||
@override_settings(BAZAAR_DYNAMIC_PRICE_KEY='panel-key')
|
||||
def test_no_discount_after_the_purchase_started(self):
|
||||
order_id = self.order()
|
||||
self.start(order_id)
|
||||
self.assertEqual(self.redeem(order_id).status_code, 400)
|
||||
self.assertEqual(Order.objects.get(pk=order_id).amount, self.three.price)
|
||||
|
||||
def test_start_is_only_for_the_owner_and_pending_orders(self):
|
||||
order_id = self.order()
|
||||
other = APIClient()
|
||||
other.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(User.objects.create(phone="09120007303").id)}')
|
||||
self.assertEqual(other.post(f'/api/payment/bazaar/orders/{order_id}/start').status_code, 404)
|
||||
Order.objects.filter(pk=order_id).update(status='paid')
|
||||
self.assertEqual(self.start(order_id).status_code, 400)
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import (
|
||||
BazaarStartPurchaseView,
|
||||
BazaarVerifyPurchaseView,
|
||||
CreateOrderView,
|
||||
InvoiceListView,
|
||||
@@ -13,6 +14,8 @@ 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(),
|
||||
name='payment-bazaar-start'),
|
||||
path('payment/invoices', InvoiceListView.as_view(), name='payment-invoices'),
|
||||
path('payment/orders/<int:order_id>', OrderDetailView.as_view(), name='payment-order-detail'),
|
||||
path('payment/orders/<int:order_id>/pay', PaymentRedirectView.as_view(), name='payment-order-pay'),
|
||||
|
||||
@@ -20,6 +20,7 @@ from .models import Order
|
||||
from .permissions import HasInternalServiceKey
|
||||
from .serializers import (
|
||||
InvoiceListResponseSerializer,
|
||||
BazaarStartResponseSerializer,
|
||||
BazaarVerifyRequestSerializer,
|
||||
BazaarVerifyResponseSerializer,
|
||||
CreateOrderRequestSerializer,
|
||||
@@ -339,9 +340,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)، بسته ندارد. پیشتر نوع
|
||||
# حدس زده میشد (اول تکخرید، بعد اشتراک).
|
||||
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():
|
||||
@@ -358,7 +362,8 @@ class BazaarVerifyPurchaseView(APIView):
|
||||
# میکرد، همان خرید دیگر روی هیچ سفارشی، حتی همین، «paid» نمیشد (بازتولید شد).
|
||||
if result.is_valid:
|
||||
order.bazaar_purchase_token = v['purchaseToken']
|
||||
order.save(update_fields=['bazaar_purchase_token'])
|
||||
order.bazaar_valid_until = result.valid_until
|
||||
order.save(update_fields=['bazaar_purchase_token', 'bazaar_valid_until'])
|
||||
|
||||
apply_order_payment_result(
|
||||
order=order,
|
||||
@@ -391,6 +396,63 @@ class BazaarVerifyPurchaseView(APIView):
|
||||
status=status.HTTP_409_CONFLICT)
|
||||
|
||||
|
||||
class BazaarStartPurchaseView(APIView):
|
||||
"""
|
||||
POST /api/payment/bazaar/orders/{id}/start — آنچه اپِ اندروید برای شروعِ خرید در بازار لازم دارد.
|
||||
|
||||
پس از ساخت سفارش (`POST /api/subscription/purchase` با `paymentMethod: bazaar`) و کدِ تخفیفِ احتمالی
|
||||
(`POST /api/discount/redeem`) صدا زده میشود. اگر کدِ تخفیف مبلغ را کم کرده، `dynamicPriceToken` همان
|
||||
مبلغ را (به ریال) برای بازار امضا میکند؛ بیتخفیف null است و بازار قیمتِ پیشخان را میگیرد (عمداً: توکن
|
||||
روی بازارِ قدیمیتر از ۱۳.۳ خطا میدهد). بعد از این، کدِ تخفیفِ تازه روی سفارش نمینشیند.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
throttle_scope = 'bazaar_start'
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
|
||||
@extend_schema(request=None, responses=BazaarStartResponseSerializer, tags=['Payment (payment)'])
|
||||
def post(self, request, order_id):
|
||||
with transaction.atomic():
|
||||
order = get_object_or_404(
|
||||
Order.objects.select_for_update(), pk=order_id, user_id=request.user.id, provider='bazaar')
|
||||
if order.status != 'pending':
|
||||
raise ValidationError({'orderId': 'این سفارش دیگر در انتظارِ پرداخت نیست.'})
|
||||
if not order.bazaar_product_sku:
|
||||
raise ValidationError({'orderId': 'این سفارش محصولِ بازار ندارد.'})
|
||||
token = None
|
||||
full_price = order.list_price if order.list_price is not None else order.amount
|
||||
if order.amount < full_price:
|
||||
if not bazaar.dynamic_price_configured():
|
||||
# redeem بیکلید تخفیف را روی سفارشِ بازار نمیگذارد؛ رسیدن به اینجا یعنی کلید بعد از آن
|
||||
# از .env رفته. بیتوکن بازار قیمتِ کامل را میگرفت.
|
||||
logger.error('bazaar start: سفارشِ %s تخفیف دارد ولی BAZAAR_DYNAMIC_PRICE_KEY نیست', order.id)
|
||||
raise bazaar.BazaarAPIError()
|
||||
price_rial = _to_rial(order.amount, order.currency)
|
||||
token = bazaar.dynamic_price_token(sku=order.bazaar_product_sku, price_rial=price_rial)
|
||||
order.bazaar_price_rial = price_rial
|
||||
order.save(update_fields=['bazaar_price_rial'])
|
||||
elif order.bazaar_price_rial is None:
|
||||
# علامتِ «خرید شروع شد» تا کدِ تخفیفِ بعدی نیاید؛ قیمت همان قیمتِ کامل است.
|
||||
order.bazaar_price_rial = _to_rial(order.amount, order.currency)
|
||||
order.save(update_fields=['bazaar_price_rial'])
|
||||
data = {
|
||||
'orderId': str(order.id),
|
||||
'sku': order.bazaar_product_sku,
|
||||
'productType': 'subscription' if order.subscription_id else 'inapp',
|
||||
'amount': order.amount,
|
||||
'dynamicPriceToken': token,
|
||||
}
|
||||
return Response(BazaarStartResponseSerializer(data).data)
|
||||
|
||||
|
||||
def _to_rial(amount: int, currency: str) -> int:
|
||||
if currency == 'IRT':
|
||||
return amount * 10
|
||||
if currency == 'IRR':
|
||||
return amount
|
||||
raise ValueError(f'unknown currency {currency!r}')
|
||||
|
||||
|
||||
class OrderDetailView(APIView):
|
||||
"""GET /api/payment/orders/{id} — وضعیت سفارش"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user