fix payment
This commit is contained in:
1 parent
4f7486152c
commit
bb3ddd1610
47 files changed
+2137
-51
No files matched your search
@@ -19,6 +19,7 @@ from . import policies
|
||||
from .models import PaymentAccessLink, PaymentNotice
|
||||
|
||||
SESSION_KEY = 'payment_access_link_id'
|
||||
SHARE_SESSION_KEY = 'payment_page_share'
|
||||
|
||||
|
||||
def token_digest(token):
|
||||
@@ -44,6 +45,24 @@ def issue_payment_link(*, notice=None, receipt=None):
|
||||
raise RuntimeError('عدم موفقیت در تولید لینک یکتای پرداخت.')
|
||||
|
||||
|
||||
def notice_share_url(request, notice):
|
||||
"""Reuse a validated capability; only a logged-in owner can issue a new one."""
|
||||
shared = request.session.get(SHARE_SESSION_KEY, {})
|
||||
token = shared.get('token', '')
|
||||
link = active_links().filter(pk=shared.get('link_id'), notice=notice,
|
||||
token_hash=token_digest(token)).first() if token else None
|
||||
grant = getattr(request, 'payment_access_link', None)
|
||||
if grant and (link is None or link.pk != grant.pk):
|
||||
raise PermissionDenied('برای کپی لینک، دوباره لینک پرداخت دریافتی را باز کنید.')
|
||||
if link is None:
|
||||
from .services import audit
|
||||
link, url = issue_payment_link(notice=notice)
|
||||
token = url.rstrip('/').rsplit('/', 1)[-1]
|
||||
request.session[SHARE_SESSION_KEY] = {'link_id': link.pk, 'token': token}
|
||||
audit(request.user, 'PAYMENT_LINK_CREATED', link)
|
||||
return settings.PUBLIC_BASE_URL.rstrip('/') + reverse('payment_short_link', args=[token])
|
||||
|
||||
|
||||
def active_links():
|
||||
return PaymentAccessLink.objects.select_related('customer', 'notice', 'receipt').filter(
|
||||
notice__deleted_at__isnull=True,
|
||||
|
||||
@@ -126,3 +126,91 @@ for model in [models.PaymentNotice,models.PaymentReceipt,models.PaymentReceiptRe
|
||||
models.ReceiptApprovalAssignment,models.ReceiptDecision,models.PaymentAuditEvent,
|
||||
models.PaymentNotification,models.PaymentNotificationAttempt]:
|
||||
admin.site.register(model,EvidenceAdmin)
|
||||
|
||||
|
||||
class ReceiptAlertRecipientInline(admin.TabularInline):
|
||||
model = models.PaymentReceiptAlertRecipient
|
||||
extra = 1
|
||||
can_delete = False
|
||||
|
||||
def has_view_permission(self, request, obj=None):
|
||||
return policies.has_permission(request.user, 'manage_receipt_alerts')
|
||||
|
||||
has_add_permission = has_view_permission
|
||||
has_change_permission = has_view_permission
|
||||
|
||||
|
||||
@admin.register(models.PaymentReceiptAlertConfiguration)
|
||||
class ReceiptAlertConfigurationAdmin(AuditedConfigurationAdmin):
|
||||
from .forms import ReceiptAlertConfigurationForm
|
||||
form = ReceiptAlertConfigurationForm
|
||||
permission = 'manage_receipt_alerts'
|
||||
singleton_configuration = True
|
||||
list_display = ('__str__', 'bale_enabled', 'sms_enabled', 'notify_revisions', 'updated_at')
|
||||
readonly_fields = ('updated_at',)
|
||||
inlines = [ReceiptAlertRecipientInline]
|
||||
fieldsets = (
|
||||
('بله — تصویر فیش', {'fields': ('bale_enabled', 'bale_token', 'clear_bale_token', 'bale_chat_id', 'bale_caption')}),
|
||||
('پیامک مدیران', {'fields': ('sms_enabled', 'sms_template')}),
|
||||
('فیش اصلاحشده', {'fields': ('notify_revisions', 'updated_at')}),
|
||||
)
|
||||
|
||||
def save_related(self, request, form, formsets, change):
|
||||
super().save_related(request, form, formsets, change)
|
||||
services.audit(request.user, 'RECEIPT_ALERT_RECIPIENTS_CHANGED', form.instance,
|
||||
{'recipients': list(form.instance.recipients.values('id', 'name', 'mobile', 'is_active'))})
|
||||
|
||||
|
||||
@admin.register(models.PaymentReceiptAlertRecipient)
|
||||
class ReceiptAlertRecipientAdmin(AuditedConfigurationAdmin):
|
||||
permission = 'manage_receipt_alerts'
|
||||
list_display = ('name', 'mobile', 'is_active')
|
||||
list_filter = ('is_active',)
|
||||
search_fields = ('name', 'mobile')
|
||||
|
||||
|
||||
@admin.register(models.PaymentReceiptAlert)
|
||||
class ReceiptAlertAdmin(EvidenceAdmin):
|
||||
list_display = ('receipt_number', 'recipient_name', 'recipient_mobile', 'channel', 'status', 'attempts', 'delivery_status')
|
||||
list_select_related = ('job', 'revision__receipt', 'recipient')
|
||||
readonly_fields = ('channel', 'status', 'attempts', 'delivery_status', 'last_error', 'sent_at', 'delivery_description')
|
||||
|
||||
def has_view_permission(self, request, obj=None):
|
||||
return policies.has_permission(request.user, 'manage_receipt_alerts') or super().has_view_permission(request, obj)
|
||||
|
||||
def get_queryset(self, request):
|
||||
if policies.has_permission(request.user, 'manage_receipt_alerts'):
|
||||
return admin.ModelAdmin.get_queryset(self, request)
|
||||
return super().get_queryset(request)
|
||||
|
||||
@admin.display(description='فیش')
|
||||
def receipt_number(self, obj):
|
||||
return obj.revision.receipt.number
|
||||
|
||||
@admin.display(description='کانال')
|
||||
def channel(self, obj):
|
||||
return obj.job.get_channel_display()
|
||||
|
||||
@admin.display(description='وضعیت ارسال')
|
||||
def status(self, obj):
|
||||
return obj.job.get_status_display()
|
||||
|
||||
@admin.display(description='تعداد تلاش')
|
||||
def attempts(self, obj):
|
||||
return obj.job.attempts
|
||||
|
||||
@admin.display(description='دریافت پیامک')
|
||||
def delivery_status(self, obj):
|
||||
return obj.job.get_delivery_status_display() or 'استعلام ثبت نشده'
|
||||
|
||||
@admin.display(description='آخرین خطا')
|
||||
def last_error(self, obj):
|
||||
return obj.job.last_error
|
||||
|
||||
@admin.display(description='زمان ارسال')
|
||||
def sent_at(self, obj):
|
||||
return obj.job.sent_at
|
||||
|
||||
@admin.display(description='شرح دریافت')
|
||||
def delivery_description(self, obj):
|
||||
return obj.job.delivery_description
|
||||
@@ -0,0 +1,22 @@
|
||||
from string import Formatter
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
ALERT_TAGS = ('amount_rial', 'customer_name', 'customer_mobile', 'received_at', 'paid_date',
|
||||
'receipt_number', 'receipt_link', 'revision_version', 'manager_name')
|
||||
DEFAULT_RECEIPT_SMS = 'فیش واریزی به مبلغ {amount_rial} ریال توسط {customer_name} به شماره تماس {customer_mobile} ارسال شد.'
|
||||
DEFAULT_RECEIPT_CAPTION = ('فیش واریزی جدید\nمشتری: {customer_name}\nشماره تماس: {customer_mobile}\n'
|
||||
'مبلغ: {amount_rial} ریال\nزمان ارسال: {received_at}\nتاریخ واریز: {paid_date}\n'
|
||||
'شماره فیش: {receipt_number} · نسخه {revision_version}\n{receipt_link}')
|
||||
ALERT_TEMPLATE_HELP = 'تگهای مجاز: ' + '، '.join('{' + tag + '}' for tag in ALERT_TAGS)
|
||||
|
||||
|
||||
def validate_alert_template(value):
|
||||
if not value.strip():
|
||||
raise ValidationError('متن اعلان نمیتواند خالی باشد.')
|
||||
try:
|
||||
for _, field, spec, conversion in Formatter().parse(value):
|
||||
if field is not None and (field not in ALERT_TAGS or spec or conversion):
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise ValidationError('قالب معتبر نیست؛ فقط تگهای معرفیشده و بدون قالببندی اضافی مجازند.')
|
||||
@@ -4,6 +4,12 @@ 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 normalize_deposit_identifier(value):
|
||||
"""Remove leading zeroes from deposit IDs only; retain internal/check digits."""
|
||||
value = str(value or '').translate(str.maketrans('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩', '01234567890123456789')).strip()
|
||||
return (value.lstrip('0') or '0') if value else ''
|
||||
|
||||
|
||||
def generate_id_algorithm(id_code: str) -> str:
|
||||
"""
|
||||
Calculates the verification code based on the reverse-engineered logic:
|
||||
@@ -39,4 +45,4 @@ def generate_id_algorithm(id_code: str) -> str:
|
||||
# فرمتدهی دو رقمی (مثلاً 6 میشود 06)
|
||||
check_digit_str = f"{check_digit:02}"
|
||||
|
||||
return f"{id_code}{check_digit_str}"
|
||||
return normalize_deposit_identifier(f"{id_code}{check_digit_str}")
|
||||
@@ -8,6 +8,29 @@ from . import validators
|
||||
from .models import BankInstitution, CompanyBankAccount, PaymentNotice
|
||||
|
||||
|
||||
class ReceiptAlertConfigurationForm(forms.ModelForm):
|
||||
clear_bale_token = forms.BooleanField(label='پاک کردن توکن ذخیرهشده', required=False)
|
||||
|
||||
class Meta:
|
||||
from .models import PaymentReceiptAlertConfiguration
|
||||
model = PaymentReceiptAlertConfiguration
|
||||
fields = '__all__'
|
||||
widgets = {'bale_token': forms.PasswordInput(render_value=False)}
|
||||
help_texts = {'bale_token': 'برای حفظ توکن فعلی این فیلد را خالی بگذارید؛ برای جایگزینی، توکن تازه وارد کنید.'}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.initial['bale_token'] = ''
|
||||
|
||||
def clean(self):
|
||||
data = super().clean()
|
||||
if data.get('clear_bale_token'):
|
||||
data['bale_token'] = ''
|
||||
elif not data.get('bale_token') and self.instance.pk:
|
||||
data['bale_token'] = self.instance.bale_token
|
||||
return data
|
||||
|
||||
|
||||
class BankAccountForm(forms.ModelForm):
|
||||
revision_token = forms.CharField(required=False, widget=forms.HiddenInput)
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ def open_link(request, token):
|
||||
logout(request)
|
||||
if not request.user.is_authenticated:
|
||||
access.grant_payment_access(request, link)
|
||||
# Retain the token across redirects in the session; the link model still stores only its hash.
|
||||
request.session[access.SHARE_SESSION_KEY] = {'link_id': link.pk, 'token': token}
|
||||
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, {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def normalize_existing_identifiers(apps, schema_editor):
|
||||
digit_map = str.maketrans('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩', '01234567890123456789')
|
||||
for name in ('PaymentNotice', 'PaymentReceiptRevision'):
|
||||
records = apps.get_model('payments', name).objects.using(schema_editor.connection.alias)
|
||||
for pk, original in records.exclude(deposit_id='').values_list('pk', 'deposit_id').iterator(chunk_size=500):
|
||||
value = original.translate(digit_map).strip()
|
||||
normalized = (value.lstrip('0') or '0') if value else ''
|
||||
if normalized != original:
|
||||
records.filter(pk=pk).update(deposit_id=normalized)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [('payments', '0005_paymentnotice_deleted_at')]
|
||||
operations = [migrations.RunPython(normalize_existing_identifiers, migrations.RunPython.noop)]
|
||||
@@ -0,0 +1,73 @@
|
||||
# Generated by Django 5.1.1 on 2026-10-07 09:49
|
||||
|
||||
import apps.payments.alert_templates
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('notifications', '0018_alter_smstemplate_body'),
|
||||
('payments', '0006_normalize_deposit_identifiers'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PaymentReceiptAlertConfiguration',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('bale_enabled', models.BooleanField(default=False, verbose_name='ارسال تصویر فیش به بله')),
|
||||
('bale_token', models.CharField(blank=True, max_length=500, verbose_name='توکن ربات بله پرداخت')),
|
||||
('bale_chat_id', models.CharField(blank=True, max_length=150, verbose_name='شناسه گروه بله')),
|
||||
('bale_caption', models.TextField(default='فیش واریزی جدید\nمشتری: {customer_name}\nشماره تماس: {customer_mobile}\nمبلغ: {amount_rial} ریال\nزمان ارسال: {received_at}\nتاریخ واریز: {paid_date}\nشماره فیش: {receipt_number} · نسخه {revision_version}\n{receipt_link}', help_text='تگ\u200cهای مجاز: {amount_rial}، {customer_name}، {customer_mobile}، {received_at}، {paid_date}، {receipt_number}، {receipt_link}، {revision_version}، {manager_name}', max_length=3000, validators=[apps.payments.alert_templates.validate_alert_template], verbose_name='متن همراه تصویر')),
|
||||
('sms_enabled', models.BooleanField(default=False, verbose_name='پیامک فیش جدید به مدیران')),
|
||||
('sms_template', models.TextField(default='فیش واریزی به مبلغ {amount_rial} ریال توسط {customer_name} به شماره تماس {customer_mobile} ارسال شد.', help_text='تگ\u200cهای مجاز: {amount_rial}، {customer_name}، {customer_mobile}، {received_at}، {paid_date}، {receipt_number}، {receipt_link}، {revision_version}، {manager_name}', max_length=1000, validators=[apps.payments.alert_templates.validate_alert_template], verbose_name='متن پیامک مدیران')),
|
||||
('notify_revisions', models.BooleanField(default=True, verbose_name='اطلاع\u200cرسانی فیش اصلاح\u200cشده مشتری')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='آخرین ویرایش')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'تنظیمات اطلاع\u200cرسانی فیش مشتری',
|
||||
'verbose_name_plural': 'تنظیمات اطلاع\u200cرسانی فیش مشتری',
|
||||
'permissions': [('manage_receipt_alerts', 'Manage receipt alert settings and recipients')],
|
||||
'constraints': [models.CheckConstraint(condition=models.Q(('pk', 1)), name='pay_receipt_alert_singleton')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PaymentReceiptAlertRecipient',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=150, verbose_name='نام مدیر')),
|
||||
('mobile', models.CharField(max_length=20, verbose_name='شماره موبایل مدیر')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='دریافت پیامک فعال')),
|
||||
('configuration', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='recipients', to='payments.paymentreceiptalertconfiguration')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'مدیر دریافت\u200cکننده پیامک فیش',
|
||||
'verbose_name_plural': 'مدیران دریافت\u200cکننده پیامک فیش',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PaymentReceiptAlert',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('recipient_name', models.CharField(blank=True, max_length=150, verbose_name='نام گیرنده هنگام ثبت')),
|
||||
('recipient_mobile', models.CharField(blank=True, max_length=20, verbose_name='موبایل گیرنده هنگام ثبت')),
|
||||
('bale_chat_id', models.CharField(blank=True, max_length=150, verbose_name='گروه هنگام ثبت')),
|
||||
('context', models.JSONField()),
|
||||
('message_template', models.TextField(verbose_name='قالب هنگام ثبت')),
|
||||
('rendered_body', models.TextField(blank=True, verbose_name='متن ارسال')),
|
||||
('job', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='receipt_alert', to='notifications.notificationjob')),
|
||||
('revision', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='alerts', to='payments.paymentreceiptrevision')),
|
||||
('recipient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.paymentreceiptalertrecipient')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'سابقه اعلان فیش مشتری',
|
||||
'verbose_name_plural': 'سوابق اعلان فیش مشتری',
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='paymentreceiptalertrecipient',
|
||||
constraint=models.UniqueConstraint(fields=('configuration', 'mobile'), name='pay_receipt_alert_mobile'),
|
||||
),
|
||||
]
|
||||
@@ -4,6 +4,8 @@ from pathlib import Path
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from apps.core.models import PublicIdModel, TimeStampedModel
|
||||
from .deposit_identifier import normalize_deposit_identifier
|
||||
from .alert_templates import DEFAULT_RECEIPT_SMS, DEFAULT_RECEIPT_CAPTION, ALERT_TEMPLATE_HELP, validate_alert_template
|
||||
|
||||
MAX_AMOUNT = 9_007_199_254_740_991
|
||||
|
||||
@@ -87,6 +89,10 @@ class PaymentNotice(PublicIdModel, TimeStampedModel):
|
||||
cancelled_at = models.DateTimeField(null=True, blank=True)
|
||||
deleted_at = models.DateTimeField(null=True, blank=True, editable=False)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.deposit_id = normalize_deposit_identifier(self.deposit_id)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at', '-pk']
|
||||
constraints = [models.CheckConstraint(condition=models.Q(amount__gt=0, amount__lte=MAX_AMOUNT), name='pay_notice_amount')]
|
||||
@@ -145,6 +151,10 @@ class PaymentReceiptRevision(models.Model):
|
||||
original_name = models.CharField(max_length=255)
|
||||
file_size = models.PositiveBigIntegerField()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.deposit_id = normalize_deposit_identifier(self.deposit_id)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
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')]
|
||||
|
||||
@@ -233,3 +243,84 @@ class PaymentNotificationAttempt(models.Model):
|
||||
provider_code = models.CharField(max_length=50, blank=True)
|
||||
provider_id = models.CharField(max_length=500, blank=True)
|
||||
error = models.TextField(blank=True)
|
||||
|
||||
|
||||
class PaymentReceiptAlertConfiguration(models.Model):
|
||||
"""Independent, initially disabled destinations for customer receipt uploads."""
|
||||
bale_enabled = models.BooleanField('ارسال تصویر فیش به بله', default=False)
|
||||
bale_token = models.CharField('توکن ربات بله پرداخت', max_length=500, blank=True)
|
||||
bale_chat_id = models.CharField('شناسه گروه بله', max_length=150, blank=True)
|
||||
bale_caption = models.TextField('متن همراه تصویر', default=DEFAULT_RECEIPT_CAPTION,
|
||||
validators=[validate_alert_template], help_text=ALERT_TEMPLATE_HELP, max_length=3000)
|
||||
sms_enabled = models.BooleanField('پیامک فیش جدید به مدیران', default=False)
|
||||
sms_template = models.TextField('متن پیامک مدیران', default=DEFAULT_RECEIPT_SMS,
|
||||
validators=[validate_alert_template], help_text=ALERT_TEMPLATE_HELP, max_length=1000)
|
||||
notify_revisions = models.BooleanField('اطلاعرسانی فیش اصلاحشده مشتری', default=True)
|
||||
updated_at = models.DateTimeField('آخرین ویرایش', auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'تنظیمات اطلاعرسانی فیش مشتری'
|
||||
verbose_name_plural = 'تنظیمات اطلاعرسانی فیش مشتری'
|
||||
permissions = [('manage_receipt_alerts', 'Manage receipt alert settings and recipients')]
|
||||
constraints = [models.CheckConstraint(condition=models.Q(pk=1), name='pay_receipt_alert_singleton')]
|
||||
|
||||
def __str__(self):
|
||||
return 'اطلاعرسانی فیش مشتری'
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.pk = 1
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
from django.core.exceptions import ValidationError
|
||||
self.pk = 1
|
||||
self.bale_token = self.bale_token.strip()
|
||||
self.bale_chat_id = self.bale_chat_id.strip()
|
||||
if self.bale_token and any(char.isspace() or char in '/?#' for char in self.bale_token):
|
||||
raise ValidationError({'bale_token': 'توکن معتبر ربات را وارد کنید.'})
|
||||
if self.bale_enabled and (not self.bale_token or not self.bale_chat_id):
|
||||
raise ValidationError('برای فعال کردن بله، توکن ربات و شناسه گروه را تکمیل کنید.')
|
||||
|
||||
|
||||
class PaymentReceiptAlertRecipient(models.Model):
|
||||
configuration = models.ForeignKey(PaymentReceiptAlertConfiguration, on_delete=models.PROTECT, related_name='recipients')
|
||||
name = models.CharField('نام مدیر', max_length=150)
|
||||
mobile = models.CharField('شماره موبایل مدیر', max_length=20)
|
||||
is_active = models.BooleanField('دریافت پیامک فعال', default=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'مدیر دریافتکننده پیامک فیش'
|
||||
verbose_name_plural = 'مدیران دریافتکننده پیامک فیش'
|
||||
constraints = [models.UniqueConstraint(fields=['configuration', 'mobile'], name='pay_receipt_alert_mobile')]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def clean(self):
|
||||
from apps.accounts.models import normalize_mobile
|
||||
from django.core.exceptions import ValidationError
|
||||
from .validators import digits
|
||||
try:
|
||||
self.mobile = normalize_mobile(digits(self.mobile))
|
||||
except ValueError:
|
||||
raise ValidationError({'mobile': 'شماره موبایل معتبر مدیر را وارد کنید.'})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class PaymentReceiptAlert(models.Model):
|
||||
job = models.OneToOneField('notifications.NotificationJob', on_delete=models.PROTECT, related_name='receipt_alert')
|
||||
revision = models.ForeignKey(PaymentReceiptRevision, on_delete=models.PROTECT, related_name='alerts')
|
||||
recipient = models.ForeignKey(PaymentReceiptAlertRecipient, null=True, blank=True, on_delete=models.PROTECT)
|
||||
recipient_name = models.CharField('نام گیرنده هنگام ثبت', max_length=150, blank=True)
|
||||
recipient_mobile = models.CharField('موبایل گیرنده هنگام ثبت', max_length=20, blank=True)
|
||||
bale_chat_id = models.CharField('گروه هنگام ثبت', max_length=150, blank=True)
|
||||
context = models.JSONField()
|
||||
message_template = models.TextField('قالب هنگام ثبت')
|
||||
rendered_body = models.TextField('متن ارسال', blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'سابقه اعلان فیش مشتری'
|
||||
verbose_name_plural = 'سوابق اعلان فیش مشتری'
|
||||
@@ -9,6 +9,10 @@ EVENTS = {'PAYMENT_NOTICE_CREATED', 'PAYMENT_RECEIPT_APPROVED'}
|
||||
TAGS = ['recipient_name', 'company_name', 'notice_number', 'receipt_number', 'amount_rial', 'invoice_number', 'payment_link']
|
||||
|
||||
|
||||
def notice_sms_pending(notice):
|
||||
return PaymentNotification.objects.filter(notice=notice, job__status__in=['PENDING', 'PROCESSING', 'RETRYING']).exists()
|
||||
|
||||
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Receipt-only outbox handlers. No network operation runs during receipt registration."""
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.core.templatetags.jalali import jalali_date, jalali_datetime
|
||||
from apps.integrations.sms.base import SMSResult
|
||||
from apps.notifications.services import enqueue
|
||||
from .alert_templates import validate_alert_template
|
||||
from .models import PaymentReceiptAlert, PaymentReceiptAlertConfiguration
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
RECEIPT_ALERT_EVENT = 'PAYMENT_RECEIPT_RECEIVED'
|
||||
|
||||
|
||||
def receipt_alert_context(revision):
|
||||
customer = revision.receipt.customer
|
||||
profile = getattr(customer, 'customer_profile', None)
|
||||
name = f'{profile.first_name} {profile.last_name}'.strip() if profile else ''
|
||||
return {'customer_name': name or customer.get_full_name() or 'نام ثبت نشده',
|
||||
'customer_mobile': customer.local_mobile, 'amount_rial': f'{revision.amount:,}',
|
||||
'received_at': jalali_datetime(revision.created_at), 'paid_date': jalali_date(revision.paid_date),
|
||||
'receipt_number': revision.receipt.number, 'revision_version': str(revision.version), 'manager_name': '',
|
||||
'receipt_link': settings.PUBLIC_BASE_URL.rstrip('/') + reverse('staff_payments:receipt_detail', args=[revision.receipt.public_id])}
|
||||
|
||||
|
||||
def queue_receipt_alerts(revision, actor):
|
||||
if actor.account_type != 'CUSTOMER' or actor.pk != revision.receipt.customer_id:
|
||||
return
|
||||
configuration = PaymentReceiptAlertConfiguration.objects.filter(pk=1).first()
|
||||
if not configuration or not (configuration.bale_enabled or configuration.sms_enabled) or (revision.version > 1 and not configuration.notify_revisions):
|
||||
return
|
||||
context = receipt_alert_context(revision)
|
||||
destinations = []
|
||||
if configuration.bale_enabled:
|
||||
destinations.append(('BALE', None, configuration.bale_caption))
|
||||
if configuration.sms_enabled:
|
||||
destinations.extend(('SMS', recipient, configuration.sms_template) for recipient in configuration.recipients.filter(is_active=True))
|
||||
for channel, recipient, template in destinations:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
job = enqueue(channel, RECEIPT_ALERT_EVENT, None,
|
||||
f'receipt-alert:{revision.pk}:{channel}:{recipient.pk if recipient else "group"}', customer=revision.receipt.customer)
|
||||
PaymentReceiptAlert.objects.get_or_create(job=job, defaults={'revision': revision, 'recipient': recipient,
|
||||
'recipient_name': recipient.name if recipient else '', 'recipient_mobile': recipient.mobile if recipient else '',
|
||||
'bale_chat_id': configuration.bale_chat_id if channel == 'BALE' else '', 'message_template': template,
|
||||
'context': {**context, 'manager_name': recipient.name if recipient else ''}})
|
||||
except Exception as error:
|
||||
logger.error('Receipt alert destination queue failed revision=%s channel=%s type=%s', revision.pk, channel, type(error).__name__)
|
||||
|
||||
|
||||
def safe_queue_receipt_alerts(revision, actor):
|
||||
# A failed configuration lookup/queue write rolls back only this savepoint.
|
||||
try:
|
||||
with transaction.atomic():
|
||||
queue_receipt_alerts(revision, actor)
|
||||
except Exception as error:
|
||||
logger.error('Receipt alert queue failed revision=%s type=%s', revision.pk, type(error).__name__)
|
||||
|
||||
|
||||
def prepare_receipt_alert(job):
|
||||
configuration = PaymentReceiptAlertConfiguration.objects.filter(pk=1).first()
|
||||
alert = PaymentReceiptAlert.objects.select_related('recipient').get(job=job)
|
||||
if job.channel == 'BALE':
|
||||
enabled = configuration and configuration.bale_enabled and configuration.bale_chat_id == alert.bale_chat_id
|
||||
else:
|
||||
enabled = configuration and configuration.sms_enabled and alert.recipient and alert.recipient.is_active and alert.recipient.mobile == alert.recipient_mobile
|
||||
if enabled:
|
||||
return True
|
||||
job.status = 'CANCELLED'
|
||||
job.last_error = 'اطلاعرسانی این کانال یا گیرنده غیرفعال شده یا مقصد تغییر کرده است.'
|
||||
job.save(update_fields=['status', 'last_error'])
|
||||
return False
|
||||
|
||||
|
||||
def send_receipt_alert(job, sms_provider_factory=None):
|
||||
"""A destination failure is returned to the worker's existing bounded retry policy."""
|
||||
try:
|
||||
alert = PaymentReceiptAlert.objects.select_related('revision').get(job=job)
|
||||
if not alert.rendered_body:
|
||||
validate_alert_template(alert.message_template)
|
||||
alert.rendered_body = alert.message_template.format(**alert.context)
|
||||
alert.save(update_fields=['rendered_body'])
|
||||
if job.channel == 'SMS':
|
||||
from apps.integrations.sms.providers import get_sms_provider
|
||||
return (sms_provider_factory or get_sms_provider)().send_message(alert.recipient_mobile, alert.rendered_body)
|
||||
from apps.integrations.bale.providers import BaleBotProvider
|
||||
configuration = PaymentReceiptAlertConfiguration.objects.get(pk=1)
|
||||
if not configuration.bale_token:
|
||||
return SMSResult(False, error='توکن ربات بله پرداخت تنظیم نشده است.')
|
||||
if len(alert.rendered_body) > 4096:
|
||||
return SMSResult(False, error='متن همراه تصویر بله بیش از ۴۰۹۶ کاراکتر است.')
|
||||
with alert.revision.image.open('rb') as image:
|
||||
return BaleBotProvider().send_photo(alert.bale_chat_id, image, alert.rendered_body,
|
||||
token=configuration.bale_token, filename=f'receipt-{alert.revision.pk}{Path(alert.revision.image.name).suffix}',
|
||||
mime_type=alert.revision.mime_type)
|
||||
except Exception as error:
|
||||
# HTTP exception strings can contain credentials in the request URL. Never persist them.
|
||||
return SMSResult(False, error=f'خطا در ارسال اعلان فیش ({type(error).__name__})')
|
||||
@@ -5,6 +5,34 @@ from django.db.models.functions import Coalesce
|
||||
from .models import PaymentReceiptRevision
|
||||
|
||||
|
||||
def notice_sms_history(notice):
|
||||
"""Separate gateway acceptance from final handset delivery; preserve every send attempt."""
|
||||
from django.db.models import Prefetch
|
||||
from .models import PaymentNotificationAttempt
|
||||
notifications = notice.paymentnotification_set.select_related('job__staff_user__staff_profile').prefetch_related(
|
||||
Prefetch('attempt_log', queryset=PaymentNotificationAttempt.objects.order_by('-started_at', '-pk'), to_attr='send_attempts')
|
||||
).order_by('-job__created_at', '-pk')
|
||||
for notification in notifications:
|
||||
job = notification.job
|
||||
notification.manual_resend = ':resend:' in job.dedupe_key
|
||||
notification.delivery_class = 'info'
|
||||
if job.status != 'SENT':
|
||||
notification.delivery_label = 'هنوز ارسال موفق ثبت نشده است.'
|
||||
elif not job.provider_message_id:
|
||||
notification.delivery_label = 'شناسه رهگیری ارائه نشده؛ دریافت توسط گوشی قابل تأیید نیست.'
|
||||
elif not job.delivery_status:
|
||||
notification.delivery_label = 'دریافت توسط گوشی قابل تأیید نیست.'
|
||||
elif job.delivery_is_final:
|
||||
notification.delivery_label = job.get_delivery_status_display()
|
||||
notification.delivery_class = 'approved' if job.delivery_status == 'DELIVERED' else 'rejected'
|
||||
elif job.delivery_status == 'PENDING':
|
||||
notification.delivery_label = 'در انتظار استعلام دریافت توسط گوشی'
|
||||
else:
|
||||
suffix = 'در حال پیگیری' if job.delivery_next_check_at else 'دریافت توسط گوشی تأیید نشده است'
|
||||
notification.delivery_label = f'نتیجه غیرقطعی: {job.get_delivery_status_display()}؛ {suffix}'
|
||||
return notifications
|
||||
|
||||
|
||||
def notices_with_totals(queryset):
|
||||
approved = PaymentReceiptRevision.objects.filter(
|
||||
receipt__notice_id=OuterRef('pk'), receipt__status='APPROVED',
|
||||
|
||||
@@ -17,6 +17,36 @@ def actor_name(actor):
|
||||
return getattr(profile, 'display_name', '') or actor.get_full_name() or actor.username
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def resend_notice_sms(actor, notice, request_key):
|
||||
from uuid import UUID
|
||||
from .notifications import context, notice_sms_pending
|
||||
from .models import PaymentNotification
|
||||
from apps.notifications.services import enqueue
|
||||
if not policies.can_create(actor, notice.owning_unit) or not policies.notice_actions(PaymentNotice.objects.filter(pk=notice.pk), actor).exists():
|
||||
raise PermissionDenied
|
||||
try:
|
||||
key = str(UUID(str(request_key)))
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
raise ValidationError('درخواست ارسال معتبر نیست؛ صفحه را دوباره باز کنید.')
|
||||
# Same customer -> notice lock order as issuance, receipt submission and deletion.
|
||||
customer = User.objects.select_for_update().get(pk=notice.customer_id)
|
||||
notice = PaymentNotice.objects.select_for_update().get(pk=notice.pk)
|
||||
if notice.deleted_at or notice.cancelled_at or not customer.is_active or not customer.mobile:
|
||||
raise ValidationError('این اعلامیه یا مشتری امکان ارسال پیامک ندارد.')
|
||||
dedupe_key = f'payment-notice:{notice.public_id}:resend:{key}'
|
||||
previous = PaymentNotification.objects.filter(notice=notice, job__dedupe_key=dedupe_key).select_related('job').first()
|
||||
if previous:
|
||||
return previous.job
|
||||
if notice_sms_pending(notice):
|
||||
raise ValidationError('پیامک این اعلامیه در صف یا در حال ارسال است؛ تا پایان تلاش جاری منتظر بمانید.')
|
||||
job = enqueue('SMS', 'PAYMENT_NOTICE_CREATED', None, dedupe_key, customer=customer, staff_user=actor)
|
||||
PaymentNotification.objects.create(job=job, notice=notice, unit=notice.owning_unit,
|
||||
context=context(customer, notice=notice, amount=notice.amount))
|
||||
audit(actor, 'NOTICE_SMS_RESEND_REQUESTED', notice, {'job_id': job.pk})
|
||||
return job
|
||||
|
||||
|
||||
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 {})
|
||||
@@ -24,7 +54,10 @@ def audit(actor, action, obj, details=None):
|
||||
|
||||
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']
|
||||
snapshot = json.loads(serializers.serialize('json', [obj]))[0]['fields']
|
||||
if 'bale_token' in snapshot:
|
||||
snapshot['bale_token'] = '[configured]' if snapshot['bale_token'] else ''
|
||||
return snapshot
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
@@ -60,7 +93,8 @@ def issue_notice(actor, data):
|
||||
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', ''))
|
||||
from .deposit_identifier import normalize_deposit_identifier
|
||||
deposit_id = normalize_deposit_identifier(data.get('deposit_id', ''))
|
||||
if account.requires_deposit_id and not deposit_id:
|
||||
raise ValidationError('برای این حساب شناسه واریز مشتری الزامی است.')
|
||||
key = data.get('submission_key')
|
||||
@@ -108,9 +142,10 @@ def assign_cycle(revision, actor):
|
||||
|
||||
|
||||
def receipt_metadata(data):
|
||||
from .deposit_identifier import normalize_deposit_identifier
|
||||
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', ''),
|
||||
'deposit_id': normalize_deposit_identifier(data.get('deposit_id', '')), 'tracking_number': data.get('tracking_number', ''),
|
||||
'description': data.get('description', '')}
|
||||
|
||||
|
||||
@@ -156,6 +191,8 @@ def _submit_receipt(actor, customer, data, file_info, created_files):
|
||||
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})
|
||||
from .receipt_alerts import safe_queue_receipt_alerts
|
||||
safe_queue_receipt_alerts(revision, actor)
|
||||
return receipt
|
||||
|
||||
|
||||
@@ -302,6 +339,8 @@ def revise_receipt(actor, receipt_id, version, data):
|
||||
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})
|
||||
from .receipt_alerts import safe_queue_receipt_alerts
|
||||
safe_queue_receipt_alerts(revision, actor)
|
||||
return receipt
|
||||
except Exception:
|
||||
for storage,name in created_files:
|
||||
|
||||
@@ -9,6 +9,7 @@ urlpatterns = [
|
||||
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>/delete/', views.notice_delete, name='notice_delete'),
|
||||
path('notices/<uuid:public_id>/resend-sms/', views.notice_resend_sms, name='notice_resend_sms'),
|
||||
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'),
|
||||
|
||||
@@ -6,6 +6,7 @@ urlpatterns = [
|
||||
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('bank-accounts/<uuid:public_id>/share-link/', views.customer_notice_share_link, name='notice_share_link'),
|
||||
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'),
|
||||
|
||||
+50
-3
@@ -42,6 +42,8 @@ def home(request):
|
||||
|
||||
|
||||
def complete_notice(notice):
|
||||
from .deposit_identifier import normalize_deposit_identifier
|
||||
notice.deposit_id = normalize_deposit_identifier(notice.deposit_id)
|
||||
snapshot = notice.account_snapshot
|
||||
approved = getattr(notice, 'approved_total', None)
|
||||
if approved is None:
|
||||
@@ -57,6 +59,8 @@ def complete_notice(notice):
|
||||
('شماره حساب', snapshot.get('account_number')), ('شبا', snapshot.get('iban')),
|
||||
('کارت', snapshot.get('card_number')), ('شناسه واریز', notice.deposit_id),
|
||||
('فاکتور', notice.invoice_number), ('توضیحات', notice.description)] if value)
|
||||
if notice.deposit_id:
|
||||
notice.copy_text += '\nوارد کردن شناسه واریز هنگام پرداخت الزامی و اجباری است؛ آن را در فرم بانکی، موبایلبانک یا اینترنتبانک وارد کنید.'
|
||||
return notice
|
||||
|
||||
|
||||
@@ -84,6 +88,17 @@ def customer_notice_detail(request, public_id):
|
||||
notice=complete_notice(notice), open_notice=str(notice.public_id), financial_page=True))
|
||||
|
||||
|
||||
@require_POST
|
||||
@financial_required(allow_payment_link=True)
|
||||
def customer_notice_share_link(request, public_id):
|
||||
notice = get_object_or_404(access.visible_payments(request, PaymentNotice.objects.all()),
|
||||
public_id=public_id, customer__is_active=True)
|
||||
url = access.notice_share_url(request, notice)
|
||||
response = JsonResponse({'url': url})
|
||||
response['Cache-Control'] = 'private, no-store'
|
||||
return response
|
||||
|
||||
|
||||
def receipt_form_page(request, customer, staff=False):
|
||||
link = getattr(request, 'payment_access_link', None)
|
||||
initial = access.receipt_initial(request, customer)
|
||||
@@ -93,9 +108,13 @@ def receipt_form_page(request, customer, staff=False):
|
||||
try:
|
||||
receipt = services.submit_receipt(customer if link else request.user, customer, form.cleaned_data, access_link=link)
|
||||
messages.success(request, 'فیش ثبت شد و در انتظار بررسی است.')
|
||||
if request.headers.get('Accept') == 'application/json':
|
||||
return JsonResponse({'redirect_url': reverse('staff_payments:receipt_detail' if staff else 'payments:receipt_detail', args=[receipt.public_id])})
|
||||
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)
|
||||
if request.method == 'POST' and request.headers.get('Accept') == 'application/json':
|
||||
return JsonResponse({'errors': form.errors.get_json_data()}, status=422)
|
||||
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]
|
||||
@@ -254,6 +273,9 @@ def filtered(queryset, request):
|
||||
@financial_required(staff=True)
|
||||
def desk(request):
|
||||
tab = request.GET.get('tab', 'receipts')
|
||||
if tab not in {'receipts', 'notices'}:
|
||||
tab = 'receipts'
|
||||
mine = tab == 'receipts' and (request.GET.get('mine') == '1' or ('tab' not in request.GET and 'mine' not in request.GET))
|
||||
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':
|
||||
@@ -274,7 +296,7 @@ def desk(request):
|
||||
items = items.filter(notice__isnull=True)
|
||||
elif request.GET.get('kind') == 'linked':
|
||||
items = items.filter(notice__isnull=False)
|
||||
if request.GET.get('mine'):
|
||||
if 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'),
|
||||
@@ -289,7 +311,12 @@ def desk(request):
|
||||
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,
|
||||
query['tab'] = tab
|
||||
if mine:
|
||||
query['mine'] = '1'
|
||||
else:
|
||||
query.pop('mine', None)
|
||||
return render(request, 'payments/desk.html', staff_context(page_obj=page, tab=tab, mine=mine, statuses=PaymentReceipt.Status.choices,
|
||||
can_create=policies.can_create(request.user), banks=CompanyBankAccount.objects.all(), query_string=query.urlencode()))
|
||||
|
||||
|
||||
@@ -337,12 +364,28 @@ def notice_detail(request, public_id):
|
||||
request.session.pop('payment_link_share', None)
|
||||
else:
|
||||
share = {}
|
||||
from uuid import uuid4
|
||||
from .notifications import notice_sms_pending
|
||||
return render(request, 'payments/notice_detail.html', staff_context(notice=notice,
|
||||
can_delete_notice=policies.can_delete_notice(request.user, notice),
|
||||
may_delete_notice=policies.may_delete_notice(request.user, 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')))
|
||||
can_resend_notice_sms=policies.can_create(request.user, notice.owning_unit) and notice.customer.is_active and not notice.cancelled_at,
|
||||
notice_sms_pending=notice_sms_pending(notice), notice_sms_request_key=str(uuid4()),
|
||||
receipts=policies.visible(notice.receipts.all(), request.user), notifications=selectors.notice_sms_history(notice)))
|
||||
|
||||
|
||||
@financial_required(staff=True)
|
||||
@require_POST
|
||||
def notice_resend_sms(request, public_id):
|
||||
notice = get_object_or_404(policies.notice_actions(PaymentNotice.objects.all(), request.user), public_id=public_id)
|
||||
try:
|
||||
services.resend_notice_sms(request.user, notice, request.POST.get('request_key', ''))
|
||||
messages.success(request, 'درخواست ارسال مجدد پیامک در صف ثبت شد؛ نتیجه ارسال و دریافت در سوابق نمایش داده میشود.')
|
||||
except ValidationError as error:
|
||||
messages.error(request, '؛ '.join(error.messages))
|
||||
return redirect('staff_payments:notice_detail', public_id=public_id)
|
||||
|
||||
|
||||
@financial_required(staff=True)
|
||||
@@ -474,9 +517,13 @@ def receipt_revise(request, public_id):
|
||||
try:
|
||||
services.revise_receipt(request.payment_customer,receipt.public_id,int(request.POST.get('version','0')),form.cleaned_data)
|
||||
messages.success(request,'نسخه اصلاحی ثبت شد و دوباره بررسی میشود.')
|
||||
if request.headers.get('Accept') == 'application/json':
|
||||
return JsonResponse({'redirect_url': reverse('payments:receipt_detail', args=[receipt.public_id])})
|
||||
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 'نسخه فیش معتبر نیست.')
|
||||
if request.method == 'POST' and request.headers.get('Accept') == 'application/json':
|
||||
return JsonResponse({'errors': form.errors.get_json_data()}, status=422)
|
||||
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)]
|
||||
|
||||
Reference in new issue
Block a user