add payment_link and banks
This commit is contained in:
1 parent
2ae79b1ca4
commit
de3135b588
100 files changed
+6167
-47
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,130 @@
|
||||
"""A payment link never authenticates a user or grants access to their whole account."""
|
||||
import hashlib
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
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 . import policies
|
||||
from .models import PaymentAccessLink, PaymentNotice
|
||||
|
||||
SESSION_KEY = 'payment_access_link_id'
|
||||
|
||||
|
||||
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
|
||||
token = secrets.token_urlsafe(32)
|
||||
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 + reverse('payments:open_link', args=[token])
|
||||
|
||||
|
||||
def active_links():
|
||||
return PaymentAccessLink.objects.select_related('customer', 'notice', 'receipt').filter(
|
||||
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
|
||||
@@ -0,0 +1,128 @@
|
||||
from django.contrib import admin
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import connection
|
||||
from . import models, policies, services
|
||||
|
||||
|
||||
@admin.register(models.PaymentAccessLink)
|
||||
class PaymentAccessLinkAdmin(admin.ModelAdmin):
|
||||
list_display = ('customer', 'notice', 'receipt', 'created_at', 'expires_at', 'revoked_at', 'view_count')
|
||||
list_filter = ('revoked_at', 'expires_at')
|
||||
exclude = ('token_hash',)
|
||||
readonly_fields = ('customer', 'notice', 'receipt', 'created_at', 'expires_at', 'revoked_at', 'view_count', 'last_used_at', 'submitted_receipts')
|
||||
actions = ['revoke_links']
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
@admin.action(description='ابطال لینکهای انتخابشده')
|
||||
def revoke_links(self, request, queryset):
|
||||
from django.utils import timezone
|
||||
for link in queryset.filter(revoked_at__isnull=True):
|
||||
link.revoked_at = timezone.now()
|
||||
link.save(update_fields=['revoked_at'])
|
||||
services.audit(request.user, 'PAYMENT_LINK_REVOKED', link)
|
||||
from .forms import BankAccountForm
|
||||
|
||||
|
||||
class AuditedConfigurationAdmin(admin.ModelAdmin):
|
||||
permission = 'manage_payment_approval_policy'
|
||||
singleton_configuration = False
|
||||
|
||||
def has_view_permission(self, request, obj=None):
|
||||
return policies.has_permission(request.user,self.permission)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return self.has_view_permission(request) and not (self.singleton_configuration and self.model.objects.exists())
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return self.has_view_permission(request,obj)
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
if not self.has_change_permission(request,obj):
|
||||
raise PermissionDenied
|
||||
current = self.model.objects.select_for_update().get(pk=obj.pk) if change else None
|
||||
before = services.configuration_snapshot(current) if current else {}
|
||||
super().save_model(request,obj,form,change)
|
||||
services.audit(request.user,'CONFIG_SAVED',obj,{'fields': form.changed_data,
|
||||
'before':before,'after':services.configuration_snapshot(obj)})
|
||||
|
||||
def save_related(self, request, form, formsets, change):
|
||||
super().save_related(request,form,formsets,change)
|
||||
if isinstance(form.instance,models.PaymentApprovalPolicy):
|
||||
services.audit(request.user,'POLICY_ASSIGNMENTS_CHANGED',form.instance,{'reviewers':list(form.instance.reviewers.values_list('pk',flat=True)), 'seniors':list(form.instance.seniors.values_list('pk',flat=True))})
|
||||
|
||||
|
||||
@admin.register(models.PaymentApprovalPolicy)
|
||||
class ApprovalPolicyAdmin(AuditedConfigurationAdmin):
|
||||
list_display = ['name','unit','is_active']
|
||||
filter_horizontal = ['reviewers','seniors']
|
||||
|
||||
def formfield_for_manytomany(self, db_field, request, **kwargs):
|
||||
from apps.accounts.models import User
|
||||
if db_field.name in ['reviewers','seniors']:
|
||||
kwargs['queryset'] = User.objects.filter(account_type='STAFF',is_active=True).exclude(staff_profile__is_active=False)
|
||||
return super().formfield_for_manytomany(db_field,request,**kwargs)
|
||||
|
||||
|
||||
@admin.register(models.PaymentAccessConfiguration)
|
||||
class AccessAdmin(AuditedConfigurationAdmin):
|
||||
permission = 'manage_payment_access'
|
||||
singleton_configuration = True
|
||||
|
||||
|
||||
@admin.register(models.CompanyBankAccount)
|
||||
class AccountAdmin(AuditedConfigurationAdmin):
|
||||
form = BankAccountForm
|
||||
list_display = ['name','bank','requires_deposit_id','is_active','available_to_staff','available_to_customers']
|
||||
permission = 'manage_company_bankaccounts'
|
||||
|
||||
def has_view_permission(self, request, obj=None):
|
||||
return policies.can_manage_banks(request.user)
|
||||
|
||||
def get_queryset(self, request):
|
||||
queryset = super().get_queryset(request)
|
||||
# Django wraps POST change forms in atomic(): lock before token validation.
|
||||
if request.method == 'POST' and connection.in_atomic_block:
|
||||
return queryset.select_for_update(of=('self',))
|
||||
return queryset
|
||||
|
||||
|
||||
@admin.register(models.BankInstitution)
|
||||
class BankAdmin(AccountAdmin):
|
||||
form = admin.ModelAdmin.form
|
||||
list_display = ['name','code','is_active','historical']
|
||||
|
||||
|
||||
class EvidenceAdmin(admin.ModelAdmin):
|
||||
def has_view_permission(self, request, obj=None):
|
||||
if self.model in [models.PaymentNotice, models.PaymentReceipt]:
|
||||
return super().has_view_permission(request, obj)
|
||||
return policies.has_permission(request.user, 'view_payment_audit')
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
def get_queryset(self, request):
|
||||
queryset = super().get_queryset(request)
|
||||
if queryset.model in [models.PaymentNotice,models.PaymentReceipt]:
|
||||
return policies.visible(queryset,request.user)
|
||||
return queryset if policies.has_permission(request.user,'view_payment_audit') else queryset.none()
|
||||
|
||||
|
||||
for model in [models.PaymentNotice,models.PaymentReceipt,models.PaymentReceiptRevision,models.ReceiptApprovalCycle,
|
||||
models.ReceiptApprovalAssignment,models.ReceiptDecision,models.PaymentAuditEvent,
|
||||
models.PaymentNotification,models.PaymentNotificationAttempt]:
|
||||
admin.site.register(model,EvidenceAdmin)
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PaymentsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.payments'
|
||||
verbose_name = 'واریزها و تأیید مالی'
|
||||
@@ -0,0 +1,15 @@
|
||||
from django.db import OperationalError, ProgrammingError
|
||||
from . import policies
|
||||
from .models import PaymentReceipt
|
||||
|
||||
|
||||
def payment_navigation(request):
|
||||
if not policies.is_staff(request.user):
|
||||
return {}
|
||||
try:
|
||||
return {'payment_can_create': policies.can_create(request.user),
|
||||
'payment_pending_count': policies.visible(PaymentReceipt.objects.all(),request.user).filter(status__in=['PENDING','PARTIAL']).count(),
|
||||
'payment_can_manage_banks': policies.can_manage_banks(request.user),
|
||||
'payment_can_view': policies.visible(PaymentReceipt.objects.all(),request.user).exists() or policies.config().open_view or request.user.has_perm('payments.view_paymentreceipt') or request.user.is_superuser}
|
||||
except (OperationalError, ProgrammingError):
|
||||
return {}
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Reuse the contact directory's scoped history; never render all customer choices."""
|
||||
from uuid import UUID
|
||||
from django.db.models import Prefetch, Q
|
||||
from django.http import Http404
|
||||
from django.urls import reverse
|
||||
from apps.accounts.models import normalize_mobile
|
||||
from apps.core.persian_text import normalize_persian_text
|
||||
from apps.tickets.selectors import staff_ticket_rows
|
||||
from .validators import digits
|
||||
|
||||
|
||||
def customer_rows(user, search=''):
|
||||
# The existing directory projection counts only tickets visible to this staff user.
|
||||
from apps.tickets.views import _customer_rows
|
||||
rows = _customer_rows(user=user).filter(is_active=True)
|
||||
if search:
|
||||
terms_query = Q()
|
||||
for term in search.split():
|
||||
mobile = term
|
||||
try:
|
||||
mobile = normalize_mobile(term)
|
||||
except ValueError:
|
||||
if term.isascii() and term.isdecimal() and term.startswith('0'):
|
||||
mobile = term[1:]
|
||||
terms_query &= (Q(mobile__icontains=mobile) | Q(username__icontains=term) |
|
||||
Q(first_name__icontains=term) | Q(last_name__icontains=term) |
|
||||
Q(customer_profile__first_name__icontains=term) |
|
||||
Q(customer_profile__last_name__icontains=term) |
|
||||
Q(customer_profile__organization_name__icontains=term))
|
||||
history = staff_ticket_rows(user).filter(
|
||||
Q(ticket_number__icontains=search) | Q(subject__icontains=search) |
|
||||
Q(subject__icontains=normalize_persian_text(search)))
|
||||
rows = rows.filter(terms_query | Q(customer_tickets__pk__in=history.values('pk'))).distinct()
|
||||
return rows.prefetch_related(Prefetch('customer_tickets',
|
||||
queryset=staff_ticket_rows(user).order_by('-created_at', '-pk')[:3],
|
||||
to_attr='payment_recent_tickets'))
|
||||
|
||||
|
||||
def customer_data(customer):
|
||||
profile = getattr(customer, 'customer_profile', None)
|
||||
name = ' '.join(filter(None, [getattr(profile, 'first_name', ''), getattr(profile, 'last_name', '')]))
|
||||
return {'value': str(customer.pk), 'id': str(customer.public_id),
|
||||
'name': name or customer.get_full_name() or 'نام ثبت نشده',
|
||||
'mobile': customer.local_mobile, 'organization': getattr(profile, 'organization_name', ''),
|
||||
'location': '، '.join(filter(None, [getattr(profile, 'city', ''), getattr(profile, 'province', '')])),
|
||||
'ticket_count': customer.ticket_count, 'active_count': customer.active_count,
|
||||
'history': [{'number': ticket.ticket_number, 'subject': ticket.subject,
|
||||
'status': ticket.get_status_display(), 'url': reverse('staff_tickets:detail', args=[ticket.public_id])}
|
||||
for ticket in customer.payment_recent_tickets]}
|
||||
|
||||
|
||||
def initial_customer(request):
|
||||
public_id = request.GET.get('customer', '')
|
||||
if not public_id or request.method != 'GET':
|
||||
return {}
|
||||
try:
|
||||
public_id = UUID(public_id)
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
raise Http404('مشتری معتبر نیست.')
|
||||
customer = customer_rows(request.user).filter(public_id=public_id).first()
|
||||
if customer is None:
|
||||
raise Http404('مشتری فعال پیدا نشد.')
|
||||
return {'customer': customer.pk}
|
||||
|
||||
|
||||
def picker_context(request, form):
|
||||
value = str(form['customer'].value() or '')
|
||||
selected = customer_rows(request.user).filter(pk=value).first() if value.isascii() and value.isdecimal() else None
|
||||
return {'customer_selection': customer_data(selected) if selected else None}
|
||||
|
||||
|
||||
def search_results(user, query):
|
||||
query = digits(query).translate(str.maketrans('يك', 'یک'))[:100]
|
||||
if len(query) < 2:
|
||||
return {'results': [], 'has_more': False}
|
||||
rows = list(customer_rows(user, query)[:21])
|
||||
return {'results': [customer_data(customer) for customer in rows[:20]], 'has_more': len(rows) > 20}
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Deposit identifier algorithm copied from repository alghortim_shenase.py.
|
||||
|
||||
The input is the customer local mobile number, retaining its leading zero.
|
||||
This is a deterministic identifier, not a random or unique-per-notice value.
|
||||
"""
|
||||
|
||||
def generate_id_algorithm(id_code: str) -> str:
|
||||
"""
|
||||
Calculates the verification code based on the reverse-engineered logic:
|
||||
1. Iterate digits from right to left (index i = 0, 1, 2...).
|
||||
2. Weights:
|
||||
- If i < 8: Weight = 8 - i (Sequence: 8, 7, 6, 5, 4, 3, 2, 1)
|
||||
- If i >= 8: Weight = i + 1 (Sequence: 9, 10, ...)
|
||||
3. Sum = Sum(Digit * Weight)
|
||||
4. Check Digit = Sum % 99
|
||||
"""
|
||||
# تمیزکاری ورودی (حذف فاصلهها و اطمینان از عددی بودن)
|
||||
if not id_code.isdigit():
|
||||
return "Error: ورودی باید فقط عدد باشد."
|
||||
|
||||
total_sum = 0
|
||||
|
||||
# پیمایش از راست به چپ
|
||||
# reversed(id_code) ارقام را برعکس میکند
|
||||
for i, char in enumerate(reversed(id_code)):
|
||||
digit = int(char)
|
||||
|
||||
# تعیین وزن بر اساس ایندکس (مکان رقم از راست)
|
||||
if i < 8:
|
||||
weight = 8 - i
|
||||
else:
|
||||
weight = i + 1
|
||||
|
||||
total_sum += digit * weight
|
||||
|
||||
# محاسبه باقیمانده بر 99
|
||||
check_digit = total_sum % 99
|
||||
|
||||
# فرمتدهی دو رقمی (مثلاً 6 میشود 06)
|
||||
check_digit_str = f"{check_digit:02}"
|
||||
|
||||
return f"{id_code}{check_digit_str}"
|
||||
@@ -0,0 +1,127 @@
|
||||
import uuid
|
||||
import jdatetime
|
||||
from django import forms
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from apps.accounts.models import User
|
||||
from . import validators
|
||||
from .models import BankInstitution, CompanyBankAccount, PaymentNotice
|
||||
|
||||
|
||||
class BankAccountForm(forms.ModelForm):
|
||||
revision_token = forms.CharField(required=False, widget=forms.HiddenInput)
|
||||
|
||||
class Meta:
|
||||
model = CompanyBankAccount
|
||||
fields = ['revision_token', 'name', 'bank', 'holder', 'account_number', 'iban', 'card_number', 'requires_deposit_id', 'is_active', 'available_to_staff', 'available_to_customers', 'sort_order']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.instance.pk:
|
||||
self.initial['revision_token'] = self.instance.updated_at.isoformat()
|
||||
|
||||
def clean_account_number(self):
|
||||
return validators.bank_number(self.cleaned_data['account_number'])
|
||||
|
||||
def clean_card_number(self):
|
||||
value = validators.bank_number(self.cleaned_data['card_number'])
|
||||
if value and (len(value) != 16 or sum((int(n) * (2 if i % 2 == 0 else 1)) // 10 + (int(n) * (2 if i % 2 == 0 else 1)) % 10 for i, n in enumerate(value)) % 10):
|
||||
raise forms.ValidationError('شماره کارت ۱۶ رقمی معتبر وارد کنید.')
|
||||
return value
|
||||
|
||||
def clean_iban(self):
|
||||
return validators.iban(self.cleaned_data['iban'])
|
||||
|
||||
def clean(self):
|
||||
data = super().clean()
|
||||
if self.instance.pk and data.get('revision_token') != self.instance.updated_at.isoformat():
|
||||
raise forms.ValidationError('حساب از زمان باز کردن فرم تغییر کرده است؛ صفحه را تازه کنید و تغییرات را دوباره بررسی کنید.')
|
||||
if not any(data.get(key) for key in ('account_number', 'iban', 'card_number')):
|
||||
raise forms.ValidationError('حداقل شماره حساب، شبا یا کارت را وارد کنید.')
|
||||
return data
|
||||
|
||||
|
||||
class NoticeForm(forms.Form):
|
||||
submission_key = forms.UUIDField(widget=forms.HiddenInput)
|
||||
customer = forms.ModelChoiceField(label='مشتری', queryset=User.objects.none(), widget=forms.HiddenInput,
|
||||
error_messages={'required': 'مشتری را از نتایج جستوجو انتخاب کنید.',
|
||||
'invalid_choice': 'مشتری انتخابشده معتبر یا فعال نیست؛ دوباره جستوجو کنید.'})
|
||||
account = forms.ModelChoiceField(label='حساب مقصد', queryset=CompanyBankAccount.objects.none())
|
||||
subject = forms.CharField(label='موضوع', max_length=200)
|
||||
amount = forms.CharField(label='مبلغ (ریال)')
|
||||
deposit_id = forms.CharField(label='شناسه واریز', max_length=100, required=False)
|
||||
invoice_number = forms.CharField(label='شماره فاکتور', max_length=100, required=False)
|
||||
description = forms.CharField(label='توضیحات', required=False, widget=forms.Textarea)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.initial.setdefault('submission_key', uuid.uuid4())
|
||||
self.fields['customer'].queryset = User.objects.filter(account_type='CUSTOMER', is_active=True).select_related('customer_profile')
|
||||
self.fields['customer'].label_from_instance = lambda u: f'{getattr(u, "customer_profile", u)} — {u.local_mobile}'
|
||||
self.fields['account'].queryset = CompanyBankAccount.objects.filter(is_active=True, available_to_staff=True).select_related('bank')
|
||||
|
||||
def clean_amount(self):
|
||||
return validators.amount(self.cleaned_data['amount'])
|
||||
|
||||
def clean(self):
|
||||
data = super().clean()
|
||||
if data.get('account') and data['account'].requires_deposit_id and not data.get('deposit_id', '').strip():
|
||||
self.add_error('deposit_id', 'این حساب شناسهدار است؛ شناسه واریز مشتری را حتماً وارد کنید.')
|
||||
return data
|
||||
|
||||
|
||||
class ReceiptForm(forms.Form):
|
||||
receipt_mode = forms.ChoiceField(label='نوع واریز', choices=[('linked', 'واریز مرتبط (پیشنهادی)'), ('independent', 'واریز مستقل')], widget=forms.RadioSelect, initial='linked')
|
||||
notice_id = forms.ModelChoiceField(label='اعلامیه واریز', queryset=PaymentNotice.objects.none(), required=False)
|
||||
paid_amount = forms.CharField(label='مبلغ پرداختشده (ریال)')
|
||||
destination_account = forms.ModelChoiceField(label='به کدام حساب ما واریز کردید؟', queryset=CompanyBankAccount.objects.none())
|
||||
deposit_id = forms.CharField(label='شناسه واریز که با آن پرداخت انجام دادهاید (اختیاری)', max_length=100, required=False)
|
||||
origin_bank = forms.ModelChoiceField(label='بانک مبدأ (اختیاری)', queryset=BankInstitution.objects.filter(is_active=True), required=False)
|
||||
origin_other = forms.CharField(label='نام بانک دیگر (اختیاری)', max_length=100, required=False)
|
||||
paid_day = forms.ChoiceField(label='روز', choices=[('', 'روز')] + [(str(i), str(i)) for i in range(1, 32)])
|
||||
paid_month = forms.ChoiceField(label='ماه', choices=[('', 'ماه')] + list(zip(map(str, range(1, 13)), ['فروردین','اردیبهشت','خرداد','تیر','مرداد','شهریور','مهر','آبان','آذر','دی','بهمن','اسفند'])))
|
||||
paid_year = forms.ChoiceField(label='سال')
|
||||
receipt_image = forms.FileField(label='تصویر فیش')
|
||||
tracking_number = forms.CharField(label='شماره پیگیری (اختیاری)', max_length=100, required=False)
|
||||
description = forms.CharField(label='توضیحات (اختیاری)', required=False, widget=forms.Textarea)
|
||||
submission_key = forms.UUIDField(widget=forms.HiddenInput)
|
||||
|
||||
def __init__(self, *args, customer, notice_ids=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
notices = PaymentNotice.objects.filter(customer=customer, cancelled_at__isnull=True)
|
||||
if notice_ids is not None:
|
||||
notices = notices.filter(pk__in=notice_ids)
|
||||
self.fields['notice_id'].queryset = notices
|
||||
self.fields['notice_id'].label_from_instance = lambda n: f'{n.subject} — {n.amount:,} ریال — {n.invoice_number}'
|
||||
accounts = Q(is_active=True, available_to_customers=True) | Q(is_active=True, paymentnotice__in=notices)
|
||||
self.fields['destination_account'].queryset = CompanyBankAccount.objects.filter(accounts).distinct()
|
||||
current = jdatetime.date.fromgregorian(date=timezone.localdate())
|
||||
self.fields['paid_year'].choices = [('', 'سال')] + [(str(y), str(y)) for y in range(current.year, current.year - 11, -1)]
|
||||
self.initial.setdefault('submission_key', uuid.uuid4())
|
||||
self.initial.setdefault('paid_month', str(current.month))
|
||||
self.initial.setdefault('paid_year', str(current.year))
|
||||
|
||||
def clean_paid_amount(self):
|
||||
return validators.amount(self.cleaned_data['paid_amount'])
|
||||
|
||||
def clean_receipt_image(self):
|
||||
upload = self.cleaned_data['receipt_image']
|
||||
self.image_metadata = validators.image(upload)
|
||||
return upload
|
||||
|
||||
def clean(self):
|
||||
data = super().clean()
|
||||
notice = data.get('notice_id') if data.get('receipt_mode') == 'linked' else None
|
||||
data['notice_id'] = notice
|
||||
if data.get('receipt_mode') == 'linked' and not notice:
|
||||
self.add_error('notice_id', 'اعلامیه را انتخاب کنید یا واریز مستقل را بزنید.')
|
||||
try:
|
||||
data['paid_date'] = validators.paid_date(data.get('paid_day'), data.get('paid_month'), data.get('paid_year'))
|
||||
if self.data.get('paid_date') and self.data['paid_date'] != data['paid_date'].isoformat():
|
||||
raise forms.ValidationError('تاریخ ارسالی با تاریخ انتخابشده سازگار نیست؛ دوباره انتخاب کنید.')
|
||||
except forms.ValidationError as error:
|
||||
self.add_error('paid_day', error)
|
||||
account = data.get('destination_account')
|
||||
if account and not account.available_to_customers and (not notice or notice.account_id != account.pk):
|
||||
self.add_error('destination_account', 'این حساب فقط برای اعلامیه مربوط به آن قابل انتخاب است.')
|
||||
return data
|
||||
@@ -0,0 +1,40 @@
|
||||
import re
|
||||
|
||||
from django.contrib.auth import logout
|
||||
from django.db import transaction
|
||||
from django.db.models import F
|
||||
from django.shortcuts import redirect, render
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.cache import never_cache
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from . import access, services
|
||||
from .models import PaymentAccessLink
|
||||
|
||||
|
||||
@never_cache
|
||||
@require_http_methods(['GET', 'POST'])
|
||||
def open_link(request, token):
|
||||
link = access.active_links().filter(token_hash=access.token_digest(token)).first() if re.fullmatch(r'[A-Za-z0-9_-]{43}', token) else None
|
||||
if link is None:
|
||||
return access.unavailable_link(request)
|
||||
if request.user.is_authenticated and request.user.pk != link.customer_id:
|
||||
confirmed = request.method == 'POST' and request.POST.get('action') == 'logout_and_open' and request.POST.get('account_id') == str(request.user.pk)
|
||||
if not confirmed:
|
||||
response = render(request, 'payments/link_confirmation.html')
|
||||
response['Referrer-Policy'] = 'same-origin'
|
||||
response['X-Robots-Tag'] = 'noindex, nofollow'
|
||||
return response
|
||||
logout(request)
|
||||
if not request.user.is_authenticated:
|
||||
access.grant_payment_access(request, link)
|
||||
with transaction.atomic():
|
||||
PaymentAccessLink.objects.filter(pk=link.pk).update(view_count=F('view_count') + 1, last_used_at=timezone.now())
|
||||
services.audit(None, 'PAYMENT_LINK_OPENED', link, {
|
||||
'ip_address': request.META.get('REMOTE_ADDR', ''),
|
||||
'user_agent': request.META.get('HTTP_USER_AGENT', '')[:500]})
|
||||
target = link.receipt or link.notice
|
||||
response = redirect('payments:receipt_detail' if link.receipt_id else 'payments:notice_detail', public_id=target.public_id)
|
||||
response['Referrer-Policy'] = 'no-referrer'
|
||||
response['X-Robots-Tag'] = 'noindex, nofollow'
|
||||
return response
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Audit administrative financial grant changes with the actual request actor.
|
||||
Only selected non-secret fields and payments permissions are captured."""
|
||||
from django.apps import apps
|
||||
from django.db import OperationalError, ProgrammingError
|
||||
from .models import PaymentAuditEvent
|
||||
|
||||
TARGETS = {'accounts/user': 'accounts.User', 'auth/group': 'auth.Group',
|
||||
'staff/organizationalunit': 'staff.OrganizationalUnit', 'staff/unitmembership': 'staff.UnitMembership',
|
||||
'staff/staffprofile': 'staff.StaffProfile'}
|
||||
|
||||
|
||||
def access_snapshot(obj):
|
||||
fields = ['is_active','account_type','user_id','unit_id','ticket_scope','role','primary_unit_id']
|
||||
result = {field:getattr(obj,field) for field in fields if hasattr(obj,field)}
|
||||
for name in ['permissions','user_permissions']:
|
||||
relation = getattr(obj,name,None)
|
||||
if relation is not None:
|
||||
result[name] = sorted(relation.filter(content_type__app_label='payments').values_list('codename',flat=True))
|
||||
if hasattr(obj,'groups'):
|
||||
result['groups'] = sorted(obj.groups.filter(permissions__content_type__app_label='payments').values_list('pk',flat=True).distinct())
|
||||
return result
|
||||
|
||||
|
||||
class FinancialGrantAuditMiddleware:
|
||||
def __init__(self,get_response):
|
||||
self.get_response=get_response
|
||||
|
||||
def __call__(self,request):
|
||||
target = self.target(request)
|
||||
before = access_snapshot(target) if target else None
|
||||
response = self.get_response(request)
|
||||
if target and response.status_code < 400:
|
||||
try:
|
||||
target.refresh_from_db()
|
||||
after=access_snapshot(target)
|
||||
if before != after:
|
||||
from .services import audit
|
||||
audit(request.user,'FINANCIAL_ACCESS_CHANGED',target,{'before':before,'after':after})
|
||||
except target.DoesNotExist:
|
||||
from .services import audit
|
||||
audit(request.user,'FINANCIAL_ACCESS_REMOVED',target,{'before':before})
|
||||
return response
|
||||
|
||||
def target(self,request):
|
||||
if request.method != 'POST' or not request.user.is_authenticated or not request.path.startswith('/x5admin/'):
|
||||
return None
|
||||
parts=request.path.strip('/').split('/')
|
||||
if len(parts)<5 or not parts[3].isdecimal():
|
||||
return None
|
||||
label=TARGETS.get('/'.join(parts[1:3]))
|
||||
if not label:
|
||||
return None
|
||||
return apps.get_model(label).objects.filter(pk=parts[3]).first()
|
||||
@@ -0,0 +1,269 @@
|
||||
# Generated by Django 5.1.1 on 2026-10-05 10:44
|
||||
|
||||
import apps.payments.models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('notifications', '0017_alter_smstemplate_event_type'),
|
||||
('staff', '0012_organizationalunit_supervisor_mobile_5'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BankInstitution',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('code', models.SlugField(unique=True)),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('historical', models.BooleanField(default=False)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['historical', 'name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PaymentAccessConfiguration',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('singleton', models.PositiveSmallIntegerField(default=1, editable=False, unique=True)),
|
||||
('open_view', models.BooleanField(default=True, verbose_name='مشاهده برای همه کارشناسان فعال')),
|
||||
('open_create', models.BooleanField(default=True, verbose_name='ایجاد برای همه کارشناسان فعال')),
|
||||
],
|
||||
options={
|
||||
'permissions': [('manage_payment_access', 'Manage payment access')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CompanyBankAccount',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('name', models.CharField(max_length=120, verbose_name='نام نمایشی حساب')),
|
||||
('holder', models.CharField(max_length=150, verbose_name='صاحب حساب')),
|
||||
('account_number', models.CharField(blank=True, max_length=40, verbose_name='شماره حساب')),
|
||||
('iban', models.CharField(blank=True, max_length=26, verbose_name='شبا')),
|
||||
('card_number', models.CharField(blank=True, max_length=16, verbose_name='شماره کارت')),
|
||||
('requires_deposit_id', models.BooleanField(default=False, verbose_name='شناسه واریز الزامی')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='فعال کلی')),
|
||||
('available_to_staff', models.BooleanField(default=True, verbose_name='قابل انتخاب کارشناسان')),
|
||||
('available_to_customers', models.BooleanField(default=True, verbose_name='قابل انتخاب مشتریان')),
|
||||
('sort_order', models.PositiveIntegerField(default=0)),
|
||||
('bank', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='payments.bankinstitution', verbose_name='بانک')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['sort_order', 'name', 'pk'],
|
||||
'permissions': [('manage_company_bankaccounts', 'Manage company bank accounts')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PaymentAuditEvent',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('actor_name', models.CharField(max_length=150)),
|
||||
('action', models.CharField(max_length=50)),
|
||||
('object_type', models.CharField(max_length=50)),
|
||||
('object_id', models.CharField(max_length=64)),
|
||||
('details', models.JSONField(default=dict)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('actor', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at', '-pk'],
|
||||
'permissions': [('view_payment_audit', 'View financial audit')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PaymentNotice',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('account_snapshot', models.JSONField()),
|
||||
('subject', models.CharField(max_length=200)),
|
||||
('amount', models.BigIntegerField()),
|
||||
('deposit_id', models.CharField(blank=True, max_length=100)),
|
||||
('invoice_number', models.CharField(blank=True, max_length=100)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('cancelled_at', models.DateTimeField(blank=True, null=True)),
|
||||
('account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='payments.companybankaccount')),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='issued_payment_notices', to=settings.AUTH_USER_MODEL)),
|
||||
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payment_notices', to=settings.AUTH_USER_MODEL)),
|
||||
('owning_unit', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='staff.organizationalunit')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at', '-pk'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PaymentNotification',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('context', models.JSONField()),
|
||||
('rendered_body', models.TextField(blank=True)),
|
||||
('template_snapshot', models.JSONField(default=dict)),
|
||||
('job', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='payment_notification', to='notifications.notificationjob')),
|
||||
('notice', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.paymentnotice')),
|
||||
('unit', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='staff.organizationalunit')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PaymentNotificationAttempt',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('started_at', models.DateTimeField(auto_now_add=True)),
|
||||
('finished_at', models.DateTimeField(null=True)),
|
||||
('success', models.BooleanField(null=True)),
|
||||
('provider_code', models.CharField(blank=True, max_length=50)),
|
||||
('provider_id', models.CharField(blank=True, max_length=500)),
|
||||
('error', models.TextField(blank=True)),
|
||||
('notification', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='attempt_log', to='payments.paymentnotification')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PaymentReceipt',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('status', models.CharField(choices=[('PENDING', 'در انتظار بررسی'), ('PARTIAL', 'تأیید ناقص'), ('APPROVED', 'تأیید شده'), ('REJECTED', 'رد شده')], db_index=True, default='PENDING', max_length=12)),
|
||||
('submission_key', models.UUIDField(unique=True)),
|
||||
('current_version', models.PositiveIntegerField(default=1)),
|
||||
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payment_receipts', to=settings.AUTH_USER_MODEL)),
|
||||
('notice', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='receipts', to='payments.paymentnotice')),
|
||||
('owning_unit', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='staff.organizationalunit')),
|
||||
('submitted_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='submitted_payment_receipts', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at', '-pk'],
|
||||
'permissions': [('approve_paymentreceipt', 'Approve payment receipt'), ('senior_approve_paymentreceipt', 'Senior final approval'), ('submit_customer_receipt', 'Submit receipt for customer'), ('view_all_payments', 'View payments across units')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PaymentReceiptRevision',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('version', models.PositiveIntegerField()),
|
||||
('amount', models.BigIntegerField()),
|
||||
('paid_date', models.DateField()),
|
||||
('account_snapshot', models.JSONField()),
|
||||
('origin_other', models.CharField(blank=True, max_length=100)),
|
||||
('deposit_id', models.CharField(blank=True, max_length=100)),
|
||||
('tracking_number', models.CharField(blank=True, max_length=100)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('destination_mismatch', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('image', models.FileField(max_length=500, upload_to=apps.payments.models.receipt_path)),
|
||||
('mime_type', models.CharField(max_length=100)),
|
||||
('sha256', models.CharField(max_length=64)),
|
||||
('original_name', models.CharField(max_length=255)),
|
||||
('file_size', models.PositiveBigIntegerField()),
|
||||
('destination', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='payments.companybankaccount')),
|
||||
('origin_bank', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.bankinstitution')),
|
||||
('receipt', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='revisions', to='payments.paymentreceipt')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ReceiptApprovalCycle',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('policy_snapshot', models.JSONField(default=dict)),
|
||||
('status', models.CharField(choices=[('PENDING', 'در انتظار بررسی'), ('PARTIAL', 'تأیید ناقص'), ('APPROVED', 'تأیید شده'), ('REJECTED', 'رد شده')], default='PENDING', max_length=12)),
|
||||
('finalized_at', models.DateTimeField(blank=True, null=True)),
|
||||
('finalized_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
|
||||
('revision', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='cycle', to='payments.paymentreceiptrevision')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ReceiptApprovalAssignment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('is_senior', models.BooleanField(default=False)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
|
||||
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='assignments', to='payments.receiptapprovalcycle')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='paymentnotification',
|
||||
name='cycle',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.receiptapprovalcycle'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ReceiptDecision',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('action', models.CharField(choices=[('APPROVE', 'تأیید'), ('REJECT', 'رد'), ('SENIOR_APPROVE', 'تأیید ارشد')], max_length=20)),
|
||||
('reason', models.TextField(blank=True)),
|
||||
('actor_name', models.CharField(max_length=150)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='decisions', to='payments.receiptapprovalcycle')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PaymentApprovalPolicy',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('name', models.CharField(max_length=100, verbose_name='عنوان سیاست')),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('reviewers', models.ManyToManyField(related_name='payment_review_policies', to=settings.AUTH_USER_MODEL, verbose_name='افراد الزامی تأیید')),
|
||||
('seniors', models.ManyToManyField(blank=True, related_name='payment_senior_policies', to=settings.AUTH_USER_MODEL, verbose_name='تأییدکنندگان ارشد')),
|
||||
('unit', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='staff.organizationalunit', verbose_name='واحد (خالی: عمومی)')),
|
||||
],
|
||||
options={
|
||||
'permissions': [('manage_payment_approval_policy', 'Manage payment approval policy')],
|
||||
'constraints': [models.UniqueConstraint(condition=models.Q(('is_active', True), ('unit__isnull', False)), fields=('unit',), name='pay_active_unit_policy'), models.UniqueConstraint(condition=models.Q(('is_active', True), ('unit__isnull', True)), fields=('is_active',), name='pay_active_global_policy')],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='paymentnotice',
|
||||
index=models.Index(fields=['customer', '-created_at'], name='payments_pa_custome_bc6e3f_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='paymentnotice',
|
||||
index=models.Index(fields=['owning_unit', '-created_at'], name='payments_pa_owning__f2b6af_idx'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='paymentnotice',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('amount__gt', 0), ('amount__lte', 9007199254740991)), name='pay_notice_amount'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='paymentreceipt',
|
||||
index=models.Index(fields=['customer', '-created_at'], name='payments_pa_custome_e266cc_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='paymentreceipt',
|
||||
index=models.Index(fields=['status', '-created_at'], name='payments_pa_status_5cd50b_idx'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='paymentreceiptrevision',
|
||||
constraint=models.UniqueConstraint(fields=('receipt', 'version'), name='pay_receipt_revision'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='paymentreceiptrevision',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('amount__gt', 0), ('amount__lte', 9007199254740991)), name='pay_receipt_amount'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='receiptapprovalassignment',
|
||||
constraint=models.UniqueConstraint(fields=('cycle', 'user', 'is_senior'), name='pay_cycle_assignment'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='receiptdecision',
|
||||
constraint=models.UniqueConstraint(fields=('cycle', 'user'), name='pay_one_vote_per_user'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
from django.db import migrations
|
||||
|
||||
BANKS = [["melli","ملی ایران"],["mellat","ملت"],["saderat","صادرات ایران"],["sepah","سپه"],["tejarat","تجارت"],["refah","رفاه کارگران"],["keshavarzi","کشاورزی"],["maskan","مسکن"],["sanat-madan","صنعت و معدن"],["tosee-saderat","توسعه صادرات ایران"],["tosee-taavon","توسعه تعاون"],["post","پست بانک ایران"],["pasargad","پاسارگاد"],["parsian","پارسیان"],["saman","سامان"],["eghtesad-novin","اقتصاد نوین"],["karafarin","کارآفرین"],["sina","سینا"],["shahr","شهر"],["sarmayeh","سرمایه"],["dey","دی"],["gardeshgari","گردشگری"],["iran-zamin","ایران زمین"],["khavarmianeh","خاورمیانه"],["resalat","قرضالحسنه رسالت"],["mehr-iran","قرضالحسنه مهر ایران"],["iran-venezuela","مشترک ایران و ونزوئلا"],["melal","مؤسسه اعتباری ملل"],["ayandeh","آینده"],["ansar","انصار"],["ghavamin","قوامین"],["hekmat","حکمت ایرانیان"],["mehr-eghtesad","مهر اقتصاد"],["kosar","مؤسسه کوثر"],["noor","مؤسسه نور"],["tosee","مؤسسه توسعه"]]
|
||||
TAGS = ['recipient_name','company_name','notice_number','receipt_number','amount_rial','invoice_number','payment_link']
|
||||
|
||||
|
||||
def seed(apps, schema_editor):
|
||||
Bank = apps.get_model('payments', 'BankInstitution')
|
||||
Template = apps.get_model('notifications', 'SMSTemplate')
|
||||
for code, name in BANKS:
|
||||
Bank.objects.get_or_create(code=code, defaults={'name':name, 'historical':code in {'ayandeh','ansar','ghavamin','hekmat','mehr-eghtesad','kosar','noor','tosee'}})
|
||||
Bank.objects.get_or_create(code='other', defaults={'name': 'سایر'})
|
||||
for event, title, body in [
|
||||
('PAYMENT_NOTICE_CREATED','اعلامیه واریز','{recipient_name}، اعلامیه واریز {notice_number} به مبلغ {amount_rial} ریال برای شما صادر شد.\nمشاهده اطلاعات: {payment_link}\n{company_name}'),
|
||||
('PAYMENT_RECEIPT_APPROVED','تأیید فیش','{recipient_name}، فیش واریز شما به شماره {receipt_number} و مبلغ {amount_rial} ریال تأیید شد.\nپیگیری: {payment_link}\n{company_name}'),
|
||||
]:
|
||||
Template.objects.get_or_create(unit=None,event_type=event, defaults={'title':title,'body':body,'allowed_tags':TAGS,'is_active':True})
|
||||
apps.get_model('payments','PaymentAccessConfiguration').objects.get_or_create(singleton=1)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [('payments','0001_initial'), ('notifications','0017_alter_smstemplate_event_type')]
|
||||
operations = [migrations.RunPython(seed, migrations.RunPython.noop)]
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.1.1 on 2026-10-05 11:05
|
||||
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('payments', '0002_initial_configuration'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='paymentnotice',
|
||||
name='issuance_key',
|
||||
field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
||||
),
|
||||
]
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# Generated by Django 5.1.1 on 2026-10-06 05:38
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('payments', '0003_paymentnotice_issuance_key'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PaymentAccessLink',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('token_hash', models.CharField(editable=False, max_length=64, unique=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('expires_at', models.DateTimeField()),
|
||||
('revoked_at', models.DateTimeField(blank=True, null=True)),
|
||||
('view_count', models.PositiveIntegerField(default=0)),
|
||||
('last_used_at', models.DateTimeField(blank=True, null=True)),
|
||||
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
|
||||
('notice', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.paymentnotice')),
|
||||
('receipt', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.paymentreceipt')),
|
||||
('submitted_receipts', models.ManyToManyField(blank=True, related_name='access_links', to='payments.paymentreceipt')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'لینک موقت پرداخت',
|
||||
'verbose_name_plural': 'لینک\u200cهای موقت پرداخت',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='paymentnotification',
|
||||
name='access_link',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.paymentaccesslink'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='paymentaccesslink',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('notice__isnull', False), ('receipt__isnull', False), _connector='OR'), name='payment_link_has_target'),
|
||||
),
|
||||
]
|
||||
Whitespace-only changes.
@@ -0,0 +1,234 @@
|
||||
"""Financial records are independent of tickets; issued instructions and evidence are snapshots."""
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from apps.core.models import PublicIdModel, TimeStampedModel
|
||||
|
||||
MAX_AMOUNT = 9_007_199_254_740_991
|
||||
|
||||
|
||||
class BankInstitution(models.Model):
|
||||
code = models.SlugField(unique=True)
|
||||
name = models.CharField(max_length=100)
|
||||
is_active = models.BooleanField(default=True)
|
||||
historical = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
ordering = ['historical', 'name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class CompanyBankAccount(PublicIdModel, TimeStampedModel):
|
||||
name = models.CharField('نام نمایشی حساب', max_length=120)
|
||||
bank = models.ForeignKey(BankInstitution, on_delete=models.PROTECT, verbose_name='بانک')
|
||||
holder = models.CharField('صاحب حساب', max_length=150)
|
||||
account_number = models.CharField('شماره حساب', max_length=40, blank=True)
|
||||
iban = models.CharField('شبا', max_length=26, blank=True)
|
||||
card_number = models.CharField('شماره کارت', max_length=16, blank=True)
|
||||
requires_deposit_id = models.BooleanField('شناسه واریز الزامی', default=False)
|
||||
is_active = models.BooleanField('فعال کلی', default=True)
|
||||
available_to_staff = models.BooleanField('قابل انتخاب کارشناسان', default=True)
|
||||
available_to_customers = models.BooleanField('قابل انتخاب مشتریان', default=True)
|
||||
sort_order = models.PositiveIntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ['sort_order', 'name', 'pk']
|
||||
permissions = [('manage_company_bankaccounts', 'Manage company bank accounts')]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def snapshot(self):
|
||||
return {key: getattr(self, key) for key in ('name', 'holder', 'account_number', 'iban', 'card_number', 'requires_deposit_id')} | {'bank': self.bank.name, 'id': str(self.public_id)}
|
||||
|
||||
|
||||
class PaymentAccessConfiguration(models.Model):
|
||||
singleton = models.PositiveSmallIntegerField(default=1, unique=True, editable=False)
|
||||
open_view = models.BooleanField('مشاهده برای همه کارشناسان فعال', default=True)
|
||||
open_create = models.BooleanField('ایجاد برای همه کارشناسان فعال', default=True)
|
||||
|
||||
class Meta:
|
||||
permissions = [('manage_payment_access', 'Manage payment access')]
|
||||
|
||||
|
||||
class PaymentApprovalPolicy(TimeStampedModel):
|
||||
name = models.CharField('عنوان سیاست', max_length=100)
|
||||
unit = models.ForeignKey('staff.OrganizationalUnit', null=True, blank=True, on_delete=models.PROTECT, verbose_name='واحد (خالی: عمومی)')
|
||||
reviewers = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='payment_review_policies', verbose_name='افراد الزامی تأیید')
|
||||
seniors = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='payment_senior_policies', blank=True, verbose_name='تأییدکنندگان ارشد')
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['unit'], condition=models.Q(is_active=True, unit__isnull=False), name='pay_active_unit_policy'),
|
||||
models.UniqueConstraint(fields=['is_active'], condition=models.Q(is_active=True, unit__isnull=True), name='pay_active_global_policy'),
|
||||
]
|
||||
permissions = [('manage_payment_approval_policy', 'Manage payment approval policy')]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class PaymentNotice(PublicIdModel, TimeStampedModel):
|
||||
issuance_key = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||
customer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='payment_notices')
|
||||
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='issued_payment_notices')
|
||||
owning_unit = models.ForeignKey('staff.OrganizationalUnit', null=True, on_delete=models.PROTECT)
|
||||
account = models.ForeignKey(CompanyBankAccount, on_delete=models.PROTECT)
|
||||
account_snapshot = models.JSONField()
|
||||
subject = models.CharField(max_length=200)
|
||||
amount = models.BigIntegerField()
|
||||
deposit_id = models.CharField(max_length=100, blank=True)
|
||||
invoice_number = models.CharField(max_length=100, blank=True)
|
||||
description = models.TextField(blank=True)
|
||||
cancelled_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at', '-pk']
|
||||
constraints = [models.CheckConstraint(condition=models.Q(amount__gt=0, amount__lte=MAX_AMOUNT), name='pay_notice_amount')]
|
||||
indexes = [models.Index(fields=['customer', '-created_at']), models.Index(fields=['owning_unit', '-created_at'])]
|
||||
|
||||
@property
|
||||
def number(self):
|
||||
return f'PN-{self.public_id.hex[:12].upper()}'
|
||||
|
||||
|
||||
class PaymentReceipt(PublicIdModel, TimeStampedModel):
|
||||
class Status(models.TextChoices):
|
||||
PENDING = 'PENDING', 'در انتظار بررسی'
|
||||
PARTIAL = 'PARTIAL', 'تأیید ناقص'
|
||||
APPROVED = 'APPROVED', 'تأیید شده'
|
||||
REJECTED = 'REJECTED', 'رد شده'
|
||||
customer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='payment_receipts')
|
||||
submitted_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='submitted_payment_receipts')
|
||||
notice = models.ForeignKey(PaymentNotice, null=True, blank=True, on_delete=models.PROTECT, related_name='receipts')
|
||||
owning_unit = models.ForeignKey('staff.OrganizationalUnit', null=True, on_delete=models.PROTECT)
|
||||
status = models.CharField(max_length=12, choices=Status.choices, default=Status.PENDING, db_index=True)
|
||||
submission_key = models.UUIDField(unique=True)
|
||||
current_version = models.PositiveIntegerField(default=1)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at', '-pk']
|
||||
indexes = [models.Index(fields=['customer', '-created_at']), models.Index(fields=['status', '-created_at'])]
|
||||
permissions = [('approve_paymentreceipt', 'Approve payment receipt'), ('senior_approve_paymentreceipt', 'Senior final approval'), ('submit_customer_receipt', 'Submit receipt for customer'), ('view_all_payments', 'View payments across units')]
|
||||
|
||||
@property
|
||||
def number(self):
|
||||
return f'PR-{self.public_id.hex[:12].upper()}'
|
||||
|
||||
|
||||
def receipt_path(instance, filename):
|
||||
return f'payments/{instance.created_at:%Y/%m}/{uuid.uuid4().hex}{Path(filename).suffix.lower()}'
|
||||
|
||||
|
||||
class PaymentReceiptRevision(models.Model):
|
||||
receipt = models.ForeignKey(PaymentReceipt, on_delete=models.PROTECT, related_name='revisions')
|
||||
version = models.PositiveIntegerField()
|
||||
amount = models.BigIntegerField()
|
||||
paid_date = models.DateField()
|
||||
destination = models.ForeignKey(CompanyBankAccount, on_delete=models.PROTECT)
|
||||
account_snapshot = models.JSONField()
|
||||
origin_bank = models.ForeignKey(BankInstitution, null=True, blank=True, on_delete=models.PROTECT)
|
||||
origin_other = models.CharField(max_length=100, blank=True)
|
||||
deposit_id = models.CharField(max_length=100, blank=True)
|
||||
tracking_number = models.CharField(max_length=100, blank=True)
|
||||
description = models.TextField(blank=True)
|
||||
destination_mismatch = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
image = models.FileField(upload_to=receipt_path, max_length=500)
|
||||
mime_type = models.CharField(max_length=100)
|
||||
sha256 = models.CharField(max_length=64)
|
||||
original_name = models.CharField(max_length=255)
|
||||
file_size = models.PositiveBigIntegerField()
|
||||
|
||||
class Meta:
|
||||
constraints = [models.UniqueConstraint(fields=['receipt', 'version'], name='pay_receipt_revision'), models.CheckConstraint(condition=models.Q(amount__gt=0, amount__lte=MAX_AMOUNT), name='pay_receipt_amount')]
|
||||
|
||||
|
||||
class ReceiptApprovalCycle(models.Model):
|
||||
revision = models.OneToOneField(PaymentReceiptRevision, on_delete=models.PROTECT, related_name='cycle')
|
||||
policy_snapshot = models.JSONField(default=dict)
|
||||
status = models.CharField(max_length=12, choices=PaymentReceipt.Status.choices, default=PaymentReceipt.Status.PENDING)
|
||||
finalized_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.PROTECT)
|
||||
finalized_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
|
||||
class ReceiptApprovalAssignment(models.Model):
|
||||
cycle = models.ForeignKey(ReceiptApprovalCycle, on_delete=models.PROTECT, related_name='assignments')
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
|
||||
is_senior = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
constraints = [models.UniqueConstraint(fields=['cycle', 'user', 'is_senior'], name='pay_cycle_assignment')]
|
||||
|
||||
|
||||
class ReceiptDecision(models.Model):
|
||||
cycle = models.ForeignKey(ReceiptApprovalCycle, on_delete=models.PROTECT, related_name='decisions')
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
|
||||
action = models.CharField(max_length=20, choices=[('APPROVE', 'تأیید'), ('REJECT', 'رد'), ('SENIOR_APPROVE', 'تأیید ارشد')])
|
||||
reason = models.TextField(blank=True)
|
||||
actor_name = models.CharField(max_length=150)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [models.UniqueConstraint(fields=['cycle', 'user'], name='pay_one_vote_per_user')]
|
||||
|
||||
|
||||
class PaymentAuditEvent(models.Model):
|
||||
actor = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL)
|
||||
actor_name = models.CharField(max_length=150)
|
||||
action = models.CharField(max_length=50)
|
||||
object_type = models.CharField(max_length=50)
|
||||
object_id = models.CharField(max_length=64)
|
||||
details = models.JSONField(default=dict)
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at', '-pk']
|
||||
permissions = [('view_payment_audit', 'View financial audit')]
|
||||
|
||||
|
||||
class PaymentAccessLink(models.Model):
|
||||
"""Revocable capability, separate from customer login and ticket magic links."""
|
||||
customer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
|
||||
notice = models.ForeignKey(PaymentNotice, null=True, blank=True, on_delete=models.PROTECT)
|
||||
receipt = models.ForeignKey(PaymentReceipt, null=True, blank=True, on_delete=models.PROTECT)
|
||||
token_hash = models.CharField(max_length=64, unique=True, editable=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
expires_at = models.DateTimeField()
|
||||
revoked_at = models.DateTimeField(null=True, blank=True)
|
||||
submitted_receipts = models.ManyToManyField(PaymentReceipt, blank=True, related_name='access_links')
|
||||
view_count = models.PositiveIntegerField(default=0)
|
||||
last_used_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
constraints = [models.CheckConstraint(
|
||||
condition=models.Q(notice__isnull=False) | models.Q(receipt__isnull=False),
|
||||
name='payment_link_has_target')]
|
||||
verbose_name = 'لینک موقت پرداخت'
|
||||
verbose_name_plural = 'لینکهای موقت پرداخت'
|
||||
|
||||
|
||||
class PaymentNotification(models.Model):
|
||||
job = models.OneToOneField('notifications.NotificationJob', on_delete=models.PROTECT, related_name='payment_notification')
|
||||
notice = models.ForeignKey(PaymentNotice, null=True, on_delete=models.PROTECT)
|
||||
cycle = models.ForeignKey(ReceiptApprovalCycle, null=True, on_delete=models.PROTECT)
|
||||
unit = models.ForeignKey('staff.OrganizationalUnit', null=True, on_delete=models.PROTECT)
|
||||
context = models.JSONField()
|
||||
rendered_body = models.TextField(blank=True)
|
||||
template_snapshot = models.JSONField(default=dict)
|
||||
access_link = models.ForeignKey(PaymentAccessLink, null=True, blank=True, on_delete=models.PROTECT)
|
||||
|
||||
|
||||
class PaymentNotificationAttempt(models.Model):
|
||||
notification = models.ForeignKey(PaymentNotification, on_delete=models.PROTECT, related_name='attempt_log')
|
||||
started_at = models.DateTimeField(auto_now_add=True)
|
||||
finished_at = models.DateTimeField(null=True)
|
||||
success = models.BooleanField(null=True)
|
||||
provider_code = models.CharField(max_length=50, blank=True)
|
||||
provider_id = models.CharField(max_length=500, blank=True)
|
||||
error = models.TextField(blank=True)
|
||||
@@ -0,0 +1,71 @@
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from apps.notifications.services import enqueue
|
||||
from .models import PaymentNotification, PaymentNotificationAttempt
|
||||
|
||||
EVENTS = {'PAYMENT_NOTICE_CREATED', 'PAYMENT_RECEIPT_APPROVED'}
|
||||
TAGS = ['recipient_name', 'company_name', 'notice_number', 'receipt_number', 'amount_rial', 'invoice_number', 'payment_link']
|
||||
|
||||
|
||||
def context(customer, notice=None, receipt=None, amount=0):
|
||||
profile = getattr(customer, 'customer_profile', None)
|
||||
return {'recipient_name': str(profile) if profile else customer.username, 'company_name': settings.COMPANY_NAME,
|
||||
'notice_number': notice.number if notice else '', 'receipt_number': receipt.number if receipt else '',
|
||||
'amount_rial': f'{amount:,}', 'invoice_number': notice.invoice_number if notice else '',
|
||||
'payment_link': settings.PUBLIC_BASE_URL + (reverse('payments:receipt_detail', args=[receipt.public_id]) if receipt else reverse('payments:notices') + f'?notice={notice.public_id}')}
|
||||
|
||||
|
||||
def queue_notice(notice):
|
||||
job = enqueue('SMS', 'PAYMENT_NOTICE_CREATED', None, f'payment-notice:{notice.public_id}', customer=notice.customer, staff_user=notice.created_by)
|
||||
PaymentNotification.objects.get_or_create(job=job, defaults={'notice': notice, 'unit': notice.owning_unit, 'context': context(notice.customer, notice=notice, amount=notice.amount)})
|
||||
|
||||
|
||||
def queue_approval(receipt, cycle):
|
||||
job = enqueue('SMS', 'PAYMENT_RECEIPT_APPROVED', None, f'payment-approved:{cycle.pk}', customer=receipt.customer, staff_user=cycle.finalized_by)
|
||||
PaymentNotification.objects.get_or_create(job=job, defaults={'cycle': cycle, 'unit': receipt.owning_unit, 'context': context(receipt.customer, notice=receipt.notice, receipt=receipt, amount=cycle.revision.amount)})
|
||||
|
||||
|
||||
def send_payment_sms(job, provider=None, provider_factory=None):
|
||||
from apps.notifications.models import SMSTemplate
|
||||
from apps.notifications.management.commands.process_notifications import _render_sms
|
||||
notification = PaymentNotification.objects.get(job=job)
|
||||
# Persist the attempt before rendering/provider construction: failed preparation is history too.
|
||||
attempt = PaymentNotificationAttempt.objects.create(notification=notification)
|
||||
phase = 'آمادهسازی قالب پیامک'
|
||||
try:
|
||||
with transaction.atomic():
|
||||
notification = PaymentNotification.objects.select_for_update().get(pk=notification.pk)
|
||||
if not notification.access_link_id:
|
||||
from .access import issue_payment_link
|
||||
receipt = notification.cycle.revision.receipt if notification.cycle_id else None
|
||||
link, url = issue_payment_link(notice=notification.notice, receipt=receipt)
|
||||
notification.access_link = link
|
||||
old_url = notification.context.get('payment_link', '')
|
||||
if old_url and notification.rendered_body:
|
||||
notification.rendered_body = notification.rendered_body.replace(old_url, url)
|
||||
notification.context = {**notification.context, 'payment_link': url}
|
||||
notification.save(update_fields=['context', 'access_link', 'rendered_body'])
|
||||
if not notification.rendered_body:
|
||||
body = _render_sms(job.event_type, notification.context, notification.unit)
|
||||
template = SMSTemplate.objects.filter(event_type=job.event_type, is_active=True, unit=notification.unit).first() if notification.unit_id else None
|
||||
template = template or SMSTemplate.objects.get(event_type=job.event_type, is_active=True, unit__isnull=True)
|
||||
notification.rendered_body = body
|
||||
notification.template_snapshot = {'id': template.pk, 'updated_at': template.updated_at.isoformat()}
|
||||
notification.save(update_fields=['rendered_body', 'template_snapshot', 'context', 'access_link'])
|
||||
phase = 'ارتباط با ارائهدهنده پیامک'
|
||||
provider = provider if provider is not None else provider_factory()
|
||||
result = provider.send_message(job.customer.mobile, notification.rendered_body)
|
||||
attempt.success = result.success
|
||||
attempt.provider_id = str(result.provider_id or '')[:500]
|
||||
attempt.provider_code = str(result.provider_code or '')[:50]
|
||||
attempt.error = str(result.error or '')[:1000]
|
||||
return result, None
|
||||
except Exception:
|
||||
attempt.success = False
|
||||
attempt.error = 'خطا در ' + phase
|
||||
raise
|
||||
finally:
|
||||
attempt.finished_at = timezone.now()
|
||||
attempt.save()
|
||||
@@ -0,0 +1,64 @@
|
||||
from django.db.models import Q
|
||||
from .models import PaymentAccessConfiguration
|
||||
|
||||
|
||||
def is_staff(user):
|
||||
profile = getattr(user, 'staff_profile', None)
|
||||
return bool(user.is_authenticated and user.is_active and user.account_type == 'STAFF' and (profile is None or profile.is_active))
|
||||
|
||||
|
||||
def has_permission(user, codename, unit=None):
|
||||
if not is_staff(user):
|
||||
return False
|
||||
if user.has_perm(f'payments.{codename}'):
|
||||
return True
|
||||
if unit is None or not unit.is_active:
|
||||
return False
|
||||
return user.unit_memberships.filter(is_active=True, unit=unit).filter(
|
||||
Q(permissions__content_type__app_label='payments', permissions__codename=codename) |
|
||||
Q(unit__permissions__content_type__app_label='payments', unit__permissions__codename=codename)
|
||||
).exists()
|
||||
|
||||
|
||||
def config():
|
||||
# No get_or_create on a read request; defaults keep initial view/create open only.
|
||||
return PaymentAccessConfiguration.objects.first() or PaymentAccessConfiguration()
|
||||
|
||||
|
||||
def can_create(user, unit=None):
|
||||
if unit is None and is_staff(user):
|
||||
from apps.tickets.policies import TicketPolicy
|
||||
unit = TicketPolicy.primary_unit(user)
|
||||
return is_staff(user) and (config().open_create or has_permission(user, 'add_paymentnotice', unit))
|
||||
|
||||
|
||||
def can_manage_banks(user):
|
||||
profile = getattr(user, 'staff_profile', None)
|
||||
return is_staff(user) and (user.is_superuser or getattr(profile, 'role', '') == 'CEO' or has_permission(user, 'manage_company_bankaccounts'))
|
||||
|
||||
|
||||
def visible(queryset, user):
|
||||
if not user.is_authenticated or not user.is_active:
|
||||
return queryset.none()
|
||||
if user.account_type == 'CUSTOMER':
|
||||
return queryset.filter(customer=user)
|
||||
if not is_staff(user):
|
||||
return queryset.none()
|
||||
if config().open_view or user.has_perm('payments.view_all_payments') or user.is_superuser:
|
||||
return queryset
|
||||
permission = f'view_{queryset.model._meta.model_name}'
|
||||
if user.has_perm(f'payments.{permission}'):
|
||||
return queryset
|
||||
units = user.unit_memberships.filter(is_active=True, unit__is_active=True).filter(
|
||||
Q(permissions__content_type__app_label='payments', permissions__codename=permission) |
|
||||
Q(unit__permissions__content_type__app_label='payments', unit__permissions__codename=permission)
|
||||
).values_list('unit_id', flat=True)
|
||||
return queryset.filter(owning_unit_id__in=units).distinct()
|
||||
|
||||
|
||||
def can_decide(user, receipt, cycle, senior=False):
|
||||
code = 'senior_approve_paymentreceipt' if senior else 'approve_paymentreceipt'
|
||||
return (is_staff(user) and receipt.submitted_by_id != user.pk and
|
||||
has_permission(user, code, receipt.owning_unit) and
|
||||
visible(type(receipt).objects.filter(pk=receipt.pk), user).exists() and
|
||||
cycle.assignments.filter(user=user, is_senior=senior).exists())
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Bounded-query projections use only the current evidence revision for financial totals."""
|
||||
from django.urls import reverse
|
||||
from django.db.models import BigIntegerField, F, OuterRef, Subquery, Sum, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
from .models import PaymentReceiptRevision
|
||||
|
||||
|
||||
def notices_with_totals(queryset):
|
||||
approved = PaymentReceiptRevision.objects.filter(
|
||||
receipt__notice_id=OuterRef('pk'), receipt__status='APPROVED',
|
||||
version=F('receipt__current_version'), destination_mismatch=False
|
||||
).values('receipt__notice_id').annotate(total=Sum('amount')).values('total')
|
||||
return queryset.annotate(approved_total=Coalesce(Subquery(approved), Value(0), output_field=BigIntegerField()))
|
||||
|
||||
|
||||
def receipts_with_current_values(queryset):
|
||||
current = PaymentReceiptRevision.objects.filter(receipt_id=OuterRef('pk'), version=OuterRef('current_version'))
|
||||
return queryset.annotate(current_amount=Subquery(current.values('amount')[:1]),
|
||||
current_paid_date=Subquery(current.values('paid_date')[:1]))
|
||||
|
||||
|
||||
def notice_form_data(notice, staff=False):
|
||||
"""Customer-owned notice summary; account numbers stay on the notice page."""
|
||||
return {'id': str(notice.pk), 'subject': notice.subject, 'amount': str(notice.amount),
|
||||
'account': notice.account_id, 'deposit': notice.deposit_id,
|
||||
'invoice': notice.invoice_number,
|
||||
'url': reverse('staff_payments:notice_detail' if staff else 'payments:notice_detail', args=[notice.public_id])}
|
||||
|
||||
|
||||
def receipt_review_summaries(receipts):
|
||||
"""Attach current-version decisions in one query, preserving snapshot actor names."""
|
||||
from .models import ReceiptDecision
|
||||
receipts = list(receipts)
|
||||
indexed = {receipt.pk: receipt for receipt in receipts}
|
||||
for receipt in receipts:
|
||||
receipt.review_decisions = []
|
||||
if indexed:
|
||||
decisions = ReceiptDecision.objects.filter(
|
||||
cycle__revision__receipt_id__in=indexed,
|
||||
cycle__revision__version=F('cycle__revision__receipt__current_version')
|
||||
).select_related('cycle__revision').order_by('created_at', 'pk')
|
||||
for decision in decisions:
|
||||
indexed[decision.cycle.revision.receipt_id].review_decisions.append(decision)
|
||||
return receipts
|
||||
|
||||
|
||||
def attach_notice_receipts(notices, visible_receipts):
|
||||
"""Only fetch linked receipts visible to the current viewer, for this page."""
|
||||
notices = list(notices)
|
||||
indexed = {notice.pk: notice for notice in notices}
|
||||
for notice in notices:
|
||||
notice.related_receipts = []
|
||||
if indexed:
|
||||
receipts = receipt_review_summaries(receipts_with_current_values(
|
||||
visible_receipts.filter(notice_id__in=indexed)))
|
||||
for receipt in receipts:
|
||||
indexed[receipt.notice_id].related_receipts.append(receipt)
|
||||
return notices
|
||||
|
||||
|
||||
def recent_pending_notice_choices(form, retain_original=None):
|
||||
"""Limit the visible menu, keeping server-side ownership validation unsliced.
|
||||
|
||||
An explicitly selected pending notice replaces the last option when needed.
|
||||
A revision retains its original notice even if it has since been settled.
|
||||
This does not change which notices the financial service may validate.
|
||||
"""
|
||||
field = form.fields['notice_id']
|
||||
pending = notices_with_totals(field.queryset).filter(amount__gt=F('approved_total'))
|
||||
available = list(pending.order_by('-created_at', '-pk')[:10])
|
||||
selected = form['notice_id'].value()
|
||||
if selected and not any(str(n.pk) == str(selected) for n in available):
|
||||
source = field.queryset if str(selected) == str(retain_original) else pending
|
||||
selected_notice = source.filter(pk=selected).first() if str(selected).isdecimal() and len(str(selected)) <= 19 and int(selected) <= 9223372036854775807 else None
|
||||
if selected_notice:
|
||||
available = [selected_notice] + available[:9]
|
||||
field.widget.choices = [('', field.empty_label)] + [(n.pk, field.label_from_instance(n)) for n in available]
|
||||
return available
|
||||
@@ -0,0 +1,286 @@
|
||||
"""All business writes go through these atomic, permission-checked operations."""
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.core import serializers
|
||||
import json
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from apps.accounts.models import User
|
||||
from apps.tickets.policies import TicketPolicy
|
||||
from . import policies, validators
|
||||
from .models import (CompanyBankAccount, PaymentNotice, PaymentReceipt, PaymentReceiptRevision,
|
||||
PaymentApprovalPolicy, ReceiptApprovalCycle, ReceiptApprovalAssignment,
|
||||
ReceiptDecision, PaymentAuditEvent)
|
||||
|
||||
|
||||
def actor_name(actor):
|
||||
profile = getattr(actor, 'staff_profile', None)
|
||||
return getattr(profile, 'display_name', '') or actor.get_full_name() or actor.username
|
||||
|
||||
|
||||
def audit(actor, action, obj, details=None):
|
||||
return PaymentAuditEvent.objects.create(actor=actor, actor_name=actor_name(actor) if actor else 'SYSTEM',
|
||||
action=action, object_type=obj._meta.label_lower, object_id=str(getattr(obj, 'public_id', obj.pk)), details=details or {})
|
||||
|
||||
|
||||
def configuration_snapshot(obj):
|
||||
"""Serialize only the financial configuration passed here, including flags and M2M IDs."""
|
||||
return json.loads(serializers.serialize('json', [obj]))[0]['fields']
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def issue_notice(actor, data):
|
||||
unit = TicketPolicy.primary_unit(actor)
|
||||
if not policies.can_create(actor, unit):
|
||||
raise PermissionDenied
|
||||
# Keep lock order consistent with receipt submission: customer, then account.
|
||||
customer = User.objects.select_for_update().get(pk=data['customer'].pk, account_type='CUSTOMER', is_active=True)
|
||||
account = CompanyBankAccount.objects.select_for_update(of=('self',)).select_related('bank').get(pk=data['account'].pk)
|
||||
if not account.is_active or not account.available_to_staff:
|
||||
raise ValidationError('این حساب دیگر برای صدور اعلامیه فعال نیست.')
|
||||
deposit_id = validators.digits(data.get('deposit_id', ''))
|
||||
if account.requires_deposit_id and not deposit_id:
|
||||
raise ValidationError('برای این حساب شناسه واریز مشتری الزامی است.')
|
||||
key = data.get('submission_key')
|
||||
if key:
|
||||
existing = PaymentNotice.objects.filter(issuance_key=key).first()
|
||||
if existing:
|
||||
if existing.created_by_id != actor.pk or existing.customer_id != customer.pk:
|
||||
raise PermissionDenied
|
||||
expected = {'account_id': account.pk, 'amount': validators.amount(data['amount']),
|
||||
'deposit_id': deposit_id, 'subject': data['subject'],
|
||||
'invoice_number': data.get('invoice_number', ''), 'description': data.get('description', '')}
|
||||
if any(getattr(existing, field) != value for field, value in expected.items()):
|
||||
raise ValidationError('این اعلامیه قبلاً ثبت شده است؛ برای اطلاعات متفاوت فرم تازه باز کنید.')
|
||||
return existing
|
||||
notice = PaymentNotice.objects.create(customer=customer, created_by=actor, owning_unit=unit, account=account,
|
||||
account_snapshot=account.snapshot(), amount=validators.amount(data['amount']), deposit_id=deposit_id,
|
||||
subject=data['subject'], invoice_number=data.get('invoice_number', ''), description=data.get('description', ''),
|
||||
**({'issuance_key': key} if key else {}))
|
||||
audit(actor, 'NOTICE_ISSUED', notice, {'account': account.snapshot(), 'amount': str(notice.amount), 'deposit_id': deposit_id})
|
||||
from .notifications import queue_notice
|
||||
queue_notice(notice)
|
||||
return notice
|
||||
|
||||
|
||||
def policy_for(unit):
|
||||
policy = PaymentApprovalPolicy.objects.filter(is_active=True, unit=unit).first() if unit else None
|
||||
return policy or PaymentApprovalPolicy.objects.filter(is_active=True, unit__isnull=True).first()
|
||||
|
||||
|
||||
def assign_cycle(revision, actor):
|
||||
policy = policy_for(revision.receipt.owning_unit)
|
||||
reviewer_ids = list(policy.reviewers.values_list('pk', flat=True)) if policy else []
|
||||
senior_ids = list(policy.seniors.values_list('pk', flat=True)) if policy else []
|
||||
selected = User.objects.filter(pk__in=set(reviewer_ids + senior_ids))
|
||||
if policy and (not reviewer_ids or any(not policies.is_staff(user) for user in selected)):
|
||||
raise ValidationError('سیاست تأیید معتبر نیست؛ مدیر باید افراد فعال تأییدکننده را تعیین کند.')
|
||||
cycle = ReceiptApprovalCycle.objects.create(revision=revision, policy_snapshot={
|
||||
'policy_id': policy.pk if policy else None, 'name': policy.name if policy else 'هنوز تعیین نشده',
|
||||
'method': 'ALL_SELECTED', 'reviewers': reviewer_ids, 'seniors': senior_ids})
|
||||
ReceiptApprovalAssignment.objects.bulk_create([
|
||||
ReceiptApprovalAssignment(cycle=cycle, user_id=pk, is_senior=senior)
|
||||
for senior, ids in [(False, reviewer_ids), (True, senior_ids)] for pk in ids])
|
||||
audit(actor, 'CYCLE_STARTED', revision.receipt, {'version': revision.version, 'policy': cycle.policy_snapshot})
|
||||
return cycle
|
||||
|
||||
|
||||
def receipt_metadata(data):
|
||||
return {'amount': data['paid_amount'], 'paid_date': data['paid_date'], 'destination': data['destination_account'],
|
||||
'origin_bank': data.get('origin_bank'), 'origin_other': data.get('origin_other', ''),
|
||||
'deposit_id': validators.digits(data.get('deposit_id', '')), 'tracking_number': data.get('tracking_number', ''),
|
||||
'description': data.get('description', '')}
|
||||
|
||||
|
||||
def validate_destination(account, notice):
|
||||
if not account.is_active:
|
||||
raise ValidationError('این حساب غیرفعال شده است؛ برای ثبت پرداخت قبلی با کارشناس هماهنگ کنید.')
|
||||
if not account.available_to_customers and (not notice or notice.account_id != account.pk):
|
||||
raise ValidationError('حساب مقصد برای این واریز قابل انتخاب نیست.')
|
||||
|
||||
|
||||
def _submit_receipt(actor, customer, data, file_info, created_files):
|
||||
# Same-customer lock serializes retries of a submission nonce before inserting it.
|
||||
User.objects.select_for_update().get(pk=customer.pk)
|
||||
existing = PaymentReceipt.objects.filter(submission_key=data['submission_key']).first()
|
||||
if existing:
|
||||
revision = existing.revisions.get(version=1)
|
||||
if existing.customer_id != customer.pk or existing.submitted_by_id != actor.pk:
|
||||
raise PermissionDenied
|
||||
if (revision.amount != data['paid_amount'] or revision.paid_date != data['paid_date'] or
|
||||
revision.destination_id != data['destination_account'].pk or revision.sha256 != file_info['sha256'] or
|
||||
existing.notice_id != getattr(data.get('notice_id'), 'pk', None)):
|
||||
raise ValidationError('این ارسال قبلاً ثبت شده است؛ برای اطلاعات متفاوت فرم تازه باز کنید.')
|
||||
return existing
|
||||
notice = data.get('notice_id')
|
||||
if notice:
|
||||
notice = PaymentNotice.objects.select_for_update().get(pk=notice.pk, customer=customer)
|
||||
if notice.cancelled_at:
|
||||
raise ValidationError('اعلامیه لغو شده است؛ واریز مستقل را انتخاب کنید.')
|
||||
account = CompanyBankAccount.objects.select_for_update().select_related('bank').get(pk=data['destination_account'].pk)
|
||||
validate_destination(account, notice)
|
||||
receipt = PaymentReceipt.objects.create(customer=customer, submitted_by=actor, notice=notice,
|
||||
owning_unit=notice.owning_unit if notice else (TicketPolicy.primary_unit(actor) if policies.is_staff(actor) else None), submission_key=data['submission_key'])
|
||||
fields = receipt_metadata(data)
|
||||
fields['destination'] = account
|
||||
fields['deposit_id'] = fields['deposit_id'] or (notice.deposit_id if notice and notice.account_id == account.pk else '')
|
||||
revision = PaymentReceiptRevision(receipt=receipt, version=1,
|
||||
account_snapshot=notice.account_snapshot if notice and notice.account_id == account.pk else account.snapshot(),
|
||||
destination_mismatch=bool(notice and notice.account_id != account.pk), image=data['receipt_image'], **file_info, **fields)
|
||||
try:
|
||||
revision.save()
|
||||
finally:
|
||||
if revision.image and revision.image._committed:
|
||||
created_files.append((revision.image.storage, revision.image.name))
|
||||
assign_cycle(revision, actor)
|
||||
audit(actor, 'RECEIPT_SUBMITTED', receipt, {'version': 1, 'amount': str(revision.amount), 'sha256': revision.sha256})
|
||||
return receipt
|
||||
|
||||
|
||||
def submit_receipt(actor, customer, data, *, access_link=None):
|
||||
if not actor.is_active or customer.account_type != 'CUSTOMER' or not customer.is_active:
|
||||
raise PermissionDenied
|
||||
if actor.pk != customer.pk and not policies.can_create(actor):
|
||||
raise PermissionDenied
|
||||
if actor.account_type == 'CUSTOMER' and actor.pk != customer.pk:
|
||||
raise PermissionDenied
|
||||
data = dict(data)
|
||||
data['paid_amount'] = validators.amount(data['paid_amount'])
|
||||
if data['paid_date'] > timezone.localdate():
|
||||
raise ValidationError('تاریخ واریز نمیتواند بعد از امروز باشد.')
|
||||
file_info = validators.image(data['receipt_image'])
|
||||
# File storage is not transactional. Remove only the file created by this failed write.
|
||||
created_files = []
|
||||
try:
|
||||
with transaction.atomic():
|
||||
if access_link:
|
||||
from .access import validate_link_submission
|
||||
validate_link_submission(access_link, customer, data)
|
||||
result = _submit_receipt(actor, customer, data, file_info, created_files)
|
||||
if access_link:
|
||||
access_link.submitted_receipts.add(result)
|
||||
return result
|
||||
except Exception:
|
||||
for storage, name in created_files:
|
||||
storage.delete(name)
|
||||
raise
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def decide_receipt(actor, receipt_id, version, action, reason=''):
|
||||
# Lock only the receipt: PostgreSQL cannot lock the nullable side of a LEFT JOIN.
|
||||
receipt = PaymentReceipt.objects.select_for_update(of=('self',)).select_related('owning_unit').get(public_id=receipt_id)
|
||||
# Never authorize a financial vote using a stale User/profile/permission cache.
|
||||
actor = User.objects.select_for_update().get(pk=actor.pk)
|
||||
if receipt.current_version != version:
|
||||
raise ValidationError('اطلاعات تغییر کرده است؛ نسخه تازه را دوباره بررسی کنید.')
|
||||
revision = receipt.revisions.get(version=version)
|
||||
cycle = ReceiptApprovalCycle.objects.select_for_update().get(revision=revision)
|
||||
if action not in {'APPROVE', 'REJECT', 'SENIOR_APPROVE'}:
|
||||
raise ValidationError('نوع تصمیم معتبر نیست.')
|
||||
allowed = policies.can_decide(actor, receipt, cycle, senior=action == 'SENIOR_APPROVE')
|
||||
# Assigned seniors may also reject with a reason; all permission, visibility,
|
||||
# own-submission and assignment checks remain in can_decide.
|
||||
if not allowed and action == 'REJECT':
|
||||
allowed = policies.can_decide(actor, receipt, cycle, senior=True)
|
||||
if not allowed:
|
||||
raise PermissionDenied
|
||||
reason = reason.strip()
|
||||
if action in {'REJECT', 'SENIOR_APPROVE'} and not reason:
|
||||
raise ValidationError('دلیل این تصمیم را بنویسید.')
|
||||
old = cycle.decisions.filter(user=actor).first()
|
||||
if old:
|
||||
if old.action == action and old.reason == reason:
|
||||
return receipt
|
||||
raise ValidationError('تصمیم شما ثبت شده است؛ تغییر آن نیازمند چرخه جدید است.')
|
||||
if receipt.status not in {'PENDING', 'PARTIAL'}:
|
||||
raise ValidationError('بررسی این فیش پایان یافته است.')
|
||||
if not cycle.assignments.filter(is_senior=False).exists():
|
||||
raise ValidationError('مدیر باید ابتدا افراد تأییدکننده را تعیین کند.')
|
||||
if revision.destination_mismatch and action != 'REJECT' and not reason:
|
||||
raise ValidationError('حساب مقصد با اعلامیه متفاوت است؛ نتیجه بررسی مغایرت را بنویسید.')
|
||||
ReceiptDecision.objects.create(cycle=cycle, user=actor, action=action, reason=reason, actor_name=actor_name(actor))
|
||||
if action == 'REJECT':
|
||||
status = 'REJECTED'
|
||||
elif action == 'SENIOR_APPROVE':
|
||||
status = 'APPROVED'
|
||||
else:
|
||||
required = set(cycle.assignments.filter(is_senior=False).values_list('user_id', flat=True))
|
||||
voted = set(cycle.decisions.filter(action='APPROVE').values_list('user_id', flat=True))
|
||||
status = 'APPROVED' if required <= voted else 'PARTIAL'
|
||||
cycle.status = receipt.status = status
|
||||
if status in {'APPROVED', 'REJECTED'}:
|
||||
cycle.finalized_by = actor
|
||||
cycle.finalized_at = timezone.now()
|
||||
cycle.save(update_fields=['status', 'finalized_by', 'finalized_at'])
|
||||
receipt.save(update_fields=['status', 'updated_at'])
|
||||
audit(actor, action, receipt, {'version': version, 'reason': reason, 'status': status, 'cycle': cycle.pk})
|
||||
if status == 'APPROVED':
|
||||
from .notifications import queue_approval
|
||||
queue_approval(receipt, cycle)
|
||||
return receipt
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def bind_unassigned_cycle(actor, receipt):
|
||||
"""An empty cycle cannot accept votes. An authorized manager can bind a newly configured policy."""
|
||||
if not policies.has_permission(actor, 'manage_payment_approval_policy'):
|
||||
raise PermissionDenied
|
||||
receipt = PaymentReceipt.objects.select_for_update().get(pk=receipt.pk)
|
||||
revision = receipt.revisions.get(version=receipt.current_version)
|
||||
cycle = ReceiptApprovalCycle.objects.select_for_update().get(revision=revision)
|
||||
if cycle.assignments.exists() or cycle.decisions.exists() or receipt.status != 'PENDING':
|
||||
raise ValidationError('سیاست این چرخه قبلاً ثبت شده است و قابل بازنویسی نیست.')
|
||||
policy = policy_for(receipt.owning_unit)
|
||||
if not policy or not policy.reviewers.exists():
|
||||
raise ValidationError('ابتدا یک سیاست فعال با افراد تأییدکننده ایجاد کنید.')
|
||||
users = list(policy.reviewers.all()) + list(policy.seniors.all())
|
||||
if any(not policies.is_staff(user) for user in users):
|
||||
raise ValidationError('همه تأییدکنندگان باید فعال باشند.')
|
||||
cycle.policy_snapshot = {'policy_id': policy.pk, 'name': policy.name, 'method': 'ALL_SELECTED', 'reviewers': list(policy.reviewers.values_list('pk', flat=True)), 'seniors': list(policy.seniors.values_list('pk', flat=True))}
|
||||
cycle.save(update_fields=['policy_snapshot'])
|
||||
ReceiptApprovalAssignment.objects.bulk_create([ReceiptApprovalAssignment(cycle=cycle, user=user, is_senior=senior) for senior, selected in [(False, policy.reviewers.all()), (True, policy.seniors.all())] for user in selected])
|
||||
audit(actor, 'EMPTY_CYCLE_ASSIGNED', receipt, cycle.policy_snapshot)
|
||||
|
||||
|
||||
def revise_receipt(actor, receipt_id, version, data):
|
||||
data = dict(data)
|
||||
data['paid_amount'] = validators.amount(data['paid_amount'])
|
||||
if data['paid_date'] > timezone.localdate():
|
||||
raise ValidationError('تاریخ واریز نمیتواند بعد از امروز باشد.')
|
||||
file_info = validators.image(data['receipt_image'])
|
||||
created_files = []
|
||||
try:
|
||||
with transaction.atomic():
|
||||
receipt = PaymentReceipt.objects.select_for_update().get(public_id=receipt_id)
|
||||
if actor.pk != receipt.customer_id and not policies.has_permission(actor,'submit_customer_receipt',receipt.owning_unit):
|
||||
raise PermissionDenied
|
||||
if not actor.is_active or receipt.status != 'REJECTED' or receipt.current_version != version:
|
||||
raise ValidationError('فقط نسخه جاری فیش ردشده قابل اصلاح است.')
|
||||
# Link identity is fixed for a revision; changing notice requires a distinct receipt.
|
||||
if getattr(data.get('notice_id'),'pk',None) != receipt.notice_id:
|
||||
raise ValidationError('ارتباط اعلامیه این فیش قابل تغییر نیست.')
|
||||
account = CompanyBankAccount.objects.select_for_update().select_related('bank').get(pk=data['destination_account'].pk)
|
||||
validate_destination(account,receipt.notice)
|
||||
fields=receipt_metadata(data)
|
||||
fields['destination']=account
|
||||
notice = receipt.notice
|
||||
fields['deposit_id'] = fields['deposit_id'] or (notice.deposit_id if notice and notice.account_id == account.pk else '')
|
||||
new_version=version+1
|
||||
revision=PaymentReceiptRevision(receipt=receipt,version=new_version,
|
||||
account_snapshot=notice.account_snapshot if notice and notice.account_id == account.pk else account.snapshot(),
|
||||
image=data['receipt_image'],destination_mismatch=bool(receipt.notice and receipt.notice.account_id!=account.pk),**file_info,**fields)
|
||||
try:
|
||||
revision.save()
|
||||
finally:
|
||||
if revision.image and revision.image._committed:
|
||||
created_files.append((revision.image.storage,revision.image.name))
|
||||
assign_cycle(revision,actor)
|
||||
receipt.current_version=new_version
|
||||
receipt.status='PENDING'
|
||||
receipt.save(update_fields=['current_version','status','updated_at'])
|
||||
audit(actor,'RECEIPT_REVISED',receipt,{'version':new_version,'previous_version':version,'sha256':revision.sha256})
|
||||
return receipt
|
||||
except Exception:
|
||||
for storage,name in created_files:
|
||||
storage.delete(name)
|
||||
raise
|
||||
@@ -0,0 +1,22 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
app_name = 'staff_payments'
|
||||
urlpatterns = [
|
||||
path('', views.desk, name='desk'),
|
||||
path('count/', views.counts, name='count'),
|
||||
path('customers/search/', views.customer_search, name='customer_search'),
|
||||
path('notices/generate-identifier/', views.generate_deposit_identifier, name='generate_identifier'),
|
||||
path('notices/new/', views.notice_new, name='notice_new'),
|
||||
path('notices/<uuid:public_id>/', views.notice_detail, name='notice_detail'),
|
||||
path('notices/<uuid:public_id>/payment-link/', views.notice_payment_link, name='notice_payment_link'),
|
||||
path('receipts/new/<uuid:customer_id>/', views.staff_receipt_new, name='receipt_new'),
|
||||
path('receipts/new/', views.staff_receipt_customer, name='receipt_customer'),
|
||||
path('receipts/<uuid:public_id>/', views.staff_receipt_detail, name='receipt_detail'),
|
||||
path('receipts/<uuid:public_id>/decide/', views.decision, name='decision'),
|
||||
path('receipts/<uuid:public_id>/assign/', views.assign_policy, name='assign_policy'),
|
||||
path('banks/', views.banks, name='banks'),
|
||||
path('banks/new/', views.bank_edit, name='bank_new'),
|
||||
path('banks/bulk/', views.bank_bulk, name='bank_bulk'),
|
||||
path('banks/<uuid:public_id>/', views.bank_edit, name='bank_edit'),
|
||||
path('banks/<uuid:public_id>/delete/', views.bank_delete, name='bank_delete'),
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
from django.urls import path
|
||||
from . import views, link_views
|
||||
app_name = 'payments'
|
||||
urlpatterns = [
|
||||
path('link/<str:token>/', link_views.open_link, name='open_link'),
|
||||
path('', views.home, name='home'),
|
||||
path('bank-accounts/', views.notices, name='notices'),
|
||||
path('bank-accounts/<uuid:public_id>/', views.customer_notice_detail, name='notice_detail'),
|
||||
path('payment-receipt/', views.receipt_new, name='receipt_new'),
|
||||
path('receipts/', views.receipt_list, name='receipt_list'),
|
||||
path('receipts/<uuid:public_id>/', views.receipt_detail, name='receipt_detail'),
|
||||
path('receipts/<uuid:public_id>/revise/', views.receipt_revise, name='receipt_revise'),
|
||||
path('receipts/<uuid:public_id>/image/', views.receipt_image, name='receipt_image'),
|
||||
]
|
||||
@@ -0,0 +1,93 @@
|
||||
import hashlib
|
||||
import re
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
import jdatetime
|
||||
import magic
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils import timezone
|
||||
from apps.attachments.models import AttachmentPolicy
|
||||
from .models import MAX_AMOUNT
|
||||
|
||||
DIGIT_MAP = str.maketrans('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩', '01234567890123456789')
|
||||
|
||||
|
||||
def digits(value):
|
||||
return str(value or '').translate(DIGIT_MAP).strip()
|
||||
|
||||
|
||||
def amount(value):
|
||||
value = digits(value)
|
||||
if not re.fullmatch(r'\d+|\d{1,3}(?:[,٬ ]\d{3})+', value):
|
||||
raise ValidationError('مبلغ را به ریال، فقط با عدد صحیح وارد کنید.')
|
||||
value = int(re.sub(r'[,٬ ]', '', value))
|
||||
if not 0 < value <= MAX_AMOUNT:
|
||||
raise ValidationError('مبلغ باید بیشتر از صفر و در محدوده مجاز باشد.')
|
||||
return value
|
||||
|
||||
|
||||
def bank_number(value):
|
||||
value = re.sub(r'[\s-]', '', digits(value))
|
||||
if value and not value.isascii():
|
||||
raise ValidationError('شماره را با ارقام معتبر وارد کنید.')
|
||||
if value and not value.isdecimal():
|
||||
raise ValidationError('شماره حساب یا کارت باید فقط عدد باشد.')
|
||||
return value
|
||||
|
||||
|
||||
def iban(value):
|
||||
value = re.sub(r'\s', '', digits(value)).upper()
|
||||
if not value:
|
||||
return ''
|
||||
if not re.fullmatch(r'IR\d{24}', value) or int(value[4:] + '1827' + value[2:4]) % 97 != 1:
|
||||
raise ValidationError('شماره شبای معتبر ایرانی وارد کنید؛ IR و ۲۴ رقم.')
|
||||
return value
|
||||
|
||||
|
||||
def paid_date(day, month, year):
|
||||
if not all((day, month, year)):
|
||||
raise ValidationError('تاریخ را کامل کنید؛ روز، ماه و سال لازماند.')
|
||||
try:
|
||||
value = jdatetime.date(int(digits(year)), int(digits(month)), int(digits(day))).togregorian()
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
raise ValidationError('تاریخ شمسی معتبر انتخاب کنید.')
|
||||
today = timezone.localdate()
|
||||
if value > today:
|
||||
raise ValidationError('تاریخ واریز نمیتواند بعد از امروز باشد.')
|
||||
if jdatetime.date.fromgregorian(date=value).year < jdatetime.date.fromgregorian(date=today).year - 10:
|
||||
raise ValidationError('تاریخ خارج از بازه ده سال گذشته است.')
|
||||
return value
|
||||
|
||||
|
||||
def image(upload):
|
||||
policy = AttachmentPolicy.objects.filter(attachment_type='IMAGE', is_enabled=True).first()
|
||||
if not policy:
|
||||
raise ValidationError('ارسال تصویر فعلاً فعال نیست؛ با کارشناس هماهنگ کنید.')
|
||||
if not upload.size or upload.size > policy.max_file_size:
|
||||
raise ValidationError('تصویر خالی یا بزرگتر از حجم مجاز است.')
|
||||
ext = Path(upload.name).suffix.lower()
|
||||
if ext not in (policy.allowed_extensions or ['.jpg', '.jpeg', '.png', '.gif', '.webp']):
|
||||
raise ValidationError('فقط تصویر مجاز فیش را انتخاب کنید.')
|
||||
head = upload.read(8192)
|
||||
upload.seek(0)
|
||||
mime = magic.from_buffer(head, mime=True)
|
||||
expected = {'.jpg':'image/jpeg', '.jpeg':'image/jpeg', '.png':'image/png', '.gif':'image/gif', '.webp':'image/webp'}
|
||||
if expected.get(ext) != mime or (policy.allowed_mime_types and mime not in policy.allowed_mime_types):
|
||||
raise ValidationError('محتوای واقعی فایل با نوع تصویر سازگار نیست.')
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('error', Image.DecompressionBombWarning)
|
||||
with Image.open(upload) as picture:
|
||||
if picture.width * picture.height > 25_000_000:
|
||||
raise ValidationError('ابعاد تصویر بیش از حد مجاز است.')
|
||||
picture.verify()
|
||||
except (UnidentifiedImageError, OSError, SyntaxError, Image.DecompressionBombError, Image.DecompressionBombWarning):
|
||||
raise ValidationError('تصویر خراب است یا باز نمیشود؛ تصویر دیگری انتخاب کنید.')
|
||||
finally:
|
||||
upload.seek(0)
|
||||
digest = hashlib.sha256()
|
||||
for chunk in upload.chunks():
|
||||
digest.update(chunk)
|
||||
upload.seek(0)
|
||||
return {'mime_type': mime, 'sha256': digest.hexdigest(), 'original_name': Path(upload.name).name[:255], 'file_size': upload.size}
|
||||
@@ -0,0 +1,466 @@
|
||||
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})
|
||||
Reference in new issue
Block a user