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

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

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

View File

@@ -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)