Files
MathApp/payment/services.py
Zareian a973c41db2 Security review fixes: admin permissions, guest sessions, payment edge cases, assistant leaks
- Admin: refund/cancel/mark-resolved/Bazaar-link actions need change permission; question and
  curriculum import need add/delete; content browser needs view_question; guest device ids are
  shown to superusers only.
- Accounts: registering a guest's phone revokes the guest's earlier sessions; self-deletion keeps
  Redemption, UserOffer and the phone's OTP rows; the public demo guest cannot be deleted
  (DEMO_GUEST_DEVICE_IDS); display-name contact filter covers any separator and social handles.
- Payment: callback without Authority is 400; a second Bazaar purchase on a paid order is 409;
  orders snapshot credits/hearts/days at creation (payment.0011).
- Assistant: a broken diagram no longer makes a streamed answer free; a late off-topic marker
  refuses the answer (not stored, not cached); a disconnect saves the partial answer; only
  KNOWN_COLLECTIONS are searched.
- Infra: secret-key file is created atomically (workers no longer split keys); no default
  INTERNAL_SERVICE_KEY; Kavenegar key and Bazaar token kept out of error logs; Swagger/ReDoc
  pinned; schema throttled; docker compose bound to 127.0.0.1; non-ASCII digit ids are 400;
  ticket text capped at 5000.
- docs/security-before-production.md: remaining open items 24-31 and the guard tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:52:25 +03:30

360 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
منطق مشترک اپ Payment که هم از views.py همین اپ و هم به‌صورت سرور-به-سرور
از اپ‌های دیگه (مثل subscription) صدا زده می‌شه. چون این پروژه یک مونولیت
جنگویی‌ه (نه میکروسرویس واقعی)، «فراخوانی سرور-به-سرور» با یک import مستقیم
پیاده‌سازی شده، نه یک HTTP call به خودمون.
"""
from django.conf import settings
from django.core import signing
from django.db import IntegrityError
from django.db import transaction as db_transaction
from django.utils import timezone
from django.utils.crypto import get_random_string
from rest_framework.exceptions import APIException
from statuslog.signals import status_changed
from .models import Order, Transaction
class IdempotencyKeyConflict(APIException):
"""اگر Idempotency-Key موجود متعلق به کاربر یا پارامترهای دیگری باشد، 409 برمی‌گرداند."""
status_code = 409
default_detail = 'این Idempotency-Key قبلاً برای درخواست دیگری استفاده شده است.'
default_code = 'idempotency_key_conflict'
def _ensure_idempotent_match(order: Order, *, user_id: int, amount: int, currency: str,
subscription_id: int | None, plan_id: int | None = None,
provider: str | None = None) -> None:
if order.user_id != user_id:
raise IdempotencyKeyConflict(
'این Idempotency-Key متعلق به کاربر دیگری است و نمی‌تواند استفاده شود.'
)
# مبلغ فقط برای سفارشی که هنوز تخفیف نگرفته مقایسه می‌شود؛ بعد از کدِ
# تخفیف، مبلغِ سفارش عمداً کمتر از قیمتِ پلن است.
discounted = order.amount < amount and order.status == 'pending'
if ((order.amount != amount and not discounted) or order.currency != currency
or order.subscription_id != subscription_id
or (plan_id is not None and order.plan_id not in (None, plan_id))
or (provider is not None and order.provider != provider)):
raise IdempotencyKeyConflict(
'این Idempotency-Key قبلاً با پارامترهای متفاوتی استفاده شده است.'
)
def call_payment_gateway(order: Order) -> str:
"""
paymentUrlی که به کلاینت برمی‌گردد: آدرسِ داخلیِ خودمان
(PaymentRedirectView)، نه مستقیماً StartPay زرین‌پال. فراخوانیِ واقعیِ
PaymentRequest به زرین‌پال (یک HTTP call خارجی) عمداً همین‌جا انجام
نمی‌شود — create_order از subscription صدا زده می‌شود و نباید موفقیتِ
ساختِ سفارش به دسترس‌بودن/سرعتِ زرین‌پال گره بخورد؛ PaymentRequest اولین
باری که کاربر واقعاً این URL را باز کند زده می‌شود (نگاه کنید به
payment/zarinpal.py و PaymentRedirectView در views.py).
"""
return f'{settings.PUBLIC_BASE_URL}/api/payment/orders/{order.id}/pay?t={pay_link_token(order.id)}'
# لینکِ پرداخت بی‌توکنِ ورود باز می‌شود (مرورگر هدرِ Bearer نمی‌فرستد) و شناسه‌ی سفارش ترتیبی
# است؛ امضای کوتاه‌عمر جلوی این را می‌گیرد که غریبه با پیمایشِ شناسه‌ها روی سفارشِ دیگران
# authority بسازد (و کدِ تخفیفِ صاحبش را ببندد) یا وضعیتِ سفارششان را از ریدایرکت بخواند.
# امضا با همان DJANGO_SECRET_KEY است؛ تا وقتی آن کلید روی سرور همان کلیدِ داخلِ ریپوست،
# این امضا هم برای کسی که ریپو را دیده جعل‌شدنی است. یک روز: لینک بلافاصله بعد از ساختِ
# سفارش باز می‌شود؛ یک روز صفحه‌ای را هم که باز مانده پوشش می‌دهد.
PAY_LINK_SALT = 'tadrisino-pay-link'
PAY_LINK_MAX_AGE_SECONDS = 24 * 3600
def pay_link_token(order_id: int) -> str:
return signing.TimestampSigner(salt=PAY_LINK_SALT).sign(str(order_id))
def pay_link_is_valid(order_id: int, token: str) -> bool:
try:
value = signing.TimestampSigner(salt=PAY_LINK_SALT).unsign(token, max_age=PAY_LINK_MAX_AGE_SECONDS)
except signing.BadSignature: # SignatureExpired هم زیرکلاسِ همین است
return False
return value == str(order_id)
def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
subscription_id: int | None = None, idempotency_key: str | None = None,
provider: str = 'gateway', bazaar_product_sku: str = '',
plan_id: int | None = None, plan_title: str = ''):
"""
ساخت سفارش با پشتیبانی از Idempotency-Key: اگر سفارشی با همین کلید قبلاً
ساخته شده باشد، همان رکورد برگردانده می‌شود (نه رکورد تکراری)، فقط اگر
متعلق به همین کاربر و همین پارامترها باشد.
"""
if not idempotency_key:
idempotency_key = get_random_string(32)
existing = Order.objects.filter(idempotency_key=idempotency_key).first()
if existing is not None:
_ensure_idempotent_match(
existing, user_id=user_id, amount=amount, currency=currency,
subscription_id=subscription_id, plan_id=plan_id, provider=provider,
)
payment_url = None if existing.provider == 'bazaar' else call_payment_gateway(existing)
return existing, payment_url
from subscription.models import Plan
plan = Plan.objects.filter(pk=plan_id).first() if plan_id else None
try:
with db_transaction.atomic():
order = Order.objects.create(
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,
plan_title=plan_title[:50],
currency=currency,
status='pending',
provider=provider,
bazaar_product_sku=bazaar_product_sku,
idempotency_key=idempotency_key,
)
except IntegrityError:
# درخواست هم‌زمان دیگری با همین کلید زودتر ساخت.
order = Order.objects.filter(idempotency_key=idempotency_key).first()
if order is None:
raise
_ensure_idempotent_match(
order, user_id=user_id, amount=amount, currency=currency,
subscription_id=subscription_id, plan_id=plan_id, provider=provider,
)
# برای provider='bazaar' هیچ paymentUrlی وجود ندارد: کلاینت اندروید مستقیماً
# با SDK بازار خرید را انجام می‌دهد و purchaseToken را به verify می‌فرستد.
payment_url = None if order.provider == 'bazaar' else call_payment_gateway(order)
return order, payment_url
def apply_order_payment_result(*, order: Order, gateway: str, gateway_ref_id: str,
new_status: str, event: str) -> bool:
"""
منطق مشترکِ «نتیجه‌ی پرداخت رسید»، هم برای ZarinpalCallbackView و هم
BazaarVerifyPurchaseView استفاده می‌شود.
idempotent: اگر Transaction مشابه (order/status/gateway_ref_id) از قبل
موجود باشد، دوباره اثر جانبی اجرا نمی‌شود. خروجی: True اگر این فراخوانی
واقعاً باعث paid شدن تازه شده باشد.
Order زیر select_for_update قفل و رفرش می‌شود و already_paid/is_duplicate
*بعد* از قفل محاسبه می‌شوند تا در برابر فراخوانی‌های هم‌زمان ایمن باشد.
"""
with db_transaction.atomic():
order = Order.objects.select_for_update().get(pk=order.pk)
already_paid = order.status == 'paid'
old_status = order.status
is_duplicate = Transaction.objects.filter(
order=order, status=new_status, gateway_ref_id=gateway_ref_id,
).exists()
# callback دیرهنگام/خارج از ترتیب با status غیر از paid نباید سفارشِ
# از قبل paid شده را به عقب برگرداند؛ چنین callback ای idempotent/no-op است.
if already_paid and new_status != 'paid':
is_duplicate = True
# نتیجه‌ی ناموفق فقط سفارشِ «در انتظار» را عوض می‌کند. کال‌بکِ زرین‌پال بی‌احراز است و
# authority از هدرِ Location ِ /pay خوانده می‌شود؛ تا ۲۰۲۶-۱۰-۰۳ یک Status=NOKِ جعلی سفارشِ
# «بازپرداخت‌شده» یا «لغوشده» را «ناموفق» می‌کرد و ردِ بازپرداخت از وضعیتِ سفارش می‌رفت
# (بازتولید شد). «paid» عمداً از این قاعده بیرون است: کسی که پول داده باید چیزش را بگیرد.
if new_status != 'paid' and old_status != 'pending':
is_duplicate = True
# reuse رفرنسِ پرداخت روی سفارش دیگر هم duplicate/no-op در نظر گرفته می‌شود.
if gateway_ref_id and Transaction.objects.filter(
gateway=gateway, gateway_ref_id=gateway_ref_id,
).exclude(order=order).exists():
is_duplicate = True
if not is_duplicate:
order.status = new_status
order.save(update_fields=['status'])
try:
# savepoint تودرتو: اگر IntegrityError بدهد فقط همین savepoint
# rollback می‌شود و atomic بیرونی سالم می‌ماند (مخصوصاً روی PostgreSQL
# که یک تراکنش بعد از IntegrityError به‌طور کامل broken می‌شود).
with db_transaction.atomic():
Transaction.objects.create(
order=order,
gateway=gateway,
gateway_ref_id=gateway_ref_id,
status=new_status,
paid_at=timezone.now() if new_status == 'paid' else None,
)
except IntegrityError:
# دفاع در عمق: قید یکتاییِ سراسری (uniq_transaction_gateway_ref_global)
# جلوی ساخت Transaction تکراری با همین رفرنس را می‌گیرد؛ در این حالت
# status سفارش به مقدار قبل از تغییر برمی‌گردد.
is_duplicate = True
if order.status != old_status:
order.status = old_status
order.save(update_fields=['status'])
else:
status_changed.send(
sender='payment.Order', object_id=order.id,
old_status=old_status, new_status=new_status,
event=event, user_id=order.user_id,
)
newly_paid = new_status == 'paid' and not already_paid and not is_duplicate
if newly_paid:
if order.subscription_id:
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)
# پورسانتِ معرف فقط روی پرداختِ واقعی (نه ثبت‌نام، نه مهمان).
from affiliate.services import record_paid_order
record_paid_order(order)
from notifications.services import notify
notify(
user_id=order.user_id,
type='order_paid',
title='پرداخت با موفقیت انجام شد',
related_id=order.id,
)
return newly_paid
def reconcile_pending_order(order: Order) -> bool:
"""
سفارشی که کاربر در درگاه پرداخت کرده ولی تأییدش نرسیده (مثلاً زرین‌پال
هنگامِ کال‌بک جواب نداد) را دوباره با verify می‌سنجد. پیش‌تر چنین سفارشی
برای همیشه pending می‌ماند: پولِ دانش‌آموز گرفته شده بود و اشتراکی در کار نبود.
فقط به سمتِ paid می‌برد، هرگز failed نمی‌کند: کاربری که هنوز در صفحه‌ی
درگاه است نباید سفارشش زیرِ پایش باطل شود. هر سفارش حداکثر دقیقه‌ای یک بار
سنجیده می‌شود. خروجی: True اگر همین فراخوانی سفارش را paid کرد.
"""
from django.core.cache import cache
from . import zarinpal
if order.provider != 'gateway' or order.status != 'pending' or not order.gateway_authority:
return False
if not cache.add(f'payment:reconcile:{order.id}', 1, 60):
return False
try:
result = zarinpal.verify_payment(
authority=order.gateway_authority, amount=order.amount, currency=order.currency,
)
except Exception: # noqa: BLE001 — درگاه در دسترس نیست؛ دفعه‌ی بعد
return False
if not result.is_valid:
return False
return apply_order_payment_result(
order=order, gateway='zarinpal', gateway_ref_id=result.ref_id,
new_status='paid', event='zarinpal_reconcile',
)
def reconcile_user_orders(user_id: int) -> None:
"""سفارش‌های 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)
class OrderStateError(Exception):
"""سفارش در وضعیتی نیست که این کار رویش معنا داشته باشد (پیامش به ادمین نشان داده می‌شود)."""
def cancel_pending_order(order: Order, *, staff: str = '') -> None:
"""
سفارشِ «در انتظار» را لغو می‌کند (پنلِ ادمین). پولی جابه‌جا نشده و چیزی داده نشده.
اگر کاربر در همان لحظه واقعاً در درگاه پرداخت کرده باشد، تأییدِ بعدیِ زرین‌پال سفارش را باز
«paid» می‌کند (apply_order_payment_result فقط سفارشِ از قبل paid را نادیده می‌گیرد)؛ این عمدی
است: کسی که پول داده باید چیزش را بگیرد.
"""
with db_transaction.atomic():
order = Order.objects.select_for_update().get(pk=order.pk)
if order.status != 'pending':
raise OrderStateError(f'سفارشِ {order.id} «{order.get_status_display()}» است؛ فقط سفارشِ در انتظار لغو می‌شود.')
order.status = 'canceled'
order.save(update_fields=['status'])
status_changed.send(sender='payment.Order', object_id=order.id, old_status='pending',
new_status='canceled', event=f'admin_cancel:{staff}'[:50], user_id=order.user_id)
def refund_order(order: Order, *, staff: str = '') -> dict:
"""
سفارشِ پرداخت‌شده را «بازپرداخت‌شده» می‌کند و هر چه داده بود تا جایی که هنوز مانده پس می‌گیرد.
**پول را برنمی‌گرداند.** برگرداندنِ پول در پنلِ زرین‌پال (یا کافه‌بازار) انجام می‌شود؛ این تابع
فقط حساب‌وکتابِ خودِ اپ را با آن یکی می‌کند. چه چیزی پس گرفته می‌شود (تصمیمِ ۲۰۲۶-۱۰-۰۲):
- بسته: اعتبارِ همین سفارش، ولی فقط آن‌قدر که هنوز خرج نشده (موجودی منفی نمی‌شود)، و روزهای
قلبِ نامحدودِ همین بسته از پایانِ کارتِ قلب.
- اشتراک: روزهای همین پلن از پایانِ اشتراک؛ اگر چیزی نماند اشتراک «لغوشده» می‌شود.
- پورسانتِ معرف برای همین سفارش به «در انتظار» برمی‌گردد.
خروجی: خلاصه برای پیامِ ادمین.
"""
from subscription.models import HeartsPass, Plan, QuestionCredit, Subscription
from subscription.services import credit_balance
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}
plan = Plan.objects.filter(pk=order.plan_id).first() if order.plan_id else None
if order.subscription_id:
sub = Subscription.objects.select_for_update().filter(pk=order.subscription_id).first()
days = order.duration_days if order.duration_days is not None else (plan.duration_days if plan else 30)
if sub is not None and sub.expires_at is not None:
sub.expires_at = max(sub.expires_at - timezone.timedelta(days=days), now)
if sub.expires_at <= now:
sub.status = 'canceled'
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(
status='pending', order_id=None, commission_amount=0)
order.status = 'refunded'
order.save(update_fields=['status'])
Transaction.objects.create(order=order, gateway='admin', status='refunded',
gateway_ref_id=f'refund-{order.id}')
status_changed.send(sender='payment.Order', object_id=order.id, old_status='paid',
new_status='refunded', event=f'admin_refund:{staff}'[:50], user_id=order.user_id)
return summary