- 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>
461 lines
26 KiB
Python
461 lines
26 KiB
Python
"""
|
||
منطق مشترک اپ Payment که هم از views.py همین اپ و هم بهصورت سرور-به-سرور
|
||
از اپهای دیگه (مثل subscription) صدا زده میشه. چون این پروژه یک مونولیت
|
||
جنگوییه (نه میکروسرویس واقعی)، «فراخوانی سرور-به-سرور» با یک import مستقیم
|
||
پیادهسازی شده، نه یک HTTP call به خودمون.
|
||
"""
|
||
import hashlib
|
||
import logging
|
||
|
||
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
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
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,
|
||
)
|
||
|
||
# یک خطِ لاگِ فنی برای هر نتیجه، تکراریها هم: در دیتابیس فقط نتیجهی پذیرفتهشده ردیف دارد
|
||
# (Transaction/StatusChangeLog)، پس کالبکِ تکراری یا جعلی بیاین خط هیچ ردی نداشت.
|
||
logger.log(
|
||
logging.WARNING if is_duplicate and new_status == 'paid' and not already_paid else logging.INFO,
|
||
'payment result: order=%s user=%s gateway=%s event=%s status=%s->%s ref=%s ignored=%s',
|
||
order.id, order.user_id, gateway, event, old_status, new_status,
|
||
gateway_ref_id or '-', is_duplicate,
|
||
)
|
||
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 zarinpal.ZarinpalAPIError: # درگاه در دسترس نیست (خودِ zarinpal لاگ کرده)؛ دفعهی بعد
|
||
return False
|
||
except Exception: # noqa: BLE001 — صفحهی وضعیت نباید با خطای بازسنجی ۵۰۰ بدهد؛ ولی دیده شود
|
||
logger.exception('reconcile: بازسنجیِ سفارشِ %s شکست خورد', order.id)
|
||
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)
|
||
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):
|
||
"""سفارش در وضعیتی نیست که این کار رویش معنا داشته باشد (پیامش به ادمین نشان داده میشود)."""
|
||
|
||
|
||
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
|