from functools import wraps from django.contrib import messages from django.core.exceptions import PermissionDenied, ValidationError from django.core.paginator import Paginator from django.db import transaction from django.db.models import Q, Count, Sum, F, Exists, OuterRef from django.db.models.deletion import ProtectedError from django.http import FileResponse, JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils import timezone from django.views.decorators.http import require_POST from . import policies, services, validators, selectors, customer_picker, access from .forms import BankAccountForm, NoticeForm, ReceiptForm from .models import (CompanyBankAccount, PaymentNotice, PaymentReceipt, PaymentReceiptRevision, PaymentAuditEvent, ReceiptDecision) def financial_required(staff=False, allow_payment_link=False): def decorate(view): @wraps(view) def wrapped(request, *args, **kwargs): blocked = access.prepare_payment_request(request, staff=staff, allow_payment_link=allow_payment_link) if blocked is not None: return blocked response = view(request, *args, **kwargs) if getattr(request, 'payment_access_link', None): response['Referrer-Policy'] = 'same-origin' response['X-Robots-Tag'] = 'noindex, nofollow' return response return wrapped return decorate def staff_context(**kwargs): return {'active_staff_section': 'payments', 'active_tab_title': 'اعلامیه‌ها و فیش‌های واریزی', **kwargs} @financial_required() def home(request): return render(request, 'payments/home.html', {'financial_page': True}) def complete_notice(notice): snapshot = notice.account_snapshot approved = getattr(notice, 'approved_total', None) if approved is None: approved = notice.receipts.filter(status='APPROVED', revisions__version=F('current_version'), revisions__destination_mismatch=False).aggregate(total=Sum('revisions__amount'))['total'] or 0 notice.approved_amount = approved notice.remaining_amount = max(0, notice.amount-approved) notice.settlement_label = 'تسویه شده' if approved >= notice.amount else ('پرداخت جزئی' if approved else 'در انتظار فیش تأییدشده') # Complete copy uses immutable data, including fields absent from the compact UI. from apps.core.templatetags.jalali import jalali_datetime notice.copy_text = '\n'.join(f'{label}: {value}' for label, value in [ ('اعلامیه', notice.number), ('تاریخ صدور', jalali_datetime(notice.created_at)), ('موضوع', notice.subject), ('مبلغ (ریال)', f'{notice.amount:,}'), ('نام حساب', snapshot.get('name')), ('بانک', snapshot.get('bank')), ('صاحب حساب', snapshot.get('holder')), ('شماره حساب', snapshot.get('account_number')), ('شبا', snapshot.get('iban')), ('کارت', snapshot.get('card_number')), ('شناسه واریز', notice.deposit_id), ('فاکتور', notice.invoice_number), ('توضیحات', notice.description)] if value) return notice @financial_required() def notices(request): items = selectors.notices_with_totals(policies.visible(PaymentNotice.objects.all(), request.user)) query = request.GET.get('q', '').strip() if query: items = items.filter(Q(subject__icontains=query) | Q(invoice_number__icontains=validators.digits(query))) page = Paginator(items, 20).get_page(request.GET.get('page')) page.object_list = selectors.attach_notice_receipts(page.object_list, policies.visible(PaymentReceipt.objects.all(), request.user)) for notice in page: complete_notice(notice) params = request.GET.copy() params.pop('page', None) return render(request, 'payments/notices.html', {'page_obj': page, 'query': query, 'query_string':params.urlencode(), 'open_notice': request.GET.get('notice', ''), 'financial_page': True}) @financial_required(allow_payment_link=True) def customer_notice_detail(request, public_id): notice = get_object_or_404(selectors.notices_with_totals( access.visible_payments(request, PaymentNotice.objects.all())), public_id=public_id) selectors.attach_notice_receipts([notice], access.visible_payments(request, PaymentReceipt.objects.all())) return render(request, 'payments/customer_notice_detail.html', access.customer_context(request, notice=complete_notice(notice), open_notice=str(notice.public_id), financial_page=True)) def receipt_form_page(request, customer, staff=False): link = getattr(request, 'payment_access_link', None) initial = access.receipt_initial(request, customer) form = ReceiptForm(request.POST or None, request.FILES or None, customer=customer, initial=initial, notice_ids=[link.notice_id] if link else None) if request.method == 'POST' and form.is_valid(): try: receipt = services.submit_receipt(customer if link else request.user, customer, form.cleaned_data, access_link=link) messages.success(request, 'فیش ثبت شد و در انتظار بررسی است.') return redirect('staff_payments:receipt_detail' if staff else 'payments:receipt_detail', public_id=receipt.public_id) except ValidationError as error: form.add_error(None, error) selected = form['notice_id'].value() available = list(form.fields['notice_id'].queryset) if staff else selectors.recent_pending_notice_choices(form) notice_data = [selectors.notice_form_data(n, staff=staff) for n in available] from apps.attachments.models import AttachmentPolicy image_policy = AttachmentPolicy.objects.filter(attachment_type='IMAGE', is_enabled=True).first() ctx = {'form': form, 'selected_notice': selected, 'notice_data': notice_data, 'financial_page': True, 'max_bytes': image_policy.max_file_size if image_policy else 0, 'staff_form': staff, 'customer': customer, 'account_requirements': {str(a.pk): a.requires_deposit_id for a in form.fields['destination_account'].queryset}} return render(request, 'payments/receipt_form.html', staff_context(**ctx) if staff else access.customer_context(request, **ctx)) @financial_required(allow_payment_link=True) def receipt_new(request): return receipt_form_page(request, request.payment_customer) @financial_required(allow_payment_link=True) def receipt_list(request): items = selectors.receipts_with_current_values(access.visible_payments(request, PaymentReceipt.objects.all()).select_related('notice')) return render(request, 'payments/receipt_list.html', access.customer_context(request, financial_page=True, page_obj=Paginator(items, 20).get_page(request.GET.get('page')))) @financial_required(staff=True) def staff_receipt_customer(request): if not policies.can_create(request.user): raise PermissionDenied from django import forms form = forms.Form(request.POST or None, initial=customer_picker.initial_customer(request)) # Reuse the same active-customer selector and display labels as notice issuance. form.fields['customer'] = NoticeForm().fields['customer'] if request.method == 'POST' and form.is_valid(): return redirect('staff_payments:receipt_new', customer_id=form.cleaned_data['customer'].public_id) return render(request, 'payments/staff_form.html',staff_context(form=form,heading='ثبت فیش برای مشتری',submit_label='ادامه', **customer_picker.picker_context(request, form))) @financial_required(staff=True) def customer_search(request): if not policies.can_create(request.user): raise PermissionDenied response = JsonResponse(customer_picker.search_results(request.user, request.GET.get('q', ''))) response['Cache-Control'] = 'private, no-store' return response @financial_required(staff=True) def staff_receipt_new(request, customer_id): if not policies.can_create(request.user): raise PermissionDenied from apps.accounts.models import User customer = get_object_or_404(User.objects.select_related('customer_profile'), public_id=customer_id, account_type='CUSTOMER', is_active=True) return receipt_form_page(request, customer, staff=True) def receipt_context(request, public_id, staff): receipt = get_object_or_404(access.visible_payments(request, PaymentReceipt.objects.select_related('customer__customer_profile', 'notice', 'owning_unit')), public_id=public_id) revision = receipt.revisions.select_related('destination', 'origin_bank', 'cycle').get(version=receipt.current_version) cycle = revision.cycle services.audit(request.payment_customer, 'RECEIPT_VIEWED', receipt) decisions = ReceiptDecision.objects.filter(cycle__revision__receipt=receipt).select_related('user', 'cycle__revision').order_by('created_at', 'pk') return {'receipt': receipt, 'revision': revision, 'cycle': cycle, 'decisions': decisions, 'payment_link_access': getattr(request, 'payment_access_link', None), 'revision_events': PaymentAuditEvent.objects.filter(object_type='payments.paymentreceipt', object_id=str(receipt.public_id), action__in=['RECEIPT_SUBMITTED', 'RECEIPT_REVISED']).order_by('created_at', 'pk'), 'can_approve': staff and policies.can_decide(request.user, receipt, cycle), 'can_senior': staff and policies.can_decide(request.user, receipt, cycle, senior=True), 'can_assign': staff and not cycle.assignments.exists() and policies.has_permission(request.user, 'manage_payment_approval_policy'), 'staff_detail': staff, 'financial_page': True, 'versions': receipt.revisions.select_related('cycle').order_by('version'), 'duplicate_image': PaymentReceiptRevision.objects.filter(sha256=revision.sha256, receipt__in=policies.visible(PaymentReceipt.objects.all(),request.user)).exclude(receipt=receipt).exists() if staff else False} @financial_required(allow_payment_link=True) def receipt_detail(request, public_id): return render(request, 'payments/receipt_detail.html', receipt_context(request, public_id, False)) @financial_required(staff=True) def staff_receipt_detail(request, public_id): return render(request, 'payments/receipt_detail.html', staff_context(**receipt_context(request, public_id, True))) @financial_required(staff=None, allow_payment_link=True) def receipt_image(request, public_id): try: version = int(request.GET.get('version', '1')) except ValueError: from django.http import Http404 raise Http404 revision = get_object_or_404(PaymentReceiptRevision.objects.select_related('receipt'), receipt__public_id=public_id, version=version) if not access.visible_payments(request, PaymentReceipt.objects.filter(pk=revision.receipt_id)).exists(): raise PermissionDenied services.audit(request.payment_customer, 'RECEIPT_FILE_REQUESTED', revision.receipt, {'version': revision.version}) # Authorize EVERY access, including debug: financial media never uses MEDIA_URL. response = FileResponse(revision.image.open('rb'), content_type=revision.mime_type, as_attachment=request.GET.get('download') == '1', filename=revision.original_name) response['Cache-Control'] = 'private, no-store' response['X-Content-Type-Options'] = 'nosniff' return response @financial_required(staff=True) @require_POST def decision(request, public_id): get_object_or_404(policies.visible(PaymentReceipt.objects.all(),request.user),public_id=public_id) try: version = int(request.POST.get('version', '0')) receipt = services.decide_receipt(request.user, public_id, version, request.POST.get('action'), request.POST.get('reason', '')) messages.success(request, 'تصمیم ثبت شد. ' + receipt.get_status_display()) except (ValidationError, ValueError) as error: messages.error(request, '؛ '.join(error.messages) if isinstance(error, ValidationError) else 'نسخه فیش معتبر نیست.') return redirect('staff_payments:receipt_detail', public_id=public_id) @financial_required(staff=True) @require_POST def assign_policy(request, public_id): receipt = get_object_or_404(policies.visible(PaymentReceipt.objects.all(), request.user), public_id=public_id) try: services.bind_unassigned_cycle(request.user, receipt) messages.success(request, 'افراد تأییدکننده تعیین شدند.') except ValidationError as error: messages.error(request, '؛ '.join(error.messages)) return redirect('staff_payments:receipt_detail', public_id=public_id) def filtered(queryset, request): query = validators.digits(request.GET.get('q', '')) if query: condition = Q(customer__username__icontains=query) | Q(customer__mobile__icontains=query) | Q(customer__customer_profile__first_name__icontains=query) | Q(customer__customer_profile__last_name__icontains=query) | Q(customer__customer_profile__province__icontains=query) | Q(customer__customer_profile__city__icontains=query) # PN/PR numbers are UUID prefixes, not database columns. prefix = query.upper().removeprefix('PN-').removeprefix('PR-').replace('-', '') if prefix and len(prefix) <= 32 and all(c in '0123456789ABCDEF' for c in prefix): uuid_prefix = '-'.join(prefix[start:end] for start, end in [(0, 8), (8, 12), (12, 16), (16, 20), (20, 32)] if len(prefix) > start) condition |= Q(public_id__istartswith=uuid_prefix) if queryset.model == PaymentNotice: condition |= Q(subject__icontains=query) | Q(invoice_number__icontains=query) else: condition |= Q(notice__invoice_number__icontains=query) | Q(revisions__tracking_number__icontains=query) queryset = queryset.filter(condition) for key, field in [('account', 'account_id' if queryset.model == PaymentNotice else 'revisions__destination_id'), ('unit', 'owning_unit_id'), ('customer', 'customer_id')]: value = request.GET.get(key, '') if value.isdecimal(): queryset = queryset.filter(**{field: value}) for key, op in [('from', 'gte'), ('to', 'lte')]: value = request.GET.get(key, '') if value: try: import datetime parsed = datetime.date.fromisoformat(value) queryset = queryset.filter(**{f'created_at__date__{op}': parsed}) except ValueError: messages.error(request, 'بازه تاریخ معتبر نیست.') return queryset.distinct() @financial_required(staff=True) def desk(request): tab = request.GET.get('tab', 'receipts') receipts = selectors.receipts_with_current_values(policies.visible(PaymentReceipt.objects.select_related('customer__customer_profile', 'notice__created_by__staff_profile'), request.user)) notice_items = selectors.notices_with_totals(policies.visible(PaymentNotice.objects.select_related('customer__customer_profile', 'created_by__staff_profile', 'account'), request.user)) if tab == 'notices': items = filtered(notice_items, request) state = request.GET.get('notice_state', '') if state == 'settled': items = items.filter(cancelled_at__isnull=True, approved_total__gte=F('amount')) elif state == 'pending': items = items.filter(cancelled_at__isnull=True, approved_total__lt=F('amount')) elif state == 'cancelled': items = items.filter(cancelled_at__isnull=False) else: items = filtered(receipts, request) status = request.GET.get('status', '') if status in PaymentReceipt.Status.values: items = items.filter(status=status) if request.GET.get('kind') == 'independent': items = items.filter(notice__isnull=True) elif request.GET.get('kind') == 'linked': items = items.filter(notice__isnull=False) if request.GET.get('mine'): my_decision = ReceiptDecision.objects.filter(cycle__revision__receipt_id=OuterRef('pk'), cycle__revision__version=OuterRef('current_version'), user=request.user) items = items.filter(status__in=['PENDING','PARTIAL'], revisions__version=F('current_version'), revisions__cycle__assignments__user=request.user).annotate( my_decision_exists=Exists(my_decision)).filter(my_decision_exists=False).distinct() page = Paginator(items.order_by('-created_at','-pk'), 25).get_page(request.GET.get('page')) if tab == 'notices': page.object_list = selectors.attach_notice_receipts(page.object_list, policies.visible(PaymentReceipt.objects.all(), request.user)) for notice in page.object_list: complete_notice(notice) else: page.object_list = selectors.receipt_review_summaries(page.object_list) query = request.GET.copy() query.pop('page', None) return render(request, 'payments/desk.html', staff_context(page_obj=page, tab=tab, statuses=PaymentReceipt.Status.choices, can_create=policies.can_create(request.user), banks=CompanyBankAccount.objects.all(), query_string=query.urlencode())) @financial_required(staff=True) @require_POST def generate_deposit_identifier(request): if not policies.can_create(request.user): raise PermissionDenied # Resolve the same active customer as NoticeForm; never trust a posted mobile. try: customer = NoticeForm().fields['customer'].clean(request.POST.get('customer')) mobile = validators.digits(customer.local_mobile) import re if not re.fullmatch(r'09[0-9]{9}', mobile): raise ValidationError('شماره موبایل مشتری معتبر نیست؛ ابتدا اطلاعات مشتری را اصلاح کنید.') except ValidationError as error: return JsonResponse({'error': '؛ '.join(error.messages)}, status=400) from .deposit_identifier import generate_id_algorithm response = JsonResponse({'identifier': generate_id_algorithm(mobile), 'customer': str(customer.pk)}) response['Cache-Control'] = 'no-store' return response @financial_required(staff=True) def notice_new(request): if not policies.can_create(request.user): raise PermissionDenied form = NoticeForm(request.POST or None, initial=customer_picker.initial_customer(request)) if request.method == 'POST' and form.is_valid(): try: notice = services.issue_notice(request.user, form.cleaned_data) messages.success(request, 'اعلامیه صادر شد و پیامک در صف اطلاع‌رسانی قرار گرفت.') return redirect('staff_payments:notice_detail', public_id=notice.public_id) except ValidationError as error: form.add_error(None, error) return render(request, 'payments/staff_form.html', staff_context(form=form, heading='ایجاد اعلامیه واریز', submit_label='صدور و اطلاع‌رسانی', identifier_accounts=list(CompanyBankAccount.objects.filter(requires_deposit_id=True).values_list('pk',flat=True)), **customer_picker.picker_context(request, form))) @financial_required(staff=True) def notice_detail(request, public_id): notice = complete_notice(get_object_or_404(policies.visible(PaymentNotice.objects.select_related('customer'), request.user), public_id=public_id)) services.audit(request.user, 'NOTICE_VIEWED', notice) share = request.session.get('payment_link_share', {}) if share.get('notice') == str(public_id): request.session.pop('payment_link_share', None) else: share = {} return render(request, 'payments/notice_detail.html', staff_context(notice=notice, can_share_payment_link=policies.can_create(request.user) and notice.customer.is_active, payment_share_url=share.get('url', ''), receipts=policies.visible(notice.receipts.all(), request.user), notifications=notice.paymentnotification_set.select_related('job'))) @financial_required(staff=True) @require_POST def notice_payment_link(request, public_id): if not policies.can_create(request.user): raise PermissionDenied notice = get_object_or_404(policies.visible(PaymentNotice.objects.all(), request.user), public_id=public_id, customer__is_active=True) link, url = access.issue_payment_link(notice=notice) services.audit(request.user, 'PAYMENT_LINK_CREATED', link) request.session['payment_link_share'] = {'notice': str(public_id), 'url': url} return redirect('staff_payments:notice_detail', public_id=public_id) @financial_required(staff=True) def counts(request): count = policies.visible(PaymentReceipt.objects.all(), request.user).filter(status__in=['PENDING', 'PARTIAL']).count() response = JsonResponse({'pending_receipts': count}) response['Cache-Control'] = 'private, no-store' return response @financial_required(staff=True) def banks(request): if not policies.can_manage_banks(request.user): raise PermissionDenied items = CompanyBankAccount.objects.select_related('bank') query = request.GET.get('q','').strip() if query: items = items.filter(Q(name__icontains=query) | Q(bank__name__icontains=query) | Q(holder__icontains=query)) for field in ['is_active', 'available_to_staff', 'available_to_customers']: if request.GET.get(field) in ['0','1']: items = items.filter(**{field: request.GET[field] == '1'}) params = request.GET.copy() params.pop('page', None) return render(request, 'payments/banks.html', staff_context(page_obj=Paginator(items,25).get_page(request.GET.get('page')), query_string=params.urlencode(), bank_flags=[{'key':key,'label':label,'value':request.GET.get(key,'')} for key,label in [('is_active','فعال کلی'),('available_to_staff','پنل کارشناسان'),('available_to_customers','پنل مشتریان')]], active_staff_section='banks', active_tab_title='بانک‌ها')) @financial_required(staff=True) def bank_edit(request, public_id=None): if not policies.can_manage_banks(request.user): raise PermissionDenied instance = get_object_or_404(CompanyBankAccount, public_id=public_id) if public_id else None before = services.configuration_snapshot(instance) if instance else {} form = BankAccountForm(request.POST or None, instance=instance) if request.method == 'POST' and form.is_valid(): with transaction.atomic(): if instance: current = CompanyBankAccount.objects.select_for_update().get(pk=instance.pk) if form.cleaned_data['revision_token'] != current.updated_at.isoformat(): form.add_error(None, 'حساب هم‌زمان تغییر کرده است؛ صفحه را تازه کنید.') return render(request,'payments/staff_form.html',staff_context(form=form,bank_account=instance, heading='ویرایش حساب',submit_label='ذخیره حساب',active_staff_section='banks')) before = services.configuration_snapshot(current) account = form.save() services.audit(request.user, 'BANK_SAVED', account, {'before': before, 'after': services.configuration_snapshot(account)}) return redirect('staff_payments:banks') return render(request,'payments/staff_form.html',staff_context(form=form, bank_account=instance, heading='ویرایش حساب' if instance else 'ایجاد حساب', submit_label='ذخیره حساب', active_staff_section='banks')) @financial_required(staff=True) @require_POST def bank_bulk(request): if not policies.can_manage_banks(request.user): raise PermissionDenied field = request.POST.get('field') if field not in ['is_active','available_to_staff','available_to_customers'] or request.POST.get('value') not in ['0','1']: raise ValidationError('عملیات معتبر نیست.') ids = [value for value in request.POST.getlist('accounts') if value.isdecimal()] with transaction.atomic(): for account in CompanyBankAccount.objects.select_for_update().filter(pk__in=ids): before = getattr(account, field) setattr(account,field,request.POST['value'] == '1') account.save(update_fields=[field,'updated_at']) services.audit(request.user,'BANK_FLAG_CHANGED',account,{'field':field,'before':before,'after':getattr(account,field)}) return redirect('staff_payments:banks') @financial_required(staff=True) @require_POST def bank_delete(request, public_id): if not policies.can_manage_banks(request.user): raise PermissionDenied account = get_object_or_404(CompanyBankAccount,public_id=public_id) try: with transaction.atomic(): services.audit(request.user,'BANK_DELETED',account,account.snapshot()) account.delete() except ProtectedError: messages.error(request,'این حساب سابقه مالی دارد؛ به جای حذف آن را غیرفعال کنید.') return redirect('staff_payments:banks') @financial_required(allow_payment_link=True) def receipt_revise(request, public_id): link = getattr(request, 'payment_access_link', None) receipt=get_object_or_404(access.visible_payments(request, PaymentReceipt.objects.all()),public_id=public_id,customer=request.payment_customer,status='REJECTED') import jdatetime current = receipt.revisions.get(version=receipt.current_version) paid = jdatetime.date.fromgregorian(date=current.paid_date) initial={'receipt_mode':'linked' if receipt.notice_id else 'independent','notice_id':receipt.notice_id, 'paid_amount':str(current.amount),'destination_account':current.destination_id,'deposit_id':current.deposit_id, 'paid_day':str(paid.day),'paid_month':str(paid.month),'paid_year':str(paid.year), 'origin_bank':current.origin_bank_id,'origin_other':current.origin_other, 'tracking_number':current.tracking_number,'description':current.description} form=ReceiptForm(request.POST or None,request.FILES or None,customer=request.payment_customer,initial=initial, notice_ids=[link.notice_id] if link else None) if request.method=='POST' and form.is_valid(): try: services.revise_receipt(request.payment_customer,receipt.public_id,int(request.POST.get('version','0')),form.cleaned_data) messages.success(request,'نسخه اصلاحی ثبت شد و دوباره بررسی می‌شود.') return redirect('payments:receipt_detail',public_id=receipt.public_id) except (ValidationError,ValueError) as error: form.add_error(None,error if isinstance(error,ValidationError) else 'نسخه فیش معتبر نیست.') from apps.attachments.models import AttachmentPolicy policy=AttachmentPolicy.objects.filter(attachment_type='IMAGE',is_enabled=True).first() notice_data=[selectors.notice_form_data(n) for n in selectors.recent_pending_notice_choices(form, retain_original=receipt.notice_id)] return render(request,'payments/receipt_form.html',{'form':form,'notice_data':notice_data,'max_bytes':policy.max_file_size if policy else 0, 'payment_link_access': link, 'account_requirements':{str(a.pk):a.requires_deposit_id for a in form.fields['destination_account'].queryset}, 'financial_page':True,'revision_version':receipt.current_version})