Files
customerTicket/tests/test_payments.py
T
2026-10-06 10:03:22 +03:30

719 lines
40 KiB
Python

import base64
import uuid
import jdatetime
import pytest
from django.contrib.auth.models import Permission
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from django.utils import timezone
from apps.accounts.models import User
from apps.staff.models import StaffProfile, OrganizationalUnit
from apps.attachments.models import AttachmentPolicy
from apps.notifications.models import NotificationJob, SMSTemplate
from apps.integrations.sms.providers import SMSResult
from apps.payments import services, policies
from apps.payments.forms import ReceiptForm, NoticeForm
from apps.payments.models import (BankInstitution, CompanyBankAccount, PaymentApprovalPolicy, PaymentReceipt,
PaymentAccessConfiguration, PaymentAuditEvent, ReceiptDecision, PaymentNotificationAttempt)
pytestmark = pytest.mark.django_db
PNG = base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=')
def upload():
return SimpleUploadedFile('receipt.png', PNG, content_type='image/png')
def grant(user, code):
user.user_permissions.add(Permission.objects.get(content_type__app_label='payments',codename=code))
for attr in ['_perm_cache','_user_perm_cache','_group_perm_cache']:
user.__dict__.pop(attr,None)
@pytest.fixture
def case(settings, tmp_path):
settings.MEDIA_ROOT = tmp_path
unit = OrganizationalUnit.objects.create(name='فروش مالی',slug='payment-sales')
users = {}
for key in ['creator','first','second','senior','outsider']:
user = User.objects.create_user(username='pay-'+key,account_type='STAFF',password='TestPayment123!')
StaffProfile.objects.create(user=user,display_name=key,primary_unit=unit)
users[key] = user
customer = User.objects.create_user(username='pay-customer',mobile='09120009999',account_type='CUSTOMER')
# Transactional PostgreSQL tests flush seed data between cases.
bank, _ = BankInstitution.objects.get_or_create(code='melli',defaults={'name':'ملی ایران'})
account = CompanyBankAccount.objects.create(name='حساب فروش ملی',holder='شرکت نمونه',bank=bank,account_number='000123456',requires_deposit_id=True)
AttachmentPolicy.objects.update_or_create(attachment_type='IMAGE',defaults={'max_file_size':10*1024*1024,'allowed_extensions':['.png'],'allowed_mime_types':['image/png']})
policy = PaymentApprovalPolicy.objects.create(name='دو تأیید',unit=unit)
policy.reviewers.add(users['first'],users['second']);policy.seniors.add(users['senior'])
for key in ['first','second']:
grant(users[key],'approve_paymentreceipt')
grant(users['senior'],'senior_approve_paymentreceipt')
notice = services.issue_notice(users['creator'],{'customer':customer,'account':account,'subject':'پرداخت فاکتور','amount':'۲۸۰٬۰۰۰٬۰۰۰','deposit_id':'۰۰۰۱۲۳','invoice_number':'INV-1','description':'توضیح همیشه نمایان'})
return {**users,'customer':customer,'account':account,'notice':notice,'policy':policy,'unit':unit}
def data(case, linked=True, **extra):
today = jdatetime.date.fromgregorian(date=timezone.localdate())
return {'receipt_mode':'linked' if linked else 'independent','notice_id':case['notice'].pk if linked else '',
'paid_amount':'۱۲۳۴۵','destination_account':case['account'].pk,'deposit_id':'000123',
'paid_day':str(today.day),'paid_month':str(today.month),'paid_year':str(today.year),
'submission_key':str(uuid.uuid4()), **extra}
def submit(case, linked=True):
form = ReceiptForm(data(case,linked),{'receipt_image':upload()},customer=case['customer'])
assert form.is_valid(), form.errors
return services.submit_receipt(case['customer'],case['customer'],form.cleaned_data)
def test_identifier_required_in_form_and_service(case):
values = {'customer':case['customer'].pk,'account':case['account'].pk,'subject':'فاکتور','amount':'100'}
form = NoticeForm(values)
assert not form.is_valid() and 'deposit_id' in form.errors
with pytest.raises(ValidationError):
services.issue_notice(case['creator'],{'customer':case['customer'],'account':case['account'],'subject':'فاکتور','amount':100})
assert NotificationJob.objects.filter(event_type='PAYMENT_NOTICE_CREATED').count() == 1
def test_multireviewer_and_final_sms_are_exactly_one(case):
receipt = submit(case)
services.decide_receipt(case['first'],receipt.public_id,1,'APPROVE')
receipt.refresh_from_db();assert receipt.status == 'PARTIAL'
assert not NotificationJob.objects.filter(event_type='PAYMENT_RECEIPT_APPROVED').exists()
services.decide_receipt(case['first'],receipt.public_id,1,'APPROVE')
assert ReceiptDecision.objects.count() == 1
services.decide_receipt(case['second'],receipt.public_id,1,'APPROVE')
receipt.refresh_from_db();assert receipt.status == 'APPROVED'
services.decide_receipt(case['second'],receipt.public_id,1,'APPROVE')
assert NotificationJob.objects.filter(event_type='PAYMENT_RECEIPT_APPROVED').count() == 1
assert receipt.revisions.get().cycle.finalized_by == case['second']
def test_senior_explicit_override_and_reason(case):
receipt = submit(case)
with pytest.raises(ValidationError):
services.decide_receipt(case['senior'],receipt.public_id,1,'SENIOR_APPROVE')
services.decide_receipt(case['senior'],receipt.public_id,1,'SENIOR_APPROVE','بررسی تطبیق انجام شد')
receipt.refresh_from_db();assert receipt.status == 'APPROVED'
assert ReceiptDecision.objects.get().actor_name == 'senior'
assert PaymentAuditEvent.objects.filter(action='SENIOR_APPROVE').exists()
@pytest.mark.parametrize('who',['customer','creator','outsider'])
def test_view_and_create_never_imply_approval(case,who):
receipt = submit(case)
with pytest.raises(PermissionDenied):
services.decide_receipt(case[who],receipt.public_id,1,'APPROVE')
def test_rejection_ends_cycle_and_preserves_evidence(case):
receipt = submit(case)
services.decide_receipt(case['first'],receipt.public_id,1,'REJECT','تصویر پرداخت خوانا نیست')
with pytest.raises(ValidationError):
services.decide_receipt(case['second'],receipt.public_id,1,'APPROVE')
with pytest.raises(ValidationError):
services.decide_receipt(case['senior'],receipt.public_id,1,'SENIOR_APPROVE','override')
assert not NotificationJob.objects.filter(event_type='PAYMENT_RECEIPT_APPROVED').exists()
def test_policy_snapshot_and_stale_version(case):
receipt = submit(case)
case['policy'].reviewers.clear()
with pytest.raises(ValidationError):
services.decide_receipt(case['first'],receipt.public_id,2,'APPROVE')
services.decide_receipt(case['first'],receipt.public_id,1,'APPROVE')
assert receipt.revisions.get().cycle.assignments.filter(is_senior=False).count() == 2
def test_snapshot_survives_bank_change(case,client):
case['account'].account_number = '9999999';case['account'].save()
case['notice'].refresh_from_db();assert case['notice'].account_snapshot['account_number'] == '000123456'
client.force_login(case['customer'])
page = client.get(reverse('payments:notices')).content.decode()
assert '000123456' in page and '9999999' not in page and 'توضیح همیشه نمایان' in page
def test_independent_receipt_and_server_date(case,client):
# Independent policies use global fallback, not an arbitrary unit's ticket rights.
case['policy'].unit=None;case['policy'].save()
receipt = submit(case,False)
assert receipt.notice is None
assert receipt.revisions.get().paid_date == timezone.localdate()
client.force_login(case['outsider'])
response = client.get(reverse('staff_payments:desk'),{'kind':'independent'})
assert receipt.number in response.content.decode()
assert client.get(reverse('staff_payments:count')).json()['pending_receipts'] == 1
@pytest.mark.parametrize('changes,field',[
({'paid_amount':'0'},'paid_amount'),({'paid_amount':'12.5'},'paid_amount'),({'paid_amount':'1,23'},'paid_amount'),
({'paid_day':''},'paid_day'),({'paid_date':'1990-01-01'},'paid_day'),({'destination_account':''},'destination_account')])
def test_validation_exact_fields(case,changes,field):
form = ReceiptForm(data(case,**changes),{'receipt_image':upload()},customer=case['customer'])
assert not form.is_valid() and field in form.errors
def test_cross_customer_notice_and_image_protection(case,client):
receipt=submit(case)
other=User.objects.create_user(username='other-customer',account_type='CUSTOMER')
client.force_login(other)
assert client.get(reverse('payments:receipt_detail',args=[receipt.public_id])).status_code == 404
assert client.get(reverse('payments:receipt_image',args=[receipt.public_id])).status_code == 403
form=ReceiptForm(data(case),{'receipt_image':upload()},customer=other)
assert not form.is_valid() and 'notice_id' in form.errors
client.force_login(case['customer'])
response=client.get(reverse('payments:receipt_image',args=[receipt.public_id]))
assert response.status_code == 200 and 'no-store' in response['Cache-Control']
assert b''.join(response.streaming_content) == PNG
def test_nonce_duplicate_is_idempotent_and_different_values_rejected(case):
values=data(case)
for i in range(2):
form=ReceiptForm(values,{'receipt_image':upload()},customer=case['customer']);assert form.is_valid()
services.submit_receipt(case['customer'],case['customer'],form.cleaned_data)
assert PaymentReceipt.objects.count() == 1
values['paid_amount']='999'
form=ReceiptForm(values,{'receipt_image':upload()},customer=case['customer']);assert form.is_valid()
with pytest.raises(ValidationError):
services.submit_receipt(case['customer'],case['customer'],form.cleaned_data)
def test_destination_flags_and_bank_permission(case,client):
case['account'].available_to_customers=False;case['account'].save()
linked=ReceiptForm(data(case),{'receipt_image':upload()},customer=case['customer']);assert linked.is_valid()
independent=ReceiptForm(data(case,False),{'receipt_image':upload()},customer=case['customer']);assert not independent.is_valid()
client.force_login(case['outsider']);assert client.get(reverse('staff_payments:banks')).status_code == 403
grant(case['creator'],'manage_company_bankaccounts');client.force_login(case['creator'])
assert client.get(reverse('staff_payments:banks')).status_code == 200
client.post(reverse('staff_payments:bank_bulk'),{'accounts':[case['account'].pk],'field':'is_active','value':'0'})
case['account'].refresh_from_db();assert not case['account'].is_active
assert PaymentAuditEvent.objects.filter(action='BANK_FLAG_CHANGED').exists()
def test_missing_policy_stays_unapproved_until_manager_assigns(case):
case['policy'].is_active=False;case['policy'].save()
receipt=submit(case)
assert not receipt.revisions.get().cycle.assignments.exists()
with pytest.raises(PermissionDenied):
services.decide_receipt(case['first'],receipt.public_id,1,'APPROVE')
case['policy'].is_active=True;case['policy'].save();grant(case['creator'],'manage_payment_approval_policy')
services.bind_unassigned_cycle(case['creator'],receipt)
assert receipt.revisions.get().cycle.assignments.count() == 3
def test_sms_uses_database_template_and_attempt_history(case,monkeypatch):
from apps.notifications.management.commands import process_notifications
template=SMSTemplate.objects.get(event_type='PAYMENT_NOTICE_CREATED',unit__isnull=True)
template.body='متن سفارشی {notice_number} {payment_link}';template.save()
captured=[]
class Provider:
def send_message(self,mobile,message):
captured.append(message);return SMSResult(True,'123')
monkeypatch.setattr(process_notifications,'get_sms_provider',lambda:Provider())
job=NotificationJob.objects.get(event_type='PAYMENT_NOTICE_CREATED')
process_notifications._send_sms(job)
template.body='جدید';template.save()
process_notifications._send_sms(job)
assert captured[0] == captured[1] and 'متن سفارشی' in captured[0] and '/chaharsu/' in captured[0]
assert PaymentNotificationAttempt.objects.filter(success=True).count() == 2
assert job.ticket is None
def test_explicit_access_and_inactive_reviewer(case):
receipt=submit(case)
PaymentAccessConfiguration.objects.update(open_view=False,open_create=False)
assert not policies.visible(PaymentReceipt.objects.all(),case['outsider']).exists()
assert not policies.can_create(case['outsider'])
StaffProfile.objects.filter(user=case['first']).update(is_active=False)
actor=User.objects.get(pk=case['first'].pk)
with pytest.raises(PermissionDenied):
services.decide_receipt(actor,receipt.public_id,1,'APPROVE')
def test_login_hub_and_existing_new_route(case,client):
client.force_login(case['customer'])
assert client.get('/').url == reverse('payments:home')
assert client.get(reverse('payments:home')).status_code == 200
assert client.get(reverse('tickets:new')).status_code == 200
assert reverse('tickets:new') == '/tickets/new/'
client.logout()
assert client.get(reverse('payments:home')).status_code == 302
def test_upload_spoof_and_missing_fields(case):
form=ReceiptForm(data(case),{'receipt_image':SimpleUploadedFile('fake.png',b'%PDF-1.4',content_type='image/png')},customer=case['customer'])
assert not form.is_valid() and 'receipt_image' in form.errors
form=ReceiptForm({},customer=case['customer'])
assert not form.is_valid()
assert {'paid_amount','paid_day','paid_month','paid_year','receipt_image','destination_account'} <= set(form.errors)
def test_revision_preserves_old_votes_and_restarts_full_policy(case):
receipt=submit(case)
services.decide_receipt(case['first'],receipt.public_id,1,'REJECT','نیاز به تصویر تازه')
form=ReceiptForm(data(case),{'receipt_image':upload()},customer=case['customer']);assert form.is_valid()
services.revise_receipt(case['customer'],receipt.public_id,1,form.cleaned_data)
receipt.refresh_from_db();assert receipt.status=='PENDING' and receipt.current_version==2
assert receipt.revisions.count()==2
assert receipt.revisions.get(version=1).cycle.decisions.count()==1
assert receipt.revisions.get(version=2).cycle.decisions.count()==0
with pytest.raises(ValidationError):
services.decide_receipt(case['second'],receipt.public_id,1,'APPROVE')
services.decide_receipt(case['first'],receipt.public_id,2,'APPROVE')
receipt.refresh_from_db();assert receipt.status=='PARTIAL'
def test_rollback_cleans_uploaded_file(case,settings):
case['policy'].reviewers.clear()
with pytest.raises(ValidationError):
submit(case)
assert PaymentReceipt.objects.count()==0
assert not list(settings.MEDIA_ROOT.rglob('*.png'))
def test_invalid_notice_query_is_not_server_error(case,client):
client.force_login(case['customer'])
assert client.get(reverse('payments:receipt_new'),{'notice':'not-a-uuid'}).status_code==404
def test_safe_login_next_and_magic_link_not_financial_session(case,client):
from apps.accounts.models import TicketMagicLink
from apps.tickets.models import TicketCategory
from apps.tickets.services import create_ticket
case['customer'].set_password('TestFinancial123!');case['customer'].save()
target=reverse('payments:notices')
client.get(reverse('accounts:password_login'),{'next':target})
response=client.post(reverse('accounts:password_login'),{'mobile':case['customer'].local_mobile,'password':'TestFinancial123!'})
assert response.url==target
client.logout();client.get(reverse('accounts:password_login'),{'next':'https://evil.example/chaharsu/'})
response=client.post(reverse('accounts:password_login'),{'mobile':case['customer'].local_mobile,'password':'TestFinancial123!'})
assert response.url==reverse('payments:home')
client.logout()
category=TicketCategory.objects.create(title='magic finance guard',slug='magic-finance-guard')
ticket=create_ticket(customer=case['customer'],category=category,subject='صرفاً تیکت',body='متن')
client.get(reverse('accounts:magic',args=[TicketMagicLink.issue(ticket)]))
assert client.get(reverse('payments:notices')).status_code==302
def test_notice_nonce_prevents_duplicate_issuance_and_sms(case):
values = {'customer':case['customer'], 'account':case['account'], 'subject':'دستور تازه',
'amount':1000, 'deposit_id':'0042', 'submission_key':uuid.uuid4()}
first = services.issue_notice(case['creator'], values)
second = services.issue_notice(case['creator'], values)
assert first.pk == second.pk
assert NotificationJob.objects.filter(event_type='PAYMENT_NOTICE_CREATED').count() == 2
with pytest.raises(ValidationError):
services.issue_notice(case['creator'], {**values, 'amount':2000})
def test_revision_retains_notice_snapshot_and_identifier(case):
receipt = submit(case)
services.decide_receipt(case['first'], receipt.public_id, 1, 'REJECT', 'مدرک تازه لازم است')
case['account'].account_number = '999999';case['account'].save()
form = ReceiptForm(data(case, deposit_id=''), {'receipt_image':upload()}, customer=case['customer'])
assert form.is_valid(), form.errors
services.revise_receipt(case['customer'], receipt.public_id, 1, form.cleaned_data)
revision = receipt.revisions.get(version=2)
assert revision.account_snapshot['account_number'] == '000123456'
assert revision.deposit_id == '000123'
def test_financial_unit_sms_template_accepts_financial_tags(case):
from apps.tickets.forms import UnitSMSTemplateForm
form = UnitSMSTemplateForm({'unit':case['unit'].pk, 'event_type':'PAYMENT_NOTICE_CREATED',
'title':'مالی', 'body':'{notice_number} {amount_rial} {payment_link}', 'is_active':True},
user=case['creator'], unit_ids=[case['unit'].pk])
assert form.is_valid(), form.errors
assert 'payment_link' in form.save().allowed_tags
def test_payment_posts_enforce_csrf_and_revoked_approval(case):
from django.test import Client
receipt = submit(case)
client = Client(enforce_csrf_checks=True)
client.force_login(case['first'])
endpoint = reverse('staff_payments:decision',args=[receipt.public_id])
assert client.post(endpoint, {'version':1,'action':'APPROVE'}).status_code == 403
case['first'].user_permissions.clear()
actor = User.objects.get(pk=case['first'].pk)
with pytest.raises(PermissionDenied):
services.decide_receipt(actor,receipt.public_id,1,'APPROVE')
receipt.refresh_from_db();assert receipt.status == 'PENDING'
assert not ReceiptDecision.objects.exists()
def test_receipt_identifier_is_optional_even_for_a_different_identifier_account(case):
other = CompanyBankAccount.objects.create(name='حساب شناسه‌دار دیگر',holder='شرکت',
bank=case['account'].bank,account_number='1111',requires_deposit_id=True)
form = ReceiptForm(data(case,destination_account=other.pk,deposit_id=''),
{'receipt_image':upload()},customer=case['customer'])
assert form.is_valid(), form.errors
receipt = services.submit_receipt(case['customer'], case['customer'], form.cleaned_data)
assert receipt.revisions.get(version=1).deposit_id == ''
@pytest.mark.parametrize('day,month,year,valid', [
('30','12','1403',True),('30','12','1402',False),('31','7','1403',False),
('31','6','1403',True),('','1','1403',False),
])
def test_jalali_calendar_validation_and_gregorian_result(day,month,year,valid):
from apps.payments import validators
import datetime
if valid:
assert isinstance(validators.paid_date(day,month,year), datetime.date)
else:
with pytest.raises(ValidationError):
validators.paid_date(day,month,year)
def test_failed_sms_template_preparation_has_attempt_history(case):
from apps.payments.notifications import send_payment_sms
SMSTemplate.objects.filter(event_type='PAYMENT_NOTICE_CREATED').update(is_active=False)
job = NotificationJob.objects.get(event_type='PAYMENT_NOTICE_CREATED')
with pytest.raises(SMSTemplate.DoesNotExist):
send_payment_sms(job,provider_factory=lambda:None)
attempt = PaymentNotificationAttempt.objects.get()
assert attempt.success is False and attempt.finished_at is not None
assert 'قالب پیامک' in attempt.error
def test_stale_bank_form_does_not_overwrite_new_flags(case,client):
grant(case['creator'],'manage_company_bankaccounts')
client.force_login(case['creator'])
account = case['account']
token = account.updated_at.isoformat()
account.is_active = False;account.save()
response = client.post(reverse('staff_payments:bank_edit',args=[account.public_id]),{
'revision_token':token,'name':'نام قدیمی','bank':account.bank_id,'holder':account.holder,
'account_number':account.account_number,'sort_order':0,'is_active':'on'})
assert response.status_code == 200
assert 'تغییر کرده است' in response.content.decode()
account.refresh_from_db();assert not account.is_active and account.name != 'نام قدیمی'
def test_customer_receipt_list_and_revised_action_queue(case,client):
receipt=submit(case)
services.decide_receipt(case['first'],receipt.public_id,1,'REJECT','مدرک تازه')
form=ReceiptForm(data(case),{'receipt_image':upload()},customer=case['customer']);assert form.is_valid()
services.revise_receipt(case['customer'],receipt.public_id,1,form.cleaned_data)
client.force_login(case['first'])
assert receipt.number in client.get(reverse('staff_payments:desk'),{'mine':'1'}).content.decode()
client.force_login(case['customer'])
assert receipt.number in client.get(reverse('payments:receipt_list')).content.decode()
def test_pwa_entry_routes_each_authenticated_role(case,client):
assert client.get(reverse('web_manifest')).json()['start_url'] == '/'
client.force_login(case['customer'])
assert client.get('/').url == reverse('payments:home')
client.force_login(case['creator'])
assert client.get('/').url == reverse('staff_tickets:list')
def test_financial_sms_model_clean_supplies_admin_readonly_tags(case):
template=SMSTemplate(unit=case['unit'],event_type='PAYMENT_RECEIPT_APPROVED',title='مالی',
body='{receipt_number} {payment_link}')
template.full_clean()
assert 'payment_link' in template.allowed_tags
template.body='{magic_link}'
with pytest.raises(ValidationError):
template.full_clean()
def test_cached_user_cannot_vote_after_permission_revocation(case):
receipt=submit(case)
actor=case['first']
assert actor.has_perm('payments.approve_paymentreceipt')
actor.user_permissions.clear()
with pytest.raises(PermissionDenied):
services.decide_receipt(actor,receipt.public_id,1,'APPROVE')
assert not ReceiptDecision.objects.exists()
def test_independent_receipt_bank_numbers_are_only_in_staff_review(case,client):
receipt=submit(case,False)
client.force_login(case['customer'])
content=client.get(reverse('payments:receipt_detail',args=[receipt.public_id])).content.decode()
assert 'شماره حساب مقصد ثبت‌شده' not in content
client.force_login(case['first'])
content=client.get(reverse('staff_payments:receipt_detail',args=[receipt.public_id])).content.decode()
assert 'شماره حساب مقصد ثبت‌شده' in content and '000123456' in content
def test_audit_permission_opens_readonly_evidence_admin(case):
from django.contrib import admin
from django.test import RequestFactory
from apps.payments.admin import EvidenceAdmin
request=RequestFactory().get('/x5admin/payments/paymentauditevent/')
request.user=case['outsider']
controller=EvidenceAdmin(PaymentAuditEvent,admin.site)
assert not controller.has_view_permission(request)
grant(request.user,'view_payment_audit')
assert controller.has_view_permission(request)
assert controller.get_queryset(request).exists()
assert not controller.has_change_permission(request)
assert not controller.has_delete_permission(request)
def test_admin_bank_audit_contains_previous_and_new_flags(case):
from django.contrib import admin
from django.test import RequestFactory
from apps.payments.admin import AccountAdmin
from apps.payments.forms import BankAccountForm
account=case['account']
grant(case['creator'],'manage_company_bankaccounts')
values={'revision_token':account.updated_at.isoformat(),'name':'حساب ویرایش‌شده','bank':account.bank_id,
'holder':account.holder,'account_number':account.account_number,'sort_order':0,
'requires_deposit_id':'on','available_to_staff':'on','available_to_customers':'on'}
form=BankAccountForm(values,instance=account)
assert form.is_valid(),form.errors
request=RequestFactory().post('/x5admin/payments/companybankaccount/')
request.user=case['creator']
AccountAdmin(CompanyBankAccount,admin.site).save_model(request,form.save(commit=False),form,True)
event=PaymentAuditEvent.objects.get(action='CONFIG_SAVED')
assert event.details['before']['is_active'] is True
assert event.details['after']['is_active'] is False
assert event.actor_id==request.user.pk
def test_customer_search_identity_history_and_permissions(case, client):
from apps.customers.models import CustomerProfile
from apps.tickets.models import Ticket, TicketCategory
customer = case['customer']
CustomerProfile.objects.create(user=customer, first_name='محمد', last_name='رضایی',
organization_name='شرکت آفتاب', city='قم')
category = TicketCategory.objects.create(title='جست‌وجوی مالی', slug='payment-picker')
visible = Ticket.objects.create(customer=customer, category=category, subject='سابقه خرید قابل مشاهده',
owner=case['creator'], selected_expert=case['creator'], origin_visibility='SELECTED', owning_unit=case['unit'], last_activity_at=timezone.now())
Ticket.objects.create(customer=customer, category=category, subject='سابقه محرمانه دیگر',
owner=case['outsider'], selected_expert=case['outsider'], origin_visibility='SELECTED', owning_unit=case['unit'], last_activity_at=timezone.now())
inactive = User.objects.create_user(username='inactive-picker', mobile='09123334444', is_active=False)
CustomerProfile.objects.create(user=inactive, first_name='محمد', last_name='رضایی')
client.force_login(case['creator'])
url = reverse('staff_payments:customer_search')
for query in ['محمد رضایی', 'شرکت آفتاب', '۰۹۱۲۰۰۰۹۹۹۹', '0912', visible.ticket_number, 'سابقه خرید قابل مشاهده']:
response = client.get(url, {'q': query})
assert response.status_code == 200
assert 'no-store' in response['Cache-Control']
result = response.json()['results'][0]
assert result['id'] == str(customer.public_id)
assert result['name'] == 'محمد رضایی'
assert result['mobile'] == customer.local_mobile
assert result['ticket_count'] == 1
assert [row['subject'] for row in result['history']] == [visible.subject]
assert client.get(url, {'q': 'سابقه محرمانه دیگر'}).json()['results'] == []
assert client.get(url, {'q': 'م'}).json()['results'] == []
configuration = PaymentAccessConfiguration.objects.first()
configuration.open_create = False
configuration.save()
assert client.get(url, {'q': 'محمد'}).status_code == 403
client.force_login(customer)
assert client.get(url, {'q': 'محمد'}).status_code == 403
def test_notice_customer_prefill_uses_uuid_and_no_full_directory(case, client):
client.force_login(case['creator'])
url = reverse('staff_payments:notice_new')
response = client.get(url, {'customer': str(case['customer'].public_id)})
assert response.status_code == 200
assert response.context['form']['customer'].value() == case['customer'].pk
assert response.context['customer_selection']['id'] == str(case['customer'].public_id)
html = response.content.decode()
assert 'type="hidden" name="customer"' in html
assert '<select name="customer"' not in html
assert client.get(url, {'customer': 'invalid'}).status_code == 404
assert client.get(url, {'customer': str(uuid.uuid4())}).status_code == 404
case['customer'].is_active = False
case['customer'].save()
assert client.get(url, {'customer': str(case['customer'].public_id)}).status_code == 404
def test_payment_shortcuts_on_contact_and_ticket(case, client):
from apps.tickets.models import Ticket, TicketCategory
ticket = Ticket.objects.create(customer=case['customer'], category=TicketCategory.objects.create(title='میان‌بر مالی', slug='payment-shortcut'),
subject='تیکت برای ایجاد اعلامیه', owner=case['creator'], selected_expert=case['creator'],
origin_visibility='SELECTED', owning_unit=case['unit'], last_activity_at=timezone.now())
client.force_login(case['creator'])
target = reverse('staff_payments:notice_new') + '?customer=' + str(case['customer'].public_id)
for url in [reverse('staff_tickets:detail', args=[ticket.public_id]), reverse('staff_tickets:contacts')]:
response = client.get(url)
assert response.status_code == 200
assert target in response.content.decode()
configuration = PaymentAccessConfiguration.objects.first()
configuration.open_create = False
configuration.save()
assert target not in client.get(reverse('staff_tickets:detail', args=[ticket.public_id])).content.decode()
def test_ticket_recent_notices_are_customer_scoped_and_permission_filtered(case, client):
from apps.payments.models import PaymentNotice
from apps.tickets.models import Ticket, TicketCategory
ticket = Ticket.objects.create(customer=case['customer'],
category=TicketCategory.objects.create(title='اعلامیه‌ها', slug='recent-notices'),
subject='اعلامیه‌های مشتری', owner=case['creator'], selected_expert=case['creator'],
origin_visibility='SELECTED', owning_unit=case['unit'], last_activity_at=timezone.now())
notices = [services.issue_notice(case['creator'], {
'customer': case['customer'], 'account': case['account'], 'subject': f'اعلامیه اخیر {index}',
'amount': 12345, 'deposit_id': '000123',
}) for index in range(3)]
notices[-1].cancelled_at = timezone.now()
notices[-1].save(update_fields=['cancelled_at'])
other = User.objects.create_user(username='other-notice-customer', account_type='CUSTOMER')
foreign = services.issue_notice(case['creator'], {'customer': other, 'account': case['account'],
'subject': 'اعلامیه مشتری دیگر', 'amount': 100, 'deposit_id': '000123'})
receipt = submit(case)
# Only confirmed current receipt amounts count towards settlement.
PaymentReceipt.objects.filter(pk=receipt.pk).update(notice=notices[0], status='APPROVED')
client.force_login(case['creator'])
url = reverse('staff_tickets:detail', args=[ticket.public_id])
response = client.get(url)
assert response.status_code == 200
assert [notice.pk for notice in response.context['customer_notices']] == [n.pk for n in reversed(notices)]
html = response.content.decode()
assert case['notice'].number not in html
assert foreign.number not in html
assert 'لغو شده' in html and 'تسویه شده' in html and 'در انتظار واریز' in html
for notice in notices:
assert reverse('staff_payments:notice_detail', args=[notice.public_id]) in html
more = reverse('staff_payments:desk') + f'?tab=notices&customer={case["customer"].pk}'
assert more.replace('&', '&amp;') in html
assert client.get(more).context['page_obj'].paginator.count == 4
configuration = PaymentAccessConfiguration.objects.first()
configuration.open_view = False
configuration.save()
hidden = client.get(url)
assert not list(hidden.context['customer_notices'])
assert all(notice.number not in hidden.content.decode() for notice in notices)
def test_customer_notice_menu_is_ten_recent_pending_and_retains_old_link(case, client):
from apps.payments.models import PaymentNotice
for index in range(12):
services.issue_notice(case['creator'], {'customer': case['customer'], 'account': case['account'],
'subject': f'واریز {index}', 'amount': 20000, 'deposit_id': '123'})
cancelled = PaymentNotice.objects.filter(customer=case['customer']).first()
cancelled.cancelled_at = timezone.now()
cancelled.save()
client.force_login(case['customer'])
response = client.get(reverse('payments:receipt_new'))
choices = response.context['notice_data']
assert len(choices) == 10
assert str(cancelled.pk) not in {n['id'] for n in choices}
assert str(case['notice'].pk) not in {n['id'] for n in choices}
direct = client.get(reverse('payments:receipt_new'), {'notice': str(case['notice'].public_id)})
assert len(direct.context['notice_data']) == 10
assert str(case['notice'].pk) in {n['id'] for n in direct.context['notice_data']}
def test_linked_receipt_rejection_history_and_staff_identity(case, client):
from apps.customers.models import CustomerProfile
CustomerProfile.objects.update_or_create(user=case['customer'], defaults={
'first_name': 'مریم', 'last_name': 'امینی', 'province': 'اصفهان', 'city': 'گلپایگان'})
receipt = submit(case)
services.decide_receipt(case['first'], receipt.public_id, 1, 'APPROVE', 'مبلغ مطابقت دارد')
services.decide_receipt(case['second'], receipt.public_id, 1, 'REJECT', 'تصویر رسید خوانا نیست')
client.force_login(case['customer'])
overview = client.get(reverse('payments:notices'), {'notice': str(case['notice'].public_id)}).content.decode()
assert str(receipt.public_id) in overview and 'تصویر رسید خوانا نیست' in overview
detail = client.get(reverse('payments:receipt_detail', args=[receipt.public_id])).content.decode()
assert 'مبلغ مطابقت دارد' in detail and 'تصویر رسید خوانا نیست' in detail
assert 'افراد تعیین‌شده' not in detail and 'سوابق ارسال و اصلاح' in detail
client.force_login(case['second'])
staff = client.get(reverse('staff_payments:receipt_detail', args=[receipt.public_id])).content.decode()
assert all(value in staff for value in ['مریم', 'امینی', 'اصفهان', 'گلپایگان', case['customer'].local_mobile])
assert 'افراد تعیین‌شده' not in staff
listing = client.get(reverse('staff_payments:desk'), {'q': 'گلپایگان'}).content.decode()
assert 'مریم' in listing and 'ایجادکننده اعلامیه: creator' in listing
def test_settled_notice_excluded_and_approvers_shown_on_staff_list(case, client):
from apps.payments.models import PaymentNotice
receipt = submit(case)
PaymentNotice.objects.filter(pk=case['notice'].pk).update(amount=12345)
services.decide_receipt(case['first'], receipt.public_id, 1, 'APPROVE', 'تطبیق مبلغ')
services.decide_receipt(case['second'], receipt.public_id, 1, 'APPROVE', 'تطبیق تصویر')
client.force_login(case['customer'])
response = client.get(reverse('payments:receipt_new'))
assert not response.context['notice_data']
client.force_login(case['first'])
for tab in ['receipts', 'notices']:
content = client.get(reverse('staff_payments:desk'), {'tab': tab}).content.decode()
assert 'first' in content and 'second' in content and 'creator' in content
number = receipt.number if tab == 'receipts' else case['notice'].number
found = client.get(reverse('staff_payments:desk'), {'tab': tab, 'q': number})
assert found.context['page_obj'].paginator.count == 1
settled = client.get(reverse('staff_payments:desk'), {'tab': 'notices', 'notice_state': 'settled'})
assert settled.context['page_obj'].paginator.count == 1
pending = client.get(reverse('staff_payments:desk'), {'tab': 'notices', 'notice_state': 'pending'})
assert pending.context['page_obj'].paginator.count == 0
def test_assigned_senior_can_reject_with_reason_and_unassigned_cannot(case, client):
receipt = submit(case)
client.force_login(case['senior'])
page = client.get(reverse('staff_payments:receipt_detail', args=[receipt.public_id])).content.decode()
assert 'value="REJECT"' in page and 'value="SENIOR_APPROVE"' in page
with pytest.raises(ValidationError):
services.decide_receipt(case['senior'], receipt.public_id, 1, 'REJECT', '')
grant(case['outsider'], 'senior_approve_paymentreceipt')
with pytest.raises(PermissionDenied):
services.decide_receipt(case['outsider'], receipt.public_id, 1, 'REJECT', 'توضیح')
services.decide_receipt(case['senior'], receipt.public_id, 1, 'REJECT', 'اطلاعات رسید با پرداخت مطابقت ندارد')
receipt.refresh_from_db()
assert receipt.status == 'REJECTED'
assert ReceiptDecision.objects.get(cycle__revision__receipt=receipt).actor_name == 'senior'
assert not NotificationJob.objects.filter(event_type='PAYMENT_RECEIPT_APPROVED').exists()
def test_customer_notice_has_independent_owned_detail_url(case, client):
receipt = submit(case)
url = reverse('payments:notice_detail', args=[case['notice'].public_id])
client.force_login(case['customer'])
listing = client.get(reverse('payments:notices')).content.decode()
assert url in listing
detail = client.get(url)
assert detail.status_code == 200
content = detail.content.decode()
assert 'توضیح همیشه نمایان' in content and str(receipt.public_id) in content
form = client.get(reverse('payments:receipt_new'), {'notice': str(case['notice'].public_id)})
assert form.context['notice_data'][0]['url'] == url
other = User.objects.create_user(username='other-notice-customer', account_type='CUSTOMER')
client.force_login(other)
assert client.get(url).status_code == 404
client.force_login(case['creator'])
assert client.get(url).status_code == 403
staff = client.get(reverse('staff_payments:receipt_new', args=[case['customer'].public_id]), {'notice': str(case['notice'].public_id)})
assert staff.context['notice_data'][0]['url'] == reverse('staff_payments:notice_detail', args=[case['notice'].public_id])
@pytest.mark.parametrize('mobile,expected', [('09120009999','0912000999938'), ('09120009021','0912000902169'), ('09000000000','0900000000090')])
def test_deposit_identifier_reference_vectors(mobile, expected):
from apps.payments.deposit_identifier import generate_id_algorithm
assert generate_id_algorithm(mobile) == expected
def test_deposit_identifier_endpoint_uses_stored_mobile_and_permissions(case, client):
from django.test import Client
endpoint = reverse('staff_payments:generate_identifier')
client.force_login(case['creator'])
before = case['customer'].payment_notices.count()
response = client.post(endpoint, {'customer': case['customer'].pk, 'mobile': '09000000000'})
assert response.status_code == 200
assert response.json()['identifier'] == '0912000999938'
assert response['Cache-Control'] == 'no-store'
assert case['customer'].payment_notices.count() == before
assert client.get(endpoint).status_code == 405
assert client.post(endpoint, {'customer': case['outsider'].pk}).status_code == 400
csrf_client = Client(enforce_csrf_checks=True)
csrf_client.force_login(case['creator'])
assert csrf_client.post(endpoint, {'customer': case['customer'].pk}).status_code == 403
client.force_login(case['customer'])
assert client.post(endpoint, {'customer': case['customer'].pk}).status_code == 403
client.force_login(case['creator'])
PaymentAccessConfiguration.objects.update_or_create(singleton=1, defaults={'open_create':False})
assert client.post(endpoint, {'customer': case['customer'].pk}).status_code == 403