fix payment

This commit is contained in:
javad committed 2026-10-07 13:39:29 +03:30
1 parent 4f7486152c
commit bb3ddd1610
47 files changed
+2137 -51

No files matched your search

+20
View File
@@ -15,6 +15,26 @@ class ConsoleBaleProvider:
return NotificationResult(True, "console")
class BaleBotProvider:
def send_photo(self, chat_id, photo, caption='', *, token=None, filename='receipt.jpg', mime_type='image/jpeg'):
"""Upload private evidence bytes; do not expose a media URL or log a bot token."""
token = settings.BALE_BOT_TOKEN if token is None else token
if not token or not chat_id:
return NotificationResult(False, error='توکن یا گروه بله تنظیم نشده است.')
field, method = ('photo', 'sendPhoto') if mime_type in ('image/jpeg', 'image/png') else ('document', 'sendDocument')
try:
response = requests.post(f"{settings.BALE_API_BASE.rstrip('/')}/bot{token}/{method}",
data={'chat_id': chat_id, 'caption': caption}, files={field: (filename, photo, mime_type)},
timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS)
if not response.ok:
return NotificationResult(False, error=f'خطای HTTP بله: {response.status_code}')
data = response.json()
result = data.get('result') if isinstance(data, dict) else None
if data.get('ok') is not True or not isinstance(result, dict) or not result.get('message_id'):
return NotificationResult(False, error='بله ارسال تصویر را تأیید نکرد.')
return NotificationResult(True, str(result['message_id']))
except (requests.RequestException, ValueError, AttributeError) as error:
return NotificationResult(False, error=f'خطا در ارتباط با بله ({type(error).__name__})')
def send_message(self, chat_id, text):
if not chat_id: return NotificationResult(False, error="Bale chat id is not configured")
response = requests.post(f"{settings.BALE_API_BASE.rstrip('/')}/bot{settings.BALE_BOT_TOKEN}/sendMessage", json={"chat_id": chat_id, "text": text, "disable_web_page_preview": True}, timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS)
@@ -85,6 +85,9 @@ def _prepare_sms(job):
def _send_sms(job):
if job.event_type == 'PAYMENT_RECEIPT_RECEIVED':
from apps.payments.receipt_alerts import send_receipt_alert
return send_receipt_alert(job, sms_provider_factory=get_sms_provider), None
if job.event_type in {"PAYMENT_NOTICE_CREATED", "PAYMENT_RECEIPT_APPROVED"}:
from apps.payments.notifications import send_payment_sms
return send_payment_sms(job, provider_factory=get_sms_provider)
@@ -107,6 +110,9 @@ def _send_sms(job):
return get_sms_provider().send_message(job.customer.mobile, message), issued
def _send_bale(job):
if job.event_type == 'PAYMENT_RECEIPT_RECEIVED':
from apps.payments.receipt_alerts import send_receipt_alert
return send_receipt_alert(job)
unit_chat_id = (getattr(job.ticket.owning_unit, "bale_chat_id", "") or "").strip()
if job.event_type == "NEW_CUSTOMER_TICKET":
return get_bale_provider().send_message(unit_chat_id, _bale_ticket_text(job.ticket)) if unit_chat_id else None
@@ -349,6 +355,10 @@ class Command(BaseCommand):
for job in jobs:
issued_link = None
try:
if job.event_type == 'PAYMENT_RECEIPT_RECEIVED':
from apps.payments.receipt_alerts import prepare_receipt_alert
if not prepare_receipt_alert(job):
continue
if job.channel == NotificationJob.Channel.SMS and not _prepare_sms(job):
continue
if job.channel == NotificationJob.Channel.BALE and job.event_type == "CUSTOMER_TICKET_REPLY" and not _prepare_customer_reply_bale(job):
+19
View File
@@ -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,
+88
View File
@@ -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
+22
View File
@@ -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('قالب معتبر نیست؛ فقط تگ‌های معرفی‌شده و بدون قالب‌بندی اضافی مجازند.')
+7 -1
View File
@@ -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}")
+23
View File
@@ -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)
+2
View File
@@ -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'),
),
]
+91
View File
@@ -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 = 'سوابق اعلان فیش مشتری'
+4
View File
@@ -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,
+102
View File
@@ -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__})')
+28
View File
@@ -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',
+42 -3
View File
@@ -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:
+1
View File
@@ -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'),
+1
View File
@@ -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
View File
@@ -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)]