diff --git a/payment/bazaar.py b/payment/bazaar.py index cf1d1cd..cb17252 100644 --- a/payment/bazaar.py +++ b/payment/bazaar.py @@ -248,7 +248,15 @@ def _auth_headers() -> dict: return {'Authorization': _get_access_token()} -def verify_purchase(*, product_sku: str, purchase_token: str, is_subscription: bool = False) -> BazaarPurchaseResult: +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: """ صحت‌سنجی purchaseToken که کلاینت اندروید بعد از خرید از SDK بازار گرفته. @@ -260,6 +268,11 @@ def verify_purchase(*, product_sku: str, purchase_token: str, is_subscription: b به گفته‌ی سند «تنها اگر error برابر not_found باشد خرید انجام نشده»؛ پس فقط همان نامعتبر است و بقیه‌ی خطاها (کلیدِ غلط، سقفِ روزانه، قطعی) ۵۰۳ اند تا یک خریدِ واقعی به‌خاطرِ خطای ما failed نشود. + + is_subscription=None (پیش‌فرض): نوعِ محصول در پیشخانِ بازار به ما گفته نشده + (۲۰۲۶-۰۹-۳۰)، پس اول تک‌خرید پرسیده می‌شود و اگر بازار گفت «Product is not + found»، همان SKU به‌عنوانِ اشتراک. با پاسخِ واقعیِ بازار روی SKU و توکنِ + ساختگی دیده شد؛ با خریدِ واقعی هنوز آزموده نشده. """ if not is_configured(): # 🔒 fail-closed: بدونِ کلیدِ بازار هیچ‌وقت به گفته‌ی کلاینت اعتماد @@ -274,35 +287,41 @@ def verify_purchase(*, product_sku: str, purchase_token: str, is_subscription: b pkg = quote(settings.BAZAAR_PACKAGE_NAME, safe='') sku = quote(product_sku, safe='') token = quote(purchase_token, safe='') - if is_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: - logger.exception('Bazaar verify-purchase request failed') - raise BazaarAPIError() + 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: + logger.exception('Bazaar verify-purchase request failed') + raise BazaarAPIError() + try: + return resp.status_code, resp.json() + except ValueError: + return resp.status_code, None - try: - data = resp.json() - except ValueError: - data = 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) error = data.get('error') if isinstance(data, dict) else None - if resp.status_code == 404 and error == 'not_found': + 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») # یعنی تنظیمِ ما غلط است، نه خریدِ کاربر؛ مثلِ بقیه‌ی خطاها ۵۰۳. - if resp.status_code != 200 or not isinstance(data, dict): - if resp.status_code in (401, 403): + if status != 200 or not isinstance(data, dict): + if status in (401, 403): cache.delete(ACCESS_TOKEN_CACHE_KEY) - logger.error('Bazaar verify-purchase failed: status=%s error=%s desc=%s', resp.status_code, + logger.error('Bazaar verify-purchase failed: status=%s error=%s desc=%s', status, error, data.get('error_description') if isinstance(data, dict) else None) raise BazaarAPIError() - if is_subscription: + if subscription: # پاسخِ اشتراک purchaseState ندارد و اشتراکِ منقضی را هم برمی‌گرداند؛ # سند می‌گوید validUntil را با اکنون مقایسه کنید. valid_until = data.get('validUntilTimestampMsec') or 0 diff --git a/payment/tests_bazaar_oauth.py b/payment/tests_bazaar_oauth.py index 35ce05e..f968e6e 100644 --- a/payment/tests_bazaar_oauth.py +++ b/payment/tests_bazaar_oauth.py @@ -199,3 +199,58 @@ class BazaarValidateAgainstDocsTests(TestCase): ) 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)