"""A payment link never authenticates a user or grants access to their whole account.""" import hashlib import uuid from datetime import timedelta from django.conf import settings from django.db import IntegrityError, transaction from django.db.models import Q from django.contrib.auth.views import redirect_to_login from django.core.exceptions import PermissionDenied from django.http import Http404 from django.shortcuts import get_object_or_404, render from django.urls import reverse from django.utils import timezone from apps.core.session_access import grant_scoped_session_access from apps.accounts.models import TicketMagicLink from . import policies from .models import PaymentAccessLink, PaymentNotice SESSION_KEY = 'payment_access_link_id' SHARE_SESSION_KEY = 'payment_page_share' def token_digest(token): return hashlib.sha256(token.encode()).hexdigest() def issue_payment_link(*, notice=None, receipt=None): target = receipt or notice if target is None: raise ValueError('Payment link requires a notice or receipt') if receipt is not None: notice = receipt.notice for attempt in range(5): token = TicketMagicLink.generate_secure_token(10) try: with transaction.atomic(): link = PaymentAccessLink.objects.create(customer=target.customer, notice=notice, receipt=receipt, token_hash=token_digest(token), expires_at=timezone.now() + timedelta(days=settings.PAYMENT_LINK_TTL_DAYS)) return link, settings.PUBLIC_BASE_URL.rstrip('/') + reverse('payment_short_link', args=[token]) except IntegrityError: if attempt == 4: raise RuntimeError('عدم موفقیت در تولید لینک یکتای پرداخت.') def notice_share_url(request, notice): """Reuse a validated capability; only a logged-in owner can issue a new one.""" shared = request.session.get(SHARE_SESSION_KEY, {}) token = shared.get('token', '') link = active_links().filter(pk=shared.get('link_id'), notice=notice, token_hash=token_digest(token)).first() if token else None grant = getattr(request, 'payment_access_link', None) if grant and (link is None or link.pk != grant.pk): raise PermissionDenied('برای کپی لینک، دوباره لینک پرداخت دریافتی را باز کنید.') if link is None: from .services import audit link, url = issue_payment_link(notice=notice) token = url.rstrip('/').rsplit('/', 1)[-1] request.session[SHARE_SESSION_KEY] = {'link_id': link.pk, 'token': token} audit(request.user, 'PAYMENT_LINK_CREATED', link) return settings.PUBLIC_BASE_URL.rstrip('/') + reverse('payment_short_link', args=[token]) def active_links(): return PaymentAccessLink.objects.select_related('customer', 'notice', 'receipt').filter( notice__deleted_at__isnull=True, revoked_at__isnull=True, expires_at__gt=timezone.now(), customer__is_active=True, customer__account_type='CUSTOMER') def grant_payment_access(request, link): # The session may already contain a ticket grant; each grant checks its own expiry. grant_scoped_session_access(request, SESSION_KEY, link.pk, link.expires_at, preserve_expiry=True) def get_payment_access(request): link_id = request.session.get(SESSION_KEY) if not isinstance(link_id, int): return None link = active_links().filter(pk=link_id).first() if link is None: request.session.pop(SESSION_KEY, None) return link def visible_payments(request, queryset): link = getattr(request, 'payment_access_link', None) if link is None: return policies.visible(queryset, request.user) owned = queryset.filter(customer_id=link.customer_id) if queryset.model == PaymentNotice: return owned.filter(pk=link.notice_id) scope = Q(pk=link.receipt_id) | Q(access_links=link) if link.notice_id: scope |= Q(notice_id=link.notice_id) return owned.filter(scope).distinct() def customer_context(request, **kwargs): return {'payment_link_access': getattr(request, 'payment_access_link', None), **kwargs} def prepare_payment_request(request, *, staff=False, allow_payment_link=False): if request.user.is_authenticated: customer = request.user.is_active and request.user.account_type == 'CUSTOMER' permitted = policies.is_staff(request.user) if staff else customer if staff is None: permitted = customer or policies.is_staff(request.user) if not permitted: raise PermissionDenied request.payment_customer = request.user return None had_link = SESSION_KEY in request.session link = get_payment_access(request) if allow_payment_link else None if link: request.payment_access_link = link request.payment_customer = link.customer return None if had_link and allow_payment_link: return unavailable_link(request) return redirect_to_login(request.get_full_path(), '/staff/login/' if staff else '/login/') def receipt_initial(request, customer): link = getattr(request, 'payment_access_link', None) identifier = request.GET.get('notice') initial = {} if link and not identifier: if request.GET.get('mode') == 'independent' or not link.notice_id or link.notice.cancelled_at: return {'receipt_mode': 'independent'} identifier = str(link.notice.public_id) if not identifier: return initial try: identifier = uuid.UUID(identifier) except ValueError: raise Http404 notices = visible_payments(request, PaymentNotice.objects.all()) if link else PaymentNotice.objects.filter(customer=customer) notice = get_object_or_404(notices, public_id=identifier, cancelled_at__isnull=True) return {'notice_id': notice.pk, 'destination_account': notice.account_id, 'deposit_id': notice.deposit_id} def validate_link_submission(link, customer, data): if not active_links().select_related(None).select_for_update(of=('self',)).filter(pk=link.pk, customer=customer).first(): raise PermissionDenied notice = data.get('notice_id') if notice is not None and notice.pk != link.notice_id: raise PermissionDenied def unavailable_link(request): response = render(request, 'payments/link_unavailable.html', {'financial_page': True}, status=410) response['Referrer-Policy'] = 'no-referrer' response['X-Robots-Tag'] = 'noindex, nofollow' return response