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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user