158 lines
6.4 KiB
Python
158 lines
6.4 KiB
Python
"""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
|