From de3135b5888b5d833ef0e3225202967cada1ee25 Mon Sep 17 00:00:00 2001 From: javad Date: Tue, 6 Oct 2026 10:03:22 +0330 Subject: [PATCH] add payment_link and banks --- PROJECT_TREE.txt | 132 +++- alghortim_shenase.py | 36 + apps/accounts/magic_access.py | 6 +- apps/accounts/views.py | 51 +- apps/core/session_access.py | 12 + apps/notifications/admin.py | 7 +- .../commands/process_notifications.py | 3 + .../0017_alter_smstemplate_event_type.py | 18 + .../migrations/0018_alter_smstemplate_body.py | 31 + apps/notifications/models.py | 28 +- apps/payments/__init__.py | 0 apps/payments/access.py | 130 ++++ apps/payments/admin.py | 128 ++++ apps/payments/apps.py | 7 + apps/payments/context_processors.py | 15 + apps/payments/customer_picker.py | 77 ++ apps/payments/deposit_identifier.py | 42 + apps/payments/forms.py | 127 ++++ apps/payments/link_views.py | 40 + apps/payments/middleware.py | 53 ++ apps/payments/migrations/0001_initial.py | 269 +++++++ .../migrations/0002_initial_configuration.py | 23 + .../0003_paymentnotice_issuance_key.py | 19 + ...aymentnotification_access_link_and_more.py | 46 ++ apps/payments/migrations/__init__.py | 0 apps/payments/models.py | 234 ++++++ apps/payments/notifications.py | 71 ++ apps/payments/policies.py | 64 ++ apps/payments/selectors.py | 78 ++ apps/payments/services.py | 286 +++++++ apps/payments/staff_urls.py | 22 + apps/payments/urls.py | 14 + apps/payments/validators.py | 93 +++ apps/payments/views.py | 466 ++++++++++++ apps/tickets/forms.py | 9 + apps/tickets/views.py | 10 +- config/settings/base.py | 9 +- config/urls.py | 14 +- docker/nginx.conf | 6 + docs/CHAHARSU_PAYMENTS_IMPLEMENTATION.md | 105 +++ docs/CHAHARSU_PAYMENTS_IMPLEMENTATION_PLAN.md | 401 ++++++++++ docs/PAYMENT_LINKS.md | 33 + static/css/chaharsu.css | 121 +++ static/css/magic-link-confirmation.css | 24 + static/css/operations.css | 18 + static/css/payment-link.css | 10 + static/js/chaharsu/bank-selection.js | 25 + static/js/chaharsu/customer-picker.js | 94 +++ static/js/chaharsu/customer-receipt.js | 44 ++ static/js/chaharsu/deposit-identifier.js | 65 ++ static/js/chaharsu/payments.js | 20 + static/js/chaharsu/receipt-amount.js | 78 ++ static/js/chaharsu/receipt-form.js | 70 ++ static/js/magic-link-confirmation.js | 21 + static/js/sms-template-tags.js | 16 + .../accounts/magic_link_confirmation.html | 30 + templates/base.html | 15 +- templates/payments/_customer_identity.html | 4 + templates/payments/_customer_picker.html | 12 + .../payments/_customer_receipt_form.html | 39 + templates/payments/_field.html | 7 + templates/payments/_icon.html | 17 + templates/payments/_link_status.html | 2 + templates/payments/_linked_receipts.html | 8 + templates/payments/_notice.html | 19 + templates/payments/_notice_shortcut.html | 1 + templates/payments/_pagination.html | 1 + templates/payments/_receipt_field.html | 6 + templates/payments/_ticket_notices.html | 20 + templates/payments/banks.html | 45 ++ templates/payments/base.html | 36 + .../payments/customer_notice_detail.html | 9 + templates/payments/desk.html | 59 ++ templates/payments/home.html | 25 + templates/payments/link_confirmation.html | 4 + templates/payments/link_unavailable.html | 5 + templates/payments/notice_detail.html | 17 + templates/payments/notices.html | 9 + templates/payments/receipt_detail.html | 29 + templates/payments/receipt_form.html | 3 + templates/payments/receipt_list.html | 3 + templates/payments/staff_form.html | 38 + templates/tickets/staff_contacts.html | 2 +- templates/tickets/staff_detail.html | 2 +- templates/tickets/staff_sms_templates.html | 5 +- tests/e2e/pages/auth.py | 2 +- tests/e2e/pages/magic_link.py | 44 ++ tests/e2e/pages/payment_link.py | 40 + tests/e2e/pages/payments.py | 33 + tests/e2e/pages/ticket_notices.py | 26 + tests/e2e/test_external_environment_smoke.py | 2 +- tests/e2e/test_magic_link_confirmation_e2e.py | 75 ++ tests/e2e/test_payment_links_e2e.py | 41 + tests/e2e/test_payments_e2e.py | 361 +++++++++ tests/e2e/test_ticket_notices_e2e.py | 53 ++ tests/test_magic_link_confirmation.py | 211 +++++ tests/test_payment_concurrency.py | 49 ++ tests/test_payment_links.py | 260 +++++++ tests/test_payments.py | 718 ++++++++++++++++++ tests/test_views.py | 6 +- 100 files changed, 6167 insertions(+), 47 deletions(-) create mode 100644 alghortim_shenase.py create mode 100644 apps/core/session_access.py create mode 100644 apps/notifications/migrations/0017_alter_smstemplate_event_type.py create mode 100644 apps/notifications/migrations/0018_alter_smstemplate_body.py create mode 100644 apps/payments/__init__.py create mode 100644 apps/payments/access.py create mode 100644 apps/payments/admin.py create mode 100644 apps/payments/apps.py create mode 100644 apps/payments/context_processors.py create mode 100644 apps/payments/customer_picker.py create mode 100644 apps/payments/deposit_identifier.py create mode 100644 apps/payments/forms.py create mode 100644 apps/payments/link_views.py create mode 100644 apps/payments/middleware.py create mode 100644 apps/payments/migrations/0001_initial.py create mode 100644 apps/payments/migrations/0002_initial_configuration.py create mode 100644 apps/payments/migrations/0003_paymentnotice_issuance_key.py create mode 100644 apps/payments/migrations/0004_paymentaccesslink_paymentnotification_access_link_and_more.py create mode 100644 apps/payments/migrations/__init__.py create mode 100644 apps/payments/models.py create mode 100644 apps/payments/notifications.py create mode 100644 apps/payments/policies.py create mode 100644 apps/payments/selectors.py create mode 100644 apps/payments/services.py create mode 100644 apps/payments/staff_urls.py create mode 100644 apps/payments/urls.py create mode 100644 apps/payments/validators.py create mode 100644 apps/payments/views.py create mode 100644 docs/CHAHARSU_PAYMENTS_IMPLEMENTATION.md create mode 100644 docs/CHAHARSU_PAYMENTS_IMPLEMENTATION_PLAN.md create mode 100644 docs/PAYMENT_LINKS.md create mode 100644 static/css/chaharsu.css create mode 100644 static/css/magic-link-confirmation.css create mode 100644 static/css/payment-link.css create mode 100644 static/js/chaharsu/bank-selection.js create mode 100644 static/js/chaharsu/customer-picker.js create mode 100644 static/js/chaharsu/customer-receipt.js create mode 100644 static/js/chaharsu/deposit-identifier.js create mode 100644 static/js/chaharsu/payments.js create mode 100644 static/js/chaharsu/receipt-amount.js create mode 100644 static/js/chaharsu/receipt-form.js create mode 100644 static/js/magic-link-confirmation.js create mode 100644 static/js/sms-template-tags.js create mode 100644 templates/accounts/magic_link_confirmation.html create mode 100644 templates/payments/_customer_identity.html create mode 100644 templates/payments/_customer_picker.html create mode 100644 templates/payments/_customer_receipt_form.html create mode 100644 templates/payments/_field.html create mode 100644 templates/payments/_icon.html create mode 100644 templates/payments/_link_status.html create mode 100644 templates/payments/_linked_receipts.html create mode 100644 templates/payments/_notice.html create mode 100644 templates/payments/_notice_shortcut.html create mode 100644 templates/payments/_pagination.html create mode 100644 templates/payments/_receipt_field.html create mode 100644 templates/payments/_ticket_notices.html create mode 100644 templates/payments/banks.html create mode 100644 templates/payments/base.html create mode 100644 templates/payments/customer_notice_detail.html create mode 100644 templates/payments/desk.html create mode 100644 templates/payments/home.html create mode 100644 templates/payments/link_confirmation.html create mode 100644 templates/payments/link_unavailable.html create mode 100644 templates/payments/notice_detail.html create mode 100644 templates/payments/notices.html create mode 100644 templates/payments/receipt_detail.html create mode 100644 templates/payments/receipt_form.html create mode 100644 templates/payments/receipt_list.html create mode 100644 templates/payments/staff_form.html create mode 100644 tests/e2e/pages/magic_link.py create mode 100644 tests/e2e/pages/payment_link.py create mode 100644 tests/e2e/pages/payments.py create mode 100644 tests/e2e/pages/ticket_notices.py create mode 100644 tests/e2e/test_magic_link_confirmation_e2e.py create mode 100644 tests/e2e/test_payment_links_e2e.py create mode 100644 tests/e2e/test_payments_e2e.py create mode 100644 tests/e2e/test_ticket_notices_e2e.py create mode 100644 tests/test_magic_link_confirmation.py create mode 100644 tests/test_payment_concurrency.py create mode 100644 tests/test_payment_links.py create mode 100644 tests/test_payments.py diff --git a/PROJECT_TREE.txt b/PROJECT_TREE.txt index 7794076..16b9bc0 100644 --- a/PROJECT_TREE.txt +++ b/PROJECT_TREE.txt @@ -1,11 +1,11 @@ Project Root: customerTicket -Generated At: 2026-10-04T06:10:18+00:00 -Python Files: 214 +Generated At: 2026-10-05T09:09:24+00:00 +Python Files: 223 Django Files: 42 -JavaScript/TypeScript Files: 21 -HTML Files: 33 -Total Files: 841 -Total Directories: 102 +JavaScript/TypeScript Files: 28 +HTML Files: 45 +Total Files: 951 +Total Directories: 106 customerTicket ├── .agents @@ -196,6 +196,7 @@ customerTicket │ │ │ ├── 0014_merge_legacy_warehouse_units.py │ │ │ ├── 0015_seed_customer_category_catalog.py │ │ │ ├── 0016_scope_legacy_public_tickets_to_units.py +│ │ │ ├── 0017_ticketaccessgrant_first_viewed_at_and_more.py │ │ │ └── __init__.py │ │ ├── __init__.py │ │ ├── admin.py @@ -207,6 +208,9 @@ customerTicket │ │ ├── policies.py │ │ ├── selectors.py │ │ ├── services.py +│ │ ├── sharing.py +│ │ ├── sharing_views.py +│ │ ├── staff_conversation.py │ │ ├── staff_urls.py │ │ └── views.py │ └── __init__.py @@ -222,6 +226,34 @@ customerTicket │ ├── urls.py │ └── wsgi.py ├── design-prototypes +│ ├── chaharsu +│ │ ├── accounts-desktop.png +│ │ ├── accounts-list-mobile.png +│ │ ├── accounts-mobile.png +│ │ ├── accounts-small.png +│ │ ├── bank-accounts.html +│ │ ├── desktop.png +│ │ ├── index.html +│ │ ├── mobile-small.png +│ │ ├── mobile.png +│ │ ├── payment-demo-data.js +│ │ ├── payment-receipt.html +│ │ ├── payment-services.js +│ │ ├── README.md +│ │ ├── receipt-amount.js +│ │ ├── receipt-banks.js +│ │ ├── receipt-date.js +│ │ ├── receipt-desktop.png +│ │ ├── receipt-independent-mobile.png +│ │ ├── receipt-mobile.png +│ │ ├── receipt-small.png +│ │ ├── receipt-validation-mobile.png +│ │ ├── receipt-validation.js +│ │ ├── requests-desktop.png +│ │ ├── requests-mobile.png +│ │ ├── requests-small.png +│ │ ├── service-pages.css +│ │ └── STYLE_GUIDE.md │ └── ticket-new-wizard │ ├── index.html │ ├── prototype.js @@ -237,6 +269,7 @@ customerTicket │ ├── PHONE_CONTACTS_API.md │ ├── PRODUCTION_SEED.md │ ├── QA_PERMISSION_MATRIX.md +│ ├── STAFF_SHARING.md │ └── THEMING.md ├── graphify-out │ ├── 2026-09-05 @@ -292,6 +325,21 @@ customerTicket │ │ ├── graph.json │ │ ├── GRAPH_REPORT.md │ │ └── manifest.json +│ ├── 2026-10-04 +│ │ ├── .graphify_analysis.json +│ │ ├── .graphify_labels.json +│ │ ├── cost.json +│ │ ├── graph.json +│ │ ├── GRAPH_REPORT.md +│ │ └── manifest.json +│ ├── before-html-semantic-2026-10-04T06-37-15-050Z +│ │ ├── .graphify_analysis.json +│ │ ├── .graphify_labels.json +│ │ ├── cost.json +│ │ ├── graph.html +│ │ ├── graph.json +│ │ ├── GRAPH_REPORT.md +│ │ └── manifest.json │ ├── cache │ │ ├── ast │ │ │ └── v0.9.68-s4 @@ -302,6 +350,8 @@ customerTicket │ │ │ ├── 02a3519f5b471b7083c161d9d9c73ab5b0ee78cf7e2cc4d5be7c2542435aa58e.json │ │ │ ├── 03040dc947a4bba22aafbc6262c9db2c8549826ae5aa1ac4cc1821806f93561f.json │ │ │ ├── 039590a02c184d21a28caec4178b981a824ec4356a225d1ce546cc212d2f550f.json +│ │ │ ├── 04b84d19d132764512441a31b31294e4f19dc963ed0a6e3abdd3eca7719e8bca.json +│ │ │ ├── 057ccb6901a83922bee4265993977ceb0d0ac425c900cb2d2f3181998df71796.json │ │ │ ├── 058b4e36240cd25d158647c300472baad5e7101ebde0319883abb0edc8c9c74d.json │ │ │ ├── 07e174cf218887aa0f8747199874faf6bc8af9347e43ff9b5a2073c089637f6f.json │ │ │ ├── 08ff4daae812563107028890c2ae7257bb5f9a376a978244c814b7a4c1d10a98.json @@ -312,8 +362,10 @@ customerTicket │ │ │ ├── 0d276b031b1e68ce07a7e0620645994c4d80c88dbdeb4b49deba5e053ef188cb.json │ │ │ ├── 0d5f84af41009fdecae2a46d4ad5fab38a79c1a5dde262e9c7f8d35707a35092.json │ │ │ ├── 0ef31d053655ee64599a0246f26c8eb61f27654ae1ffbd41b87500438fc9b06a.json +│ │ │ ├── 0f28b01b2484ce84b5251ff848065a2c6f7f574665305b5ecf8cb31a755a7982.json │ │ │ ├── 11d2b71feb1480622fb18ee3795cf070421b146e4273b5016fad81e7cc8e2c7e.json │ │ │ ├── 11ff4da3afbce9cae19102862deefdf725fa363130fad753fa189fa4f6c8c4de.json +│ │ │ ├── 12a877e28c12d0651d6487751c6b746afbe8361f9ba7e1b672f09bd596fbcf82.json │ │ │ ├── 12db2ab20d1e0be8080b5edd6cc40cacfdff350dd585126a0e0ce144073ad12f.json │ │ │ ├── 12f95828a2c1818402459627e36481199020e31faa280d214f81700fb9ad520d.json │ │ │ ├── 1436cce90bf2f00f050c9eb67cddf5672ed6a3db185ef663c257ee7936b175ae.json @@ -341,6 +393,7 @@ customerTicket │ │ │ ├── 2b7cc5d0dec7234082c1797ab637b916ac7df39277f8fbf109ae90190fc1e900.json │ │ │ ├── 2c8d91fbfca457646cf60edb03fd3a89a2494a6b6612087854edeac5a119739a.json │ │ │ ├── 2df1edd1fd32c467f237babe4a94f2ed73b67270ede6fa8298b32eab7c3eb80b.json +│ │ │ ├── 2e7903ddca9e29c53f1706f2755303e56d60436e494d066d011fdba93e701903.json │ │ │ ├── 2fd5953e7926f320bdeac2d559967758aef4632bf7dfa26a5ee50ad386fc9b67.json │ │ │ ├── 3142f9d4517f137483c716dd61985811bbf006b85506791e9a2ee37d58b2f53e.json │ │ │ ├── 32a4505d16f4fd1efc5bc0c2ea7c8900419856b21a41b06d988cc424258ea1c9.json @@ -354,11 +407,13 @@ customerTicket │ │ │ ├── 360e95dd7f9428a51961a1f4ae7968a6e3101b8df00027432c6c743f64ccf9f8.json │ │ │ ├── 367d8bf55d8d85048571009c40b04ef43211cc9c630af6222fc5db65fb382cc8.json │ │ │ ├── 36c68425778aff8c4320f570cc4e4ec8b3c40094abdeffc556f5488085c587e5.json +│ │ │ ├── 36f5a5ada8b3f2cb753e47b20cfc0baf6e6fcb2fb67ac8d7df658ec8b82b34f4.json │ │ │ ├── 393b2ca571bf671f26f243678a1be73266fe7435c43d7c837f1b53d4dc54cc66.json │ │ │ ├── 3948717314f47afa835e760073df4764ac427f4858f5ec0ee6a2f298ac393d49.json │ │ │ ├── 3b9252049dbf8ef67e64a756bb41325a6cf50190004d4442119d438620bacc72.json │ │ │ ├── 3c4a42b0b79b95acf81967479efdc4cd0a369e3b53b771fe87478d6327ddf0eb.json │ │ │ ├── 3cec8055583c7d94e03ced535376a8bd9d3d335cd6c7e328f38dc2a9e6b1e397.json +│ │ │ ├── 3d66d87daf2039a3fcd22d5d844e30c00443bed258dc048fbf939d97258e08d2.json │ │ │ ├── 3f0cad4a4a6eb440a2b133bf72f2e72d6e7ef3668f4c8dbc2c759dbd1a41eb6f.json │ │ │ ├── 401b3df2aa1a5c618d917ae2422e552c59dfbc5220b312fe7977d751168f6987.json │ │ │ ├── 408f6eb568e45e950fdc170776dabf9087829df0db630e16643fba8995318d26.json @@ -373,6 +428,7 @@ customerTicket │ │ │ ├── 485d542ea0237235e2d09999130ffa61c11da13873b62ca3e9228896f29da35f.json │ │ │ ├── 48f2dd332e3e37756440716095b3b0ba781ebd5abae50130c6b8bfee5a0468cd.json │ │ │ ├── 4993b53d428cbbb1e334ea396aae6397586a1295a35f39da038cfe72f0b89fd4.json +│ │ │ ├── 49dbf05487834af2e8fb3c1c4048f93565e6ca40251d04486a137b8f960adcac.json │ │ │ ├── 4ae084687e4372e69f349e8bbc5cf603768a032d3d9ee4d2504e6709ed8ee440.json │ │ │ ├── 4ae419f04a3a3b1221faef43b8526910f1958336162fbce432bab0e596ef2aa1.json │ │ │ ├── 4bf46c6c0f27e14312f7c1311bca1bf5e1bfee29ea5cb35e6f94142141985d23.json @@ -383,13 +439,16 @@ customerTicket │ │ │ ├── 508a0054ec5c5b3353e94566a0f728d533ec47721e20e6037aa55e2d665a5143.json │ │ │ ├── 52cd74ad5803b940489587043429b741434ae931fa4d244f5b0dc53582cee050.json │ │ │ ├── 5323b09e3c325ba8c161f5e2cab233b25cfa1b96ff042c6ae79ae1b029e2b1bc.json +│ │ │ ├── 54d567d298ddc3c274327011a29bd4f2f2cb31efaae5ecb3a7a94164a3eb3c1f.json │ │ │ ├── 57bc96866536f0d67300ed29e56bd0f45fa9119c787fac805f228adcab18dc6f.json │ │ │ ├── 582c91a8ddabbea5521fc65d724e4fb3672c070f9213fae7b58595fcf62f3c85.json │ │ │ ├── 584447cf308657a696ef8465181b12d2c06f9dc5ff69752bea66bd0b9631feec.json │ │ │ ├── 596a13e9b322f8c3332a3ae214ca7bf368ba6158ff3adcc7db4582f102ea51dc.json │ │ │ ├── 5a34ded8b2dd4c10c4251d287580918b88690c7058dd2299d2d670170104241d.json │ │ │ ├── 5d43f2c9afc3451745d2a1b5f1ed17bc138c9b19caff61beadbc40a4e970b525.json +│ │ │ ├── 5f759ce7c1bcc67b3972da0ff15616dedf748eb2a85e029802d6f0e0feb053ee.json │ │ │ ├── 5fcd0fdc47a301cbd07130dfa7545017b5975c29b9d7458ec65938ed503506a6.json +│ │ │ ├── 608fed7bef745c90c049732f5db2efe3982e483670dac36fff2b6e22ff2953fa.json │ │ │ ├── 60ef2d6fded9e070e35cfed119e8325010aa76bce63aa74ca0f25005d8ed9517.json │ │ │ ├── 62dab578f0debe93a8df61a1776953980abd254ae6f1eea77afb00dfefd28f15.json │ │ │ ├── 62e55882d2ce81dcfe5df1d58395e767ded55d6e39997d82cc3489c5e02ae910.json @@ -405,10 +464,12 @@ customerTicket │ │ │ ├── 6bd49215f8d2739a9e365dcc8855b95cfa34352c067a9e9ff48a623318c4ddaa.json │ │ │ ├── 6c69494ba124d4bc564bcd3a3df1f33ae0ee0769001a2dfc46825ef8b44dc3e2.json │ │ │ ├── 6d3b094dc56f9b26ce7374ba28e460b85248711223cf516f8e732c5d8f4d9d75.json +│ │ │ ├── 6d5685e1562faf37f6c30c614bcc7628edd19cd8181d5791aff416daf9fd5a6c.json │ │ │ ├── 6ee9154979fb497a8ec229a7177660af53a487e1aa17c7b555cdc67797615162.json │ │ │ ├── 7093cbd0a855750457814388b6e527fd3b612163f7a2b898ed6ce470dae74843.json │ │ │ ├── 72ae8a21c9106c639725190cc02f4e07efe7c645896cc4410923ad36751fbb2e.json │ │ │ ├── 72de23f55766289c980e6ccf604ca5d162e0989a222271b7496775ba4d749e3a.json +│ │ │ ├── 7379dad88b1741f6f4d999fcea3eb0ac4ffe56f72c07dc11881e94615457bd52.json │ │ │ ├── 74c2a0c0f83087673b100b7555e506c71d2a6a941677e388a1de59371b6d9afe.json │ │ │ ├── 75fad6124616f6d9e50ce9a475d4e989955c5fae7c5566b99992d986ef9a1f93.json │ │ │ ├── 77916c3b035a577b24da1a1dbcf47db7307af4e0774fecc549f7c95b91994a20.json @@ -422,6 +483,8 @@ customerTicket │ │ │ ├── 7d276789b80c8d9d1677556b3fb33b2da05988e97f6bf28903a044bb9fd64894.json │ │ │ ├── 7e136779a95d8e8e2ac6cad2192890254fd0d54d46b4f5514debba0f22618d75.json │ │ │ ├── 7e1c0a38a3ed90205d1829bdef528887ef12c928d4da797e06a79b3e7a1a92ba.json +│ │ │ ├── 7eaffccd2a080e53d836bffdbe88facc2bfdde45ea602c9a800ee4e06c9f2f94.json +│ │ │ ├── 7f6e099e26893c4841b257491053beb516a92f256378a8e60e01d18e2635295f.json │ │ │ ├── 7fa9734f0b6c5bbde268cb55ce9ba5016e9fd7162daaa665ed305a2d7e999bbe.json │ │ │ ├── 80468d8a995fb4092dfe5e57e25592c06713287d48367b675ae4d00955d4b25f.json │ │ │ ├── 806a3b07b690ca3871b1854aaff6f9042e48cf7aaa9bb9b6f15ab9634eae3b37.json @@ -430,13 +493,16 @@ customerTicket │ │ │ ├── 82fcc152dd67510406aa894d1e38939bac365093de9f34db4dfe2485806fda4f.json │ │ │ ├── 833fb89a8d72afc195b8f5bd7338109d8ce7be79f240dac31c4dcd36c8c674f6.json │ │ │ ├── 84579b3014e1a83e4201862df1e93378f5f953fbb547fea30965e335be5e7b1e.json +│ │ │ ├── 8493b4822bc3ed0ccd847d04d26598953d76d438e8381c1102922ac8530cd4d4.json │ │ │ ├── 84dedd4165eed360c4051690b2aa84f6ecc08f3f053f4129ca6f3e33c334ba35.json │ │ │ ├── 853fba8af82971f4fa56d957282d45d335d2e794f8e11001977161265af2ec79.json │ │ │ ├── 8595a5d566d43428818ee229e64f6b56453cc9ca61fd68be0b1c3c783d326315.json │ │ │ ├── 88ade181c6611ec33d9bcf5fe739dc809d81875b6571b9f60f87dafb4f5941b2.json │ │ │ ├── 899b16856382d32c4e5b39d02cb251bdedaf35c5a81bede5c19bfaff092208e9.json │ │ │ ├── 8e4cb8d07f4d43e171550ff2fc9423efd032b911d7ffee51259e437d4dc32d4f.json +│ │ │ ├── 91c2dc58207c4311391f22d524f461032f7ef3c9443d8f584edb2128966821bb.json │ │ │ ├── 91d83c62f39f219ccb6125502bf8e0a71a587dfc0a66d977a9bb93c8f2dc5dc6.json +│ │ │ ├── 91f4a9afed43fafc3d99d7fe5eaa945cd5c89e29d4d6f6448b630c3e58db05e8.json │ │ │ ├── 9301321aa7fb1a7beff5518eb54fbf1bf7145e0aa76d3ed29ae4cb38f1057da9.json │ │ │ ├── 9326d87e36b2dc24e6173aa3346f46e7df9fc3353fdb409304af4363c286a41e.json │ │ │ ├── 9697c37cf3a88d2b4eb5419b2381951a9aca966172316db79e7b4c32557d5e87.json @@ -449,6 +515,7 @@ customerTicket │ │ │ ├── 99f94b7b3b60f627c482cc195709b98cd2faa55f14c19c4bf5e0af8a88f34b7a.json │ │ │ ├── 9b267b0c45b93cecc1294ca1e040e5a18fdc9e0bee69a0ca8571ee11becdee97.json │ │ │ ├── 9b8bd2aff50e08b6f053b74a409c9439db41e692e29aabca5b67057884d945af.json +│ │ │ ├── 9cac12079d625e44ef3f62c6e4c9c7848f1cdd4d086c9251b3c81101cf4c114e.json │ │ │ ├── 9ce16f69c6b04f703adb24ec7498a2d2ecbf6f7524c459deb25111247e550698.json │ │ │ ├── 9d0e2a1fb14529aabd31c82c8d8f5543c0068366932934038ba93e234f291e22.json │ │ │ ├── 9ed5e291859abba38b07725bc6cf0275a1548e937888a5ba177674afaa48b96f.json @@ -479,6 +546,7 @@ customerTicket │ │ │ ├── b396b497b11e135cfc19ccbee84e3e67df0e401a412f51c0336c89cb3609b5f5.json │ │ │ ├── b4446ca11a1f4f6ea45256e755ed56d6ba7c330278e15ead8cae2de28d5e18d8.json │ │ │ ├── b6e731246dc10854a0fa0455c3c5816e29887b1a8c436d518926e1c2a5e36239.json +│ │ │ ├── b7ddd6ec7284c45f0925a557b161afd7c9d01171b06c3f723ac4da599ecf47d2.json │ │ │ ├── b922fd1a31f3cf602f197c872f3bac6e69c79255e502ddcbf1626f846ec3baee.json │ │ │ ├── bb3d09cc5d1bc380e841e2eb19cb497391117101c73c358e6db03db4d6ae51e7.json │ │ │ ├── c1060dca2a3f437f386324975be659215fe9f7e689137dc85e823879b462d11f.json @@ -500,6 +568,7 @@ customerTicket │ │ │ ├── d188f9185e71f8910607d275d82fea22199ba0e6009a5b1e34e5a20f041f0d71.json │ │ │ ├── d2513d4f5ff7e6088864ff497613f47d4083c1ca378649a3f1db6d45a3f3fb2b.json │ │ │ ├── d26a8fc663e9e5fcf33c87592f8c0456e7fe9ec3390bcb902631144d455c2922.json +│ │ │ ├── d54677c4320658676f86c09348933f298192a048b637aef94d607eea8a92b474.json │ │ │ ├── d55e8987ca5ff35748b62290dc14150b1df53163c6f5a5cbe19d03058368f1c6.json │ │ │ ├── d561ececcdd4e7ff59595389fa499591f4e7f3c5324a58dbdd749ccf29e90cd1.json │ │ │ ├── d5dec4474de6c46020e987f20aa3fe54c9bb9c6726a067a75ed94091d552b87f.json @@ -509,6 +578,7 @@ customerTicket │ │ │ ├── d9516c3232e0f78225764bf4f9586c88932df925c45de71f4eb1c883a9ed5b5d.json │ │ │ ├── d9c81db9cf9f2fee8a4985367d33b1c0ab1d7e6901e85473070c3bfd6bfe32c6.json │ │ │ ├── daa187dac6877c746c02269141a7e7db178ac32a0b3a4bfeaaa8ed046b6dcffb.json +│ │ │ ├── dbaef2f4942a9f7ecde0afc10e4eebfe3a2809af1d8b76cb036bd417f19274b1.json │ │ │ ├── dc90ac5e8d6766f8001bc2a40d21c203717d7a9f469956c2f6d9bedf3ae51aea.json │ │ │ ├── dd795078450fe69f61a445d3cf1275420be57381b005597cb5003859b2025800.json │ │ │ ├── de1655ae1d42e56f0cdb8788c0f6ba1c7c6e6725e6ef5e80969b92ec3e68ac78.json @@ -526,6 +596,7 @@ customerTicket │ │ │ ├── e8f3b285df9ff4d30c78eb1eded3eefdd86cfe325a7c8c29fb8457b6dde5e712.json │ │ │ ├── e90aa709aa08fcf3294352b588ce8c8c902f3ef59a503d4b8d9a292a9c478096.json │ │ │ ├── e974cd0006d8160a6ef1316083bac9e699adfd87acc7edb404ae7eba1c799b68.json +│ │ │ ├── e9b4ac230d46fa80048827a24c4211b7a71a741f2997ffaf0921b25a40f505c5.json │ │ │ ├── ea56445d1314587e18b1992d9599da03e1419ea7bc4929854b9039d6947de33b.json │ │ │ ├── ea8564b376c7156927c3f412f1a7173248d6b2fd7dcfc3492c4c9d7c44dae889.json │ │ │ ├── eb1dd65efa5a99811ccca80ff03a73cb155ad86d13635010a44c09c13953604d.json @@ -548,6 +619,7 @@ customerTicket │ │ │ ├── f8769e3eeb23aebc01d72869a2bc6e635d7dc82440bc2b0b0e11e6b6b17f5109.json │ │ │ ├── f8fc55a028176d1ea9a1b580a8653d4c45ae7466445f37e6c23867f12821c44c.json │ │ │ ├── fb9736a4ef3aca74573f131c7cbd9f015d88dd755ffba3edd324aec95149175f.json +│ │ │ ├── fca6b7e4c9f92410b2590539af80d6b499509255af4ef88282912c6c79a09330.json │ │ │ ├── fde577f1231f9b033560bfe075896efc6bf8d6079a030a159035584c7d39843b.json │ │ │ ├── fe032050843e4e1c28f5712205df74fa1698390169658c4ddc4555b6bcce8872.json │ │ │ ├── fe0c3eed2ff5c4b5d2116aba4494d599ae3c27004136e99f5d455f351281fa78.json @@ -568,6 +640,7 @@ customerTicket │ │ │ ├── 1e1ba446fa646a9dbd2244d5138ad84e217e8f238f2508603cfe4c290423dde8.json │ │ │ ├── 1f440edc6feb5bf8c2a462b1b4a0b44bd22b4a763286fb2b340c797c282b5bcd.json │ │ │ ├── 1f916be2dc79752fb2aa8e0c386470f43cc983c9021de885b62ca962ffea960f.json +│ │ │ ├── 20d91cfaf9ec884fd9025e385620d6a4aea0b3d48cb26e5297808f3abc5fdfd2.json │ │ │ ├── 2233df96365dd9e332d4744056d02289ae3a003a6fb8f3342a205860e03f8665.json │ │ │ ├── 22569dac279084bd62c922dbc391f9a5a7cc2e1051ef043bde5cfc72df0ad864.json │ │ │ ├── 23f032018f0612c58670d22539577856b5479f6107878d558e5e062bac9da30b.json @@ -586,6 +659,7 @@ customerTicket │ │ │ ├── 527fef297bee36f729186cbfe4bee9b0a41ad7bc98896815e2058c5348b76706.json │ │ │ ├── 5533327da089db48f0050a2d48af495de46f562ea2e29fcfdf3a527ece013770.json │ │ │ ├── 57240e5b1ee2390b9f26b44f11253b97b1c6b279affdedc34cbeeadc74181504.json +│ │ │ ├── 5931c8253737ddb54edc00f7f0edb0dfcf52d0f4b34d1c94e05e918a991505ec.json │ │ │ ├── 5aa8fb6425a50cdd0fdd3fe862960efbb179a8098df347292940f564737404ef.json │ │ │ ├── 5b5bd6e41d3308af6e19908a392f853875cd8a1f1a10f6e195d21aeab1a6a586.json │ │ │ ├── 660dafe22e562cecf6aaf4afdf658996dd024a1436c57e9815c82caa0c3e1782.json @@ -624,6 +698,7 @@ customerTicket │ │ │ ├── c931d64d02e6800ca7dcb354e7043c889c2ab0f9c6bd72723e09da0b754501ef.json │ │ │ ├── ca98ecfba0b2ad600c63c4f743e41528e2add0a2b48774b065aa0f60dcd905e3.json │ │ │ ├── cf5a494e78799d9b7557e6021056f1f896a5e8d8378b85d5a4879eeaceb8bec7.json +│ │ │ ├── d2a670f53c70d2f82ff69615cfec3ab85a8b106dbd812f47cd3f0351c63ad69a.json │ │ │ ├── ddf9f939efdbd2f15fe815fd608b3048eb2c439343c9700f446739f72582a91b.json │ │ │ ├── e00a1732e6e7df91a7aabb9bd609e2a5985d5cf7215635cde4a7495a0112efec.json │ │ │ ├── e10d4f4e825fa6fd025ab71d55be4304798b707016c9b0b0f6cc0da6589a8435.json @@ -708,12 +783,33 @@ customerTicket │ ├── .graphify_python │ ├── .graphify_root │ ├── .graphify_semantic.json +│ ├── .html_semantic_extract.json │ ├── .vocab.txt │ ├── cost.json +│ ├── db-before-announcement-fix-20261004-115635.sqlite3 +│ ├── db-before-staff-sharing.sqlite3 │ ├── graph.html │ ├── graph.json │ ├── GRAPH_REPORT.md -│ └── manifest.json +│ ├── manifest.json +│ ├── shared-ticket-metadata-1280.png +│ ├── shared-ticket-metadata-390.png +│ ├── staff-inline-desktop-closed.png +│ ├── staff-inline-desktop-history.png +│ ├── staff-inline-desktop-opened.png +│ ├── staff-inline-mobile-closed.png +│ ├── staff-inline-mobile-history.png +│ ├── staff-inline-mobile-opened.png +│ ├── staff-sharing-desktop-audit.png +│ ├── staff-sharing-desktop-message.png +│ ├── staff-sharing-desktop-ticket.png +│ ├── staff-sharing-mobile-audit.png +│ ├── staff-sharing-mobile-message.png +│ ├── staff-sharing-mobile-ticket.png +│ ├── unified-shares-message-1280.png +│ ├── unified-shares-message-390.png +│ ├── unified-shares-queue-1280.png +│ └── unified-shares-queue-390.png ├── production-seed │ ├── data.json.gz │ ├── manifest.json @@ -725,6 +821,7 @@ customerTicket │ │ ├── dispatches.css │ │ ├── enhancements.css │ │ ├── operations.css +│ │ ├── staff-sharing.css │ │ ├── tailwind.css │ │ ├── ticket-composer-v2.css │ │ └── ticket-composer.css @@ -748,6 +845,7 @@ customerTicket │ │ ├── location-picker.js │ │ ├── pwa.js │ │ ├── staff-customer-picker.js +│ │ ├── staff-sharing.js │ │ ├── ticket-intent-search.js │ │ ├── ticket-unit-options.js │ │ ├── ui.js @@ -782,7 +880,15 @@ customerTicket │ │ ├── _messages.html │ │ ├── _older_messages.html │ │ ├── _staff_customer_results.html +│ │ ├── _staff_message_share.html +│ │ ├── _staff_message_share_form.html +│ │ ├── _staff_messages.html +│ │ ├── _staff_older_messages.html +│ │ ├── _staff_share_audit_row.html +│ │ ├── _staff_share_icon.html +│ │ ├── _staff_sharing_audit.html │ │ ├── _staff_ticket_results.html +│ │ ├── _staff_ticket_share.html │ │ ├── _voip_call_button.html │ │ ├── customer_detail.html │ │ ├── customer_list.html @@ -888,7 +994,10 @@ customerTicket │ │ ├── tests_e2e_test_staff_operations_ui.py__test_dispatch_report_presents_delivery_and_engagement_professionally │ │ │ ├── failure.png │ │ │ └── trace.zip -│ │ └── tests_e2e_test_staff_operations_ui.py__test_staff_operations_pages_are_authorized_and_visually_styled +│ │ ├── tests_e2e_test_staff_operations_ui.py__test_staff_operations_pages_are_authorized_and_visually_styled +│ │ │ ├── failure.png +│ │ │ └── trace.zip +│ │ └── tests_e2e_test_staff_sharing_e2e.py__test_staff_can_share_one_message_track_opening_and_revoke │ │ ├── failure.png │ │ └── trace.zip │ ├── customer-chat-guidance.png @@ -900,6 +1009,7 @@ customerTicket │ │ │ ├── auth.py │ │ │ ├── base.py │ │ │ ├── customer_new_ticket.py +│ │ │ ├── sharing.py │ │ │ └── tickets.py │ │ ├── config.py │ │ ├── conftest.py @@ -911,7 +1021,8 @@ customerTicket │ │ ├── test_organization_permissions_e2e.py │ │ ├── test_public_ticket_workflows_e2e.py │ │ ├── test_pwa_versioning.py -│ │ └── test_staff_operations_ui.py +│ │ ├── test_staff_operations_ui.py +│ │ └── test_staff_sharing_e2e.py │ ├── js │ │ └── chat-coach.test.mjs │ ├── test_announcements.py @@ -927,6 +1038,9 @@ customerTicket │ ├── test_persian_text.py │ ├── test_production_seed.py │ ├── test_role_workflow_regression.py +│ ├── test_staff_inline_sharing.py +│ ├── test_staff_shared_queue.py +│ ├── test_staff_sharing.py │ ├── test_views.py │ └── test_voip_calls.py ├── .dockerignore diff --git a/alghortim_shenase.py b/alghortim_shenase.py new file mode 100644 index 0000000..4941434 --- /dev/null +++ b/alghortim_shenase.py @@ -0,0 +1,36 @@ +def generate_id_algorithm(id_code: str) -> str: + """ + Calculates the verification code based on the reverse-engineered logic: + 1. Iterate digits from right to left (index i = 0, 1, 2...). + 2. Weights: + - If i < 8: Weight = 8 - i (Sequence: 8, 7, 6, 5, 4, 3, 2, 1) + - If i >= 8: Weight = i + 1 (Sequence: 9, 10, ...) + 3. Sum = Sum(Digit * Weight) + 4. Check Digit = Sum % 99 + """ + # تمیزکاری ورودی (حذف فاصله‌ها و اطمینان از عددی بودن) + if not id_code.isdigit(): + return "Error: ورودی باید فقط عدد باشد." + + total_sum = 0 + + # پیمایش از راست به چپ + # reversed(id_code) ارقام را برعکس می‌کند + for i, char in enumerate(reversed(id_code)): + digit = int(char) + + # تعیین وزن بر اساس ایندکس (مکان رقم از راست) + if i < 8: + weight = 8 - i + else: + weight = i + 1 + + total_sum += digit * weight + + # محاسبه باقی‌مانده بر 99 + check_digit = total_sum % 99 + + # فرمت‌دهی دو رقمی (مثلاً 6 می‌شود 06) + check_digit_str = f"{check_digit:02}" + + return f"{id_code}{check_digit_str}" \ No newline at end of file diff --git a/apps/accounts/magic_access.py b/apps/accounts/magic_access.py index fab13e2..398366a 100644 --- a/apps/accounts/magic_access.py +++ b/apps/accounts/magic_access.py @@ -1,4 +1,5 @@ from django.utils import timezone +from apps.core.session_access import grant_scoped_session_access from .models import TicketMagicLink @@ -8,10 +9,7 @@ MAGIC_LINK_SESSION_KEY = "ticket_magic_link_id" def grant_ticket_magic_access(request, link): """Grant an anonymous session access to exactly one ticket until link expiry.""" - request.session.cycle_key() - request.session[MAGIC_LINK_SESSION_KEY] = link.pk - remaining = max(1, int((link.expires_at - timezone.now()).total_seconds())) - request.session.set_expiry(remaining) + grant_scoped_session_access(request, MAGIC_LINK_SESSION_KEY, link.pk, link.expires_at) def get_ticket_magic_access(request, ticket=None): diff --git a/apps/accounts/views.py b/apps/accounts/views.py index 155772e..9edeefe 100644 --- a/apps/accounts/views.py +++ b/apps/accounts/views.py @@ -2,8 +2,11 @@ from django.contrib import messages from django.conf import settings from django.contrib.auth import login, logout from django.shortcuts import get_object_or_404, redirect, render +from django.utils.http import url_has_allowed_host_and_scheme +from django.urls import reverse from django.utils import timezone from django.views.decorators.http import require_GET, require_http_methods, require_POST +from django.views.decorators.cache import never_cache from .forms import CustomerPasswordLoginForm, CustomerProfileForm, MobileForm, OTPForm, StaffLoginForm from apps.customers.models import CustomerProfile from django.contrib.auth.decorators import login_required @@ -16,9 +19,22 @@ import logging logger = logging.getLogger(__name__) +def _remember_destination(request): + target = request.GET.get("next", "") + if target and target.startswith(("/chaharsu/", "/tickets/")) and url_has_allowed_host_and_scheme(target, allowed_hosts={request.get_host()}, require_https=request.is_secure()): + request.session["next"] = target + + +def _login_destination(request): + target = request.session.pop("next", "") + if target.startswith(("/chaharsu/", "/tickets/")) and url_has_allowed_host_and_scheme(target, allowed_hosts={request.get_host()}, require_https=request.is_secure()): + return target + return "staff_tickets:list" if request.user.account_type == User.AccountType.STAFF else "payments:home" + + def _authenticated_home(request): if not request.user.is_authenticated: return None - return redirect("staff_tickets:list" if request.user.account_type==User.AccountType.STAFF else "tickets:list") + return redirect("staff_tickets:list" if request.user.account_type==User.AccountType.STAFF else "payments:home") @require_GET def home(request): @@ -27,6 +43,7 @@ def home(request): @require_http_methods(["GET", "POST"]) def customer_login(request): + _remember_destination(request) if response:=_authenticated_home(request): return response form = MobileForm(request.POST or None) if request.method == "POST" and form.is_valid(): @@ -60,18 +77,19 @@ def verify_otp(request): if not user.is_active: form.add_error(None, "حساب شما غیرفعال است.") else: login(request, user, backend="django.contrib.auth.backends.ModelBackend") - return redirect(request.session.pop("next", "tickets:list")) + return redirect(_login_destination(request)) else: form.add_error("code", "کد نامعتبر یا منقضی است.") return render(request, "accounts/otp.html", {"form": form, "mobile": challenge.mobile}) @require_http_methods(["GET","POST"]) def customer_password_login(request): + _remember_destination(request) if response:=_authenticated_home(request): return response form=CustomerPasswordLoginForm(request.POST or None) if request.method=="POST" and form.is_valid(): user=form.authenticate(request) if user and user.is_active: - login(request,user); return redirect(request.session.pop("next","tickets:list")) + login(request,user); return redirect(_login_destination(request)) form.add_error(None,"شماره موبایل یا رمز عبور صحیح نیست.") return render(request,"accounts/customer_password_login.html",{"form":form}) @@ -106,17 +124,30 @@ def logout_view(request): response["Cache-Control"]="no-store" return response +@never_cache +@require_http_methods(["GET", "POST"]) def consume_magic_link(request, token): link = TicketMagicLink.objects.select_related("customer", "ticket").filter(token_hash=TicketMagicLink.digest(token)).first() - if not link or link.revoked_at or link.expires_at <= timezone.now(): + if not link or link.revoked_at or link.expires_at <= timezone.now() or not link.customer.is_active: request.session["next"] = f"/tickets/{link.ticket.public_id}/" if link else "/tickets/" messages.error(request, "این لینک منقضی شده یا اشتباه است. لطفاً با شماره موبایل خود وارد شوید تا بتوانید پیام‌ها را ببینید.") - return redirect("accounts:login") - if request.user.is_authenticated: - if request.user.pk != link.customer_id: - from django.core.exceptions import PermissionDenied - raise PermissionDenied - else: + response = redirect("accounts:login") + response["Referrer-Policy"] = "no-referrer" + return response + if request.user.is_authenticated and request.user.pk != link.customer_id: + confirmed = ( + request.method == "POST" + and request.POST.get("action") == "logout_and_open" + and request.POST.get("account_id") == str(request.user.pk) + ) + if not confirmed: + response = render(request, "accounts/magic_link_confirmation.html") + # Same-origin form submissions need an Origin for Django CSRF checks. + response["Referrer-Policy"] = "same-origin" + return response + # Logout flushes the old session; grant access only in the new one. + logout(request) + if not request.user.is_authenticated: grant_ticket_magic_access(request, link) ip_address = request.META.get("REMOTE_ADDR") user_agent = request.META.get("HTTP_USER_AGENT", "")[:500] diff --git a/apps/core/session_access.py b/apps/core/session_access.py new file mode 100644 index 0000000..c468dac --- /dev/null +++ b/apps/core/session_access.py @@ -0,0 +1,12 @@ +from django.utils import timezone + + +def grant_scoped_session_access(request, grant_key, grant_id, expires_at, *, preserve_expiry=False): + """Rotate anonymous session keys; resource grants still validate their own expiry.""" + session = request.session + session.cycle_key() + session[grant_key] = grant_id + seconds = max(1, int((expires_at - timezone.now()).total_seconds())) + if preserve_expiry: + seconds = max(session.get_expiry_age(), seconds) + session.set_expiry(seconds) diff --git a/apps/notifications/admin.py b/apps/notifications/admin.py index 044776d..0a5e81b 100644 --- a/apps/notifications/admin.py +++ b/apps/notifications/admin.py @@ -16,7 +16,12 @@ class AnnouncementJobAdmin(admin.ModelAdmin): class SMSTemplateAdmin(admin.ModelAdmin): list_display=("event_type","unit","title","is_active","updated_at") list_filter=("unit","is_active","event_type") - readonly_fields=("allowed_tags",) + readonly_fields=("allowed_tags_display",) + exclude=("allowed_tags",) + + @admin.display(description='Allowed tags / تگ‌های مجاز') + def allowed_tags_display(self, obj): + return obj.supported_tags @admin.register(NotificationPolicy) class NotificationPolicyAdmin(admin.ModelAdmin): list_display=("key","quiet_period_seconds","is_active","updated_at") diff --git a/apps/notifications/management/commands/process_notifications.py b/apps/notifications/management/commands/process_notifications.py index dc513bb..1f20b45 100644 --- a/apps/notifications/management/commands/process_notifications.py +++ b/apps/notifications/management/commands/process_notifications.py @@ -73,6 +73,9 @@ def _bale_ticket_text(ticket, message=None, *, is_reply=False, message_count=1, return "\n".join(lines) def _send_sms(job): + 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) issued = None if job.event_type == "UNIT_SUPERVISOR_NEW_TICKET": recipient = job.payload.get("recipient_mobile", "") diff --git a/apps/notifications/migrations/0017_alter_smstemplate_event_type.py b/apps/notifications/migrations/0017_alter_smstemplate_event_type.py new file mode 100644 index 0000000..768c0c3 --- /dev/null +++ b/apps/notifications/migrations/0017_alter_smstemplate_event_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.1 on 2026-10-05 10:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0016_announcementjob_requested_by_announcementjob_source_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='smstemplate', + name='event_type', + field=models.CharField(choices=[('CUSTOMER_TICKET_CREATED', 'ثبت تیکت توسط مشتری'), ('STAFF_TICKET_CREATED', 'ثبت تیکت توسط کارشناس'), ('STAFF_REPLY', 'پاسخ کارشناس'), ('PAYMENT_NOTICE_CREATED', 'صدور اعلامیه واریز'), ('PAYMENT_RECEIPT_APPROVED', 'تأیید نهایی فیش واریز'), ('UNIT_SUPERVISOR_NEW_TICKET', 'اطلاع تیکت جدید به سرپرستان واحد')], max_length=50), + ), + ] diff --git a/apps/notifications/migrations/0018_alter_smstemplate_body.py b/apps/notifications/migrations/0018_alter_smstemplate_body.py new file mode 100644 index 0000000..915da0a --- /dev/null +++ b/apps/notifications/migrations/0018_alter_smstemplate_body.py @@ -0,0 +1,31 @@ +# Generated by Django 5.1.1 on 2026-10-06 05:38 + +from django.db import migrations, models + + +def align_template_tags(apps, schema_editor): + template = apps.get_model('notifications', 'SMSTemplate') + ticket_tags = ['magic_link', 'first_name', 'last_name', 'recipient_name', 'unit_name', 'company_name', + 'ticket_number', 'subject', 'province', 'city', 'expert_name', 'customer_mobile', 'category_name'] + payment_tags = ['recipient_name', 'company_name', 'notice_number', 'receipt_number', 'amount_rial', 'invoice_number', 'payment_link'] + for item in template.objects.all(): + tags = payment_tags if item.event_type in {'PAYMENT_NOTICE_CREATED', 'PAYMENT_RECEIPT_APPROVED'} else ticket_tags + if item.event_type == 'UNIT_SUPERVISOR_NEW_TICKET': + tags = [tag for tag in tags if tag != 'magic_link'] + template.objects.filter(pk=item.pk).update(allowed_tags=tags) + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0017_alter_smstemplate_event_type'), + ] + + operations = [ + migrations.AlterField( + model_name='smstemplate', + name='body', + field=models.TextField(help_text='تگ\u200cها بر اساس رویداد مشخص\u200cاند: {magic_link} برای تیکت و {payment_link} برای پرداخت.'), + ), + migrations.RunPython(align_template_tags, migrations.RunPython.noop), + ] diff --git a/apps/notifications/models.py b/apps/notifications/models.py index ce9c729..ebb067b 100644 --- a/apps/notifications/models.py +++ b/apps/notifications/models.py @@ -13,20 +13,44 @@ class SMSTemplate(PersianTextNormalizationMixin, models.Model): CUSTOMER_TICKET_CREATED="CUSTOMER_TICKET_CREATED","ثبت تیکت توسط مشتری" STAFF_TICKET_CREATED="STAFF_TICKET_CREATED","ثبت تیکت توسط کارشناس" STAFF_REPLY="STAFF_REPLY","پاسخ کارشناس" + PAYMENT_NOTICE_CREATED="PAYMENT_NOTICE_CREATED","صدور اعلامیه واریز" + PAYMENT_RECEIPT_APPROVED="PAYMENT_RECEIPT_APPROVED","تأیید نهایی فیش واریز" UNIT_SUPERVISOR_NEW_TICKET="UNIT_SUPERVISOR_NEW_TICKET","اطلاع تیکت جدید به سرپرستان واحد" unit=models.ForeignKey("staff.OrganizationalUnit",on_delete=models.CASCADE,null=True,blank=True,related_name="sms_templates",help_text="خالی یعنی الگوی عمومی و fallback.") event_type=models.CharField(max_length=50,choices=Event.choices) title=models.CharField(max_length=120) - body=models.TextField(help_text="تگ‌ها را مانند {magic_link} در متن قرار دهید.") + body=models.TextField(help_text="تگ‌ها بر اساس رویداد مشخص‌اند: {magic_link} برای تیکت و {payment_link} برای پرداخت.") allowed_tags=models.JSONField(default=sms_template_tags,blank=True) is_active=models.BooleanField(default=True) updated_at=models.DateTimeField(auto_now=True) class Meta: constraints=[models.UniqueConstraint(fields=["unit","event_type"],name="unique_sms_template_per_unit"),models.UniqueConstraint(fields=["event_type"],condition=models.Q(unit__isnull=True),name="unique_global_sms_template_event")] def __str__(self): return self.get_event_type_display() + @classmethod + def tags_for_event(cls, event_type): + if event_type in {'PAYMENT_NOTICE_CREATED', 'PAYMENT_RECEIPT_APPROVED'}: + from apps.payments.notifications import TAGS + return list(TAGS) + tags = sms_template_tags() + return [tag for tag in tags if tag != 'magic_link'] if event_type == 'UNIT_SUPERVISOR_NEW_TICKET' else tags + + @property + def supported_tags(self): + return ', '.join('{' + tag + '}' for tag in self.tags_for_event(self.event_type)) + + def save(self, *args, **kwargs): + self.allowed_tags = self.tags_for_event(self.event_type) + if kwargs.get('update_fields') is not None: + kwargs['update_fields'] = set(kwargs['update_fields']) | {'allowed_tags'} + return super().save(*args, **kwargs) + def clean(self): + self.allowed_tags = self.tags_for_event(self.event_type) from string import Formatter - tags={field for _,field,_,_ in Formatter().parse(self.body) if field} + try: + tags={field for _,field,_,_ in Formatter().parse(self.body) if field} + except ValueError: + raise ValidationError({'body': 'ساختار تگ معتبر نیست؛ هر تگ باید بین { و } قرار بگیرد.'}) invalid=tags-set(self.allowed_tags) if invalid: raise ValidationError({"body":f"تگ‌های غیرمجاز: {', '.join(sorted(invalid))}"}) diff --git a/apps/payments/__init__.py b/apps/payments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/payments/access.py b/apps/payments/access.py new file mode 100644 index 0000000..2bb66fd --- /dev/null +++ b/apps/payments/access.py @@ -0,0 +1,130 @@ +"""A payment link never authenticates a user or grants access to their whole account.""" +import hashlib +import secrets +import uuid +from datetime import timedelta + +from django.conf import settings +from django.db.models import Q +from django.contrib.auth.views import redirect_to_login +from django.core.exceptions import PermissionDenied +from django.http import Http404 +from django.shortcuts import get_object_or_404, render +from django.urls import reverse +from django.utils import timezone +from apps.core.session_access import grant_scoped_session_access + +from . import policies +from .models import PaymentAccessLink, PaymentNotice + +SESSION_KEY = 'payment_access_link_id' + + +def token_digest(token): + return hashlib.sha256(token.encode()).hexdigest() + + +def issue_payment_link(*, notice=None, receipt=None): + target = receipt or notice + if target is None: + raise ValueError('Payment link requires a notice or receipt') + if receipt is not None: + notice = receipt.notice + token = secrets.token_urlsafe(32) + link = PaymentAccessLink.objects.create(customer=target.customer, notice=notice, receipt=receipt, + token_hash=token_digest(token), + expires_at=timezone.now() + timedelta(days=settings.PAYMENT_LINK_TTL_DAYS)) + return link, settings.PUBLIC_BASE_URL + reverse('payments:open_link', args=[token]) + + +def active_links(): + return PaymentAccessLink.objects.select_related('customer', 'notice', 'receipt').filter( + revoked_at__isnull=True, expires_at__gt=timezone.now(), customer__is_active=True, + customer__account_type='CUSTOMER') + + +def grant_payment_access(request, link): + # The session may already contain a ticket grant; each grant checks its own expiry. + grant_scoped_session_access(request, SESSION_KEY, link.pk, link.expires_at, preserve_expiry=True) + + +def get_payment_access(request): + link_id = request.session.get(SESSION_KEY) + if not isinstance(link_id, int): + return None + link = active_links().filter(pk=link_id).first() + if link is None: + request.session.pop(SESSION_KEY, None) + return link + + +def visible_payments(request, queryset): + link = getattr(request, 'payment_access_link', None) + if link is None: + return policies.visible(queryset, request.user) + owned = queryset.filter(customer_id=link.customer_id) + if queryset.model == PaymentNotice: + return owned.filter(pk=link.notice_id) + scope = Q(pk=link.receipt_id) | Q(access_links=link) + if link.notice_id: + scope |= Q(notice_id=link.notice_id) + return owned.filter(scope).distinct() + + +def customer_context(request, **kwargs): + return {'payment_link_access': getattr(request, 'payment_access_link', None), **kwargs} + + +def prepare_payment_request(request, *, staff=False, allow_payment_link=False): + if request.user.is_authenticated: + customer = request.user.is_active and request.user.account_type == 'CUSTOMER' + permitted = policies.is_staff(request.user) if staff else customer + if staff is None: + permitted = customer or policies.is_staff(request.user) + if not permitted: + raise PermissionDenied + request.payment_customer = request.user + return None + had_link = SESSION_KEY in request.session + link = get_payment_access(request) if allow_payment_link else None + if link: + request.payment_access_link = link + request.payment_customer = link.customer + return None + if had_link and allow_payment_link: + return unavailable_link(request) + return redirect_to_login(request.get_full_path(), '/staff/login/' if staff else '/login/') + + +def receipt_initial(request, customer): + link = getattr(request, 'payment_access_link', None) + identifier = request.GET.get('notice') + initial = {} + if link and not identifier: + if request.GET.get('mode') == 'independent' or not link.notice_id or link.notice.cancelled_at: + return {'receipt_mode': 'independent'} + identifier = str(link.notice.public_id) + if not identifier: + return initial + try: + identifier = uuid.UUID(identifier) + except ValueError: + raise Http404 + notices = visible_payments(request, PaymentNotice.objects.all()) if link else PaymentNotice.objects.filter(customer=customer) + notice = get_object_or_404(notices, public_id=identifier, cancelled_at__isnull=True) + return {'notice_id': notice.pk, 'destination_account': notice.account_id, 'deposit_id': notice.deposit_id} + + +def validate_link_submission(link, customer, data): + if not active_links().select_related(None).select_for_update(of=('self',)).filter(pk=link.pk, customer=customer).first(): + raise PermissionDenied + notice = data.get('notice_id') + if notice is not None and notice.pk != link.notice_id: + raise PermissionDenied + + +def unavailable_link(request): + response = render(request, 'payments/link_unavailable.html', {'financial_page': True}, status=410) + response['Referrer-Policy'] = 'no-referrer' + response['X-Robots-Tag'] = 'noindex, nofollow' + return response diff --git a/apps/payments/admin.py b/apps/payments/admin.py new file mode 100644 index 0000000..33c6d2a --- /dev/null +++ b/apps/payments/admin.py @@ -0,0 +1,128 @@ +from django.contrib import admin +from django.core.exceptions import PermissionDenied +from django.db import connection +from . import models, policies, services + + +@admin.register(models.PaymentAccessLink) +class PaymentAccessLinkAdmin(admin.ModelAdmin): + list_display = ('customer', 'notice', 'receipt', 'created_at', 'expires_at', 'revoked_at', 'view_count') + list_filter = ('revoked_at', 'expires_at') + exclude = ('token_hash',) + readonly_fields = ('customer', 'notice', 'receipt', 'created_at', 'expires_at', 'revoked_at', 'view_count', 'last_used_at', 'submitted_receipts') + actions = ['revoke_links'] + + def has_add_permission(self, request): + return False + + def has_delete_permission(self, request, obj=None): + return False + + @admin.action(description='ابطال لینک‌های انتخاب‌شده') + def revoke_links(self, request, queryset): + from django.utils import timezone + for link in queryset.filter(revoked_at__isnull=True): + link.revoked_at = timezone.now() + link.save(update_fields=['revoked_at']) + services.audit(request.user, 'PAYMENT_LINK_REVOKED', link) +from .forms import BankAccountForm + + +class AuditedConfigurationAdmin(admin.ModelAdmin): + permission = 'manage_payment_approval_policy' + singleton_configuration = False + + def has_view_permission(self, request, obj=None): + return policies.has_permission(request.user,self.permission) + + def has_add_permission(self, request): + return self.has_view_permission(request) and not (self.singleton_configuration and self.model.objects.exists()) + + def has_change_permission(self, request, obj=None): + return self.has_view_permission(request,obj) + + def has_delete_permission(self, request, obj=None): + return False + + def save_model(self, request, obj, form, change): + if not self.has_change_permission(request,obj): + raise PermissionDenied + current = self.model.objects.select_for_update().get(pk=obj.pk) if change else None + before = services.configuration_snapshot(current) if current else {} + super().save_model(request,obj,form,change) + services.audit(request.user,'CONFIG_SAVED',obj,{'fields': form.changed_data, + 'before':before,'after':services.configuration_snapshot(obj)}) + + def save_related(self, request, form, formsets, change): + super().save_related(request,form,formsets,change) + if isinstance(form.instance,models.PaymentApprovalPolicy): + services.audit(request.user,'POLICY_ASSIGNMENTS_CHANGED',form.instance,{'reviewers':list(form.instance.reviewers.values_list('pk',flat=True)), 'seniors':list(form.instance.seniors.values_list('pk',flat=True))}) + + +@admin.register(models.PaymentApprovalPolicy) +class ApprovalPolicyAdmin(AuditedConfigurationAdmin): + list_display = ['name','unit','is_active'] + filter_horizontal = ['reviewers','seniors'] + + def formfield_for_manytomany(self, db_field, request, **kwargs): + from apps.accounts.models import User + if db_field.name in ['reviewers','seniors']: + kwargs['queryset'] = User.objects.filter(account_type='STAFF',is_active=True).exclude(staff_profile__is_active=False) + return super().formfield_for_manytomany(db_field,request,**kwargs) + + +@admin.register(models.PaymentAccessConfiguration) +class AccessAdmin(AuditedConfigurationAdmin): + permission = 'manage_payment_access' + singleton_configuration = True + + +@admin.register(models.CompanyBankAccount) +class AccountAdmin(AuditedConfigurationAdmin): + form = BankAccountForm + list_display = ['name','bank','requires_deposit_id','is_active','available_to_staff','available_to_customers'] + permission = 'manage_company_bankaccounts' + + def has_view_permission(self, request, obj=None): + return policies.can_manage_banks(request.user) + + def get_queryset(self, request): + queryset = super().get_queryset(request) + # Django wraps POST change forms in atomic(): lock before token validation. + if request.method == 'POST' and connection.in_atomic_block: + return queryset.select_for_update(of=('self',)) + return queryset + + +@admin.register(models.BankInstitution) +class BankAdmin(AccountAdmin): + form = admin.ModelAdmin.form + list_display = ['name','code','is_active','historical'] + + +class EvidenceAdmin(admin.ModelAdmin): + def has_view_permission(self, request, obj=None): + if self.model in [models.PaymentNotice, models.PaymentReceipt]: + return super().has_view_permission(request, obj) + return policies.has_permission(request.user, 'view_payment_audit') + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False + + def get_queryset(self, request): + queryset = super().get_queryset(request) + if queryset.model in [models.PaymentNotice,models.PaymentReceipt]: + return policies.visible(queryset,request.user) + return queryset if policies.has_permission(request.user,'view_payment_audit') else queryset.none() + + +for model in [models.PaymentNotice,models.PaymentReceipt,models.PaymentReceiptRevision,models.ReceiptApprovalCycle, + models.ReceiptApprovalAssignment,models.ReceiptDecision,models.PaymentAuditEvent, + models.PaymentNotification,models.PaymentNotificationAttempt]: + admin.site.register(model,EvidenceAdmin) diff --git a/apps/payments/apps.py b/apps/payments/apps.py new file mode 100644 index 0000000..3dd3d7b --- /dev/null +++ b/apps/payments/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class PaymentsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.payments' + verbose_name = 'واریزها و تأیید مالی' diff --git a/apps/payments/context_processors.py b/apps/payments/context_processors.py new file mode 100644 index 0000000..401580d --- /dev/null +++ b/apps/payments/context_processors.py @@ -0,0 +1,15 @@ +from django.db import OperationalError, ProgrammingError +from . import policies +from .models import PaymentReceipt + + +def payment_navigation(request): + if not policies.is_staff(request.user): + return {} + try: + return {'payment_can_create': policies.can_create(request.user), + 'payment_pending_count': policies.visible(PaymentReceipt.objects.all(),request.user).filter(status__in=['PENDING','PARTIAL']).count(), + 'payment_can_manage_banks': policies.can_manage_banks(request.user), + 'payment_can_view': policies.visible(PaymentReceipt.objects.all(),request.user).exists() or policies.config().open_view or request.user.has_perm('payments.view_paymentreceipt') or request.user.is_superuser} + except (OperationalError, ProgrammingError): + return {} diff --git a/apps/payments/customer_picker.py b/apps/payments/customer_picker.py new file mode 100644 index 0000000..824b887 --- /dev/null +++ b/apps/payments/customer_picker.py @@ -0,0 +1,77 @@ +"""Reuse the contact directory's scoped history; never render all customer choices.""" +from uuid import UUID +from django.db.models import Prefetch, Q +from django.http import Http404 +from django.urls import reverse +from apps.accounts.models import normalize_mobile +from apps.core.persian_text import normalize_persian_text +from apps.tickets.selectors import staff_ticket_rows +from .validators import digits + + +def customer_rows(user, search=''): + # The existing directory projection counts only tickets visible to this staff user. + from apps.tickets.views import _customer_rows + rows = _customer_rows(user=user).filter(is_active=True) + if search: + terms_query = Q() + for term in search.split(): + mobile = term + try: + mobile = normalize_mobile(term) + except ValueError: + if term.isascii() and term.isdecimal() and term.startswith('0'): + mobile = term[1:] + terms_query &= (Q(mobile__icontains=mobile) | Q(username__icontains=term) | + Q(first_name__icontains=term) | Q(last_name__icontains=term) | + Q(customer_profile__first_name__icontains=term) | + Q(customer_profile__last_name__icontains=term) | + Q(customer_profile__organization_name__icontains=term)) + history = staff_ticket_rows(user).filter( + Q(ticket_number__icontains=search) | Q(subject__icontains=search) | + Q(subject__icontains=normalize_persian_text(search))) + rows = rows.filter(terms_query | Q(customer_tickets__pk__in=history.values('pk'))).distinct() + return rows.prefetch_related(Prefetch('customer_tickets', + queryset=staff_ticket_rows(user).order_by('-created_at', '-pk')[:3], + to_attr='payment_recent_tickets')) + + +def customer_data(customer): + profile = getattr(customer, 'customer_profile', None) + name = ' '.join(filter(None, [getattr(profile, 'first_name', ''), getattr(profile, 'last_name', '')])) + return {'value': str(customer.pk), 'id': str(customer.public_id), + 'name': name or customer.get_full_name() or 'نام ثبت نشده', + 'mobile': customer.local_mobile, 'organization': getattr(profile, 'organization_name', ''), + 'location': '، '.join(filter(None, [getattr(profile, 'city', ''), getattr(profile, 'province', '')])), + 'ticket_count': customer.ticket_count, 'active_count': customer.active_count, + 'history': [{'number': ticket.ticket_number, 'subject': ticket.subject, + 'status': ticket.get_status_display(), 'url': reverse('staff_tickets:detail', args=[ticket.public_id])} + for ticket in customer.payment_recent_tickets]} + + +def initial_customer(request): + public_id = request.GET.get('customer', '') + if not public_id or request.method != 'GET': + return {} + try: + public_id = UUID(public_id) + except (ValueError, TypeError, AttributeError): + raise Http404('مشتری معتبر نیست.') + customer = customer_rows(request.user).filter(public_id=public_id).first() + if customer is None: + raise Http404('مشتری فعال پیدا نشد.') + return {'customer': customer.pk} + + +def picker_context(request, form): + value = str(form['customer'].value() or '') + selected = customer_rows(request.user).filter(pk=value).first() if value.isascii() and value.isdecimal() else None + return {'customer_selection': customer_data(selected) if selected else None} + + +def search_results(user, query): + query = digits(query).translate(str.maketrans('يك', 'یک'))[:100] + if len(query) < 2: + return {'results': [], 'has_more': False} + rows = list(customer_rows(user, query)[:21]) + return {'results': [customer_data(customer) for customer in rows[:20]], 'has_more': len(rows) > 20} diff --git a/apps/payments/deposit_identifier.py b/apps/payments/deposit_identifier.py new file mode 100644 index 0000000..fc50762 --- /dev/null +++ b/apps/payments/deposit_identifier.py @@ -0,0 +1,42 @@ +"""Deposit identifier algorithm copied from repository alghortim_shenase.py. + +The input is the customer local mobile number, retaining its leading zero. +This is a deterministic identifier, not a random or unique-per-notice value. +""" + +def generate_id_algorithm(id_code: str) -> str: + """ + Calculates the verification code based on the reverse-engineered logic: + 1. Iterate digits from right to left (index i = 0, 1, 2...). + 2. Weights: + - If i < 8: Weight = 8 - i (Sequence: 8, 7, 6, 5, 4, 3, 2, 1) + - If i >= 8: Weight = i + 1 (Sequence: 9, 10, ...) + 3. Sum = Sum(Digit * Weight) + 4. Check Digit = Sum % 99 + """ + # تمیزکاری ورودی (حذف فاصله‌ها و اطمینان از عددی بودن) + if not id_code.isdigit(): + return "Error: ورودی باید فقط عدد باشد." + + total_sum = 0 + + # پیمایش از راست به چپ + # reversed(id_code) ارقام را برعکس می‌کند + for i, char in enumerate(reversed(id_code)): + digit = int(char) + + # تعیین وزن بر اساس ایندکس (مکان رقم از راست) + if i < 8: + weight = 8 - i + else: + weight = i + 1 + + total_sum += digit * weight + + # محاسبه باقی‌مانده بر 99 + check_digit = total_sum % 99 + + # فرمت‌دهی دو رقمی (مثلاً 6 می‌شود 06) + check_digit_str = f"{check_digit:02}" + + return f"{id_code}{check_digit_str}" diff --git a/apps/payments/forms.py b/apps/payments/forms.py new file mode 100644 index 0000000..0a60b30 --- /dev/null +++ b/apps/payments/forms.py @@ -0,0 +1,127 @@ +import uuid +import jdatetime +from django import forms +from django.db.models import Q +from django.utils import timezone +from apps.accounts.models import User +from . import validators +from .models import BankInstitution, CompanyBankAccount, PaymentNotice + + +class BankAccountForm(forms.ModelForm): + revision_token = forms.CharField(required=False, widget=forms.HiddenInput) + + class Meta: + model = CompanyBankAccount + fields = ['revision_token', 'name', 'bank', 'holder', 'account_number', 'iban', 'card_number', 'requires_deposit_id', 'is_active', 'available_to_staff', 'available_to_customers', 'sort_order'] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance.pk: + self.initial['revision_token'] = self.instance.updated_at.isoformat() + + def clean_account_number(self): + return validators.bank_number(self.cleaned_data['account_number']) + + def clean_card_number(self): + value = validators.bank_number(self.cleaned_data['card_number']) + if value and (len(value) != 16 or sum((int(n) * (2 if i % 2 == 0 else 1)) // 10 + (int(n) * (2 if i % 2 == 0 else 1)) % 10 for i, n in enumerate(value)) % 10): + raise forms.ValidationError('شماره کارت ۱۶ رقمی معتبر وارد کنید.') + return value + + def clean_iban(self): + return validators.iban(self.cleaned_data['iban']) + + def clean(self): + data = super().clean() + if self.instance.pk and data.get('revision_token') != self.instance.updated_at.isoformat(): + raise forms.ValidationError('حساب از زمان باز کردن فرم تغییر کرده است؛ صفحه را تازه کنید و تغییرات را دوباره بررسی کنید.') + if not any(data.get(key) for key in ('account_number', 'iban', 'card_number')): + raise forms.ValidationError('حداقل شماره حساب، شبا یا کارت را وارد کنید.') + return data + + +class NoticeForm(forms.Form): + submission_key = forms.UUIDField(widget=forms.HiddenInput) + customer = forms.ModelChoiceField(label='مشتری', queryset=User.objects.none(), widget=forms.HiddenInput, + error_messages={'required': 'مشتری را از نتایج جست‌وجو انتخاب کنید.', + 'invalid_choice': 'مشتری انتخاب‌شده معتبر یا فعال نیست؛ دوباره جست‌وجو کنید.'}) + account = forms.ModelChoiceField(label='حساب مقصد', queryset=CompanyBankAccount.objects.none()) + subject = forms.CharField(label='موضوع', max_length=200) + amount = forms.CharField(label='مبلغ (ریال)') + deposit_id = forms.CharField(label='شناسه واریز', max_length=100, required=False) + invoice_number = forms.CharField(label='شماره فاکتور', max_length=100, required=False) + description = forms.CharField(label='توضیحات', required=False, widget=forms.Textarea) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.initial.setdefault('submission_key', uuid.uuid4()) + self.fields['customer'].queryset = User.objects.filter(account_type='CUSTOMER', is_active=True).select_related('customer_profile') + self.fields['customer'].label_from_instance = lambda u: f'{getattr(u, "customer_profile", u)} — {u.local_mobile}' + self.fields['account'].queryset = CompanyBankAccount.objects.filter(is_active=True, available_to_staff=True).select_related('bank') + + def clean_amount(self): + return validators.amount(self.cleaned_data['amount']) + + def clean(self): + data = super().clean() + if data.get('account') and data['account'].requires_deposit_id and not data.get('deposit_id', '').strip(): + self.add_error('deposit_id', 'این حساب شناسه‌دار است؛ شناسه واریز مشتری را حتماً وارد کنید.') + return data + + +class ReceiptForm(forms.Form): + receipt_mode = forms.ChoiceField(label='نوع واریز', choices=[('linked', 'واریز مرتبط (پیشنهادی)'), ('independent', 'واریز مستقل')], widget=forms.RadioSelect, initial='linked') + notice_id = forms.ModelChoiceField(label='اعلامیه واریز', queryset=PaymentNotice.objects.none(), required=False) + paid_amount = forms.CharField(label='مبلغ پرداخت‌شده (ریال)') + destination_account = forms.ModelChoiceField(label='به کدام حساب ما واریز کردید؟', queryset=CompanyBankAccount.objects.none()) + deposit_id = forms.CharField(label='شناسه واریز که با آن پرداخت انجام داده‌اید (اختیاری)', max_length=100, required=False) + origin_bank = forms.ModelChoiceField(label='بانک مبدأ (اختیاری)', queryset=BankInstitution.objects.filter(is_active=True), required=False) + origin_other = forms.CharField(label='نام بانک دیگر (اختیاری)', max_length=100, required=False) + paid_day = forms.ChoiceField(label='روز', choices=[('', 'روز')] + [(str(i), str(i)) for i in range(1, 32)]) + paid_month = forms.ChoiceField(label='ماه', choices=[('', 'ماه')] + list(zip(map(str, range(1, 13)), ['فروردین','اردیبهشت','خرداد','تیر','مرداد','شهریور','مهر','آبان','آذر','دی','بهمن','اسفند']))) + paid_year = forms.ChoiceField(label='سال') + receipt_image = forms.FileField(label='تصویر فیش') + tracking_number = forms.CharField(label='شماره پیگیری (اختیاری)', max_length=100, required=False) + description = forms.CharField(label='توضیحات (اختیاری)', required=False, widget=forms.Textarea) + submission_key = forms.UUIDField(widget=forms.HiddenInput) + + def __init__(self, *args, customer, notice_ids=None, **kwargs): + super().__init__(*args, **kwargs) + notices = PaymentNotice.objects.filter(customer=customer, cancelled_at__isnull=True) + if notice_ids is not None: + notices = notices.filter(pk__in=notice_ids) + self.fields['notice_id'].queryset = notices + self.fields['notice_id'].label_from_instance = lambda n: f'{n.subject} — {n.amount:,} ریال — {n.invoice_number}' + accounts = Q(is_active=True, available_to_customers=True) | Q(is_active=True, paymentnotice__in=notices) + self.fields['destination_account'].queryset = CompanyBankAccount.objects.filter(accounts).distinct() + current = jdatetime.date.fromgregorian(date=timezone.localdate()) + self.fields['paid_year'].choices = [('', 'سال')] + [(str(y), str(y)) for y in range(current.year, current.year - 11, -1)] + self.initial.setdefault('submission_key', uuid.uuid4()) + self.initial.setdefault('paid_month', str(current.month)) + self.initial.setdefault('paid_year', str(current.year)) + + def clean_paid_amount(self): + return validators.amount(self.cleaned_data['paid_amount']) + + def clean_receipt_image(self): + upload = self.cleaned_data['receipt_image'] + self.image_metadata = validators.image(upload) + return upload + + def clean(self): + data = super().clean() + notice = data.get('notice_id') if data.get('receipt_mode') == 'linked' else None + data['notice_id'] = notice + if data.get('receipt_mode') == 'linked' and not notice: + self.add_error('notice_id', 'اعلامیه را انتخاب کنید یا واریز مستقل را بزنید.') + try: + data['paid_date'] = validators.paid_date(data.get('paid_day'), data.get('paid_month'), data.get('paid_year')) + if self.data.get('paid_date') and self.data['paid_date'] != data['paid_date'].isoformat(): + raise forms.ValidationError('تاریخ ارسالی با تاریخ انتخاب‌شده سازگار نیست؛ دوباره انتخاب کنید.') + except forms.ValidationError as error: + self.add_error('paid_day', error) + account = data.get('destination_account') + if account and not account.available_to_customers and (not notice or notice.account_id != account.pk): + self.add_error('destination_account', 'این حساب فقط برای اعلامیه مربوط به آن قابل انتخاب است.') + return data diff --git a/apps/payments/link_views.py b/apps/payments/link_views.py new file mode 100644 index 0000000..57629a8 --- /dev/null +++ b/apps/payments/link_views.py @@ -0,0 +1,40 @@ +import re + +from django.contrib.auth import logout +from django.db import transaction +from django.db.models import F +from django.shortcuts import redirect, render +from django.utils import timezone +from django.views.decorators.cache import never_cache +from django.views.decorators.http import require_http_methods + +from . import access, services +from .models import PaymentAccessLink + + +@never_cache +@require_http_methods(['GET', 'POST']) +def open_link(request, token): + link = access.active_links().filter(token_hash=access.token_digest(token)).first() if re.fullmatch(r'[A-Za-z0-9_-]{43}', token) else None + if link is None: + return access.unavailable_link(request) + if request.user.is_authenticated and request.user.pk != link.customer_id: + confirmed = request.method == 'POST' and request.POST.get('action') == 'logout_and_open' and request.POST.get('account_id') == str(request.user.pk) + if not confirmed: + response = render(request, 'payments/link_confirmation.html') + response['Referrer-Policy'] = 'same-origin' + response['X-Robots-Tag'] = 'noindex, nofollow' + return response + logout(request) + if not request.user.is_authenticated: + access.grant_payment_access(request, link) + with transaction.atomic(): + PaymentAccessLink.objects.filter(pk=link.pk).update(view_count=F('view_count') + 1, last_used_at=timezone.now()) + services.audit(None, 'PAYMENT_LINK_OPENED', link, { + 'ip_address': request.META.get('REMOTE_ADDR', ''), + 'user_agent': request.META.get('HTTP_USER_AGENT', '')[:500]}) + target = link.receipt or link.notice + response = redirect('payments:receipt_detail' if link.receipt_id else 'payments:notice_detail', public_id=target.public_id) + response['Referrer-Policy'] = 'no-referrer' + response['X-Robots-Tag'] = 'noindex, nofollow' + return response diff --git a/apps/payments/middleware.py b/apps/payments/middleware.py new file mode 100644 index 0000000..1a396d0 --- /dev/null +++ b/apps/payments/middleware.py @@ -0,0 +1,53 @@ +"""Audit administrative financial grant changes with the actual request actor. +Only selected non-secret fields and payments permissions are captured.""" +from django.apps import apps +from django.db import OperationalError, ProgrammingError +from .models import PaymentAuditEvent + +TARGETS = {'accounts/user': 'accounts.User', 'auth/group': 'auth.Group', + 'staff/organizationalunit': 'staff.OrganizationalUnit', 'staff/unitmembership': 'staff.UnitMembership', + 'staff/staffprofile': 'staff.StaffProfile'} + + +def access_snapshot(obj): + fields = ['is_active','account_type','user_id','unit_id','ticket_scope','role','primary_unit_id'] + result = {field:getattr(obj,field) for field in fields if hasattr(obj,field)} + for name in ['permissions','user_permissions']: + relation = getattr(obj,name,None) + if relation is not None: + result[name] = sorted(relation.filter(content_type__app_label='payments').values_list('codename',flat=True)) + if hasattr(obj,'groups'): + result['groups'] = sorted(obj.groups.filter(permissions__content_type__app_label='payments').values_list('pk',flat=True).distinct()) + return result + + +class FinancialGrantAuditMiddleware: + def __init__(self,get_response): + self.get_response=get_response + + def __call__(self,request): + target = self.target(request) + before = access_snapshot(target) if target else None + response = self.get_response(request) + if target and response.status_code < 400: + try: + target.refresh_from_db() + after=access_snapshot(target) + if before != after: + from .services import audit + audit(request.user,'FINANCIAL_ACCESS_CHANGED',target,{'before':before,'after':after}) + except target.DoesNotExist: + from .services import audit + audit(request.user,'FINANCIAL_ACCESS_REMOVED',target,{'before':before}) + return response + + def target(self,request): + if request.method != 'POST' or not request.user.is_authenticated or not request.path.startswith('/x5admin/'): + return None + parts=request.path.strip('/').split('/') + if len(parts)<5 or not parts[3].isdecimal(): + return None + label=TARGETS.get('/'.join(parts[1:3])) + if not label: + return None + return apps.get_model(label).objects.filter(pk=parts[3]).first() diff --git a/apps/payments/migrations/0001_initial.py b/apps/payments/migrations/0001_initial.py new file mode 100644 index 0000000..9cc3734 --- /dev/null +++ b/apps/payments/migrations/0001_initial.py @@ -0,0 +1,269 @@ +# Generated by Django 5.1.1 on 2026-10-05 10:44 + +import apps.payments.models +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('notifications', '0017_alter_smstemplate_event_type'), + ('staff', '0012_organizationalunit_supervisor_mobile_5'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='BankInstitution', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.SlugField(unique=True)), + ('name', models.CharField(max_length=100)), + ('is_active', models.BooleanField(default=True)), + ('historical', models.BooleanField(default=False)), + ], + options={ + 'ordering': ['historical', 'name'], + }, + ), + migrations.CreateModel( + name='PaymentAccessConfiguration', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('singleton', models.PositiveSmallIntegerField(default=1, editable=False, unique=True)), + ('open_view', models.BooleanField(default=True, verbose_name='مشاهده برای همه کارشناسان فعال')), + ('open_create', models.BooleanField(default=True, verbose_name='ایجاد برای همه کارشناسان فعال')), + ], + options={ + 'permissions': [('manage_payment_access', 'Manage payment access')], + }, + ), + migrations.CreateModel( + name='CompanyBankAccount', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('name', models.CharField(max_length=120, verbose_name='نام نمایشی حساب')), + ('holder', models.CharField(max_length=150, verbose_name='صاحب حساب')), + ('account_number', models.CharField(blank=True, max_length=40, verbose_name='شماره حساب')), + ('iban', models.CharField(blank=True, max_length=26, verbose_name='شبا')), + ('card_number', models.CharField(blank=True, max_length=16, verbose_name='شماره کارت')), + ('requires_deposit_id', models.BooleanField(default=False, verbose_name='شناسه واریز الزامی')), + ('is_active', models.BooleanField(default=True, verbose_name='فعال کلی')), + ('available_to_staff', models.BooleanField(default=True, verbose_name='قابل انتخاب کارشناسان')), + ('available_to_customers', models.BooleanField(default=True, verbose_name='قابل انتخاب مشتریان')), + ('sort_order', models.PositiveIntegerField(default=0)), + ('bank', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='payments.bankinstitution', verbose_name='بانک')), + ], + options={ + 'ordering': ['sort_order', 'name', 'pk'], + 'permissions': [('manage_company_bankaccounts', 'Manage company bank accounts')], + }, + ), + migrations.CreateModel( + name='PaymentAuditEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('actor_name', models.CharField(max_length=150)), + ('action', models.CharField(max_length=50)), + ('object_type', models.CharField(max_length=50)), + ('object_id', models.CharField(max_length=64)), + ('details', models.JSONField(default=dict)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('actor', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at', '-pk'], + 'permissions': [('view_payment_audit', 'View financial audit')], + }, + ), + migrations.CreateModel( + name='PaymentNotice', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('account_snapshot', models.JSONField()), + ('subject', models.CharField(max_length=200)), + ('amount', models.BigIntegerField()), + ('deposit_id', models.CharField(blank=True, max_length=100)), + ('invoice_number', models.CharField(blank=True, max_length=100)), + ('description', models.TextField(blank=True)), + ('cancelled_at', models.DateTimeField(blank=True, null=True)), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='payments.companybankaccount')), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='issued_payment_notices', to=settings.AUTH_USER_MODEL)), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payment_notices', to=settings.AUTH_USER_MODEL)), + ('owning_unit', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='staff.organizationalunit')), + ], + options={ + 'ordering': ['-created_at', '-pk'], + }, + ), + migrations.CreateModel( + name='PaymentNotification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('context', models.JSONField()), + ('rendered_body', models.TextField(blank=True)), + ('template_snapshot', models.JSONField(default=dict)), + ('job', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='payment_notification', to='notifications.notificationjob')), + ('notice', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.paymentnotice')), + ('unit', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='staff.organizationalunit')), + ], + ), + migrations.CreateModel( + name='PaymentNotificationAttempt', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('started_at', models.DateTimeField(auto_now_add=True)), + ('finished_at', models.DateTimeField(null=True)), + ('success', models.BooleanField(null=True)), + ('provider_code', models.CharField(blank=True, max_length=50)), + ('provider_id', models.CharField(blank=True, max_length=500)), + ('error', models.TextField(blank=True)), + ('notification', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='attempt_log', to='payments.paymentnotification')), + ], + ), + migrations.CreateModel( + name='PaymentReceipt', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('status', models.CharField(choices=[('PENDING', 'در انتظار بررسی'), ('PARTIAL', 'تأیید ناقص'), ('APPROVED', 'تأیید شده'), ('REJECTED', 'رد شده')], db_index=True, default='PENDING', max_length=12)), + ('submission_key', models.UUIDField(unique=True)), + ('current_version', models.PositiveIntegerField(default=1)), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payment_receipts', to=settings.AUTH_USER_MODEL)), + ('notice', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='receipts', to='payments.paymentnotice')), + ('owning_unit', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='staff.organizationalunit')), + ('submitted_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='submitted_payment_receipts', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at', '-pk'], + 'permissions': [('approve_paymentreceipt', 'Approve payment receipt'), ('senior_approve_paymentreceipt', 'Senior final approval'), ('submit_customer_receipt', 'Submit receipt for customer'), ('view_all_payments', 'View payments across units')], + }, + ), + migrations.CreateModel( + name='PaymentReceiptRevision', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('version', models.PositiveIntegerField()), + ('amount', models.BigIntegerField()), + ('paid_date', models.DateField()), + ('account_snapshot', models.JSONField()), + ('origin_other', models.CharField(blank=True, max_length=100)), + ('deposit_id', models.CharField(blank=True, max_length=100)), + ('tracking_number', models.CharField(blank=True, max_length=100)), + ('description', models.TextField(blank=True)), + ('destination_mismatch', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('image', models.FileField(max_length=500, upload_to=apps.payments.models.receipt_path)), + ('mime_type', models.CharField(max_length=100)), + ('sha256', models.CharField(max_length=64)), + ('original_name', models.CharField(max_length=255)), + ('file_size', models.PositiveBigIntegerField()), + ('destination', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='payments.companybankaccount')), + ('origin_bank', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.bankinstitution')), + ('receipt', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='revisions', to='payments.paymentreceipt')), + ], + ), + migrations.CreateModel( + name='ReceiptApprovalCycle', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('policy_snapshot', models.JSONField(default=dict)), + ('status', models.CharField(choices=[('PENDING', 'در انتظار بررسی'), ('PARTIAL', 'تأیید ناقص'), ('APPROVED', 'تأیید شده'), ('REJECTED', 'رد شده')], default='PENDING', max_length=12)), + ('finalized_at', models.DateTimeField(blank=True, null=True)), + ('finalized_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ('revision', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='cycle', to='payments.paymentreceiptrevision')), + ], + ), + migrations.CreateModel( + name='ReceiptApprovalAssignment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_senior', models.BooleanField(default=False)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ('cycle', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='assignments', to='payments.receiptapprovalcycle')), + ], + ), + migrations.AddField( + model_name='paymentnotification', + name='cycle', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.receiptapprovalcycle'), + ), + migrations.CreateModel( + name='ReceiptDecision', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('action', models.CharField(choices=[('APPROVE', 'تأیید'), ('REJECT', 'رد'), ('SENIOR_APPROVE', 'تأیید ارشد')], max_length=20)), + ('reason', models.TextField(blank=True)), + ('actor_name', models.CharField(max_length=150)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('cycle', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='decisions', to='payments.receiptapprovalcycle')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='PaymentApprovalPolicy', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=100, verbose_name='عنوان سیاست')), + ('is_active', models.BooleanField(default=True)), + ('reviewers', models.ManyToManyField(related_name='payment_review_policies', to=settings.AUTH_USER_MODEL, verbose_name='افراد الزامی تأیید')), + ('seniors', models.ManyToManyField(blank=True, related_name='payment_senior_policies', to=settings.AUTH_USER_MODEL, verbose_name='تأییدکنندگان ارشد')), + ('unit', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='staff.organizationalunit', verbose_name='واحد (خالی: عمومی)')), + ], + options={ + 'permissions': [('manage_payment_approval_policy', 'Manage payment approval policy')], + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_active', True), ('unit__isnull', False)), fields=('unit',), name='pay_active_unit_policy'), models.UniqueConstraint(condition=models.Q(('is_active', True), ('unit__isnull', True)), fields=('is_active',), name='pay_active_global_policy')], + }, + ), + migrations.AddIndex( + model_name='paymentnotice', + index=models.Index(fields=['customer', '-created_at'], name='payments_pa_custome_bc6e3f_idx'), + ), + migrations.AddIndex( + model_name='paymentnotice', + index=models.Index(fields=['owning_unit', '-created_at'], name='payments_pa_owning__f2b6af_idx'), + ), + migrations.AddConstraint( + model_name='paymentnotice', + constraint=models.CheckConstraint(condition=models.Q(('amount__gt', 0), ('amount__lte', 9007199254740991)), name='pay_notice_amount'), + ), + migrations.AddIndex( + model_name='paymentreceipt', + index=models.Index(fields=['customer', '-created_at'], name='payments_pa_custome_e266cc_idx'), + ), + migrations.AddIndex( + model_name='paymentreceipt', + index=models.Index(fields=['status', '-created_at'], name='payments_pa_status_5cd50b_idx'), + ), + migrations.AddConstraint( + model_name='paymentreceiptrevision', + constraint=models.UniqueConstraint(fields=('receipt', 'version'), name='pay_receipt_revision'), + ), + migrations.AddConstraint( + model_name='paymentreceiptrevision', + constraint=models.CheckConstraint(condition=models.Q(('amount__gt', 0), ('amount__lte', 9007199254740991)), name='pay_receipt_amount'), + ), + migrations.AddConstraint( + model_name='receiptapprovalassignment', + constraint=models.UniqueConstraint(fields=('cycle', 'user', 'is_senior'), name='pay_cycle_assignment'), + ), + migrations.AddConstraint( + model_name='receiptdecision', + constraint=models.UniqueConstraint(fields=('cycle', 'user'), name='pay_one_vote_per_user'), + ), + ] diff --git a/apps/payments/migrations/0002_initial_configuration.py b/apps/payments/migrations/0002_initial_configuration.py new file mode 100644 index 0000000..c042fcd --- /dev/null +++ b/apps/payments/migrations/0002_initial_configuration.py @@ -0,0 +1,23 @@ +from django.db import migrations + +BANKS = [["melli","ملی ایران"],["mellat","ملت"],["saderat","صادرات ایران"],["sepah","سپه"],["tejarat","تجارت"],["refah","رفاه کارگران"],["keshavarzi","کشاورزی"],["maskan","مسکن"],["sanat-madan","صنعت و معدن"],["tosee-saderat","توسعه صادرات ایران"],["tosee-taavon","توسعه تعاون"],["post","پست بانک ایران"],["pasargad","پاسارگاد"],["parsian","پارسیان"],["saman","سامان"],["eghtesad-novin","اقتصاد نوین"],["karafarin","کارآفرین"],["sina","سینا"],["shahr","شهر"],["sarmayeh","سرمایه"],["dey","دی"],["gardeshgari","گردشگری"],["iran-zamin","ایران زمین"],["khavarmianeh","خاورمیانه"],["resalat","قرض‌الحسنه رسالت"],["mehr-iran","قرض‌الحسنه مهر ایران"],["iran-venezuela","مشترک ایران و ونزوئلا"],["melal","مؤسسه اعتباری ملل"],["ayandeh","آینده"],["ansar","انصار"],["ghavamin","قوامین"],["hekmat","حکمت ایرانیان"],["mehr-eghtesad","مهر اقتصاد"],["kosar","مؤسسه کوثر"],["noor","مؤسسه نور"],["tosee","مؤسسه توسعه"]] +TAGS = ['recipient_name','company_name','notice_number','receipt_number','amount_rial','invoice_number','payment_link'] + + +def seed(apps, schema_editor): + Bank = apps.get_model('payments', 'BankInstitution') + Template = apps.get_model('notifications', 'SMSTemplate') + for code, name in BANKS: + Bank.objects.get_or_create(code=code, defaults={'name':name, 'historical':code in {'ayandeh','ansar','ghavamin','hekmat','mehr-eghtesad','kosar','noor','tosee'}}) + Bank.objects.get_or_create(code='other', defaults={'name': 'سایر'}) + for event, title, body in [ + ('PAYMENT_NOTICE_CREATED','اعلامیه واریز','{recipient_name}، اعلامیه واریز {notice_number} به مبلغ {amount_rial} ریال برای شما صادر شد.\nمشاهده اطلاعات: {payment_link}\n{company_name}'), + ('PAYMENT_RECEIPT_APPROVED','تأیید فیش','{recipient_name}، فیش واریز شما به شماره {receipt_number} و مبلغ {amount_rial} ریال تأیید شد.\nپیگیری: {payment_link}\n{company_name}'), + ]: + Template.objects.get_or_create(unit=None,event_type=event, defaults={'title':title,'body':body,'allowed_tags':TAGS,'is_active':True}) + apps.get_model('payments','PaymentAccessConfiguration').objects.get_or_create(singleton=1) + + +class Migration(migrations.Migration): + dependencies = [('payments','0001_initial'), ('notifications','0017_alter_smstemplate_event_type')] + operations = [migrations.RunPython(seed, migrations.RunPython.noop)] diff --git a/apps/payments/migrations/0003_paymentnotice_issuance_key.py b/apps/payments/migrations/0003_paymentnotice_issuance_key.py new file mode 100644 index 0000000..1a5ce17 --- /dev/null +++ b/apps/payments/migrations/0003_paymentnotice_issuance_key.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.1 on 2026-10-05 11:05 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0002_initial_configuration'), + ] + + operations = [ + migrations.AddField( + model_name='paymentnotice', + name='issuance_key', + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ] diff --git a/apps/payments/migrations/0004_paymentaccesslink_paymentnotification_access_link_and_more.py b/apps/payments/migrations/0004_paymentaccesslink_paymentnotification_access_link_and_more.py new file mode 100644 index 0000000..810c49c --- /dev/null +++ b/apps/payments/migrations/0004_paymentaccesslink_paymentnotification_access_link_and_more.py @@ -0,0 +1,46 @@ +# Generated by Django 5.1.1 on 2026-10-06 05:38 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0003_paymentnotice_issuance_key'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='PaymentAccessLink', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token_hash', models.CharField(editable=False, max_length=64, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('expires_at', models.DateTimeField()), + ('revoked_at', models.DateTimeField(blank=True, null=True)), + ('view_count', models.PositiveIntegerField(default=0)), + ('last_used_at', models.DateTimeField(blank=True, null=True)), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ('notice', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.paymentnotice')), + ('receipt', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.paymentreceipt')), + ('submitted_receipts', models.ManyToManyField(blank=True, related_name='access_links', to='payments.paymentreceipt')), + ], + options={ + 'verbose_name': 'لینک موقت پرداخت', + 'verbose_name_plural': 'لینک\u200cهای موقت پرداخت', + 'ordering': ['-created_at'], + }, + ), + migrations.AddField( + model_name='paymentnotification', + name='access_link', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.paymentaccesslink'), + ), + migrations.AddConstraint( + model_name='paymentaccesslink', + constraint=models.CheckConstraint(condition=models.Q(('notice__isnull', False), ('receipt__isnull', False), _connector='OR'), name='payment_link_has_target'), + ), + ] diff --git a/apps/payments/migrations/__init__.py b/apps/payments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/payments/models.py b/apps/payments/models.py new file mode 100644 index 0000000..9d65da1 --- /dev/null +++ b/apps/payments/models.py @@ -0,0 +1,234 @@ +"""Financial records are independent of tickets; issued instructions and evidence are snapshots.""" +import uuid +from pathlib import Path +from django.conf import settings +from django.db import models +from apps.core.models import PublicIdModel, TimeStampedModel + +MAX_AMOUNT = 9_007_199_254_740_991 + + +class BankInstitution(models.Model): + code = models.SlugField(unique=True) + name = models.CharField(max_length=100) + is_active = models.BooleanField(default=True) + historical = models.BooleanField(default=False) + + class Meta: + ordering = ['historical', 'name'] + + def __str__(self): + return self.name + + +class CompanyBankAccount(PublicIdModel, TimeStampedModel): + name = models.CharField('نام نمایشی حساب', max_length=120) + bank = models.ForeignKey(BankInstitution, on_delete=models.PROTECT, verbose_name='بانک') + holder = models.CharField('صاحب حساب', max_length=150) + account_number = models.CharField('شماره حساب', max_length=40, blank=True) + iban = models.CharField('شبا', max_length=26, blank=True) + card_number = models.CharField('شماره کارت', max_length=16, blank=True) + requires_deposit_id = models.BooleanField('شناسه واریز الزامی', default=False) + is_active = models.BooleanField('فعال کلی', default=True) + available_to_staff = models.BooleanField('قابل انتخاب کارشناسان', default=True) + available_to_customers = models.BooleanField('قابل انتخاب مشتریان', default=True) + sort_order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ['sort_order', 'name', 'pk'] + permissions = [('manage_company_bankaccounts', 'Manage company bank accounts')] + + def __str__(self): + return self.name + + def snapshot(self): + return {key: getattr(self, key) for key in ('name', 'holder', 'account_number', 'iban', 'card_number', 'requires_deposit_id')} | {'bank': self.bank.name, 'id': str(self.public_id)} + + +class PaymentAccessConfiguration(models.Model): + singleton = models.PositiveSmallIntegerField(default=1, unique=True, editable=False) + open_view = models.BooleanField('مشاهده برای همه کارشناسان فعال', default=True) + open_create = models.BooleanField('ایجاد برای همه کارشناسان فعال', default=True) + + class Meta: + permissions = [('manage_payment_access', 'Manage payment access')] + + +class PaymentApprovalPolicy(TimeStampedModel): + name = models.CharField('عنوان سیاست', max_length=100) + unit = models.ForeignKey('staff.OrganizationalUnit', null=True, blank=True, on_delete=models.PROTECT, verbose_name='واحد (خالی: عمومی)') + reviewers = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='payment_review_policies', verbose_name='افراد الزامی تأیید') + seniors = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='payment_senior_policies', blank=True, verbose_name='تأییدکنندگان ارشد') + is_active = models.BooleanField(default=True) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=['unit'], condition=models.Q(is_active=True, unit__isnull=False), name='pay_active_unit_policy'), + models.UniqueConstraint(fields=['is_active'], condition=models.Q(is_active=True, unit__isnull=True), name='pay_active_global_policy'), + ] + permissions = [('manage_payment_approval_policy', 'Manage payment approval policy')] + + def __str__(self): + return self.name + + +class PaymentNotice(PublicIdModel, TimeStampedModel): + issuance_key = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) + customer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='payment_notices') + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='issued_payment_notices') + owning_unit = models.ForeignKey('staff.OrganizationalUnit', null=True, on_delete=models.PROTECT) + account = models.ForeignKey(CompanyBankAccount, on_delete=models.PROTECT) + account_snapshot = models.JSONField() + subject = models.CharField(max_length=200) + amount = models.BigIntegerField() + deposit_id = models.CharField(max_length=100, blank=True) + invoice_number = models.CharField(max_length=100, blank=True) + description = models.TextField(blank=True) + cancelled_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ['-created_at', '-pk'] + constraints = [models.CheckConstraint(condition=models.Q(amount__gt=0, amount__lte=MAX_AMOUNT), name='pay_notice_amount')] + indexes = [models.Index(fields=['customer', '-created_at']), models.Index(fields=['owning_unit', '-created_at'])] + + @property + def number(self): + return f'PN-{self.public_id.hex[:12].upper()}' + + +class PaymentReceipt(PublicIdModel, TimeStampedModel): + class Status(models.TextChoices): + PENDING = 'PENDING', 'در انتظار بررسی' + PARTIAL = 'PARTIAL', 'تأیید ناقص' + APPROVED = 'APPROVED', 'تأیید شده' + REJECTED = 'REJECTED', 'رد شده' + customer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='payment_receipts') + submitted_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='submitted_payment_receipts') + notice = models.ForeignKey(PaymentNotice, null=True, blank=True, on_delete=models.PROTECT, related_name='receipts') + owning_unit = models.ForeignKey('staff.OrganizationalUnit', null=True, on_delete=models.PROTECT) + status = models.CharField(max_length=12, choices=Status.choices, default=Status.PENDING, db_index=True) + submission_key = models.UUIDField(unique=True) + current_version = models.PositiveIntegerField(default=1) + + class Meta: + ordering = ['-created_at', '-pk'] + indexes = [models.Index(fields=['customer', '-created_at']), models.Index(fields=['status', '-created_at'])] + permissions = [('approve_paymentreceipt', 'Approve payment receipt'), ('senior_approve_paymentreceipt', 'Senior final approval'), ('submit_customer_receipt', 'Submit receipt for customer'), ('view_all_payments', 'View payments across units')] + + @property + def number(self): + return f'PR-{self.public_id.hex[:12].upper()}' + + +def receipt_path(instance, filename): + return f'payments/{instance.created_at:%Y/%m}/{uuid.uuid4().hex}{Path(filename).suffix.lower()}' + + +class PaymentReceiptRevision(models.Model): + receipt = models.ForeignKey(PaymentReceipt, on_delete=models.PROTECT, related_name='revisions') + version = models.PositiveIntegerField() + amount = models.BigIntegerField() + paid_date = models.DateField() + destination = models.ForeignKey(CompanyBankAccount, on_delete=models.PROTECT) + account_snapshot = models.JSONField() + origin_bank = models.ForeignKey(BankInstitution, null=True, blank=True, on_delete=models.PROTECT) + origin_other = models.CharField(max_length=100, blank=True) + deposit_id = models.CharField(max_length=100, blank=True) + tracking_number = models.CharField(max_length=100, blank=True) + description = models.TextField(blank=True) + destination_mismatch = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + image = models.FileField(upload_to=receipt_path, max_length=500) + mime_type = models.CharField(max_length=100) + sha256 = models.CharField(max_length=64) + original_name = models.CharField(max_length=255) + file_size = models.PositiveBigIntegerField() + + class Meta: + constraints = [models.UniqueConstraint(fields=['receipt', 'version'], name='pay_receipt_revision'), models.CheckConstraint(condition=models.Q(amount__gt=0, amount__lte=MAX_AMOUNT), name='pay_receipt_amount')] + + +class ReceiptApprovalCycle(models.Model): + revision = models.OneToOneField(PaymentReceiptRevision, on_delete=models.PROTECT, related_name='cycle') + policy_snapshot = models.JSONField(default=dict) + status = models.CharField(max_length=12, choices=PaymentReceipt.Status.choices, default=PaymentReceipt.Status.PENDING) + finalized_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.PROTECT) + finalized_at = models.DateTimeField(null=True, blank=True) + + +class ReceiptApprovalAssignment(models.Model): + cycle = models.ForeignKey(ReceiptApprovalCycle, on_delete=models.PROTECT, related_name='assignments') + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT) + is_senior = models.BooleanField(default=False) + + class Meta: + constraints = [models.UniqueConstraint(fields=['cycle', 'user', 'is_senior'], name='pay_cycle_assignment')] + + +class ReceiptDecision(models.Model): + cycle = models.ForeignKey(ReceiptApprovalCycle, on_delete=models.PROTECT, related_name='decisions') + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT) + action = models.CharField(max_length=20, choices=[('APPROVE', 'تأیید'), ('REJECT', 'رد'), ('SENIOR_APPROVE', 'تأیید ارشد')]) + reason = models.TextField(blank=True) + actor_name = models.CharField(max_length=150) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [models.UniqueConstraint(fields=['cycle', 'user'], name='pay_one_vote_per_user')] + + +class PaymentAuditEvent(models.Model): + actor = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL) + actor_name = models.CharField(max_length=150) + action = models.CharField(max_length=50) + object_type = models.CharField(max_length=50) + object_id = models.CharField(max_length=64) + details = models.JSONField(default=dict) + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + + class Meta: + ordering = ['-created_at', '-pk'] + permissions = [('view_payment_audit', 'View financial audit')] + + +class PaymentAccessLink(models.Model): + """Revocable capability, separate from customer login and ticket magic links.""" + customer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT) + notice = models.ForeignKey(PaymentNotice, null=True, blank=True, on_delete=models.PROTECT) + receipt = models.ForeignKey(PaymentReceipt, null=True, blank=True, on_delete=models.PROTECT) + token_hash = models.CharField(max_length=64, unique=True, editable=False) + created_at = models.DateTimeField(auto_now_add=True) + expires_at = models.DateTimeField() + revoked_at = models.DateTimeField(null=True, blank=True) + submitted_receipts = models.ManyToManyField(PaymentReceipt, blank=True, related_name='access_links') + view_count = models.PositiveIntegerField(default=0) + last_used_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ['-created_at'] + constraints = [models.CheckConstraint( + condition=models.Q(notice__isnull=False) | models.Q(receipt__isnull=False), + name='payment_link_has_target')] + verbose_name = 'لینک موقت پرداخت' + verbose_name_plural = 'لینک‌های موقت پرداخت' + + +class PaymentNotification(models.Model): + job = models.OneToOneField('notifications.NotificationJob', on_delete=models.PROTECT, related_name='payment_notification') + notice = models.ForeignKey(PaymentNotice, null=True, on_delete=models.PROTECT) + cycle = models.ForeignKey(ReceiptApprovalCycle, null=True, on_delete=models.PROTECT) + unit = models.ForeignKey('staff.OrganizationalUnit', null=True, on_delete=models.PROTECT) + context = models.JSONField() + rendered_body = models.TextField(blank=True) + template_snapshot = models.JSONField(default=dict) + access_link = models.ForeignKey(PaymentAccessLink, null=True, blank=True, on_delete=models.PROTECT) + + +class PaymentNotificationAttempt(models.Model): + notification = models.ForeignKey(PaymentNotification, on_delete=models.PROTECT, related_name='attempt_log') + started_at = models.DateTimeField(auto_now_add=True) + finished_at = models.DateTimeField(null=True) + success = models.BooleanField(null=True) + provider_code = models.CharField(max_length=50, blank=True) + provider_id = models.CharField(max_length=500, blank=True) + error = models.TextField(blank=True) diff --git a/apps/payments/notifications.py b/apps/payments/notifications.py new file mode 100644 index 0000000..eb46093 --- /dev/null +++ b/apps/payments/notifications.py @@ -0,0 +1,71 @@ +from django.conf import settings +from django.db import transaction +from django.urls import reverse +from django.utils import timezone +from apps.notifications.services import enqueue +from .models import PaymentNotification, PaymentNotificationAttempt + +EVENTS = {'PAYMENT_NOTICE_CREATED', 'PAYMENT_RECEIPT_APPROVED'} +TAGS = ['recipient_name', 'company_name', 'notice_number', 'receipt_number', 'amount_rial', 'invoice_number', 'payment_link'] + + +def context(customer, notice=None, receipt=None, amount=0): + profile = getattr(customer, 'customer_profile', None) + return {'recipient_name': str(profile) if profile else customer.username, 'company_name': settings.COMPANY_NAME, + 'notice_number': notice.number if notice else '', 'receipt_number': receipt.number if receipt else '', + 'amount_rial': f'{amount:,}', 'invoice_number': notice.invoice_number if notice else '', + 'payment_link': settings.PUBLIC_BASE_URL + (reverse('payments:receipt_detail', args=[receipt.public_id]) if receipt else reverse('payments:notices') + f'?notice={notice.public_id}')} + + +def queue_notice(notice): + job = enqueue('SMS', 'PAYMENT_NOTICE_CREATED', None, f'payment-notice:{notice.public_id}', customer=notice.customer, staff_user=notice.created_by) + PaymentNotification.objects.get_or_create(job=job, defaults={'notice': notice, 'unit': notice.owning_unit, 'context': context(notice.customer, notice=notice, amount=notice.amount)}) + + +def queue_approval(receipt, cycle): + job = enqueue('SMS', 'PAYMENT_RECEIPT_APPROVED', None, f'payment-approved:{cycle.pk}', customer=receipt.customer, staff_user=cycle.finalized_by) + PaymentNotification.objects.get_or_create(job=job, defaults={'cycle': cycle, 'unit': receipt.owning_unit, 'context': context(receipt.customer, notice=receipt.notice, receipt=receipt, amount=cycle.revision.amount)}) + + +def send_payment_sms(job, provider=None, provider_factory=None): + from apps.notifications.models import SMSTemplate + from apps.notifications.management.commands.process_notifications import _render_sms + notification = PaymentNotification.objects.get(job=job) + # Persist the attempt before rendering/provider construction: failed preparation is history too. + attempt = PaymentNotificationAttempt.objects.create(notification=notification) + phase = 'آماده‌سازی قالب پیامک' + try: + with transaction.atomic(): + notification = PaymentNotification.objects.select_for_update().get(pk=notification.pk) + if not notification.access_link_id: + from .access import issue_payment_link + receipt = notification.cycle.revision.receipt if notification.cycle_id else None + link, url = issue_payment_link(notice=notification.notice, receipt=receipt) + notification.access_link = link + old_url = notification.context.get('payment_link', '') + if old_url and notification.rendered_body: + notification.rendered_body = notification.rendered_body.replace(old_url, url) + notification.context = {**notification.context, 'payment_link': url} + notification.save(update_fields=['context', 'access_link', 'rendered_body']) + if not notification.rendered_body: + body = _render_sms(job.event_type, notification.context, notification.unit) + template = SMSTemplate.objects.filter(event_type=job.event_type, is_active=True, unit=notification.unit).first() if notification.unit_id else None + template = template or SMSTemplate.objects.get(event_type=job.event_type, is_active=True, unit__isnull=True) + notification.rendered_body = body + notification.template_snapshot = {'id': template.pk, 'updated_at': template.updated_at.isoformat()} + notification.save(update_fields=['rendered_body', 'template_snapshot', 'context', 'access_link']) + phase = 'ارتباط با ارائه‌دهنده پیامک' + provider = provider if provider is not None else provider_factory() + result = provider.send_message(job.customer.mobile, notification.rendered_body) + attempt.success = result.success + attempt.provider_id = str(result.provider_id or '')[:500] + attempt.provider_code = str(result.provider_code or '')[:50] + attempt.error = str(result.error or '')[:1000] + return result, None + except Exception: + attempt.success = False + attempt.error = 'خطا در ' + phase + raise + finally: + attempt.finished_at = timezone.now() + attempt.save() diff --git a/apps/payments/policies.py b/apps/payments/policies.py new file mode 100644 index 0000000..6b24868 --- /dev/null +++ b/apps/payments/policies.py @@ -0,0 +1,64 @@ +from django.db.models import Q +from .models import PaymentAccessConfiguration + + +def is_staff(user): + profile = getattr(user, 'staff_profile', None) + return bool(user.is_authenticated and user.is_active and user.account_type == 'STAFF' and (profile is None or profile.is_active)) + + +def has_permission(user, codename, unit=None): + if not is_staff(user): + return False + if user.has_perm(f'payments.{codename}'): + return True + if unit is None or not unit.is_active: + return False + return user.unit_memberships.filter(is_active=True, unit=unit).filter( + Q(permissions__content_type__app_label='payments', permissions__codename=codename) | + Q(unit__permissions__content_type__app_label='payments', unit__permissions__codename=codename) + ).exists() + + +def config(): + # No get_or_create on a read request; defaults keep initial view/create open only. + return PaymentAccessConfiguration.objects.first() or PaymentAccessConfiguration() + + +def can_create(user, unit=None): + if unit is None and is_staff(user): + from apps.tickets.policies import TicketPolicy + unit = TicketPolicy.primary_unit(user) + return is_staff(user) and (config().open_create or has_permission(user, 'add_paymentnotice', unit)) + + +def can_manage_banks(user): + profile = getattr(user, 'staff_profile', None) + return is_staff(user) and (user.is_superuser or getattr(profile, 'role', '') == 'CEO' or has_permission(user, 'manage_company_bankaccounts')) + + +def visible(queryset, user): + if not user.is_authenticated or not user.is_active: + return queryset.none() + if user.account_type == 'CUSTOMER': + return queryset.filter(customer=user) + if not is_staff(user): + return queryset.none() + if config().open_view or user.has_perm('payments.view_all_payments') or user.is_superuser: + return queryset + permission = f'view_{queryset.model._meta.model_name}' + if user.has_perm(f'payments.{permission}'): + return queryset + units = user.unit_memberships.filter(is_active=True, unit__is_active=True).filter( + Q(permissions__content_type__app_label='payments', permissions__codename=permission) | + Q(unit__permissions__content_type__app_label='payments', unit__permissions__codename=permission) + ).values_list('unit_id', flat=True) + return queryset.filter(owning_unit_id__in=units).distinct() + + +def can_decide(user, receipt, cycle, senior=False): + code = 'senior_approve_paymentreceipt' if senior else 'approve_paymentreceipt' + return (is_staff(user) and receipt.submitted_by_id != user.pk and + has_permission(user, code, receipt.owning_unit) and + visible(type(receipt).objects.filter(pk=receipt.pk), user).exists() and + cycle.assignments.filter(user=user, is_senior=senior).exists()) diff --git a/apps/payments/selectors.py b/apps/payments/selectors.py new file mode 100644 index 0000000..9ebe03c --- /dev/null +++ b/apps/payments/selectors.py @@ -0,0 +1,78 @@ +"""Bounded-query projections use only the current evidence revision for financial totals.""" +from django.urls import reverse +from django.db.models import BigIntegerField, F, OuterRef, Subquery, Sum, Value +from django.db.models.functions import Coalesce +from .models import PaymentReceiptRevision + + +def notices_with_totals(queryset): + approved = PaymentReceiptRevision.objects.filter( + receipt__notice_id=OuterRef('pk'), receipt__status='APPROVED', + version=F('receipt__current_version'), destination_mismatch=False + ).values('receipt__notice_id').annotate(total=Sum('amount')).values('total') + return queryset.annotate(approved_total=Coalesce(Subquery(approved), Value(0), output_field=BigIntegerField())) + + +def receipts_with_current_values(queryset): + current = PaymentReceiptRevision.objects.filter(receipt_id=OuterRef('pk'), version=OuterRef('current_version')) + return queryset.annotate(current_amount=Subquery(current.values('amount')[:1]), + current_paid_date=Subquery(current.values('paid_date')[:1])) + + +def notice_form_data(notice, staff=False): + """Customer-owned notice summary; account numbers stay on the notice page.""" + return {'id': str(notice.pk), 'subject': notice.subject, 'amount': str(notice.amount), + 'account': notice.account_id, 'deposit': notice.deposit_id, + 'invoice': notice.invoice_number, + 'url': reverse('staff_payments:notice_detail' if staff else 'payments:notice_detail', args=[notice.public_id])} + + +def receipt_review_summaries(receipts): + """Attach current-version decisions in one query, preserving snapshot actor names.""" + from .models import ReceiptDecision + receipts = list(receipts) + indexed = {receipt.pk: receipt for receipt in receipts} + for receipt in receipts: + receipt.review_decisions = [] + if indexed: + decisions = ReceiptDecision.objects.filter( + cycle__revision__receipt_id__in=indexed, + cycle__revision__version=F('cycle__revision__receipt__current_version') + ).select_related('cycle__revision').order_by('created_at', 'pk') + for decision in decisions: + indexed[decision.cycle.revision.receipt_id].review_decisions.append(decision) + return receipts + + +def attach_notice_receipts(notices, visible_receipts): + """Only fetch linked receipts visible to the current viewer, for this page.""" + notices = list(notices) + indexed = {notice.pk: notice for notice in notices} + for notice in notices: + notice.related_receipts = [] + if indexed: + receipts = receipt_review_summaries(receipts_with_current_values( + visible_receipts.filter(notice_id__in=indexed))) + for receipt in receipts: + indexed[receipt.notice_id].related_receipts.append(receipt) + return notices + + +def recent_pending_notice_choices(form, retain_original=None): + """Limit the visible menu, keeping server-side ownership validation unsliced. + + An explicitly selected pending notice replaces the last option when needed. + A revision retains its original notice even if it has since been settled. + This does not change which notices the financial service may validate. + """ + field = form.fields['notice_id'] + pending = notices_with_totals(field.queryset).filter(amount__gt=F('approved_total')) + available = list(pending.order_by('-created_at', '-pk')[:10]) + selected = form['notice_id'].value() + if selected and not any(str(n.pk) == str(selected) for n in available): + source = field.queryset if str(selected) == str(retain_original) else pending + selected_notice = source.filter(pk=selected).first() if str(selected).isdecimal() and len(str(selected)) <= 19 and int(selected) <= 9223372036854775807 else None + if selected_notice: + available = [selected_notice] + available[:9] + field.widget.choices = [('', field.empty_label)] + [(n.pk, field.label_from_instance(n)) for n in available] + return available diff --git a/apps/payments/services.py b/apps/payments/services.py new file mode 100644 index 0000000..0e4bda0 --- /dev/null +++ b/apps/payments/services.py @@ -0,0 +1,286 @@ +"""All business writes go through these atomic, permission-checked operations.""" +from django.core.exceptions import PermissionDenied, ValidationError +from django.core import serializers +import json +from django.db import transaction +from django.utils import timezone +from apps.accounts.models import User +from apps.tickets.policies import TicketPolicy +from . import policies, validators +from .models import (CompanyBankAccount, PaymentNotice, PaymentReceipt, PaymentReceiptRevision, + PaymentApprovalPolicy, ReceiptApprovalCycle, ReceiptApprovalAssignment, + ReceiptDecision, PaymentAuditEvent) + + +def actor_name(actor): + profile = getattr(actor, 'staff_profile', None) + return getattr(profile, 'display_name', '') or actor.get_full_name() or actor.username + + +def audit(actor, action, obj, details=None): + return PaymentAuditEvent.objects.create(actor=actor, actor_name=actor_name(actor) if actor else 'SYSTEM', + action=action, object_type=obj._meta.label_lower, object_id=str(getattr(obj, 'public_id', obj.pk)), details=details or {}) + + +def configuration_snapshot(obj): + """Serialize only the financial configuration passed here, including flags and M2M IDs.""" + return json.loads(serializers.serialize('json', [obj]))[0]['fields'] + + +@transaction.atomic +def issue_notice(actor, data): + unit = TicketPolicy.primary_unit(actor) + if not policies.can_create(actor, unit): + raise PermissionDenied + # Keep lock order consistent with receipt submission: customer, then account. + customer = User.objects.select_for_update().get(pk=data['customer'].pk, account_type='CUSTOMER', is_active=True) + account = CompanyBankAccount.objects.select_for_update(of=('self',)).select_related('bank').get(pk=data['account'].pk) + if not account.is_active or not account.available_to_staff: + raise ValidationError('این حساب دیگر برای صدور اعلامیه فعال نیست.') + deposit_id = validators.digits(data.get('deposit_id', '')) + if account.requires_deposit_id and not deposit_id: + raise ValidationError('برای این حساب شناسه واریز مشتری الزامی است.') + key = data.get('submission_key') + if key: + existing = PaymentNotice.objects.filter(issuance_key=key).first() + if existing: + if existing.created_by_id != actor.pk or existing.customer_id != customer.pk: + raise PermissionDenied + expected = {'account_id': account.pk, 'amount': validators.amount(data['amount']), + 'deposit_id': deposit_id, 'subject': data['subject'], + 'invoice_number': data.get('invoice_number', ''), 'description': data.get('description', '')} + if any(getattr(existing, field) != value for field, value in expected.items()): + raise ValidationError('این اعلامیه قبلاً ثبت شده است؛ برای اطلاعات متفاوت فرم تازه باز کنید.') + return existing + notice = PaymentNotice.objects.create(customer=customer, created_by=actor, owning_unit=unit, account=account, + account_snapshot=account.snapshot(), amount=validators.amount(data['amount']), deposit_id=deposit_id, + subject=data['subject'], invoice_number=data.get('invoice_number', ''), description=data.get('description', ''), + **({'issuance_key': key} if key else {})) + audit(actor, 'NOTICE_ISSUED', notice, {'account': account.snapshot(), 'amount': str(notice.amount), 'deposit_id': deposit_id}) + from .notifications import queue_notice + queue_notice(notice) + return notice + + +def policy_for(unit): + policy = PaymentApprovalPolicy.objects.filter(is_active=True, unit=unit).first() if unit else None + return policy or PaymentApprovalPolicy.objects.filter(is_active=True, unit__isnull=True).first() + + +def assign_cycle(revision, actor): + policy = policy_for(revision.receipt.owning_unit) + reviewer_ids = list(policy.reviewers.values_list('pk', flat=True)) if policy else [] + senior_ids = list(policy.seniors.values_list('pk', flat=True)) if policy else [] + selected = User.objects.filter(pk__in=set(reviewer_ids + senior_ids)) + if policy and (not reviewer_ids or any(not policies.is_staff(user) for user in selected)): + raise ValidationError('سیاست تأیید معتبر نیست؛ مدیر باید افراد فعال تأییدکننده را تعیین کند.') + cycle = ReceiptApprovalCycle.objects.create(revision=revision, policy_snapshot={ + 'policy_id': policy.pk if policy else None, 'name': policy.name if policy else 'هنوز تعیین نشده', + 'method': 'ALL_SELECTED', 'reviewers': reviewer_ids, 'seniors': senior_ids}) + ReceiptApprovalAssignment.objects.bulk_create([ + ReceiptApprovalAssignment(cycle=cycle, user_id=pk, is_senior=senior) + for senior, ids in [(False, reviewer_ids), (True, senior_ids)] for pk in ids]) + audit(actor, 'CYCLE_STARTED', revision.receipt, {'version': revision.version, 'policy': cycle.policy_snapshot}) + return cycle + + +def receipt_metadata(data): + return {'amount': data['paid_amount'], 'paid_date': data['paid_date'], 'destination': data['destination_account'], + 'origin_bank': data.get('origin_bank'), 'origin_other': data.get('origin_other', ''), + 'deposit_id': validators.digits(data.get('deposit_id', '')), 'tracking_number': data.get('tracking_number', ''), + 'description': data.get('description', '')} + + +def validate_destination(account, notice): + if not account.is_active: + raise ValidationError('این حساب غیرفعال شده است؛ برای ثبت پرداخت قبلی با کارشناس هماهنگ کنید.') + if not account.available_to_customers and (not notice or notice.account_id != account.pk): + raise ValidationError('حساب مقصد برای این واریز قابل انتخاب نیست.') + + +def _submit_receipt(actor, customer, data, file_info, created_files): + # Same-customer lock serializes retries of a submission nonce before inserting it. + User.objects.select_for_update().get(pk=customer.pk) + existing = PaymentReceipt.objects.filter(submission_key=data['submission_key']).first() + if existing: + revision = existing.revisions.get(version=1) + if existing.customer_id != customer.pk or existing.submitted_by_id != actor.pk: + raise PermissionDenied + if (revision.amount != data['paid_amount'] or revision.paid_date != data['paid_date'] or + revision.destination_id != data['destination_account'].pk or revision.sha256 != file_info['sha256'] or + existing.notice_id != getattr(data.get('notice_id'), 'pk', None)): + raise ValidationError('این ارسال قبلاً ثبت شده است؛ برای اطلاعات متفاوت فرم تازه باز کنید.') + return existing + notice = data.get('notice_id') + if notice: + notice = PaymentNotice.objects.select_for_update().get(pk=notice.pk, customer=customer) + if notice.cancelled_at: + raise ValidationError('اعلامیه لغو شده است؛ واریز مستقل را انتخاب کنید.') + account = CompanyBankAccount.objects.select_for_update().select_related('bank').get(pk=data['destination_account'].pk) + validate_destination(account, notice) + receipt = PaymentReceipt.objects.create(customer=customer, submitted_by=actor, notice=notice, + owning_unit=notice.owning_unit if notice else (TicketPolicy.primary_unit(actor) if policies.is_staff(actor) else None), submission_key=data['submission_key']) + fields = receipt_metadata(data) + fields['destination'] = account + fields['deposit_id'] = fields['deposit_id'] or (notice.deposit_id if notice and notice.account_id == account.pk else '') + revision = PaymentReceiptRevision(receipt=receipt, version=1, + account_snapshot=notice.account_snapshot if notice and notice.account_id == account.pk else account.snapshot(), + destination_mismatch=bool(notice and notice.account_id != account.pk), image=data['receipt_image'], **file_info, **fields) + try: + revision.save() + finally: + if revision.image and revision.image._committed: + created_files.append((revision.image.storage, revision.image.name)) + assign_cycle(revision, actor) + audit(actor, 'RECEIPT_SUBMITTED', receipt, {'version': 1, 'amount': str(revision.amount), 'sha256': revision.sha256}) + return receipt + + +def submit_receipt(actor, customer, data, *, access_link=None): + if not actor.is_active or customer.account_type != 'CUSTOMER' or not customer.is_active: + raise PermissionDenied + if actor.pk != customer.pk and not policies.can_create(actor): + raise PermissionDenied + if actor.account_type == 'CUSTOMER' and actor.pk != customer.pk: + raise PermissionDenied + data = dict(data) + data['paid_amount'] = validators.amount(data['paid_amount']) + if data['paid_date'] > timezone.localdate(): + raise ValidationError('تاریخ واریز نمی‌تواند بعد از امروز باشد.') + file_info = validators.image(data['receipt_image']) + # File storage is not transactional. Remove only the file created by this failed write. + created_files = [] + try: + with transaction.atomic(): + if access_link: + from .access import validate_link_submission + validate_link_submission(access_link, customer, data) + result = _submit_receipt(actor, customer, data, file_info, created_files) + if access_link: + access_link.submitted_receipts.add(result) + return result + except Exception: + for storage, name in created_files: + storage.delete(name) + raise + + +@transaction.atomic +def decide_receipt(actor, receipt_id, version, action, reason=''): + # Lock only the receipt: PostgreSQL cannot lock the nullable side of a LEFT JOIN. + receipt = PaymentReceipt.objects.select_for_update(of=('self',)).select_related('owning_unit').get(public_id=receipt_id) + # Never authorize a financial vote using a stale User/profile/permission cache. + actor = User.objects.select_for_update().get(pk=actor.pk) + if receipt.current_version != version: + raise ValidationError('اطلاعات تغییر کرده است؛ نسخه تازه را دوباره بررسی کنید.') + revision = receipt.revisions.get(version=version) + cycle = ReceiptApprovalCycle.objects.select_for_update().get(revision=revision) + if action not in {'APPROVE', 'REJECT', 'SENIOR_APPROVE'}: + raise ValidationError('نوع تصمیم معتبر نیست.') + allowed = policies.can_decide(actor, receipt, cycle, senior=action == 'SENIOR_APPROVE') + # Assigned seniors may also reject with a reason; all permission, visibility, + # own-submission and assignment checks remain in can_decide. + if not allowed and action == 'REJECT': + allowed = policies.can_decide(actor, receipt, cycle, senior=True) + if not allowed: + raise PermissionDenied + reason = reason.strip() + if action in {'REJECT', 'SENIOR_APPROVE'} and not reason: + raise ValidationError('دلیل این تصمیم را بنویسید.') + old = cycle.decisions.filter(user=actor).first() + if old: + if old.action == action and old.reason == reason: + return receipt + raise ValidationError('تصمیم شما ثبت شده است؛ تغییر آن نیازمند چرخه جدید است.') + if receipt.status not in {'PENDING', 'PARTIAL'}: + raise ValidationError('بررسی این فیش پایان یافته است.') + if not cycle.assignments.filter(is_senior=False).exists(): + raise ValidationError('مدیر باید ابتدا افراد تأییدکننده را تعیین کند.') + if revision.destination_mismatch and action != 'REJECT' and not reason: + raise ValidationError('حساب مقصد با اعلامیه متفاوت است؛ نتیجه بررسی مغایرت را بنویسید.') + ReceiptDecision.objects.create(cycle=cycle, user=actor, action=action, reason=reason, actor_name=actor_name(actor)) + if action == 'REJECT': + status = 'REJECTED' + elif action == 'SENIOR_APPROVE': + status = 'APPROVED' + else: + required = set(cycle.assignments.filter(is_senior=False).values_list('user_id', flat=True)) + voted = set(cycle.decisions.filter(action='APPROVE').values_list('user_id', flat=True)) + status = 'APPROVED' if required <= voted else 'PARTIAL' + cycle.status = receipt.status = status + if status in {'APPROVED', 'REJECTED'}: + cycle.finalized_by = actor + cycle.finalized_at = timezone.now() + cycle.save(update_fields=['status', 'finalized_by', 'finalized_at']) + receipt.save(update_fields=['status', 'updated_at']) + audit(actor, action, receipt, {'version': version, 'reason': reason, 'status': status, 'cycle': cycle.pk}) + if status == 'APPROVED': + from .notifications import queue_approval + queue_approval(receipt, cycle) + return receipt + + +@transaction.atomic +def bind_unassigned_cycle(actor, receipt): + """An empty cycle cannot accept votes. An authorized manager can bind a newly configured policy.""" + if not policies.has_permission(actor, 'manage_payment_approval_policy'): + raise PermissionDenied + receipt = PaymentReceipt.objects.select_for_update().get(pk=receipt.pk) + revision = receipt.revisions.get(version=receipt.current_version) + cycle = ReceiptApprovalCycle.objects.select_for_update().get(revision=revision) + if cycle.assignments.exists() or cycle.decisions.exists() or receipt.status != 'PENDING': + raise ValidationError('سیاست این چرخه قبلاً ثبت شده است و قابل بازنویسی نیست.') + policy = policy_for(receipt.owning_unit) + if not policy or not policy.reviewers.exists(): + raise ValidationError('ابتدا یک سیاست فعال با افراد تأییدکننده ایجاد کنید.') + users = list(policy.reviewers.all()) + list(policy.seniors.all()) + if any(not policies.is_staff(user) for user in users): + raise ValidationError('همه تأییدکنندگان باید فعال باشند.') + cycle.policy_snapshot = {'policy_id': policy.pk, 'name': policy.name, 'method': 'ALL_SELECTED', 'reviewers': list(policy.reviewers.values_list('pk', flat=True)), 'seniors': list(policy.seniors.values_list('pk', flat=True))} + cycle.save(update_fields=['policy_snapshot']) + ReceiptApprovalAssignment.objects.bulk_create([ReceiptApprovalAssignment(cycle=cycle, user=user, is_senior=senior) for senior, selected in [(False, policy.reviewers.all()), (True, policy.seniors.all())] for user in selected]) + audit(actor, 'EMPTY_CYCLE_ASSIGNED', receipt, cycle.policy_snapshot) + + +def revise_receipt(actor, receipt_id, version, data): + data = dict(data) + data['paid_amount'] = validators.amount(data['paid_amount']) + if data['paid_date'] > timezone.localdate(): + raise ValidationError('تاریخ واریز نمی‌تواند بعد از امروز باشد.') + file_info = validators.image(data['receipt_image']) + created_files = [] + try: + with transaction.atomic(): + receipt = PaymentReceipt.objects.select_for_update().get(public_id=receipt_id) + if actor.pk != receipt.customer_id and not policies.has_permission(actor,'submit_customer_receipt',receipt.owning_unit): + raise PermissionDenied + if not actor.is_active or receipt.status != 'REJECTED' or receipt.current_version != version: + raise ValidationError('فقط نسخه جاری فیش ردشده قابل اصلاح است.') + # Link identity is fixed for a revision; changing notice requires a distinct receipt. + if getattr(data.get('notice_id'),'pk',None) != receipt.notice_id: + raise ValidationError('ارتباط اعلامیه این فیش قابل تغییر نیست.') + account = CompanyBankAccount.objects.select_for_update().select_related('bank').get(pk=data['destination_account'].pk) + validate_destination(account,receipt.notice) + fields=receipt_metadata(data) + fields['destination']=account + notice = receipt.notice + fields['deposit_id'] = fields['deposit_id'] or (notice.deposit_id if notice and notice.account_id == account.pk else '') + new_version=version+1 + revision=PaymentReceiptRevision(receipt=receipt,version=new_version, + account_snapshot=notice.account_snapshot if notice and notice.account_id == account.pk else account.snapshot(), + image=data['receipt_image'],destination_mismatch=bool(receipt.notice and receipt.notice.account_id!=account.pk),**file_info,**fields) + try: + revision.save() + finally: + if revision.image and revision.image._committed: + created_files.append((revision.image.storage,revision.image.name)) + assign_cycle(revision,actor) + receipt.current_version=new_version + receipt.status='PENDING' + receipt.save(update_fields=['current_version','status','updated_at']) + audit(actor,'RECEIPT_REVISED',receipt,{'version':new_version,'previous_version':version,'sha256':revision.sha256}) + return receipt + except Exception: + for storage,name in created_files: + storage.delete(name) + raise diff --git a/apps/payments/staff_urls.py b/apps/payments/staff_urls.py new file mode 100644 index 0000000..c91a8e4 --- /dev/null +++ b/apps/payments/staff_urls.py @@ -0,0 +1,22 @@ +from django.urls import path +from . import views +app_name = 'staff_payments' +urlpatterns = [ + path('', views.desk, name='desk'), + path('count/', views.counts, name='count'), + path('customers/search/', views.customer_search, name='customer_search'), + path('notices/generate-identifier/', views.generate_deposit_identifier, name='generate_identifier'), + path('notices/new/', views.notice_new, name='notice_new'), + path('notices//', views.notice_detail, name='notice_detail'), + path('notices//payment-link/', views.notice_payment_link, name='notice_payment_link'), + path('receipts/new//', views.staff_receipt_new, name='receipt_new'), + path('receipts/new/', views.staff_receipt_customer, name='receipt_customer'), + path('receipts//', views.staff_receipt_detail, name='receipt_detail'), + path('receipts//decide/', views.decision, name='decision'), + path('receipts//assign/', views.assign_policy, name='assign_policy'), + path('banks/', views.banks, name='banks'), + path('banks/new/', views.bank_edit, name='bank_new'), + path('banks/bulk/', views.bank_bulk, name='bank_bulk'), + path('banks//', views.bank_edit, name='bank_edit'), + path('banks//delete/', views.bank_delete, name='bank_delete'), +] diff --git a/apps/payments/urls.py b/apps/payments/urls.py new file mode 100644 index 0000000..c06447e --- /dev/null +++ b/apps/payments/urls.py @@ -0,0 +1,14 @@ +from django.urls import path +from . import views, link_views +app_name = 'payments' +urlpatterns = [ + path('link//', link_views.open_link, name='open_link'), + path('', views.home, name='home'), + path('bank-accounts/', views.notices, name='notices'), + path('bank-accounts//', views.customer_notice_detail, name='notice_detail'), + path('payment-receipt/', views.receipt_new, name='receipt_new'), + path('receipts/', views.receipt_list, name='receipt_list'), + path('receipts//', views.receipt_detail, name='receipt_detail'), + path('receipts//revise/', views.receipt_revise, name='receipt_revise'), + path('receipts//image/', views.receipt_image, name='receipt_image'), +] diff --git a/apps/payments/validators.py b/apps/payments/validators.py new file mode 100644 index 0000000..0ca0765 --- /dev/null +++ b/apps/payments/validators.py @@ -0,0 +1,93 @@ +import hashlib +import re +import warnings +from pathlib import Path +import jdatetime +import magic +from PIL import Image, UnidentifiedImageError +from django.core.exceptions import ValidationError +from django.utils import timezone +from apps.attachments.models import AttachmentPolicy +from .models import MAX_AMOUNT + +DIGIT_MAP = str.maketrans('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩', '01234567890123456789') + + +def digits(value): + return str(value or '').translate(DIGIT_MAP).strip() + + +def amount(value): + value = digits(value) + if not re.fullmatch(r'\d+|\d{1,3}(?:[,٬ ]\d{3})+', value): + raise ValidationError('مبلغ را به ریال، فقط با عدد صحیح وارد کنید.') + value = int(re.sub(r'[,٬ ]', '', value)) + if not 0 < value <= MAX_AMOUNT: + raise ValidationError('مبلغ باید بیشتر از صفر و در محدوده مجاز باشد.') + return value + + +def bank_number(value): + value = re.sub(r'[\s-]', '', digits(value)) + if value and not value.isascii(): + raise ValidationError('شماره را با ارقام معتبر وارد کنید.') + if value and not value.isdecimal(): + raise ValidationError('شماره حساب یا کارت باید فقط عدد باشد.') + return value + + +def iban(value): + value = re.sub(r'\s', '', digits(value)).upper() + if not value: + return '' + if not re.fullmatch(r'IR\d{24}', value) or int(value[4:] + '1827' + value[2:4]) % 97 != 1: + raise ValidationError('شماره شبای معتبر ایرانی وارد کنید؛ IR و ۲۴ رقم.') + return value + + +def paid_date(day, month, year): + if not all((day, month, year)): + raise ValidationError('تاریخ را کامل کنید؛ روز، ماه و سال لازم‌اند.') + try: + value = jdatetime.date(int(digits(year)), int(digits(month)), int(digits(day))).togregorian() + except (ValueError, TypeError, OverflowError): + raise ValidationError('تاریخ شمسی معتبر انتخاب کنید.') + today = timezone.localdate() + if value > today: + raise ValidationError('تاریخ واریز نمی‌تواند بعد از امروز باشد.') + if jdatetime.date.fromgregorian(date=value).year < jdatetime.date.fromgregorian(date=today).year - 10: + raise ValidationError('تاریخ خارج از بازه ده سال گذشته است.') + return value + + +def image(upload): + policy = AttachmentPolicy.objects.filter(attachment_type='IMAGE', is_enabled=True).first() + if not policy: + raise ValidationError('ارسال تصویر فعلاً فعال نیست؛ با کارشناس هماهنگ کنید.') + if not upload.size or upload.size > policy.max_file_size: + raise ValidationError('تصویر خالی یا بزرگ‌تر از حجم مجاز است.') + ext = Path(upload.name).suffix.lower() + if ext not in (policy.allowed_extensions or ['.jpg', '.jpeg', '.png', '.gif', '.webp']): + raise ValidationError('فقط تصویر مجاز فیش را انتخاب کنید.') + head = upload.read(8192) + upload.seek(0) + mime = magic.from_buffer(head, mime=True) + expected = {'.jpg':'image/jpeg', '.jpeg':'image/jpeg', '.png':'image/png', '.gif':'image/gif', '.webp':'image/webp'} + if expected.get(ext) != mime or (policy.allowed_mime_types and mime not in policy.allowed_mime_types): + raise ValidationError('محتوای واقعی فایل با نوع تصویر سازگار نیست.') + try: + with warnings.catch_warnings(): + warnings.simplefilter('error', Image.DecompressionBombWarning) + with Image.open(upload) as picture: + if picture.width * picture.height > 25_000_000: + raise ValidationError('ابعاد تصویر بیش از حد مجاز است.') + picture.verify() + except (UnidentifiedImageError, OSError, SyntaxError, Image.DecompressionBombError, Image.DecompressionBombWarning): + raise ValidationError('تصویر خراب است یا باز نمی‌شود؛ تصویر دیگری انتخاب کنید.') + finally: + upload.seek(0) + digest = hashlib.sha256() + for chunk in upload.chunks(): + digest.update(chunk) + upload.seek(0) + return {'mime_type': mime, 'sha256': digest.hexdigest(), 'original_name': Path(upload.name).name[:255], 'file_size': upload.size} diff --git a/apps/payments/views.py b/apps/payments/views.py new file mode 100644 index 0000000..e6b2197 --- /dev/null +++ b/apps/payments/views.py @@ -0,0 +1,466 @@ +from functools import wraps +from django.contrib import messages +from django.core.exceptions import PermissionDenied, ValidationError +from django.core.paginator import Paginator +from django.db import transaction +from django.db.models import Q, Count, Sum, F, Exists, OuterRef +from django.db.models.deletion import ProtectedError +from django.http import FileResponse, JsonResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.utils import timezone +from django.views.decorators.http import require_POST +from . import policies, services, validators, selectors, customer_picker, access +from .forms import BankAccountForm, NoticeForm, ReceiptForm +from .models import (CompanyBankAccount, PaymentNotice, PaymentReceipt, PaymentReceiptRevision, + PaymentAuditEvent, ReceiptDecision) + + +def financial_required(staff=False, allow_payment_link=False): + def decorate(view): + @wraps(view) + def wrapped(request, *args, **kwargs): + blocked = access.prepare_payment_request(request, staff=staff, allow_payment_link=allow_payment_link) + if blocked is not None: + return blocked + response = view(request, *args, **kwargs) + if getattr(request, 'payment_access_link', None): + response['Referrer-Policy'] = 'same-origin' + response['X-Robots-Tag'] = 'noindex, nofollow' + return response + return wrapped + return decorate + + +def staff_context(**kwargs): + return {'active_staff_section': 'payments', 'active_tab_title': 'اعلامیه‌ها و فیش‌های واریزی', **kwargs} + + +@financial_required() +def home(request): + return render(request, 'payments/home.html', {'financial_page': True}) + + +def complete_notice(notice): + snapshot = notice.account_snapshot + approved = getattr(notice, 'approved_total', None) + if approved is None: + approved = notice.receipts.filter(status='APPROVED', revisions__version=F('current_version'), revisions__destination_mismatch=False).aggregate(total=Sum('revisions__amount'))['total'] or 0 + notice.approved_amount = approved + notice.remaining_amount = max(0, notice.amount-approved) + notice.settlement_label = 'تسویه شده' if approved >= notice.amount else ('پرداخت جزئی' if approved else 'در انتظار فیش تأییدشده') + # Complete copy uses immutable data, including fields absent from the compact UI. + from apps.core.templatetags.jalali import jalali_datetime + notice.copy_text = '\n'.join(f'{label}: {value}' for label, value in [ + ('اعلامیه', notice.number), ('تاریخ صدور', jalali_datetime(notice.created_at)), ('موضوع', notice.subject), ('مبلغ (ریال)', f'{notice.amount:,}'), + ('نام حساب', snapshot.get('name')), ('بانک', snapshot.get('bank')), ('صاحب حساب', snapshot.get('holder')), + ('شماره حساب', snapshot.get('account_number')), ('شبا', snapshot.get('iban')), + ('کارت', snapshot.get('card_number')), ('شناسه واریز', notice.deposit_id), + ('فاکتور', notice.invoice_number), ('توضیحات', notice.description)] if value) + return notice + + +@financial_required() +def notices(request): + items = selectors.notices_with_totals(policies.visible(PaymentNotice.objects.all(), request.user)) + query = request.GET.get('q', '').strip() + if query: + items = items.filter(Q(subject__icontains=query) | Q(invoice_number__icontains=validators.digits(query))) + page = Paginator(items, 20).get_page(request.GET.get('page')) + page.object_list = selectors.attach_notice_receipts(page.object_list, policies.visible(PaymentReceipt.objects.all(), request.user)) + for notice in page: + complete_notice(notice) + params = request.GET.copy() + params.pop('page', None) + return render(request, 'payments/notices.html', {'page_obj': page, 'query': query, 'query_string':params.urlencode(), 'open_notice': request.GET.get('notice', ''), 'financial_page': True}) + + +@financial_required(allow_payment_link=True) +def customer_notice_detail(request, public_id): + notice = get_object_or_404(selectors.notices_with_totals( + access.visible_payments(request, PaymentNotice.objects.all())), public_id=public_id) + selectors.attach_notice_receipts([notice], access.visible_payments(request, PaymentReceipt.objects.all())) + return render(request, 'payments/customer_notice_detail.html', access.customer_context(request, + notice=complete_notice(notice), open_notice=str(notice.public_id), financial_page=True)) + + +def receipt_form_page(request, customer, staff=False): + link = getattr(request, 'payment_access_link', None) + initial = access.receipt_initial(request, customer) + form = ReceiptForm(request.POST or None, request.FILES or None, customer=customer, initial=initial, + notice_ids=[link.notice_id] if link else None) + if request.method == 'POST' and form.is_valid(): + try: + receipt = services.submit_receipt(customer if link else request.user, customer, form.cleaned_data, access_link=link) + messages.success(request, 'فیش ثبت شد و در انتظار بررسی است.') + return redirect('staff_payments:receipt_detail' if staff else 'payments:receipt_detail', public_id=receipt.public_id) + except ValidationError as error: + form.add_error(None, error) + selected = form['notice_id'].value() + available = list(form.fields['notice_id'].queryset) if staff else selectors.recent_pending_notice_choices(form) + notice_data = [selectors.notice_form_data(n, staff=staff) for n in available] + from apps.attachments.models import AttachmentPolicy + image_policy = AttachmentPolicy.objects.filter(attachment_type='IMAGE', is_enabled=True).first() + ctx = {'form': form, 'selected_notice': selected, 'notice_data': notice_data, 'financial_page': True, + 'max_bytes': image_policy.max_file_size if image_policy else 0, 'staff_form': staff, 'customer': customer, + 'account_requirements': {str(a.pk): a.requires_deposit_id for a in form.fields['destination_account'].queryset}} + return render(request, 'payments/receipt_form.html', staff_context(**ctx) if staff else access.customer_context(request, **ctx)) + + +@financial_required(allow_payment_link=True) +def receipt_new(request): + return receipt_form_page(request, request.payment_customer) + + +@financial_required(allow_payment_link=True) +def receipt_list(request): + items = selectors.receipts_with_current_values(access.visible_payments(request, PaymentReceipt.objects.all()).select_related('notice')) + return render(request, 'payments/receipt_list.html', access.customer_context(request, financial_page=True, + page_obj=Paginator(items, 20).get_page(request.GET.get('page')))) + + +@financial_required(staff=True) +def staff_receipt_customer(request): + if not policies.can_create(request.user): + raise PermissionDenied + from django import forms + form = forms.Form(request.POST or None, initial=customer_picker.initial_customer(request)) + # Reuse the same active-customer selector and display labels as notice issuance. + form.fields['customer'] = NoticeForm().fields['customer'] + if request.method == 'POST' and form.is_valid(): + return redirect('staff_payments:receipt_new', customer_id=form.cleaned_data['customer'].public_id) + return render(request, 'payments/staff_form.html',staff_context(form=form,heading='ثبت فیش برای مشتری',submit_label='ادامه', **customer_picker.picker_context(request, form))) + + +@financial_required(staff=True) +def customer_search(request): + if not policies.can_create(request.user): + raise PermissionDenied + response = JsonResponse(customer_picker.search_results(request.user, request.GET.get('q', ''))) + response['Cache-Control'] = 'private, no-store' + return response + + +@financial_required(staff=True) +def staff_receipt_new(request, customer_id): + if not policies.can_create(request.user): + raise PermissionDenied + from apps.accounts.models import User + customer = get_object_or_404(User.objects.select_related('customer_profile'), public_id=customer_id, account_type='CUSTOMER', is_active=True) + return receipt_form_page(request, customer, staff=True) + + +def receipt_context(request, public_id, staff): + receipt = get_object_or_404(access.visible_payments(request, PaymentReceipt.objects.select_related('customer__customer_profile', 'notice', 'owning_unit')), public_id=public_id) + revision = receipt.revisions.select_related('destination', 'origin_bank', 'cycle').get(version=receipt.current_version) + cycle = revision.cycle + services.audit(request.payment_customer, 'RECEIPT_VIEWED', receipt) + decisions = ReceiptDecision.objects.filter(cycle__revision__receipt=receipt).select_related('user', 'cycle__revision').order_by('created_at', 'pk') + return {'receipt': receipt, 'revision': revision, 'cycle': cycle, 'decisions': decisions, + 'payment_link_access': getattr(request, 'payment_access_link', None), + 'revision_events': PaymentAuditEvent.objects.filter(object_type='payments.paymentreceipt', object_id=str(receipt.public_id), action__in=['RECEIPT_SUBMITTED', 'RECEIPT_REVISED']).order_by('created_at', 'pk'), + 'can_approve': staff and policies.can_decide(request.user, receipt, cycle), + 'can_senior': staff and policies.can_decide(request.user, receipt, cycle, senior=True), + 'can_assign': staff and not cycle.assignments.exists() and policies.has_permission(request.user, 'manage_payment_approval_policy'), + 'staff_detail': staff, 'financial_page': True, 'versions': receipt.revisions.select_related('cycle').order_by('version'), + 'duplicate_image': PaymentReceiptRevision.objects.filter(sha256=revision.sha256, + receipt__in=policies.visible(PaymentReceipt.objects.all(),request.user)).exclude(receipt=receipt).exists() if staff else False} + + +@financial_required(allow_payment_link=True) +def receipt_detail(request, public_id): + return render(request, 'payments/receipt_detail.html', receipt_context(request, public_id, False)) + + +@financial_required(staff=True) +def staff_receipt_detail(request, public_id): + return render(request, 'payments/receipt_detail.html', staff_context(**receipt_context(request, public_id, True))) + + +@financial_required(staff=None, allow_payment_link=True) +def receipt_image(request, public_id): + try: + version = int(request.GET.get('version', '1')) + except ValueError: + from django.http import Http404 + raise Http404 + revision = get_object_or_404(PaymentReceiptRevision.objects.select_related('receipt'), receipt__public_id=public_id, version=version) + if not access.visible_payments(request, PaymentReceipt.objects.filter(pk=revision.receipt_id)).exists(): + raise PermissionDenied + services.audit(request.payment_customer, 'RECEIPT_FILE_REQUESTED', revision.receipt, {'version': revision.version}) + # Authorize EVERY access, including debug: financial media never uses MEDIA_URL. + response = FileResponse(revision.image.open('rb'), content_type=revision.mime_type, + as_attachment=request.GET.get('download') == '1', filename=revision.original_name) + response['Cache-Control'] = 'private, no-store' + response['X-Content-Type-Options'] = 'nosniff' + return response + + +@financial_required(staff=True) +@require_POST +def decision(request, public_id): + get_object_or_404(policies.visible(PaymentReceipt.objects.all(),request.user),public_id=public_id) + try: + version = int(request.POST.get('version', '0')) + receipt = services.decide_receipt(request.user, public_id, version, request.POST.get('action'), request.POST.get('reason', '')) + messages.success(request, 'تصمیم ثبت شد. ' + receipt.get_status_display()) + except (ValidationError, ValueError) as error: + messages.error(request, '؛ '.join(error.messages) if isinstance(error, ValidationError) else 'نسخه فیش معتبر نیست.') + return redirect('staff_payments:receipt_detail', public_id=public_id) + + +@financial_required(staff=True) +@require_POST +def assign_policy(request, public_id): + receipt = get_object_or_404(policies.visible(PaymentReceipt.objects.all(), request.user), public_id=public_id) + try: + services.bind_unassigned_cycle(request.user, receipt) + messages.success(request, 'افراد تأییدکننده تعیین شدند.') + except ValidationError as error: + messages.error(request, '؛ '.join(error.messages)) + return redirect('staff_payments:receipt_detail', public_id=public_id) + + +def filtered(queryset, request): + query = validators.digits(request.GET.get('q', '')) + if query: + condition = Q(customer__username__icontains=query) | Q(customer__mobile__icontains=query) | Q(customer__customer_profile__first_name__icontains=query) | Q(customer__customer_profile__last_name__icontains=query) | Q(customer__customer_profile__province__icontains=query) | Q(customer__customer_profile__city__icontains=query) + # PN/PR numbers are UUID prefixes, not database columns. + prefix = query.upper().removeprefix('PN-').removeprefix('PR-').replace('-', '') + if prefix and len(prefix) <= 32 and all(c in '0123456789ABCDEF' for c in prefix): + uuid_prefix = '-'.join(prefix[start:end] for start, end in [(0, 8), (8, 12), (12, 16), (16, 20), (20, 32)] if len(prefix) > start) + condition |= Q(public_id__istartswith=uuid_prefix) + if queryset.model == PaymentNotice: + condition |= Q(subject__icontains=query) | Q(invoice_number__icontains=query) + else: + condition |= Q(notice__invoice_number__icontains=query) | Q(revisions__tracking_number__icontains=query) + queryset = queryset.filter(condition) + for key, field in [('account', 'account_id' if queryset.model == PaymentNotice else 'revisions__destination_id'), ('unit', 'owning_unit_id'), ('customer', 'customer_id')]: + value = request.GET.get(key, '') + if value.isdecimal(): + queryset = queryset.filter(**{field: value}) + for key, op in [('from', 'gte'), ('to', 'lte')]: + value = request.GET.get(key, '') + if value: + try: + import datetime + parsed = datetime.date.fromisoformat(value) + queryset = queryset.filter(**{f'created_at__date__{op}': parsed}) + except ValueError: + messages.error(request, 'بازه تاریخ معتبر نیست.') + return queryset.distinct() + + +@financial_required(staff=True) +def desk(request): + tab = request.GET.get('tab', 'receipts') + receipts = selectors.receipts_with_current_values(policies.visible(PaymentReceipt.objects.select_related('customer__customer_profile', 'notice__created_by__staff_profile'), request.user)) + notice_items = selectors.notices_with_totals(policies.visible(PaymentNotice.objects.select_related('customer__customer_profile', 'created_by__staff_profile', 'account'), request.user)) + if tab == 'notices': + items = filtered(notice_items, request) + state = request.GET.get('notice_state', '') + if state == 'settled': + items = items.filter(cancelled_at__isnull=True, approved_total__gte=F('amount')) + elif state == 'pending': + items = items.filter(cancelled_at__isnull=True, approved_total__lt=F('amount')) + elif state == 'cancelled': + items = items.filter(cancelled_at__isnull=False) + else: + items = filtered(receipts, request) + status = request.GET.get('status', '') + if status in PaymentReceipt.Status.values: + items = items.filter(status=status) + if request.GET.get('kind') == 'independent': + items = items.filter(notice__isnull=True) + elif request.GET.get('kind') == 'linked': + items = items.filter(notice__isnull=False) + if request.GET.get('mine'): + my_decision = ReceiptDecision.objects.filter(cycle__revision__receipt_id=OuterRef('pk'), + cycle__revision__version=OuterRef('current_version'), user=request.user) + items = items.filter(status__in=['PENDING','PARTIAL'], revisions__version=F('current_version'), + revisions__cycle__assignments__user=request.user).annotate( + my_decision_exists=Exists(my_decision)).filter(my_decision_exists=False).distinct() + page = Paginator(items.order_by('-created_at','-pk'), 25).get_page(request.GET.get('page')) + if tab == 'notices': + page.object_list = selectors.attach_notice_receipts(page.object_list, policies.visible(PaymentReceipt.objects.all(), request.user)) + for notice in page.object_list: + complete_notice(notice) + else: + page.object_list = selectors.receipt_review_summaries(page.object_list) + query = request.GET.copy() + query.pop('page', None) + return render(request, 'payments/desk.html', staff_context(page_obj=page, tab=tab, statuses=PaymentReceipt.Status.choices, + can_create=policies.can_create(request.user), banks=CompanyBankAccount.objects.all(), query_string=query.urlencode())) + + +@financial_required(staff=True) +@require_POST +def generate_deposit_identifier(request): + if not policies.can_create(request.user): + raise PermissionDenied + # Resolve the same active customer as NoticeForm; never trust a posted mobile. + try: + customer = NoticeForm().fields['customer'].clean(request.POST.get('customer')) + mobile = validators.digits(customer.local_mobile) + import re + if not re.fullmatch(r'09[0-9]{9}', mobile): + raise ValidationError('شماره موبایل مشتری معتبر نیست؛ ابتدا اطلاعات مشتری را اصلاح کنید.') + except ValidationError as error: + return JsonResponse({'error': '؛ '.join(error.messages)}, status=400) + from .deposit_identifier import generate_id_algorithm + response = JsonResponse({'identifier': generate_id_algorithm(mobile), 'customer': str(customer.pk)}) + response['Cache-Control'] = 'no-store' + return response + + +@financial_required(staff=True) +def notice_new(request): + if not policies.can_create(request.user): + raise PermissionDenied + form = NoticeForm(request.POST or None, initial=customer_picker.initial_customer(request)) + if request.method == 'POST' and form.is_valid(): + try: + notice = services.issue_notice(request.user, form.cleaned_data) + messages.success(request, 'اعلامیه صادر شد و پیامک در صف اطلاع‌رسانی قرار گرفت.') + return redirect('staff_payments:notice_detail', public_id=notice.public_id) + except ValidationError as error: + form.add_error(None, error) + return render(request, 'payments/staff_form.html', staff_context(form=form, heading='ایجاد اعلامیه واریز', submit_label='صدور و اطلاع‌رسانی', identifier_accounts=list(CompanyBankAccount.objects.filter(requires_deposit_id=True).values_list('pk',flat=True)), **customer_picker.picker_context(request, form))) + + +@financial_required(staff=True) +def notice_detail(request, public_id): + notice = complete_notice(get_object_or_404(policies.visible(PaymentNotice.objects.select_related('customer'), request.user), public_id=public_id)) + services.audit(request.user, 'NOTICE_VIEWED', notice) + share = request.session.get('payment_link_share', {}) + if share.get('notice') == str(public_id): + request.session.pop('payment_link_share', None) + else: + share = {} + return render(request, 'payments/notice_detail.html', staff_context(notice=notice, + can_share_payment_link=policies.can_create(request.user) and notice.customer.is_active, + payment_share_url=share.get('url', ''), + receipts=policies.visible(notice.receipts.all(), request.user), notifications=notice.paymentnotification_set.select_related('job'))) + + +@financial_required(staff=True) +@require_POST +def notice_payment_link(request, public_id): + if not policies.can_create(request.user): + raise PermissionDenied + notice = get_object_or_404(policies.visible(PaymentNotice.objects.all(), request.user), + public_id=public_id, customer__is_active=True) + link, url = access.issue_payment_link(notice=notice) + services.audit(request.user, 'PAYMENT_LINK_CREATED', link) + request.session['payment_link_share'] = {'notice': str(public_id), 'url': url} + return redirect('staff_payments:notice_detail', public_id=public_id) + + +@financial_required(staff=True) +def counts(request): + count = policies.visible(PaymentReceipt.objects.all(), request.user).filter(status__in=['PENDING', 'PARTIAL']).count() + response = JsonResponse({'pending_receipts': count}) + response['Cache-Control'] = 'private, no-store' + return response + + +@financial_required(staff=True) +def banks(request): + if not policies.can_manage_banks(request.user): + raise PermissionDenied + items = CompanyBankAccount.objects.select_related('bank') + query = request.GET.get('q','').strip() + if query: + items = items.filter(Q(name__icontains=query) | Q(bank__name__icontains=query) | Q(holder__icontains=query)) + for field in ['is_active', 'available_to_staff', 'available_to_customers']: + if request.GET.get(field) in ['0','1']: + items = items.filter(**{field: request.GET[field] == '1'}) + params = request.GET.copy() + params.pop('page', None) + return render(request, 'payments/banks.html', staff_context(page_obj=Paginator(items,25).get_page(request.GET.get('page')), + query_string=params.urlencode(), bank_flags=[{'key':key,'label':label,'value':request.GET.get(key,'')} for key,label in [('is_active','فعال کلی'),('available_to_staff','پنل کارشناسان'),('available_to_customers','پنل مشتریان')]], active_staff_section='banks', active_tab_title='بانک‌ها')) + + +@financial_required(staff=True) +def bank_edit(request, public_id=None): + if not policies.can_manage_banks(request.user): + raise PermissionDenied + instance = get_object_or_404(CompanyBankAccount, public_id=public_id) if public_id else None + before = services.configuration_snapshot(instance) if instance else {} + form = BankAccountForm(request.POST or None, instance=instance) + if request.method == 'POST' and form.is_valid(): + with transaction.atomic(): + if instance: + current = CompanyBankAccount.objects.select_for_update().get(pk=instance.pk) + if form.cleaned_data['revision_token'] != current.updated_at.isoformat(): + form.add_error(None, 'حساب هم‌زمان تغییر کرده است؛ صفحه را تازه کنید.') + return render(request,'payments/staff_form.html',staff_context(form=form,bank_account=instance, + heading='ویرایش حساب',submit_label='ذخیره حساب',active_staff_section='banks')) + before = services.configuration_snapshot(current) + account = form.save() + services.audit(request.user, 'BANK_SAVED', account, {'before': before, 'after': services.configuration_snapshot(account)}) + return redirect('staff_payments:banks') + return render(request,'payments/staff_form.html',staff_context(form=form, bank_account=instance, heading='ویرایش حساب' if instance else 'ایجاد حساب', submit_label='ذخیره حساب', active_staff_section='banks')) + + +@financial_required(staff=True) +@require_POST +def bank_bulk(request): + if not policies.can_manage_banks(request.user): + raise PermissionDenied + field = request.POST.get('field') + if field not in ['is_active','available_to_staff','available_to_customers'] or request.POST.get('value') not in ['0','1']: + raise ValidationError('عملیات معتبر نیست.') + ids = [value for value in request.POST.getlist('accounts') if value.isdecimal()] + with transaction.atomic(): + for account in CompanyBankAccount.objects.select_for_update().filter(pk__in=ids): + before = getattr(account, field) + setattr(account,field,request.POST['value'] == '1') + account.save(update_fields=[field,'updated_at']) + services.audit(request.user,'BANK_FLAG_CHANGED',account,{'field':field,'before':before,'after':getattr(account,field)}) + return redirect('staff_payments:banks') + + +@financial_required(staff=True) +@require_POST +def bank_delete(request, public_id): + if not policies.can_manage_banks(request.user): + raise PermissionDenied + account = get_object_or_404(CompanyBankAccount,public_id=public_id) + try: + with transaction.atomic(): + services.audit(request.user,'BANK_DELETED',account,account.snapshot()) + account.delete() + except ProtectedError: + messages.error(request,'این حساب سابقه مالی دارد؛ به جای حذف آن را غیرفعال کنید.') + return redirect('staff_payments:banks') + + +@financial_required(allow_payment_link=True) +def receipt_revise(request, public_id): + link = getattr(request, 'payment_access_link', None) + receipt=get_object_or_404(access.visible_payments(request, PaymentReceipt.objects.all()),public_id=public_id,customer=request.payment_customer,status='REJECTED') + import jdatetime + current = receipt.revisions.get(version=receipt.current_version) + paid = jdatetime.date.fromgregorian(date=current.paid_date) + initial={'receipt_mode':'linked' if receipt.notice_id else 'independent','notice_id':receipt.notice_id, + 'paid_amount':str(current.amount),'destination_account':current.destination_id,'deposit_id':current.deposit_id, + 'paid_day':str(paid.day),'paid_month':str(paid.month),'paid_year':str(paid.year), + 'origin_bank':current.origin_bank_id,'origin_other':current.origin_other, + 'tracking_number':current.tracking_number,'description':current.description} + form=ReceiptForm(request.POST or None,request.FILES or None,customer=request.payment_customer,initial=initial, + notice_ids=[link.notice_id] if link else None) + if request.method=='POST' and form.is_valid(): + try: + services.revise_receipt(request.payment_customer,receipt.public_id,int(request.POST.get('version','0')),form.cleaned_data) + messages.success(request,'نسخه اصلاحی ثبت شد و دوباره بررسی می‌شود.') + return redirect('payments:receipt_detail',public_id=receipt.public_id) + except (ValidationError,ValueError) as error: + form.add_error(None,error if isinstance(error,ValidationError) else 'نسخه فیش معتبر نیست.') + from apps.attachments.models import AttachmentPolicy + policy=AttachmentPolicy.objects.filter(attachment_type='IMAGE',is_enabled=True).first() + notice_data=[selectors.notice_form_data(n) for n in selectors.recent_pending_notice_choices(form, retain_original=receipt.notice_id)] + return render(request,'payments/receipt_form.html',{'form':form,'notice_data':notice_data,'max_bytes':policy.max_file_size if policy else 0, + 'payment_link_access': link, + 'account_requirements':{str(a.pk):a.requires_deposit_id for a in form.fields['destination_account'].queryset}, + 'financial_page':True,'revision_version':receipt.current_version}) diff --git a/apps/tickets/forms.py b/apps/tickets/forms.py index 14f0f73..dbca2b5 100644 --- a/apps/tickets/forms.py +++ b/apps/tickets/forms.py @@ -264,3 +264,12 @@ class UnitSMSTemplateForm(forms.ModelForm): super().__init__(*args, **kwargs) self.fields["unit"].queryset = OrganizationalUnit.objects.filter(pk__in=unit_ids if unit_ids is not None else TicketPolicy.unit_ids(user), is_active=True) self.fields["unit"].required = True + + def clean(self): + data = super().clean() + if data.get('event_type') in {'PAYMENT_NOTICE_CREATED', 'PAYMENT_RECEIPT_APPROVED'}: + from apps.payments.notifications import TAGS + self.instance.allowed_tags = list(TAGS) + elif data.get('event_type'): + self.instance.allowed_tags = SMSTemplate._meta.get_field('allowed_tags').default() + return data diff --git a/apps/tickets/views.py b/apps/tickets/views.py index 5c06535..f905cd3 100644 --- a/apps/tickets/views.py +++ b/apps/tickets/views.py @@ -444,6 +444,9 @@ def staff_customer_search(request): @staff_required @never_cache def staff_detail(request,public_id): + from apps.payments import policies as payment_policies, selectors as payment_selectors + from apps.payments.models import PaymentNotice + ticket=get_object_or_404(Ticket.objects.select_related("customer__customer_profile","category","owner__staff_profile","selected_expert__staff_profile","group_dispatch__unit","group_dispatch__contact_group"),public_id=public_id) visible=visible_messages(ticket,request.user) conversation,has_older,older_cursor=conversation_page(visible.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile")) @@ -466,7 +469,10 @@ def staff_detail(request,public_id): group_dispatch = ticket.group_dispatch can_resolve_dispatch = bool(group_dispatch and DispatchPolicy.can_view(request.user, group_dispatch) and (request.user.has_perm("tickets.change_ticket") or TicketPolicy.has_unit_permission(request.user, group_dispatch.unit, "tickets.change_ticket"))) group_open_count = group_dispatch.tickets.exclude(status__in=(Ticket.Status.RESOLVED, Ticket.Status.CLOSED)).count() if group_dispatch else 0 - return render(request,"tickets/staff_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":StaffReplyForm(),"transfer_form":TransferForm(),"share_form":StaffTicketShareForm(actor=request.user),"can_claim":TicketPolicy.can_claim(request.user,ticket),"can_transfer":TicketPolicy.can_transfer(request.user,ticket),"can_share":TicketPolicy.can_share(request.user,ticket),"can_delete":TicketPolicy.can_delete(request.user,ticket),"can_resolve_dispatch":can_resolve_dispatch,"group_open_count":group_open_count,"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"sms_jobs":sms_jobs,"announcement_jobs":announcement_jobs,"can_request_announcement":can_request_announcement,"announcement_ready":announcement_ready,"viewer_user":request.user,"voip_call_methods":_voip_call_methods(request.user),"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else "",**message_sharing,**sharing_context(ticket,request.user)}) + customer_notices = payment_selectors.notices_with_totals( + payment_policies.visible(PaymentNotice.objects.filter(customer=ticket.customer), request.user) + ).order_by('-created_at', '-pk')[:3] + return render(request,"tickets/staff_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":StaffReplyForm(),"transfer_form":TransferForm(),"share_form":StaffTicketShareForm(actor=request.user),"can_claim":TicketPolicy.can_claim(request.user,ticket),"can_transfer":TicketPolicy.can_transfer(request.user,ticket),"can_share":TicketPolicy.can_share(request.user,ticket),"can_delete":TicketPolicy.can_delete(request.user,ticket),"can_resolve_dispatch":can_resolve_dispatch,"group_open_count":group_open_count,"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"customer_notices":customer_notices,"sms_jobs":sms_jobs,"announcement_jobs":announcement_jobs,"can_request_announcement":can_request_announcement,"announcement_ready":announcement_ready,"viewer_user":request.user,"voip_call_methods":_voip_call_methods(request.user),"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else "",**message_sharing,**sharing_context(ticket,request.user)}) @staff_required @@ -1021,4 +1027,4 @@ def staff_sms_templates(request, template_id=None): raise PermissionDenied form.save(); messages.success(request, "الگوی پیامک واحد ذخیره شد.") return redirect("staff_tickets:sms_templates") - return render(request, "tickets/staff_sms_templates.html", {"form": form, "templates": templates, "allowed_tags": SMSTemplate._meta.get_field("allowed_tags").default(), "active_staff_section": "templates", "active_tab_title": "الگوهای پیامک"}) + return render(request, "tickets/staff_sms_templates.html", {"form": form, "templates": templates, "allowed_tags": SMSTemplate.tags_for_event(form['event_type'].value()), "sms_tag_map": {event: SMSTemplate.tags_for_event(event) for event in SMSTemplate.Event.values}, "active_staff_section": "templates", "active_tab_title": "الگوهای پیامک"}) diff --git a/config/settings/base.py b/config/settings/base.py index 50d8a3d..3c7e437 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -21,20 +21,20 @@ DEBUG = env_bool("DJANGO_DEBUG") ALLOWED_HOSTS = list(dict.fromkeys(env_list("DJANGO_ALLOWED_HOSTS") + ["localhost", "127.0.0.1"])) INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", - "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "django.contrib.humanize", "apps.core", "apps.accounts", "apps.customers", "apps.staff", "apps.tickets", - "apps.messaging", "apps.attachments", "apps.notifications", "apps.phone_contacts_api", + "apps.messaging", "apps.attachments", "apps.notifications", "apps.phone_contacts_api", "apps.payments", ] MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.locale.LocaleMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", "apps.core.middleware.NoStoreHTMLMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", "apps.payments.middleware.FinancialGrantAuditMiddleware", "apps.core.middleware.NoStoreHTMLMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", ] ROOT_URLCONF = "config.urls" TEMPLATES = [{"BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [BASE_DIR / "templates"], "APP_DIRS": True, - "OPTIONS": {"context_processors": ["django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", "apps.core.context_processors.static_asset_version", "apps.core.context_processors.site_branding", "apps.core.context_processors.staff_operations_access"]}}] + "OPTIONS": {"context_processors": ["django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", "apps.core.context_processors.static_asset_version", "apps.core.context_processors.site_branding", "apps.core.context_processors.staff_operations_access", "apps.payments.context_processors.payment_navigation"]}}] WSGI_APPLICATION = "config.wsgi.application" ASGI_APPLICATION = "config.asgi.application" database_url = os.getenv("DATABASE_URL", "").strip() @@ -95,6 +95,7 @@ OTP_RATE_LIMIT_PER_IP = int(os.getenv("OTP_RATE_LIMIT_PER_IP", "30")) DEV_OTP_MOBILE = "" DEV_OTP_CODE = "" MAGIC_LINK_TTL_DAYS = int(os.getenv("MAGIC_LINK_TTL_DAYS", "7")) +PAYMENT_LINK_TTL_DAYS = int(os.getenv("PAYMENT_LINK_TTL_DAYS", str(MAGIC_LINK_TTL_DAYS))) MAX_NOTIFICATION_ATTEMPTS = int(os.getenv("MAX_NOTIFICATION_ATTEMPTS", "5")) SMS_MAX_RETRIES = int(os.getenv("SMS_MAX_RETRIES", "3")) SMS_RETRY_DELAY_SECONDS = int(os.getenv("SMS_RETRY_DELAY_SECONDS", "180")) diff --git a/config/urls.py b/config/urls.py index a00d645..b197b23 100644 --- a/config/urls.py +++ b/config/urls.py @@ -39,7 +39,7 @@ def web_manifest(request): icon_url = static_url("images/pwa-icon.png") response = JsonResponse({ "id": "/", "name": branding.pwa_name, "short_name": branding.pwa_title, - "description": branding.pwa_description, "start_url": "/tickets/", "scope": "/", + "description": branding.pwa_description, "start_url": "/", "scope": "/", "display": "standalone", "background_color": "#eeeeee", "theme_color": "#08376b", "lang": "fa", "dir": "rtl", "icons": [{"src": icon_url, "sizes": "1024x1024", "type": "image/png", "purpose": "any maskable"}], @@ -55,11 +55,21 @@ urlpatterns = [ path("x5admin/", admin.site.urls), path("", include("apps.accounts.urls")), path("tickets/", include("apps.tickets.customer_urls")), + path("chaharsu/", include("apps.payments.urls")), + path("staff/payments/", include("apps.payments.staff_urls")), path("staff/", include("apps.tickets.staff_urls")), path("messages/", include("apps.messaging.urls")), path("files/", include("apps.attachments.urls")), path("api/phone-contacts", include("apps.phone_contacts_api.urls")), ] +def debug_media(request, path): + import posixpath + if posixpath.normpath(path.replace("\\", "/")).lstrip("/").startswith("payments/"): + from django.http import Http404 + raise Http404 + return serve(request, path, document_root=settings.MEDIA_ROOT) + + if settings.DEBUG: - urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + urlpatterns += [path("media/", debug_media)] diff --git a/docker/nginx.conf b/docker/nginx.conf index 1f22d83..0b54d60 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -21,6 +21,12 @@ server { access_log off; } + # Financial evidence requires a current authenticated application permission check. + # Never expose it through bearer-style signed media links. + location ^~ /protected-media/payments/ { + return 404; + } + location /protected-media/ { alias /app/media/; secure_link $arg_md5,$arg_expires; diff --git a/docs/CHAHARSU_PAYMENTS_IMPLEMENTATION.md b/docs/CHAHARSU_PAYMENTS_IMPLEMENTATION.md new file mode 100644 index 0000000..dfe3719 --- /dev/null +++ b/docs/CHAHARSU_PAYMENTS_IMPLEMENTATION.md @@ -0,0 +1,105 @@ +# راهنمای پیاده‌سازی و راه‌اندازی چهارسو و پرداخت‌ها + +تاریخ: ۲۰۲۶/۱۰/۰۵ + +## مسیرها و سازگاری + +- ورود عادی مشتری و مسیر `/` به `/chaharsu/` هدایت می‌شوند. لینک داخلی معتبرِ ادامه ورود حفظ می‌شود؛ لینک خارجی پذیرفته نمی‌شود. +- `/tickets/` همچنان تاریخچه فعلی درخواست‌هاست. `/tickets/new/`، قالب و منطق ثبت درخواست تغییر نکرده‌اند. +- خانه چهارسو فقط خدمات دارد: ارتباط با کارشناسان، ارسال فیش و شماره حساب‌ها. +- شماره حساب‌ها: `/chaharsu/bank-accounts/`؛ فرم فیش: `/chaharsu/payment-receipt/`؛ سوابق فیش مشتری: `/chaharsu/receipts/`. +- میزکار کارشناسان: `/staff/payments/`؛ مدیریت حساب‌ها: `/staff/payments/banks/`. +- PWA از `/` شروع می‌شود تا مشتری به چهارسو و کارشناس به میزکار خودش برسد. رفتار ثبت service worker و URLهای نسخه‌دار حفظ شده‌اند. + +## ساختار و قواعد مالی + +اپ مستقل `apps.payments` شامل مدل‌ها، سیاست دسترسی، اعتبارسنجی، سرویس‌های تراکنشی، خواندن داده، اعلان‌ها و صفحات است. وابستگی‌های بیرونی از زیرساخت فعلی کاربران، واحدها، پیامک و سیاست تصویر استفاده می‌کنند. `PaymentReceiptRevision` هم اطلاعات نسخه و هم مدرک تصویر را نگه می‌دارد؛ مدل جداگانه Document لازم نشده است. + +- اعلامیه، فیش و تصمیم تأیید مستقل‌اند. ثبت تصویر یا تأیید یک نفر از دو نفر، پرداخت را نهایی نمی‌کند. +- مبلغ عدد صحیح **ریال** است؛ نمایش حروفی تومان در مرورگر با BigInt انجام می‌شود و مقدار ریال به سرور می‌رود. +- نمایش تاریخ شمسی و انتخاب سه‌قسمتی است. سرور با jdatetime روز/ماه/سال را مستقل از JS بررسی و Gregorian را در DateField ذخیره می‌کند. تاریخ ناقص، غیرواقعی، آینده و خارج از بازه مجاز رد می‌شود. +- شناسه‌ها، شماره‌ها و شبا رشته‌اند؛ صفر ابتدایی حفظ می‌شود. شبا و کارت موجود اعتبارسنجی می‌شوند. +- حساب شناسه‌دار در صدور اعلامیه الزاماً شناسه می‌خواهد. در ثبت فیش، شناسه اعلامیه فقط برای **همان حساب** معتبر است؛ مقصد متفاوت شناسه خودش را لازم دارد. +- حساب فقط فعال در پنل کارشناسان، در زمینه اعلامیه اختصاصی همان مشتری قابل استفاده است؛ در واریز مستقل قابل استفاده نیست. غیرفعال کلی بودن، ثبت جدید را متوقف می‌کند و سوابق را پنهان نمی‌کند. +- snapshot حساب و شناسه اعلامیه حفظ می‌شود؛ ویرایش بانک، شماره تاریخی اعلامیه یا نسخه فیش را تغییر نمی‌دهد. +- فرم صدور و فرم فیش nonce دارند؛ ارسال تکراری همان داده رکورد یا پیامک جدید ایجاد نمی‌کند. استفاده مجدد از nonce برای اطلاعات مالی متفاوت رد می‌شود. +- وضعیت اعلامیه از مبلغ نسخه جاری فیش‌های تأییدشده بدون مغایرت مقصد محاسبه می‌شود؛ پرداخت جزئی و باقی‌مانده نمایش داده می‌شوند. + +## تنظیمات ضروری پیش از استفاده مالی واقعی + +۱. migrationها را اجرا کنید: + +```bash +.venv/Scripts/python.exe manage.py migrate +``` + +۲. مدیرعامل فعال، superuser از نوع STAFF یا کارشناس دارای مجوز `payments.manage_company_bankaccounts`، حساب‌های **واقعی** شرکت را در منوی «بانک‌ها» تعریف کند. اطلاعات واقعی حساب در migration قرار نگرفته است. برای حساب شناسه‌دار، «شناسه واریز الزامی» را فعال کنید. سه وضعیت فعال کلی، کارشناسان و مشتریان مستقل‌اند و تغییر جمعی هم دارند. حساب دارای سابقه قابل حذف نیست؛ غیرفعال کنید. فرم ویرایش قدیمی نمی‌تواند تغییر جدید را بازنویسی کند. + +۳. در مدیریت Django `/x5admin/`، بخش `PaymentApprovalPolicy`، یک سیاست عمومی یا سیاست واحد بسازید. همه افراد فهرست `reviewers` باید رأی بدهند؛ یک نفر یعنی تأیید تک‌نفره، چند نفر یعنی شرط تأیید همه اشخاص تعیین‌شده. افراد `seniors` می‌توانند با دلیل، تأیید نهایی ارشد انجام دهند. + +۴. **انتخاب فرد در سیاست به‌تنهایی کافی نیست.** مجوزهای زیر را به افراد مجاز بدهید؛ مدیر مالی یا نقش FINANCE خودکار تأییدکننده نمی‌شود: + +| مجوز | کاربرد | +| --- | --- | +| `payments.approve_paymentreceipt` | رأی عادی فردِ تعیین‌شده در سیاست | +| `payments.senior_approve_paymentreceipt` | تأیید ارشد فردِ تعیین‌شده در seniors | +| `payments.manage_payment_approval_policy` | مدیریت سیاست‌ها و تخصیص سیاست فعال به چرخه خالی | +| `payments.manage_company_bankaccounts` | مدیریت حساب‌ها | +| `payments.manage_payment_access` | تنظیم باز یا محدود بودن مشاهده و ایجاد | +| `payments.view_all_payments` | مشاهده مالی در همه واحدها | +| `payments.view_payment_audit` | دسترسی ممیزی مالی در مدیریت | + +مجوزهای تأیید می‌توانند سراسری، در عضویت فعال واحد یا در مجوزهای واحد فعال باشند. مشاهده همان فیش و تخصیص همان چرخه نیز لازم است. ثبت‌کننده کارشناس، فیشی را که خودش ثبت کرده تأیید نمی‌کند. مدیریت Django علاوه بر مجوز مالی، شرایط ورود استاندارد پنل admin را لازم دارد. + +۵. نبود سیاست فعال باعث **ماندن فیش در انتظار بررسی** می‌شود. تأیید خودکار یا تعیین فرضی مدیرعامل انجام نشده است. پس از تعریف سیاست، مدیر مجاز از جزئیات فیش، «اعمال سیاست فعال تأیید» را برای چرخه خالی بزند. سیاست چرخه دارای تخصیص/رأی بازنویسی نمی‌شود. + +۶. تنظیم پیش‌فرض `PaymentAccessConfiguration` مشاهده و ایجاد را برای همه کارشناسان فعال باز می‌گذارد. برای محدودسازی بعدی این دو گزینه را غیرفعال و مجوزهای `view_paymentnotice`، `view_paymentreceipt` و `add_paymentnotice` را در دامنه موردنظر بدهید. تأیید از ابتدا محدود است. + +## نسخه‌ها، تأیید و ممیزی + +تصمیم در transaction و با قفل فیش و چرخه، تطبیق نسخه جاری و رأی یکتا ثبت می‌شود. PostgreSQL برای join واحد nullable فقط خود فیش را قفل می‌کند. ترتیب قفل مشتری/حساب در صدور و ثبت یکسان است. پیامک نهایی فقط با نهایی‌شدن شرط تأیید صف می‌شود. + +فیش ردشده از صفحه مشتری با تصویر جدید اصلاح می‌شود. نسخه پیشین، فایل و رأی‌ها باقی می‌مانند؛ نسخه تازه از ابتدا با سیاست جدید بررسی می‌شود و رأی قدیمی در تأیید جدید شمرده نمی‌شود. رد و ارشد دلیل لازم دارند؛ مغایرت مقصد برای تأیید دلیل بررسی لازم دارد. تصویر تکراری روی فیش دیگر به کارشناس هشدار داده می‌شود. + +صدور، ارسال، اصلاح، شروع چرخه، تخصیص، رأی، مشاهده مدرک و تغییر حساب با نام/شناسه عامل، زمان و جزئیات در `PaymentAuditEvent` ثبت می‌شوند. تغییرات مجوز مالی در مسیرهای مدیریت User/Group/Unit/Membership/StaffProfile نیز ممیزی می‌شوند. سیاست و داده‌های ممیزی از admin قابل حذف/ویرایش عادی نیستند. تغییر مستقیم دیتابیس خارج از مسیرهای برنامه، نیازمند کنترل عملیاتی و ممیزی مستقل دیتابیس است. + +## تصاویر و امنیت مرورگر + +تصاویر در `payments/` رسانه خصوصی ذخیره می‌شوند و فقط endpoint احراز هویت‌شده تصویر آن‌ها را ارائه می‌دهد. مجوز در هر بار مشاهده/دانلود بررسی می‌شود؛ Magic Link تیکت اجازه مالی نمی‌دهد. لینک مستقیم `/media/payments/` در توسعه و مسیر signed `/protected-media/payments/` در nginx مسدودند. پاسخ مالی/تصویر `no-store` است و service worker داده مالی را cache نمی‌کند. + +اعتبارسنجی تصویر شامل سقف حجم سیاست فعلی، extension، MIME واقعی، decode با Pillow، سقف ابعاد و SHA-256 است. فایل ساخته‌شده در شکست transaction پاک می‌شود. دانلود نسخه قبلی همچنان کنترل دسترسی دارد. + +فونت، SVG، CSS و JS داخل پروژه‌اند؛ وابستگی CDN اضافه نشده است. CSS چهارسو scoped است. کپی کامل اطلاعات از snapshot کامل، شامل شرح و فاکتور و تاریخ، ساخته می‌شود؛ فقط فیلدهای بانکی ضروری کپی تکی دارند. در HTTP شبکه محلی، اگر Clipboard مجاز نباشد، پنجره قابل انتخاب و کپی دستی نمایش داده می‌شود. + +## پیامک + +دو رویداد دیتابیسی `PAYMENT_NOTICE_CREATED` و `PAYMENT_RECEIPT_APPROVED` به `SMSTemplate` اضافه شده‌اند. migration الگوهای عمومی اولیه را ایجاد می‌کند؛ override واحد در الگوهای پیامک فعلی قابل تنظیم است. تگ‌ها: + +`recipient_name`, `company_name`, `notice_number`, `receipt_number`, `amount_rial`, `invoice_number`, `payment_link` + +صف فعلی `NotificationJob` و worker `process_notifications` به کار می‌روند. اعلامیه به معنی صف‌شدن پیامک است؛ دریافت پیامک نیازمند اجرای worker و تنظیم provider است. وضعیت ارسال و دلیوری مستقل از وضعیت مالی است. متن و نسخه الگو در اولین تلاش موفق آماده‌سازی snapshot می‌شود؛ retry همان متن را حفظ می‌کند. تلاش ناموفقِ آماده‌سازی قالب یا ارتباط provider نیز تاریخچه دارد. URL پیامک ورود کامل مشتری را لازم دارد. + +در آزمون‌ها provider mock شده است؛ پیامک واقعی برای مشتری ارسال نشده و worker روی صف واقعی توسعه اجرا نشده است. + +## آزمون و محدودیت محیط + +نتایج اجرا (اجرای مجدد را نباید با تعداد آزمون‌های یکتا جمع کرد): + +- مجموعه کامل سمت سرور: **۳۲۲ موفق، ۴ skipped**؛ ۴۶ آزمون مرورگر در این اجرای جدا انتخاب نشده‌اند (`chaharsu-backend-final.log`). +- کنترل مجدد بخش‌های مالی، view، مجوز و دامنه: **۱۸۱ موفق** (`chaharsu-focused-final.log`). +- مجموعه کامل مرورگر: **۴۴ موفق، ۲ skipped**؛ دو smoke test محیط خارجی بدون تنظیمات آن محیط اجرا نشده‌اند (`chaharsu-e2e-final.log`). +- پس از تکمیل سناریوی ایجاد اعلامیه و ارسال فیش مرتبط: **۵ آزمون مرورگر مالی و یک آزمون مجوز audit موفق** (`chaharsu-payments-e2e-final.log`). +- کنترل نهایی ثبت مقادیر قبل/بعد حساب بانکی، جلوگیری از ویرایش فرم قدیمی و مجوز حساب مقصد: **۳ موفق** (`chaharsu-audit-final.log`). +- آزمون نمونه قبلی ثبت تیکت: **۳ موفق** (`chaharsu-prototype-test.log`)؛ آزمون JavaScript موجود: **۱ موفق**. بررسی syntax هر سه فایل JS جدید نیز موفق بود. +- بررسی نهایی Django بدون خطا؛ کنترل صفحه واقعی با نشست مشتری و اندازه **۳۲۰×۵۶۸** موفق: هر سه خدمت کامل بالای نوار پایین دیده می‌شوند، overflow افقی وجود ندارد و دارایی‌ها نسخه‌دارند (`chaharsu-live-final.log`). سرور توسعه روی `0.0.0.0:8000` فعال است. +- Graphify به‌روز و روابط معماری بازبینی شد. Ripwire `--test-gate` فهرست آزمون‌ها و مسیرهای dispatch فاقد اتصال ایستای آزمون را ارائه کرد (exit 4، به معنای تعهد بررسی، نه شکست pytest). آزمون‌های فوق اجرا شدند؛ `--quality-delta` با **gating=0 و preexisting-worse=0** پایان یافت. برای کد جدید موارد پیچیدگی و callbackهای چارچوب نیز گزارش شده‌اند؛ این گزارش جایگزین تست اجرایی نیست. + +گزارش‌های فوق در `graphify-out/` نگهداری می‌شوند. آزمون‌های PostgreSQL در `tests/test_payment_concurrency.py` از connectionهای جدا استفاده می‌کنند و در SQLite صریحاً skip می‌شوند. تست SQLite اثبات صحت قفل PostgreSQL نیست. پیش از انتشار مالی روی production این **۴ آزمون** باید روی پایگاه **آزمایشی جداگانه PostgreSQL** اجرا شوند؛ در این محیط Docker daemon در دسترس نیست و binary قابل اجرای PostgreSQL موجود نیست. + +پشتیبان دیتابیس توسعه پیش از migration در مسیر ignoredِ `graphify-out/db-pre-chaharsu.sqlite3` تهیه شد. `bootstrap_demo` اجرا نشده، دسترسی یا حساب واقعی فرضی ایجاد نشده و عملیات git commit/push انجام نشده است. + +## انتخاب مشتری و میان‌بر ایجاد اعلامیه + +انتخاب مشتری در ایجاد اعلامیه و انتخاب مشتری برای ثبت فیش، جست‌وجویی است؛ فهرست کامل مشتریان در HTML بارگیری نمی‌شود. جست‌وجو با نام، موبایل فارسی/لاتین، سازمان، شماره یا موضوع تیکت انجام می‌شود. هر پاسخ حداکثر ۲۰ مشتری فعال را همراه سه سابقه اخیر قابل مشاهده برمی‌گرداند؛ سوابق خصوصی خارج از مجوز کارشناس در نتیجه یا شمارنده دیده نمی‌شوند. انتخاب خودکار نتیجه اول وجود ندارد. نام، موبایل، سازمان و موقعیت در کارت انتخاب‌شده نمایش داده می‌شوند و تغییر عبارت جست‌وجو، انتخاب قبلی را پاک می‌کند. + +دکمه «ایجاد اعلامیه واریز» در باکس مشتری تیکت و کارت مخاطب، فقط برای کارشناس دارای مجوز ایجاد و مشتری فعال نمایش داده می‌شود. پارامتر `customer` در لینک، UUID مشتری است؛ GET تنها مشتری فعال را پیش‌انتخاب می‌کند و هیچ اعلامیه یا پیامکی ایجاد نمی‌کند. POST همچنان شناسه مشتری را با queryset فعال فرم و مجوز مالی سرور اعتبارسنجی می‌کند. پاسخ جست‌وجو `private, no-store` است و داده مشتری در حافظه پایدار مرورگر ذخیره نمی‌شود. diff --git a/docs/CHAHARSU_PAYMENTS_IMPLEMENTATION_PLAN.md b/docs/CHAHARSU_PAYMENTS_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..3cfc7d1 --- /dev/null +++ b/docs/CHAHARSU_PAYMENTS_IMPLEMENTATION_PLAN.md @@ -0,0 +1,401 @@ +# طرح ادغام چهارسو و مدیریت اعلامیه و فیش واریز + +تاریخ بررسی: ۲۰۲۶/۱۰/۰۵ — وضعیت: طرح مبنای پیاده‌سازی؛ گزارش اجرا و تفاوت مسیر نهایی در [راهنمای پیاده‌سازی](CHAHARSU_PAYMENTS_IMPLEMENTATION.md) ثبت شده است. + +## ۱. نظر کارشناسی و تصمیم معماری + +این قابلیت یک زیرسامانه مالی با گردش تأیید است؛ انتقال HTML نمونه به قالب‌های اصلی به‌تنهایی آن را کامل نمی‌کند. پیشنهاد، افزودن اپ مستقل `apps.payments` داخل معماری Django Modular Monolith موجود و استفاده از زیرساخت کاربران، واحدها، پیامک، رسانه خصوصی و برند فعلی است. منطق مالی در `apps/tickets/views.py` یا مدل `Ticket` انباشته نشود. + +چهار مفهوم باید مستقل بمانند: + +| مفهوم | معنی | +| --- | --- | +| حساب شرکت | حساب بانکی مدیریت‌شده و وضعیت مجاز بودن استفاده از آن | +| اعلامیه واریز | دستور پرداخت اختصاصی کارشناس برای یک مشتری، مبلغ و مقصد مشخص | +| فیش واریز | مدرک پرداخت مشتری؛ با اعلامیه یا مستقل از آن | +| تأیید فیش | تصمیم اشخاص مجاز درباره یک نسخه مشخص از مدرک، مطابق سیاست تأیید | + +**تأیید مربوط به فیش است.** اعلامیه پیش از دریافت مدرک صرفاً صادر می‌شود. وضعیت پرداخت اعلامیه از فیش‌های تأییدشده محاسبه می‌شود؛ ایجاد اعلامیه، بارگذاری تصویر یا ارسال پیامک به معنی تأیید پرداخت نیست. این قابلیت به‌خودی‌خود تطبیق خودکار با تراکنش بانک یا سند حسابداری ایجاد نمی‌کند. + +اصل مهم: دسترسی گسترده اولیه برای ایجاد و مشاهده در پنل کارشناسان، مستقل از مجوز محدود تأیید است. این تصمیم نباید دسترسی تیکت‌ها یا واحدهای سازمانی را گسترده کند. + +## ۲. مبنای بررسی و حدود قطعیت + +درخت پروژه `PROJECT_TREE.txt`، گراف موجود Graphify، اسناد سازمانی و طراحی، سورس بخش‌های مؤثر، فایل‌های تست پروژه و E2E و ابزارهای مرورگر بررسی شدند. مسیر تحلیل، درخت پروژه → Graphify برای معماری و ارتباط اسناد → Ripwire برای نمادها و وابستگی سورس → تطبیق مستقیم با سورس و تست‌ها بود. Graphify و Ripwire برای یک سؤال هم‌زمان اجرا نشدند. + +نمونه‌های بررسی Graphify: ارتباط‌های `UnitMembership`، `SMSTemplate` و مفهوم نمونه بودن صفحه شماره حساب‌ها. Ripwire برای برنامه قابلیت، بدنه سیاست دسترسی و ارسال پیامک، استفاده‌های `StaffProfile` و شعاع اثر `_send_sms` استفاده شد. ارتباط‌های استنباطی گراف با سورس تطبیق داده شدند؛ گراف جای حقیقت اجرایی نیست. شمارش‌های Ripwire حد پایین‌اند و resolveهای مبهم و فایل‌های بدون grammar وجود دارند؛ پوشش Django ORM، template و URL باید با خواندن مستقیم تکمیل شود. + +در این مرحله تست‌ها **خوانده شدند و اجرا نشدند**؛ نتیجه سبز یا صحت اجرای فعلی ادعا نمی‌شود. دیتابیس عملیاتی، افراد دارای مجوز فعلی و متن زنده الگوهای پیامک استعلام نشدند. شیوه دیتابیس‌محور از مدل‌ها، migrationها، فرم‌ها، worker و تست‌ها استخراج شده است؛ پیش از استقرار باید تنظیمات واقعی، با دسترسی فقط‌خواندنی، تطبیق داده شوند. + +کد، قالب، CSS، migration و داده فعلی تغییر نمی‌کند؛ خروجی این مرحله همین سند است. تغییرات از قبل موجود در `PROJECT_TREE.txt` و `apps/customers/migrations/0005_contacteditlog.py` خارج از این کارند. + +## ۳. واقعیت فعلی و نقاط حساس + +| حوزه | شاهد در مخزن | نتیجه برای طراحی | +| --- | --- | --- | +| کاربران | `apps/accounts/models.py:User` از `AbstractUser` و دارای `account_type` است | `is_staff` صرفاً به معنی اختیار مالی نیست؛ مشتری و کارشناس تفکیک شوند | +| نقش‌ها | `apps/staff/models.py:StaffProfile` و `ROLE_GROUPS` | ذخیره پروفایل، گروه نقش را همگام می‌کند؛ مجوز مالی به نام اشخاص یا عنوان شغلی hardcode نشود | +| واحدها | `OrganizationalUnit.permissions` و `UnitMembership.permissions`، عضویت فعال و `ticket_scope` | مجوز مستقیم، گروهی، واحدی و شخص در واحد موجودند؛ دامنه مالی مستقل تعریف شود | +| سیاست تیکت | `apps/tickets/policies.py:TicketPolicy` | `can_operate` برای کارشناس فعالِ دارای دسترسی تیکت گسترده است؛ برای تأیید مالی قابل استفاده نیست | +| همگام‌سازی نقش | `apps/core/management/commands/bootstrap.py` | `group.permissions.set(...)` می‌تواند مجوز گروهی سفارشی را حذف کند؛ اجرای bootstrap برای اعطای مجوز مالی مناسب نیست | +| صفحه مشتری | `customer_list`، `templates/tickets/customer_list.html` | `/tickets/` هم خانه و هم سوابق است؛ سوابق باید مقصد مستقل پیدا کند | +| ورود و PWA | `apps/accounts/views.py`، `config/urls.py`، `templates/base.html` | نام مسیر `tickets:list` و start_url فعلی در مهاجرت خانه حفظ شوند | +| پیامک | `NotificationJob`، `enqueue` و `process_notifications` | صف پایدار و dedupe وجود دارد؛ worker فعلاً فقط رویدادهای شناخته‌شده تیکت را می‌فرستد | +| متن پیامک | `SMSTemplate` و `_render_sms` | الگوی فعال واحد، سپس عمومی؛ allowed_tags و context کنترل می‌شوند | +| فایل | `Attachment.message` اجباری و مسیر وابسته به تیکت است | فیش مستقل نباید با تیکت یا پیام ساختگی ذخیره شود | +| تحویل فایل | `apps/attachments/delivery.py` و Nginx | امضای کوتاه‌عمر پس از احراز مجوز؛ view مخصوص مالی لازم است | +| تاریخ | `apps/core/templatetags/jalali.py` و `jdatetime` | نمایش شمسی و ذخیره میلادی؛ برای زمان رویدادها UTC و نمایش ایران | +| طراحی | `design-prototypes/chaharsu/STYLE_GUIDE.md` و README | prototype قرارداد UX است، نه پنل، دیتابیس یا ارسال واقعی | + +دو سند سازمانی قدیمی در بعضی توضیحات با وضعیت امروز یکسان نیستند: توضیح دسترسی legacy بدون واحد با `viewable_q` فعلی تفاوت دارد؛ توضیح نبود استعلام دلیوری نیز با worker و تست‌های جدید دلیوری به‌روز نیست. در این موارد سورس و تست فعلی مبناست. وضعیت تحویل SMS با تأیید مالی اشتباه نشود. + +## ۴. معماری و مرز مسئولیت‌ها + +```mermaid +flowchart TD + C[چهارسو: مشتری احراز هویت‌شده] --> CV[payments customer views/forms] + S[میزکار کارشناسان] --> SV[payments staff views/forms] + B[بانک‌ها و تنظیمات مجاز] --> SV + CV --> P[PaymentPolicy و selectors محدودشده] + SV --> P + P --> W[services: صدور، ثبت، تصمیم، تغییر وضعیت] + W --> D[(حساب، اعلامیه، فیش، چرخه و ممیزی)] + W --> O[(NotificationJob با dedupe)] + O --> N[worker موجود و رویدادهای مالی] + N --> T[(SMSTemplate)] + N --> SMS[provider پیامک موجود] + CV --> F[رسانه خصوصی با مجوز مالی] + SV --> F +``` + +ساختار پیشنهادی: `models.py`، `policies.py`، `selectors.py`، `forms.py`، `services/` برای حساب/اعلامیه/فیش/تأیید، `notifications.py`، `customer_views.py`، `staff_views.py`، URLهای جدا و `admin.py`. همه تغییرات وضعیت از سرویس واحد عبور کنند؛ فرم، admin و view مستقیماً وضعیت تأیید را ننویسند. + +برای اتصال به واحدها، `owning_unit` و `created_by` در اعلامیه ثبت شود. استقلال مالی به معنی بی‌نیازی از ثبت منشأ سازمانی نیست. اطلاعات تیکت، در صورت نیاز آینده، فقط ارتباط اختیاری خواهد داشت؛ مبنای دسترسی مالی نباشد. + +## ۵. مدل داده پیشنهادی + +نام‌ها پیشنهادی‌اند؛ قبل از کدنویسی در migration design نهایی شوند. + +| مدل | داده‌های اصلی و قرارداد | +| --- | --- | +| `BankInstitution` | نام/کد بانک مبدأ و نام‌های تاریخی، فعال بودن، ترتیب؛ «سایر» با متن اختیاری؛ کاتالوگ محلی قابل نگهداری | +| `CompanyBankAccount` | نام نمایشی حساب، بانک، صاحب حساب، شماره حساب، شبا، کارت اختیاری، سه پرچم فعال، ترتیب، سازنده/ویرایشگر، زمان، نسخه | +| `PaymentNotice` | public UUID، شماره نمایشی یکتا، مشتری، واحد، صادرکننده، موضوع، مبلغ صحیح ریال، حساب مرجع، شناسه واریز اختیاری، شماره فاکتور، توضیحات، وضعیت صدور/لغو، تاریخ صدور و snapshot اطلاعات پرداخت | +| `PaymentReceipt` | public UUID، مشتری، `notice` اختیاری، ثبت‌کننده و نوع CUSTOMER/STAFF، نسخه جاری، وضعیت جاری و زمان ثبت | +| `PaymentReceiptRevision` | نسخه تغییرناپذیر: مبلغ واقعی، تاریخ پرداخت میلادی، حساب مقصد واقعی و snapshot آن، بانک مبدأ، شماره پیگیری اختیاری، توضیحات و فایل | +| `ReceiptDocument` | فایل خصوصی با UUID، نام اصلی، MIME واقعی، اندازه، SHA-256، آپلودکننده و زمان؛ مرتبط با نسخه فیش | +| `PaymentApprovalPolicy` | نسخه تنظیمات، روش تأیید، افراد منتخب، ارشدهای مجاز و واحد/دامنه سیاست؛ ویرایش با مجوز و لاگ | +| `ReceiptApprovalCycle` | نسخه فیش، snapshot سیاست، حد نصاب/روش، وضعیت، نهایی‌کننده و زمان؛ تنها یک چرخه جاری | +| `ReceiptApprovalAssignment` | افراد و نقش عادی/ارشدِ همان چرخه؛ مستقل از تغییر تنظیمات آینده | +| `ReceiptDecision` | چرخه، کاربر تصمیم‌گیر، APPROVE/REJECT/SENIOR_APPROVE، دلیل و زمان؛ یک رأی معتبر هر شخص در هر چرخه | +| `PaymentAuditEvent` | عملیات، موضوع، actor و snapshot هویت، زمان، قبل/بعد مجاز، دلیل، request ID و ارتباط نسخه/چرخه | +| `PaymentAccessConfiguration` | حالت ALL_ACTIVE_STAFF یا EXPLICIT برای مشاهده/ایجاد؛ جدا از سیاست تأیید | +| `PaymentNotification` | پیوند مالی به `NotificationJob`، رویداد و نسخه/چرخه، context و مشخصات الگوی مصرف‌شده؛ امکان گزارش پیامک بدون تیکت | +| `PaymentNotificationAttempt` | سابقه تغییرناپذیر هر تلاش مالی، زمان شروع/پایان، نتیجه، کد و مرجع provider؛ تلاش متوقف‌شده نیز قابل تشخیص و مستقل از reset شمارنده job | + +شماره حساب، کارت، شبا، شناسه واریز و فاکتور رشته‌اند تا صفر ابتدایی از بین نرود. ارقام استاندارد ذخیره ASCII؛ نمایش می‌تواند فارسی باشد. شبا با طول/کشور/checksum و کارتِ موجود با قواعد شماره معتبر بررسی شود؛ اعتبار شماره اثبات مالکیت حساب نیست. همه حساب‌ها الزاماً کارت ندارند. + +مبلغ `BigIntegerField` با قید مثبت و سقف مشخص قابل پشتیبانی سیستم پیشنهاد می‌شود. در Python محاسبه صحیح، در JS رشته عددی/BigInt؛ تبدیل تومان بدون float و بدون گرد کردن. سقف عملیاتی و سقف جمع مبالغ جدا کنترل شوند. مبلغ expected اعلامیه و paid فیش دو فیلد متفاوت‌اند. + +قیود پایگاه داده: شماره/public_id یکتا، نسخه یکتا در هر فیش، رأی یکتای `(cycle, reviewer)`، چرخه جاری یکتا، dedupe پیامک یکتا، مبلغ مثبت و حالت‌های سازگار. هم‌مالک بودن notice و receipt و صلاحیت حساب در سرویس و فرم دوباره بررسی شوند؛ `CheckConstraint` معمولی نمی‌تواند تمام روابط بین جدول‌ها را تضمین کند. + +روی مشتری/زمان، وضعیت/زمان، واحد/زمان، حساب/زمان و چرخه/کاربر index لازم است. حذف حساب یا رکورد مرجع استفاده‌شده با `PROTECT` یا آرشیو محدود شود؛ حذف فیش تأییدشده عملیات CRUD عادی نیست. + +## ۶. مجوزها و دامنه مشاهده + +«همه» در این طرح یعنی **کارشناسان داخلی فعال**، نه مشتریان، کاربران غیرفعال یا هر حساب دارای `is_staff`. پروفایل غیرفعال نیز مجوز ندارد. استثنای admin بدون StaffProfile مانند رفتار فعلی باید صریح و آزموده تعریف شود. + +| اقدام | وضعیت اولیه | قابلیت محدودسازی بعدی | +| --- | --- | --- | +| دیدن میزکار اعلامیه‌ها و فیش‌ها | همه کارشناسان فعال | مجوز مشاهده و دامنه سراسری/واحدی/تخصیص شخصی | +| ایجاد اعلامیه | همه کارشناسان فعال | مجوز ایجاد و واحدهای مجاز | +| ثبت فیش توسط staff برای مشتری | همه کارشناسان فعال، با ثبت هویت ثبت‌کننده | مجوز مستقل ثبت فیش برای دیگری | +| مشاهده مشتری | فقط اعلامیه‌ها/فیش‌های خودش | همیشه object-level؛ تغییرپذیر به مشاهده همگانی نیست | +| ارسال مشتری | فقط برای خودش؛ notice باید متعلق به خودش باشد | کنترل مقصد و ارتباط در سرور | +| رأی تأیید یا رد | فقط شخص دارای مجوز و تخصیص در چرخه | ALL_SELECTED یا حد نصاب تعیین‌شده | +| تأیید ارشد | شخص دارای مجوز ویژه و تعیین‌شده به‌عنوان ارشد | هرگز صرف عنوان مدیرعامل یا `is_staff` | +| مدیریت بانک‌ها | مدیرعامل فعال، superuser و افراد دارای مجوز صریح | مجوز اختصاصی؛ سایر نقش‌ها به‌صورت پیش‌فرض ممنوع | +| تغییر سیاست/افراد تأیید | افراد دارای مجوز مدیریت تأیید | جدا از مشاهده یا ایجاد اعلامیه | +| مدیریت الگوهای مالی SMS | مجوز مربوط به SMSTemplate و دامنه واحد/عمومی | ایجاد اعلامیه مجوز تغییر متن SMS نمی‌دهد | + +کدهای پیشنهادی: `view_paymentnotice`، `add_paymentnotice`، `view_paymentreceipt`، `submit_customer_receipt`، `approve_paymentreceipt`، `senior_approve_paymentreceipt`، `manage_company_bankaccounts`، `manage_payment_approval_policy`، `manage_payment_access` و `view_payment_audit`. دامنه سراسری مجوز جدا داشته باشد؛ مجوز واحدی فقط برای همان واحد اعمال شود. + +`PaymentPolicy` الگوی ترکیب permissionهای Django و عضویت واحدی را به کار می‌گیرد، اما به `TicketPolicy.can_operate` یا `tickets.view_all_tickets` متکی نمی‌شود. مشتری حتی با حدس UUID، تغییر notice_id، account_id، پارامتر جست‌وجو، export یا URL فایل به داده دیگری دسترسی نمی‌گیرد. + +پیشنهاد برای فاز اول: حالت ALL_ACTIVE_STAFF فقط در مشاهده/ایجاد فعال باشد. مجوزهای تأیید به هیچ گروه فروش یا مالی به‌طور عمومی seed نشوند. کاربران تأییدکننده و ارشد از admin/تنظیمات انتخاب شوند. superuser بتواند سیاست را مدیریت کند، اما امضای مالیِ خارج از چرخه را به‌صورت پنهان جایگزین رأی نکند؛ برای تأیید ارشد باید در مسیر صریح آن قرار گیرد. + +مجوز مالی مستقیم یا membership اختصاصی در برابر `StaffProfile.save` و bootstrap پایدارتر است؛ اگر گروه‌های مالی مستقل ایجاد شدند، bootstrap گروه‌های مدیریت‌شده موجود نباید آن‌ها را بازنویسی کند. نیازی به چند boolean مالی در مدل `User` یا بازنویسی enum نقش‌ها نیست. + +## ۷. تأیید چندنفره و ارشد + +روش اولیه پیشنهادی `ALL_SELECTED` است: اگر الف و ب انتخاب شوند، رأی هر دو الزامی است؛ تأیید دو شخص دیگر جای آن‌ها را نمی‌گیرد. `QUORUM` برای «دو نفر از یک مجموعه» مفهوم دیگری است و فقط با نیاز کسب‌وکار فعال شود. حالت تک‌نفره همان ALL_SELECTED با یک شخص است. + +هنگام ارسال فیش، سیاست معتبر و افراد آن در چرخه snapshot شوند. هیچ چرخه‌ای با فهرست خالی، شخص غیرفعال یا حد نصاب غیرممکن قابل شروع نیست. سازنده اعلامیه یا مشتری نتواند سیاست ضعیف‌تری انتخاب کند؛ سیاست از تنظیمات مدیریت تعیین شود. + +```mermaid +stateDiagram-v2 + [*] --> PENDING: ثبت نسخه فیش + PENDING --> PARTIALLY_APPROVED: رأی عادی و ناتمام بودن شرط + PARTIALLY_APPROVED --> APPROVED: تکمیل شرط تأیید + PENDING --> APPROVED: تک‌نفره یا تأیید ارشد مجاز + PARTIALLY_APPROVED --> APPROVED: تأیید ارشد مجاز + PENDING --> REJECTED: رد با دلیل + PARTIALLY_APPROVED --> REJECTED: رد با دلیل + REJECTED --> PENDING: نسخه اصلاحی و چرخه جدید +``` + +پیشنهاد پیش‌فرض: یک رد معتبر، چرخه را خاتمه دهد؛ اصلاح مشتری نسخه و چرخه جدید ایجاد کند. این قاعده و ممنوعیت تأیید مدرکی که همان کارشناس ثبت کرده، تصمیم‌های کسب‌وکاری نیازمند تعیین‌اند؛ در بخش ۱۵ ثبت شده‌اند. + +هر اقدام در `transaction.atomic` با قفل ردیف فیش و چرخه جاری انجام شود. مجوز جاری، نسخه مورد انتظار، وضعیت و رأی تکراری داخل قفل بررسی شوند. قید یکتا لایه دوم دفاع است. ترتیب قفل‌ها ثابت باشد؛ در PostgreSQL، در صورت join nullable، الگوی قفل `of=("self",)` مانند تجربه تست Announcement رعایت شود. + +رأی تکراری همان کاربر شمارش را بالا نبرد. رأی آخر و تأیید ارشد هم‌زمان فقط یک انتقال نهایی و یک پیامک تولید کنند. درخواست صفحه قدیمی نتواند نسخه جدید را تأیید کند؛ پاسخ «اطلاعات تغییر کرده؛ دوباره بررسی کنید» بدهد. + +ارشد با اقدام مشخص «تأیید نهایی ارشد»، نمایش اثر و ثبت دلیل، انتظار سایر افراد را پایان می‌دهد. این اختیار برای چرخه جاریِ در انتظار است؛ رأی رد یا تأیید نهایی پیشین را پاک نمی‌کند. اصلاح تصمیم نهایی باید مسیر مجاز و ممیزی‌شده جدا داشته باشد. + +تغییر مبلغ، تاریخ، مقصد یا تصویر پس از شروع تصمیم‌گیری، نسخه جدید و چرخه جدید می‌خواهد. رأی روی نسخه قبلی اعتبار نسخه جدید نیست. خروج کاربر از سازمان مانع رأی آینده او می‌شود، اما سوابق رأی قبلی را حذف نمی‌کند. جایگزینی افراد چرخه معلق با مجوز مدیریت، دلیل، بستن چرخه قبلی و ثبت چرخه تازه انجام شود. + +## ۸. مدیریت بانک‌ها و کنترل جریان مالی + +در sidebar راست «بانک‌ها» فقط برای مدیرعامل/admin مجاز و کاربران تعیین‌شده نمایش داده شود. کنترل endpoint و عملیات bulk مستقل از پنهان‌کردن منو لازم است. + +سه پرچم روی **حساب شرکت** باشند، نه فقط نام بانک؛ ممکن است دو حساب یک بانک سیاست متفاوت داشته باشند: + +- `is_active`: قطع کلی انتخاب جدید. +- `available_to_staff`: قابل انتخاب برای صدور اعلامیه توسط کارشناس. +- `available_to_customers`: قابل انتخاب در فرم فیش مستقل مشتری؛ فقط نام حساب نمایش داده می‌شود. + +eligibility staff برابر `is_active AND available_to_staff` و eligibility مستقل مشتری برابر `is_active AND available_to_customers` است. وضعیت کلی غیرفعال بر دو گزینه دیگر اولویت دارد. فعال کردن چند حساب یا خاموش کردن چند حساب با POST، CSRF، مجوز، transaction و لاگ هر حساب انجام شود. + +ویرایش، افزودن، جست‌وجو، فیلتر سه وضعیت و صفحه‌بندی؛ انتخاب ردیف‌ها و تأیید تغییر جمعی. «انتخاب همه» روشن کند منظور صفحه جاری است یا تمام نتیجه فیلتر؛ در فاز اول صفحه جاری ساده‌تر است. رکورد استفاده‌نشده با مجوز قابل حذف؛ استفاده‌شده آرشیو شود و علت عدم حذف نمایش داده شود. + +**تعارض مهم سیاست مقصد:** حساب فقط-staff ممکن است در اعلامیه اختصاصی مشتری باشد ولی در dropdown مستقل مشتری نباشد. پیشنهاد: نمایش و انتخاب همان حساب صرفاً در context اعلامیه معتبر خودش مجاز باشد؛ dropdown مستقل همچنان فقط حساب‌های customer-enabled را ارائه کند. اگر خاموش کردن customer باید اعلامیه‌های موجود را هم متوقف کند، باید اعلامیه نیز صریحاً لغو/جایگزین شود. این رفتار پیش از اجرا تعیین شود. + +خاموش‌کردن حساب نباید سوابق مالی را پنهان یا بررسی فیش پرداخت گذشته را متوقف کند. در فرم قدیمی، تغییر eligibility در لحظه POST دوباره بررسی شود؛ برای پرداختی که قبلاً انجام شده، مسیر استثنای ثبت واقعیت با دلیل و بررسی کارشناس لازم است. بازگرداندن خودکار حساب دیگری یا تغییر بی‌خبر مقصد ممنوع است. + +اطلاعات بانکی در اعلامیه صادرشده snapshot شوند. تغییر اطلاعات واقعی حساب بهتر است رکورد/نسخه جدید بسازد؛ تغییر نام نمایشی نیز سابقه اعلان قبلی را بازنویسی نکند. شناسه واریز و مبلغ روی اعلامیه‌اند، چون هر بار ممکن است متفاوت باشند. + +## ۹. میزکار اعلامیه‌ها و بررسی فیش‌ها + +نام پیشنهادی منو «اعلامیه واریز» و عنوان صفحه «اعلامیه‌ها و فیش‌های واریزی» است. دکمه اصلی «ایجاد اعلامیه واریز»؛ ثبت فیش توسط کارشناس اقدام ثانویه. در همان میزکار، تب‌های «اعلامیه‌ها»، «فیش‌ها» و فیلتر «نیازمند اقدام من» وجود داشته باشند. تب فیش‌ها **فیش مستقل را نیز** نشان دهد؛ query با inner join روی notice آن را حذف نکند. + +فرم صدور: انتخاب مشتری از فهرست فعلی مخاطبان، موضوع، حساب مجاز staff، مبلغ ریال، شناسه اختیاری، فاکتور و توضیحات. نام مشتری و صاحب حساب مشخص باشد. انتخاب مشتری موجود، مشخصات تماس او را ناخواسته ویرایش نکند؛ فرم ایجاد تیکت امروز مسئولیت‌های بیشتری دارد و نباید عیناً reuse شود. + +قانون انتشار: draft پیامک ندارد؛ «صدور و اطلاع‌رسانی» اعلامیه را برای مشتری قابل مشاهده و job پیامک را در همان transaction ایجاد می‌کند. شکست پیامک اعلامیه را حذف نمی‌کند؛ وضعیت جدا «پیامک ناموفق» و مسیر retry مجاز نمایش داده شود. + +فیلترهای مشترک: متن/شماره، مشتری و موبایل، فاکتور، بازه تاریخ، حساب، صادرکننده و واحد. فیش‌ها: وضعیت، مرتبط/مستقل، تأییدکننده و «اقدام من». وضعیت ارسال/تحویل SMS فیلتر جداست. مرتب‌سازی allowlist؛ پیش‌فرض تازه‌ترین با tie-breaker ID و صفحه‌بندی سمت سرور، مثلاً ۲۵ ردیف با سقف مشخص. + +فیلترها با query string حفظ شوند؛ تغییر فیلتر page را بازنشانی کند. تاریخ فیلتر شمسی نمایش و در سرور میلادی معتبر شود؛ فیلتر datetime بر اساس ابتدا/انتهای روز ایران باشد. جست‌وجوی ارقام فارسی و موبایل با الگوی normalize موجود سازگار شود. قبل از search/pagination/count، queryset مجاز محدود شود. + +ردیف فیش: مشتری، مبلغ، تاریخ، حساب، شماره اعلامیه یا «مستقل»، وضعیت و پیشرفت «۱ از ۲ تأیید». جزئیات: تصویر قابل بزرگ‌نمایی، داده پرداخت، اختلاف با اعلامیه و timeline نام/زمان/دلیل تصمیم‌ها. رأی عادی با متن «ثبت تأیید من» و نتیجه روشن؛ در تأیید چندنفره از عبارت گمراه‌کننده «فیش تأیید شد» استفاده نشود. + +دکمه رد دلیل اجباری دارد. افراد بدون مجوز، اطلاعات و سوابق را می‌بینند ولی دکمه تصمیم ندارند؛ POST جعلی نیز ممنوع است. نام کسانی که هنوز باید تأیید کنند و نهایی‌کننده عادی/ارشد مشخص باشد. جدول در موبایل به کارت خوانا تبدیل شود؛ اطلاعات اصلی در ستون‌های باریک بریده نشوند. + +badge sidebar: **تعداد فیش‌های در انتظار بررسی در دامنه قابل مشاهده**، شامل مستقل و مرتبط و تأیید جزئی؛ هر فیش یک بار، نه تعداد رأی یا اعلامیه. «نیازمند اقدام من» شمارش مستقل داخل صفحه دارد. ایجاد اعلامیه بدون دریافت فیش این badge را افزایش نمی‌دهد. پس از رأی آخر کم می‌شود؛ پس از رأی جزئی باقی می‌ماند. + +شمارنده از selector مشترک میزکار استفاده کند؛ `distinct`/Exists و prefetch مناسب، بدون N+1. refresh اولیه سرور و در صورت نیاز polling محلی مشابه shares با توقف در صفحه پنهان، و پاسخ JSON `private,no-store`. badge جدا از `staff_unread_shares` باشد؛ دسترسی گسترده مالی، count تیکت‌های واحدهای دیگر را تغییر ندهد. + +## ۱۰. چهارسو و قرارداد UX مشتری + +مسیر `/tickets/` و نام `tickets:list` برای ورود و PWA حفظ شود، اما مقصد به خانه خدمات تبدیل شود. سوابق به `/tickets/requests/` با نام `tickets:history` منتقل شود؛ لینک‌های فعلی «درخواست‌های من» به آن مقصد اصلاح شوند. جزئیات تیکت، Magic Link و ثبت درخواست فعلی باقی بمانند. + +مسیرهای پیشنهادی مالی مشتری `/payments/notices/`، `/payments/receipts/new/` و `/payments/receipts//`؛ staff زیر `/staff/payments/` و `/staff/banks/`. include جدا با namespace مستقل؛ بدون تغییر namespace تیکت و بدون تداخل الگوهای UUID موجود. + +خانه مطابق نمونه: کارت مشترک «ارتباط با کارشناسان» با دو اقدام ثبت درخواست/درخواست‌های من؛ جمله «سؤال بپرسید، درخواست ثبت کنید یا برای پیگیری و هماهنگی با کارشناسان در ارتباط باشید.» در موبایل و دسکتاپ دیده شود. دو خدمت مالی جدا؛ هیچ فهرست «درخواست‌های اخیر» در خانه قرار نگیرد. + +«شماره حساب‌ها» در واقع فهرست اعلامیه‌های اختصاصی است؛ catalog کامل حساب‌های شرکت برای مشتری نمایش داده نشود. کارت‌های compact بازشونده با مبلغ/موضوع/وضعیت؛ اطلاعات تکمیلی و توضیحات **داخل کارت بازشده همیشه نمایان** و بدون accordion دوم. راهنمای کوتاه درخواست اطلاعات و شناسه از کارشناس باقی بماند. + +کپی تکی فقط مبلغ، حساب، شبا، کارت و شناسه موجود؛ مقدار عددی بدون جداکننده نمایشی. «کپی کامل اطلاعات» از snapshot کامل و مجاز اعلامیه ساخته شود، نه DOM قابل مشاهده؛ بانک، صاحب حساب، فاکتور، توضیحات و واحد ریال را هم شامل شود. Clipboard فقط بعد از موفقیت بازخورد بدهد؛ fallback انتخاب دستی و Escape و بازگشت فوکوس حفظ شود. + +فرم فیش: مرتبط اولین گزینه پیشنهادی و مستقل همیشه در دسترس. ورود از اعلامیه همان مورد را انتخاب کند؛ تغییر حالت فایل، مبلغ و تاریخ را پاک نکند. حساب مقصد اجباری و فقط نام‌ها در dropdown؛ مبدأ اختیاری با بانک‌های محلی، نام‌های تاریخی و «سایر». داده‌های fixture و ID ساختگی وارد production نشوند. + +مبلغ ریال اجباری با هزارگان و معادل دقیق تومان به حروف؛ تاریخ کامل و تصویر اجباری. سه select روز/ماه/سال شمسی و «امروز» مینیمال حفظ شود. `paid_date` خروجی میلادی `YYYY-MM-DD`، بدون تبدیل midnight به timestamp؛ روز ابتدا خالی. بازه سال و ممنوعیت آینده از سیاست سرور بیاید. jdatetime سمت سرور، کامل بودن تاریخ، کبیسه، تاریخ غیرآینده و تطبیق شمسی/میلادی را بررسی کند؛ hidden input قابل اعتماد نیست. بدون JS نیز سه مقدار شمسی با فرم سرور قابل اعتبارسنجی و تبدیل باشند. + +مقصد متفاوت با اعلامیه: مبلغ یا انتخاب کاربر خودکار بازنویسی نشود. ثبت واقعی با نشان «مغایرت مقصد» و دلیل/بررسی ویژه پیشنهاد می‌شود؛ ارتباط فیش با اعلامیه به‌تنهایی تطبیق یا تسویه آن نیست. چنین فیشی قبل از تعیین تکلیف مجاز، در جمع تسویه اعلامیه نیاید. + +اعتبارسنجی مرورگر از قواعد واقعی سرور تغذیه شود؛ خطای فارسی زیر فیلد + summary لینک‌دار + aria-invalid/describedby + هدایت به اولین کنترل. پاسخ validation سرور همان اطلاعات را حفظ کند؛ مرورگر ممکن است انتخاب مجدد فایل را لازم کند و باید صریح بگوید. submit دوباره با idempotency token و وضعیت pending مدیریت شود. موفقیت فقط بعد از commit واقعی: «فیش ثبت شد و در انتظار بررسی است». + +ظاهر از `STYLE_GUIDE.md`: وزیرمتن موجود، RTL، متن عملیاتی حداقل ۱۴px، ورودی ۱۶px، کنترل اصلی حداقل ۴۴px، رنگ سرمه‌ای/سفید و سطوح موجود. جفت‌رنگ‌های دارای کنتراست ناکافی نمونه تکثیر نشوند. همه فونت، SVG، JS و CSS محلی و versioned؛ CDN یا fetch دارایی خارجی ممنوع. داده مالی از سرور خود پروژه می‌آید؛ SMS بک‌اند وابستگی CDN رابط محسوب نمی‌شود. + +SVGها در include محلی مشترک؛ CSS مالی scoped و asset صفحه‌ای، بدون تغییر عمومی بی‌هدف. امضا از `developer_credit` دیتابیس فعلی تأمین شود. تنظیمات hero/PageTitle برای خانه جدید جدا و بدون overwrite متن سفارشی موجود طراحی شوند. شرط دیدن سه خدمت روی ۳۲۰×۵۶۸ در حالت معمول آزموده شود؛ بزرگ‌نمایی متن و نمایش خطا نباید قربانی حذف اسکرول شوند. + +## ۱۱. پیامک مالی با زیرساخت موجود + +`NotificationJob.ticket` nullable است و `enqueue` می‌تواند با `ticket=None` job بسازد. اما `_send_sms` رویداد ناشناخته را رد می‌کند و `_sms_context` به `job.ticket` وابسته است. بنابراین افزودن Event به مدل به‌تنهایی کافی نیست. + +پیشنهاد: `PaymentNotification` در اپ مالی، job موجود را به notice/receipt revision/cycle مربوط کند؛ worker handler مالی مشخص داشته باشد و context مالی را مستقل بسازد. نیازی به تیکت صوری، worker دوم یا تغییر nullable بودن Attachment برای این اتصال نیست. وابستگی migration از payments به notifications قابل طراحی است؛ notifications نباید به models مالی در سطح module import دوری پیدا کند. + +دو رویداد جدید در `SMSTemplate.Event`: + +- `PAYMENT_NOTICE_CREATED`: فقط هنگام صدور اعلامیه، یک بار برای مشتری. +- `PAYMENT_RECEIPT_APPROVED`: فقط انتقال نهایی چرخه به APPROVED؛ رأی جزئی پیامک «تأیید شد» ندارد. + +تگ‌های مالی محدود مانند `{recipient_name}`، `{company_name}`، `{notice_number}`، `{receipt_number}`، `{amount_rial}`، `{invoice_number}`، `{payment_link}`. allowed_tags هر رویداد با context واقعی آن سازگار باشد؛ قالب مالی نتواند اشتباهاً `{magic_link}` تیکت طلب کند. برای فیش مستقل، فاکتور/اعلامیه غایب متن خالی یا fallback مناسب دارد. renderer عمومی موجود قابل استفاده است؛ context تیکت قابل استفاده مستقیم نیست. + +defaultهای عمومی با data migration idempotent و `get_or_create` ایجاد شوند؛ متن‌های ویرایش‌شده مشتری overwrite نشوند. الگوی واحدی فعال، سپس الگوی عمومی فعال مانند امروز. متن از DB قابل تغییر بماند؛ UI ویرایش برای allowed_tags مالی و preview بدون ارسال واقعی به‌روز شود. نبود template/context معتبر، خطای قابل مشاهده و قابل پیگیری باشد. + +ثبت business event، ممیزی و enqueue job در همان transaction انجام شود؛ `on_commit` صرفاً برای wake-up اختیاری و نه جای ذخیره outbox. کلید dedupe شامل نوع رویداد و notice version یا receipt cycle نهایی باشد. نتیجه SMS موفق/ناموفق اثری بر صحت تصمیم مالی ندارد. + +برای ممیزی دقیق، context، template ID/updated_at و متن مصرف‌شده در اولین تلاش ثبت و در retry همان متن استفاده شود؛ ویرایش template روی اعلان‌های آینده اثر کند. متن و payload فقط در دسترس اشخاص مجاز گزارش باشد، در log عمومی یا خطای کاربر چاپ نشود. سابقه تلاش‌ها append-only لازم است؛ job فعلی شمارنده و آخرین خطا دارد و به‌تنهایی تاریخچه کامل تمام تلاش‌ها نیست. + +retry پایه SMS فعلی و استعلام delivery حفظ شوند. retry خاص ۴۵ دقیقه‌ای امروز فقط برای `STAFF_TICKET_CREATED` است؛ خودکار به مالی تعمیم داده نشود. debounce/cancel مربوط به خواندن مکالمه `STAFF_REPLY` نیز به پیامک مالی تعمیم داده نشود. خرابی پیامک مالی پردازش سایر jobها را متوقف نکند. + +لینک SMS مالی به صفحه احراز هویت‌شده خود پروژه و `next` امن اشاره کند. Magic Link امروز مجوز فقط یک تیکت می‌دهد و session کامل مشتری نمی‌سازد؛ برای اطلاعات بانکی/فیش استفاده نشود. ادامه مقصد پس از OTP باید با تست مسیر ورود پشتیبانی شود. ایجاد access token جدید مالی تصمیم جداست و در این طرح لازم نیست. + +در سورس فعلی، ورود OTP و رمز مقصد را از `session["next"]` می‌خوانند؛ دریافت و اعتبارسنجی `GET next` برای لینک مالی مسیر آماده‌ای نیست. در طرح اجرایی باید مقصد محلی allowlistشده هنگام ورود ذخیره، پس از ورود و بررسی مالکیت مصرف و از redirect به دامنه خارجی جلوگیری شود. مسیر ورود از SMS برای کاربر واردشده، مهمان و حساب مشتری دیگر جدا آزموده شود. + +dedupe ثبت job را یکتا می‌کند؛ crash پس از پذیرش provider و قبل از save می‌تواند ارسال دوباره ایجاد کند. بدون idempotency provider، ادعای exactly-once SMS نکنیم. وضعیت مبهم ارسال در ممیزی و گزارش قابل تشخیص باشد؛ رأی و تأیید نهایی داخل DB باید یکتا بمانند. + +## ۱۲. فایل خصوصی و ثبت سوابق + +برای فیش `ReceiptDocument` با storage فعلی ولی مسیر خصوصی مالی مستقل استفاده شود. اعتبارسنجی موجود magic/MIME/سایز قابل استخراج و reuse است؛ وابستگی به Message/Ticket حذف شود و تست پیوست‌های قدیمی حفظ شود. تصویر باید واقعاً قابل decode و محدودیت ابعاد/حجم داشته باشد؛ سیاست IMAGE واقعی DB منبع محدودیت UI و سرور است. مقادیر نمونه ۱۰MB قانون دائمی نیستند. + +view تصویر/download ابتدا مالک مشتری یا دامنه staff مالی را کنترل کند، سپس از signed_media_redirect موجود استفاده کند. URL مستقیم `/media/...` مجوز نیست؛ در DEBUG و Nginx نیز این مرز آزموده شود. امضای صادرشده تا انقضای کوتاه‌عمر یک bearer URL است؛ ابطال مجوز، URL قبلاً صادرشده را فوری باطل نمی‌کند. اگر ابطال فوری لازم شد، تحویل فایل از مسیر کنترل‌شونده هر درخواست انتخاب شود. + +فایل و DB transaction واحد ندارند؛ خطای validation/rollback پس از ذخیره باید orphan cleanup داشته باشد. نام ذخیره UUID و نسخه ثابت؛ جایگزینی فایل پس از رأی ممنوع. هش یکسان مدرک هشدار تکراری بدهد، اما به‌تنهایی دلیل رد قطعی نیست؛ یک تصویر ممکن است چند پرداخت را نشان دهد. + +ممیزی اقدامات: ایجاد/ویرایش/صدور/لغو اعلامیه، ثبت/نسخه اصلاحی فیش، رأی/رد/ارشد، تغییر سیاست و تخصیص، CRUD/فعال‌سازی جمعی حساب، retry و تلاش ارسال پیامک. هر رویداد actor ID + snapshot نام/شناسه عمومی، زمان aware UTC و نمایش شمسی ایران، دلیل و request ID داشته باشد. timeline جزئیات فیش، تمام رأی‌دهندگان و نهایی‌کننده را نشان دهد. + +تغییر permission مالی در UserAdmin، GroupAdmin، واحد یا membership نیز عملیات مالی مؤثر است و باید actor و قبل/بعد ثبت شود. صرف log سرویس تأیید این تغییرات را پوشش نمی‌دهد؛ adminهای موجود با hook ممیزی اختصاصی permissionهای payments و فرم مدیریت دسترسی به سرویس ممیزی متصل شوند. تغییر برنامه‌ای فاقد actor به نام SYSTEM و با شناسه عملیات ثبت شود؛ به signal بدون امکان تشخیص actor اکتفا نشود. + +بازدید جزئیات و درخواست فایل حساس نیز access log داشته باشد. دانلود واقعی در Nginx با redirect موفق از Django یکسان نیست؛ برای اثبات انتقال فایل باید log رسانه با correlation بررسی شود. کپی Clipboard محلی، server event قطعی ندارد؛ «ثبت هر کار» برای عملیات معتبر سرور تضمین می‌شود، telemetry کپی فقط گزارش کمکی است و موفقیت واقعی انتقال به پیام‌رسان قابل اثبات نیست. + +audit و decision در admin readonly و حذف/ویرایش عادی ممنوع؛ تمام مسیرهای UI/admin/service این قرارداد را رعایت کنند. نگهداری سوابق هنگام حذف کاربر با PROTECT یا SET_NULL + snapshot تعیین شود. DB admin همچنان اختیار فنی دارد؛ audit اپلیکیشن ادعای مصونیت مطلق از تغییر دیتابیس نیست. مهلت نگهداری فایل و log باید سیاست مشخص داشته باشد. + +## ۱۳. دامنه تغییر و ریسک رگرسیون + +| مسیر | تغییر مورد انتظار | ریسک و کنترل | +| --- | --- | --- | +| `apps/payments/` جدید | مدل، سرویس، policy، فرم، view و migration | بیشترین منطق جدید؛ تست مستقل دامنه و concurrency | +| `config/settings/base.py` و `config/urls.py` | ثبت app و URLهای مستقل | حفظ routeهای موجود و start_url | +| `apps/accounts/views.py` | ادامه امن مقصد مالی پس از OTP/رمز | حفظ ورود فعلی و جلوگیری از open redirect یا دورزدن مالکیت | +| `apps/tickets/customer_urls.py` و `customer_list` | خانه خدمات + history مستقل | تغییر آگاهانه انتظارات تست خانه، حفظ new/detail/magic | +| `templates/base.html` و context processor | منو مالی/بانک، badge و active section | desktop/mobile، permissions، shares counter و CSS isolation | +| قالب/assetهای مالی جدید | انتقال قرارداد نمونه به Django | بدون fixture، CDN یا موفقیت جعلی؛ version تمام assetها | +| `apps/notifications/models.py` و worker | Event و routing/context مالی | شکست event جدید نباید رویدادهای قدیمی را خراب کند | +| فرم/admin SMS موجود | tags و تنظیم template مالی | unit/global access و عدم overwrite متن DB | +| admin کاربران/گروه/واحد و membership | ممیزی تغییر مجوزهای مالی | ثبت actor بدون تغییر رفتار مجوزهای تیکت | +| اعتبارسنجی attachments | استخراج validator مشترک در صورت نیاز | مرز مجوز فایل تیکت تغییر نکند | +| تنظیمات branding/PageTitle | متن خانه خدمات و امضا | حفظ تنظیمات سفارشی و تست branding | +| production seed | اضافه کردن تنظیمات مالیِ مجاز | عدم وارد کردن outbox/تأییدها یا حساب واقعی ناخواسته | +| تست‌ها و اسناد QA | تست‌های جدید و اصلاح موارد تغییر محصول | عدم تضعیف assertions امنیتی موجود | + +از تغییر ساختار بنیادی `User`، `StaffProfile.Role`، مجوزهای تیکت و routing Magic Link برای این قابلیت پرهیز شود. new financial permissions در existing admin قابل مدیریت‌اند؛ افزودن inline/read-only وضعیت در UserAdmin فقط اگر UX مدیریت لازم داشت انجام شود. + +عدد دقیق فایل‌های نهایی تا قراردادهای بخش ۱۵ قطعی نیست؛ تغییر محدود به چند HTML نیست. پیش از هر ویرایش غیرساده، `--impact` و `--uses` برای symbol واقعی هدف، و پس از آن `--test-gate` و `--quality-delta` مطابق دستور پروژه اجرا شوند. یافته ابزار، جای آزمون dynamic URL/template/ORM را نمی‌گیرد. + +## ۱۴. برنامه اجرایی مرحله‌بندی‌شده و معیار خروج + +| مرحله | کار | شرط پایان | +| --- | --- | --- | +| ۰ | تثبیت قراردادهای بخش ۱۵، baseline تست‌ها و بررسی فقط‌خواندنی تنظیمات واقعی | تصمیم‌ها ثبت و رگرسیون‌های قبلی از جدید تفکیک شوند | +| ۱ | اپ payments، schema/constraints، PermissionPolicy، audit و تنظیم دسترسی | مشتری دیگر/کارشناس غیرفعال/POST جعلی دسترسی ندارد؛ ALL_STAFF فقط view/create را باز می‌کند | +| ۲ | مدیریت حساب staff/admin و bulk state | سه پرچم و snapshot/آرشیو درست؛ فقط افراد مجاز CRUD دارند | +| ۳ | صدور اعلامیه، فرم مشتری picker و SMS template/handler/outbox | یک اعلامیه اختصاصی + یک job؛ failure مستقل و retry قابل پیگیری | +| ۴ | خانه چهارسو و history مستقل و صفحه اعلامیه مشتری | UX نمونه با داده واقعی، سه خدمت خانه و عدم افشای حساب عمومی | +| ۵ | ثبت فیش مستقل/مرتبط، فایل خصوصی، تاریخ/مبلغ/مقصد و revision | validation دوطرفه، idempotency، ownership، خطاهای قابل فهم و ثبت واقعی | +| ۶ | رأی تک/چندنفره و ارشد، timeline و SMS نهایی | raceها و رأی تکراری امن؛ قبل از پایان شرط هیچ پیامک تأیید ارسال نشود | +| ۷ | میزکار جست‌وجو/فیلتر/صفحه‌بندی، شمارنده sidebar | فیش مستقل حذف نشود؛ badge دقیق و queries محدود/بدون N+1 | +| ۸ | یکپارچگی، E2E، PostgreSQL concurrency و مستند عملیات | مجموعه رگرسیون + سناریوهای جدید + بازبینی رسانه/PWA پاس شوند | +| ۹ | استقرار مرحله‌ای، آموزش، پایش و بازگشت UI | دسترسی/سیاست/worker پیش از باز شدن ارسال مشتری آماده باشند | + +ترتیب وابستگی: مدل و policy → حساب‌ها → اعلامیه → فیش → تأیید → میزکار/شمارنده → یکپارچگی. UI را می‌توان پیش‌تر preview کرد، اما ارسال مشتری قبل از آماده شدن مسیر بررسی/تأیید فعال نشود. هیچ نیاز اجباری مانند چندنفره یا ارشد به «بعداً» منتقل نشود. + +feature flag برای customer hub و عملیات مالی، مهاجرت‌های افزایشی و rollout ابتدا داخلی پیشنهاد می‌شود. خاموش کردن قابلیت، داده ثبت‌شده را حذف نکند و دسترسی staff برای رسیدگی باقی بماند. rollback کد تا قبل از اولین فیش ساده‌تر است؛ بعد از آن برگشت مخرب schema ممنوع و forward fix لازم است. + +استقرار شامل migrate، build محلی CSS، collectstatic و rollout web/notification-worker سازگار با هر دو رویداد جدید است. worker جدید پیش از صدور event مالی فعال شود؛ worker قدیمی رویداد مالی را Unsupported می‌داند. schema تغییرات قدیم را حذف نکند. backup دیتابیس و media، آموزش افراد تأیید و چک تنظیمات template لازم‌اند. + +برآورد زمان دقیق بدون تصمیم‌های سیاستی و baseline اجرا قابل اتکا نیست. پروژه باید در تحویل‌های قابل آزمون بالا مدیریت شود؛ تکمیل صرف صفحه یا مدل معیار اتمام نیست. + +## ۱۵. تصمیم‌های کسب‌وکاری پیش از پیاده‌سازی + +این موارد مانع تهیه طرح نیستند؛ پیش از کدنویسی سیاست مالی باید پاسخ ثبت‌شده داشته باشند: + +1. افراد اولیه دارای تأیید، ارشد و مدیریت سیاست چه کسانی‌اند؟ انتخاب از DB، نه username hardcode. +2. آیا هر اعلامیه سیاست مخصوص دارد یا سیاست پیش‌فرض واحد؟ پیشنهاد اولیه سیاست نسخه‌دار واحد + fallback عمومی، انتخاب توسط مدیر مجاز. +3. «دو نفر» یعنی هر دو فرد مشخص یا دو نفر از یک مجموعه؟ پیش‌فرض این سند همه افراد منتخب است. +4. رد یک نفر چرخه را می‌بندد؟ پیشنهاد بله، اصلاح با نسخه تازه؛ تأیید ارشد روی چرخه ردشده نیازمند گردش جداست. +5. آیا ثبت‌کننده staff می‌تواند فیش ثبت‌شده خودش را تأیید کند؟ پیشنهاد جلوگیری برای تفکیک وظایف، ولی باید با نیاز شرکت تطبیق داده شود. +6. پرداخت جزئی/چند فیش برای یک اعلامیه مجاز است؟ پیشنهاد مدل یک‌به‌چند از ابتدا؛ تسویه بر جمع مبالغ معتبر تأییدشده، اضافه‌پرداخت با وضعیت مشخص. لغو اعلامیه پرداخت‌شده تسویه را پاک نکند. +7. حساب staff-only در اعلامیه اختصاصی قابل پرداخت مشتری باشد؟ پیشنهاد بله فقط در context آن اعلامیه؛ حساب globally-disabled و اعلامیه قدیمی چه رفتار/مهلت ثبت پرداخت گذشته‌ای دارند؟ +8. مشتری مقصدی متفاوت با اعلامیه انتخاب کرد چه شود؟ پیشنهاد ثبت واقعیت با پرچم مغایرت و بررسی ویژه، بدون تسویه خودکار اعلامیه. +9. مبلغ دقیق چه سقفی دارد، تاریخ پرداخت تا چند سال قبل مجاز است و آیا فیش بیش از یک تصویر نیاز دارد؟ پیشنهاد اولیه یک تصویر اجباری مطابق نمونه و سیاست IMAGE سرور. +10. تغییر/لغو تصمیم نهایی، سیاست نگهداری فایل و لاگ، و پیامک اصلاح/رد لازم است؟ دو SMS الزامی فعلی صدور و تأیید نهایی‌اند؛ رویدادهای اضافی نیاز جدا هستند. + +## ۱۶. تست‌های موجود بررسی‌شده و قرارداد رگرسیون + +| فایل/گروه | قرارداد مؤثر | +| --- | --- | +| `tests/test_permission_architecture.py` | نقش، membership، unit isolation، SELF/UNIT، مجوز عملیات و جعل گزینه فرم | +| `tests/test_role_workflow_regression.py` | نمایندگان نقش‌ها، واحد اولیه، public/private، فرم/گزینه و rollback پیوست | +| `tests/test_organizational_ticketing.py` | dispatch ایزوله، contact shared با group واحدی، audit، template واحدی، فایل، magic و retry | +| `tests/test_domain.py` | OTP، تیکت/پیام، attachment policy، SMS outbox/template، debounce/retry/delivery و Magic Link | +| `tests/test_views.py` | home/history فعلی، branding، sidebar، PWA، magic scope، polling و no-store | +| `tests/test_staff_sharing.py` | اشتراک کل/یک پیام، فایل خصوصی، ابطال، CSRF و عدم افشای همسایه‌ها | +| `tests/test_staff_shared_queue.py` و `test_staff_inline_sharing.py` | مجوز عملیاتی همه نقش‌های فعال روی تیکت قابل مشاهده؛ قابل تعمیم به approval نیست | +| `tests/test_announcements.py` | صف تماس صوتی، قفل PostgreSQL و عدم توقف کار worker در خطای provider | +| `tests/test_voip_calls.py` | مجوز/مسیر تماس و عدم تغییر غیرضروری داده در عملیات | +| `tests/test_branding_operations.py` | متن‌ها و عنوان‌های DB، امضا و layout پنل | +| `tests/test_customer_category_catalog.py` و `test_customer_unit_categories.py` | category واحدی، گزینه‌های فعال و جست‌وجوی معنایی فرم مشتری | +| `tests/test_deployment.py` | private media/Nginx، worker، شبکه و PostgreSQL در استقرار | +| `tests/test_production_seed.py` | allowlist مدل‌ها، permissions با natural keys، import و marker تکرارپذیر | +| `tests/test_env.py` | تنظیم محیط و اولویت منبع تنظیمات | +| `tests/test_jalali_templates.py` و `test_persian_text.py` | تاریخ نمایشی شمسی و حفظ رشته‌های فنی/اعداد در normalization | +| `apps/phone_contacts_api/test_api.py` | API مخاطب، token، فعال بودن و normalization شماره؛ داده مالی نباید به این API افزوده شود | +| `tests/js/chat-coach.test.mjs` | رفتار JS مکالمه؛ انتقال CSS/JS نباید آن را خراب کند | +| `tests/e2e/test_public_ticket_workflows_e2e.py` | مسیر واقعی ثبت/پیام/فایل مشتری و کارشناس، scope Magic Link | +| `tests/e2e/test_organization_permissions_e2e.py` | رفتار واقعی نقش‌ها، گزینه‌های AJAX و مرز واحد | +| `tests/e2e/test_staff_operations_ui.py` | فرم staff، picker، برند، sidebar، گزارش دلیوری و موبایل | +| `tests/e2e/test_staff_sharing_e2e.py` | اشتراک واقعی و فایل در مرورگر | +| `tests/e2e/test_customer_unit_categories_e2e.py` | جست‌وجوی دسته مشتری و انتخاب واحد | +| `tests/e2e/test_chat_coach.py` | راهنمای مکالمه و تعامل مرورگر | +| `tests/e2e/test_mobile_ticket_viewport.py` | صفحه‌کلید، composer، viewport و اسکرول موبایل | +| `tests/e2e/test_pwa_versioning.py` | URL/cache نسخه‌دار و lifecycle service worker | +| `tests/e2e/test_external_environment_smoke.py` | smoke فقط‌خواندنی محیط خارجی؛ جدا از آزمون DB محلی | +| `design-prototypes/ticket-new-wizard/test_prototype_e2e.py` | تست مستقل prototype روی پورت ۴۱۷۳؛ تست production چهارسو نیست | + +`pytest.ini`، fixtureهای `tests/e2e/conftest.py`، config/README و Page Objectهای auth/base/customer_new_ticket/sharing/tickets نیز مبنا قرار گرفتند. اجرای تست مستقل prototype نیازمند سرور خودش است و نباید با مجموعه live_server اشتباه شود. + +تست‌های home که حضور `recent-requests` در `/tickets/` را الزام می‌کنند با تغییر محصول باید به history منتقل شوند؛ assertions مجوز، فایل، PWA و branding تضعیف نشوند. تیکت‌های تاریخی با دسته «فیش واریزی» خودکار به رکورد تأییدشده مالی تبدیل نشوند. + +## ۱۷. پوشش آزمون جدید و معیار پذیرش نهایی + +### دامنه، مجوز و دیتابیس + +- مشتری الف نتواند اعلامیه/فیش/فایل مشتری ب را ببیند یا مرتبط کند؛ staff غیرفعال، مجوز لغوشده، CSRF و POST جعلی پوشش داده شوند. +- ALL_STAFF مشاهده/ایجاد بدون مجوز تأیید؛ حالت EXPLICIT واقعاً queryset/جزئیات/count/files را محدود کند؛ role finance مجوز تأیید خودکار نگیرد. +- مدیرعامل/کاربر مجاز بانک‌ها را مدیریت کند؛ سه flag، bulk، stale form، حساب استفاده‌شده و snapshot تاریخی پوشش داده شوند. +- مبلغ صفر/منفی/اعشاری/بسیار بزرگ، رقم فارسی/عربی، هزارگان نامعتبر، تاریخ ناقص/آینده/اسفند کبیسه و تغییر روز در ماه کوتاه آزموده شوند. +- فیش مستقل account label فقط، فیش مرتبط با owner معتبر، مقصد متفاوت و expected/paid متفاوت، چند فیش/پرداخت جزئی طبق سیاست مصوب. +- فایل spoof، خراب، خالی/بزرگ، decode و مجوز دانلود؛ rollback DB و پاک‌سازی فایل باقی‌مانده. +- idempotency ثبت، رأی تکراری، شرط دو شخص مشخص، رأی آخر هم‌زمان، approve/reject race، ارشد/رأی عادی هم‌زمان، stale revision و تغییر مجوز حین اقدام. +- قفل‌ها و unique constraints با PostgreSQL و `TransactionTestCase` یا pytest transactional و connectionهای جدا؛ SQLite اثبات رفتار lock PostgreSQL نیست. +- اصلاح نسخه رأی‌های قدیمی را معتبر نکند؛ رد و ارشد دلیل/actor/time ثبت کنند؛ سوابق قابل حذف عادی نباشند. +- صدور فقط یک outbox، رأی جزئی صفر SMS تأیید، نهایی شدن یک SMS، نبود template، unit/global override، tag غلط، provider failure/retry و کارکرد رویدادهای قدیمی. +- شمارنده شامل مستقل/جزئی، حذف تکرار join، فیلتر permission قبل از count، pagination پایدار و تعداد query محدود. + +### مرورگر و تجربه کاربر + +- ورود مشتری به خانه چهارسو و مسیر جدا تاریخچه؛ خانه بدون سوابق و سه خدمت در اندازه معمول ۳۲۰×۵۶۸، بدون overflow. +- صفحات مستقل شماره حساب‌ها/فیش، اطلاعات تکمیلی نمایان، کپی تکی/کامل و fallback Clipboard با اطلاعات مجاز کامل. +- فیش مرتبط اول، مستقل در دسترس، حفظ داده هنگام تغییر نوع، مقصد فقط نام، خطا و فوکوس مناسب. +- ورودی مبلغ: تایپ وسط متن، selection، Backspace/Delete کنار جداکننده، paste ارقام مختلف، مبلغ بزرگ و معادل دقیق تومان. +- تاریخ سه‌قسمتی، امروز به وقت ایران، تغییر ماه کبیسه، ارسال Gregorian و حفظ تاریخ در تغییر حالت؛ حالت نبود پشتیبانی Intl/JS مسیر قابل استفاده سرور داشته باشد. +- ثبت واقعی → صفحه پیگیری وضعیت؛ پیام موفقیت جعلی prototype حذف شود؛ reload و double click فیش تکراری نسازد. +- کارشناس صدور و مشاهده همه/محدود، فیش مستقل در میزکار، جست‌وجو/فیلتر/page، تأیید دو نفر و ارشد، timeline و badge. +- فونت/asset/SVG همگی از پروژه؛ intercept شبکه دارایی خارجی، PWA عدم cache مالی/فایل و نسخه‌های جدید را کنترل کند. +- desktop/mobile sidebar active state، shares badge و composer صفحات قدیمی رگرسیون نداشته باشند؛ keyboard، zoom، safe-area و متن بلند بررسی شوند. + +تست‌های E2E جدید زیر `tests/e2e/` و Page Objectهای مالی زیر `tests/e2e/pages/`؛ fixture Chromium/BrowserContext function-scoped فعلی، marker e2e و live_server transactional. بدون sleep ثابت و assertions تنها روی نتیجه قابل مشاهده. provider پیامک mock شود؛ تست‌ها پیامک واقعی نفرستند. screenshot/trace شکست از fixture موجود استفاده کند. + +فرمان‌های اجرای آینده از WSL و با venv مخزن: + +```bash +.venv/Scripts/python.exe -m pytest tests -m "not e2e" -q +.venv/Scripts/python.exe -m pytest apps/phone_contacts_api/test_api.py -q +.venv/Scripts/python.exe -m pytest tests/e2e -m "e2e and not external_e2e" -q +node --test tests/js/chat-coach.test.mjs +``` + +تست‌های جدید قفل/هم‌زمانی نیز به suite افزوده و روی PostgreSQL اجرا شوند. تغییر frontend caching/PWA به‌طور خاص باید `tests/test_views.py` و `tests/e2e/test_pwa_versioning.py` را پاس کند. این فرمان‌ها در مرحله تحلیل اجرا نشده‌اند. + +معیار تحویل: یک کارشناس اعلامیه اختصاصی صادر کند، پیامک از template DB صف شود، مشتری فقط مورد خودش را ببیند و فیش مستقل/مرتبط ثبت کند، اشخاص دقیقاً تعیین‌شده یا ارشد مجاز تأیید کنند، همه سوابق و نسخه‌ها ثبت و قابل بررسی باشند، SMS نهایی فقط بعد از پایان شرط ارسال شود، و جریان‌های فعلی تیکت/دسترسی/فایل/PWA سالم باقی بمانند. diff --git a/docs/PAYMENT_LINKS.md b/docs/PAYMENT_LINKS.md new file mode 100644 index 0000000..139612a --- /dev/null +++ b/docs/PAYMENT_LINKS.md @@ -0,0 +1,33 @@ +# لینک موقت پرداخت + +لینک `{payment_link}` در پیامک صدور اعلامیه و تأیید نهایی فیش، اکنون یک مجوز مستقل است؛ کاربر با آن وارد حساب نمی‌شود. مسیر ورودی `/chaharsu/link//` پس از بررسی، به آدرس بدون توکن منتقل می‌شود. + +## محدوده و عمر دسترسی + +- اعتبار پیش‌فرض ۷ روز است. `PAYMENT_LINK_TTL_DAYS` قابل تنظیم است و در نبود آن، از `MAGIC_LINK_TTL_DAYS` استفاده می‌شود. +- لینک اعلامیه فقط همان اعلامیه، فیش‌های آن و فیش‌های مستقلی را که از طریق همان لینک ثبت شده‌اند نمایش می‌دهد؛ دسترسی به سایر اعلامیه‌های مشتری یا حساب کامل او نمی‌دهد. +- مشاهده اطلاعات بانکی، کپی تک‌فیلد و کامل، ثبت فیش مرتبط و مستقل، مشاهده وضعیت، دریافت تصویر و اصلاح فیش ردشده بدون ورود ممکن است. +- فیلد «شناسه واریز که با آن پرداخت انجام داده‌اید» هنگام ارسال فیش اختیاری است؛ خالی‌بودن آن مانع ارسال نمی‌شود. هشدار ثبت شناسه در عملیات پرداخت بانکی همچنان نمایش داده می‌شود. +- انقضا، ابطال و غیرفعال‌شدن مشتری در هر درخواست دوباره بررسی می‌شود. CSRF و اعتبارسنجی تصویر، مبلغ و تاریخ همچنان برقرارند. +- تأیید و رد فقط برای کاربران دارای مجوز و تعیین‌شده در سیاست تأیید باقی می‌ماند. لینک پرداخت مجوز تأیید نمی‌دهد و لینک تیکت مجوز مالی نمی‌دهد. +- اگر حساب دیگری باز باشد، خروج تنها پس از تأیید صریح کاربر انجام می‌شود. + +## پیامک و مدیریت + +هنگام آماده‌سازی نخستین ارسال پیامک، لینک ساخته می‌شود تا تأخیر صف از زمان اعتبار کم نکند. تلاش‌های مجدد همان لینک و متن ثبت‌شده را استفاده می‌کنند. برای پیامک‌های آماده‌شده قدیمی که هنوز لینک موقت ندارند، فقط URL قبلی جایگزین می‌شود و متن سفارشی حفظ می‌شود؛ پیامکی که قبلاً به گوشی ارسال شده قابل تغییر نیست. + +در جزئیات اعلامیه کارشناس، «ساخت لینک تازه پرداخت» لینک قابل کپی می‌سازد؛ این کار پیامک ارسال نمی‌کند. لینک‌های قبلی تا انقضا یا ابطال معتبر می‌مانند. در ادمین، مدل «لینک‌های موقت پرداخت» امکان بررسی زمان‌ها و ابطال لینک را دارد. ایجاد، بازکردن و ابطال در ممیزی مالی ثبت می‌شوند و توکن در ردیف ممیزی ثبت نمی‌شود. + +مدل `SMSTemplate` تگ‌های مجاز را بر اساس رویداد تعیین و ذخیره می‌کند. رویدادهای مالی از `{payment_link}` و رویدادهای تیکت مشتری از `{magic_link}` پشتیبانی می‌کنند. صفحه مدیریت پیامک با تغییر رویداد، فهرست تگ‌های مناسب را نشان می‌دهد. مهاجرت داده فقط تگ‌ها را هماهنگ می‌کند و متن‌های سفارشی را تغییر نمی‌دهد. + +شماره حساب، شبا و سایر اطلاعات از snapshot اعلامیه نمایش داده می‌شوند. شناسه واریز و تذکر آن قرمز است. امضای مسیر بدون ورود از `SiteBranding.developer_credit_text` خوانده می‌شود. + +## تست دائمی + +از WSL و در ریشه پروژه اجرا کنید: + +```bash +.venv/Scripts/python.exe -m pytest tests/test_payment_links.py tests/e2e/test_payment_links_e2e.py -q +``` + +تست‌ها از دیتابیس جداگانه، سرور transactional و مرورگر مدیریت‌شده پروژه استفاده می‌کنند و پیامک واقعی ارسال نمی‌کنند. تست‌های مرورگر در عرض‌های ۳۲۰، ۳۹۰ و ۱۴۴۰ پیکسل اجرا می‌شوند. تست‌های سرور شامل لینک پیامک، تکرار ارسال، عدم ورود به حساب، محدوده مشتری، انقضا و ابطال، CSRF، ارسال فیش مرتبط و مستقل، اصلاح و کنترل تصویرند. diff --git a/static/css/chaharsu.css b/static/css/chaharsu.css new file mode 100644 index 0000000..663655b --- /dev/null +++ b/static/css/chaharsu.css @@ -0,0 +1,121 @@ +/* Chaharsu tokens follow design-prototypes/chaharsu/STYLE_GUIDE.md. + Page-scoped components: no global form or ticket composer overrides. */ +.chaharsu{max-width:900px;margin:auto;color:#08376b;font-size:14px;line-height:1.7;overflow-wrap:anywhere}.chaharsu h1{font-size:24px;font-weight:900;line-height:1.5}.chaharsu h2{font-size:18px;font-weight:900}.chaharsu h3{font-size:14px;font-weight:800}.chaharsu p{margin-block:8px}.chaharsu-heading{margin-bottom:20px}.chaharsu-heading>a,.chaharsu-heading>span{color:#526c86;font-size:12px}.chaharsu-heading p{color:#526c86}.pay-card{background:#fff;border:1px solid #dce5ed;border-radius:20px;padding:16px;margin-block:12px}.pay-button,.pay-copy{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:8px 16px;border:1px solid #08376b;border-radius:12px;background:#08376b;color:#fff;font:inherit;font-weight:800;text-decoration:none;cursor:pointer}.pay-button.secondary,.pay-copy{background:#fff;color:#08376b}.pay-actions,.service-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center}.pay-actions{margin-block:16px}.pay-button:focus-visible,.pay-copy:focus-visible,.chaharsu a:focus-visible,.chaharsu input:focus-visible,.chaharsu select:focus-visible,.chaharsu textarea:focus-visible,.payment-notice summary:focus-visible{outline:3px solid #1977ce;outline-offset:3px}.pay-button:disabled{opacity:.6;cursor:wait}.pay-form input:not([type=radio]):not([type=checkbox]),.pay-form select,.pay-form textarea,.pay-filters input,.pay-filters select,.pay-search input{width:100%;min-height:44px;border:1px solid #a1b4c7;border-radius:12px;padding:8px;font:inherit;font-size:16px;background:#fff;color:#08376b}.pay-form input[type=file]{padding:12px;border-style:dashed}.pay-form textarea{min-height:90px}.pay-field{margin-block:16px}.pay-field label{display:block;margin-bottom:6px;font-weight:700}.pay-field [type=radio],.pay-field [type=checkbox]{width:20px;height:20px;margin-inline:6px}.field-error,.pay-errors{color:#9f1f1f;font-size:14px;line-height:1.8}.pay-errors{border:1px solid #9f1f1f;background:#fff4f3;padding:12px;border-radius:12px;margin-block:12px}.pay-errors a{color:inherit;text-decoration:underline}.pay-form [aria-invalid=true]{border-color:#9f1f1f}.pay-today{font:inherit;color:#08376b;border:1px solid #dce5ed;border-radius:8px;min-height:44px;padding:4px 12px;background:#fff;margin-top:6px}.pay-supplement{background:#f6fbff;border-radius:12px;padding:12px;margin-block:12px}.payment-notice summary{cursor:pointer;min-height:44px;list-style-position:inside;display:flex;flex-wrap:wrap;gap:8px;align-items:center}.payment-notice summary strong{flex:1;min-width:120px}.payment-notice summary b{font-size:18px}.payment-notice summary small{width:100%;color:#526c86;font-size:12px}.payment-values>div{display:grid;grid-template-columns:1fr auto;gap:8px;padding-block:9px;border-bottom:1px solid #e7edf2}.payment-values dt{grid-column:1/-1;font-weight:700}.payment-values dd{font-size:16px;text-align:right;overflow-wrap:anywhere}.pay-search,.pay-filters{display:flex;gap:8px;flex-wrap:wrap}.pay-search input{flex:1;min-width:140px}.pay-filters>*{flex:1;min-width:130px}.pay-row{display:flex;flex-wrap:wrap;gap:12px;align-items:center;min-height:60px}.pay-row time{font-size:12px;color:#526c86}.pay-tabs,.pay-pagination{display:flex;gap:16px;margin-block:16px;align-items:center}.pay-tabs a,.pay-pagination a{min-height:44px;padding:8px}.receipt-preview{max-width:100%;max-height:600px;object-fit:contain;display:block;margin-block:12px}.service-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}.expert-service{grid-column:1/-1;border-radius:20px;background:#08376b;border-bottom:3px solid #fa9f00;padding:12px 14px;color:#fff}.expert-service p{color:#cadbea;font-size:14px;line-height:1.65}.expert-service h2{color:#fff}.service-actions>a{flex:1;padding-inline:8px;white-space:nowrap}.pay-button.light{color:#08376b;background:#fff;border-color:#fff}.pay-button.outline-light{background:transparent;border-color:#cadbea}.financial-service{display:block;padding:12px;background:#fff;border:1px solid #dce5ed;border-radius:20px;color:#08376b}.financial-service h2{font-size:14px;line-height:1.7;margin-block:6px}.financial-service p{font-size:14px;color:#526c86;line-height:1.65;margin:0}.financial-service svg{width:24px;height:24px;stroke:currentColor;stroke-width:1.7;fill:none;stroke-linecap:round;stroke-linejoin:round}.chaharsu-home .chaharsu-heading{margin-bottom:12px}.chaharsu-home h1{font-size:22px}.chaharsu-home .chaharsu-heading p{margin:0}.pay-copy-status{position:fixed;bottom:100px;right:16px;background:#08376b;color:#fff;padding:12px;border-radius:12px;z-index:100}.pay-manual-copy{max-width:520px;width:calc(100% - 24px);border:1px solid #dce5ed;border-radius:20px;padding:20px;color:#08376b}.pay-manual-copy textarea{width:100%;min-height:200px;font:inherit}.pay-manual-copy::backdrop{background:#0009}.pay-form [data-field=paid_day],.pay-form [data-field=paid_month],.pay-form [data-field=paid_year]{display:inline-block;width:calc(33.33% - 8px);vertical-align:top}.pay-form [data-field=paid_day] .field-error{min-width:200px}.pay-form [data-field=receipt_mode] label{padding-block:4px}.pay-form [data-field=paid_amount] input{direction:ltr;text-align:right} +@media(min-width:640px){.chaharsu h1{font-size:28px}.chaharsu h2{font-size:20px}.pay-card{padding:22px}.payment-values>div{grid-template-columns:130px 1fr auto;align-items:center}.payment-values dt{grid-column:auto}.chaharsu-home{max-width:1120px}.financial-service{padding:24px}.financial-service h2{font-size:17px}.expert-service{padding:20px}.service-grid{gap:16px}.pay-form{max-width:700px;margin-inline:auto}} +@media(min-width:900px){.service-grid{grid-template-columns:repeat(4,1fr)}.expert-service{grid-column:span 2}} +@media(prefers-reduced-motion:reduce){.chaharsu *{scroll-behavior:auto}} +.chaharsu-dock{grid-template-columns:repeat(3,minmax(0,1fr))}.chaharsu-dock a{font-size:12px;gap:4px}.chaharsu-dock svg{flex-shrink:0} + +/* Prototype-aligned customer shell. Loaded only by payment templates. */ +.cs-icon{width:22px;height:22px;flex-shrink:0} +.cs-header{position:sticky;top:0;z-index:40;background:#eeeeeeed;border-bottom:1px solid #dce5ed;backdrop-filter:blur(14px)} +.cs-header-inner{max-width:1120px;margin:auto;min-height:76px;padding:0 24px;display:flex;align-items:center;justify-content:space-between;gap:20px} +.cs-brand{display:flex;align-items:center;gap:14px;color:#08376b;flex-shrink:0} +.cs-brand img{width:85px;height:auto;object-fit:contain}.cs-brand-divider{height:32px;width:1px;background:#cdd7e0} +.cs-brand b{display:block;font-size:20px;font-weight:900;line-height:1.4}.cs-brand small{display:block;margin-top:3px;font-size:11px;color:#526c86} +.cs-nav,.cs-header-actions{display:flex;align-items:center;gap:12px}.cs-nav a{min-height:44px;padding:10px 16px;border-radius:12px;color:#526c86;font-size:14px;font-weight:700}.cs-nav a[aria-current]{background:white;color:#08376b} +.cs-profile{display:flex;align-items:center;gap:8px;min-height:44px;padding:8px 12px;background:#fff;border:1px solid #dce5ed;border-radius:14px;color:#08376b;font-size:14px;font-weight:700} +.cs-header-actions form{margin:0}.cs-logout{min-height:44px;padding:8px;color:#526c86;background:transparent;border:0;box-shadow:none;font-size:14px}.cs-logout:hover{background:#fff;color:#08376b;transform:none;box-shadow:none} +.cs-header a:focus-visible,.cs-header button:focus-visible{outline:3px solid #1977ce;outline-offset:3px} +.customer-main:has(.chaharsu){max-width:1120px;padding:30px 24px 16px}.customer-main:has(.chaharsu-home){padding-top:28px} +.cs-footer{max-width:1120px;margin:12px auto 0;padding:0 24px 24px;text-align:center;font-size:12px;line-height:1.9;color:#526c86} +.chaharsu-home .chaharsu-heading{margin-bottom:20px}.chaharsu-home h1{font-size:28px}.chaharsu-home .chaharsu-heading p{margin-top:4px;font-size:14px} +.chaharsu-home .service-grid{align-items:stretch}.expert-service{display:flex;flex-direction:column;min-width:0;padding:24px 20px 20px;border-radius:22px} +.expert-service-head{display:grid;grid-template-columns:34px minmax(0,1fr);column-gap:12px;align-items:center}.expert-service-head h2{margin:0;font-size:20px}.expert-service-head p{grid-column:2;margin:6px 0 0;line-height:1.7} +.service-icon{display:grid;place-items:center;width:48px;height:48px;border-radius:14px;background:#e5f4ff;color:#1977ce}.expert-service-head .service-icon{width:34px;height:34px;background:#1977ce;color:#fff} +.service-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:auto;padding-top:24px} +.service-actions .pay-button{gap:8px;font-size:14px;font-weight:800}.service-actions .pay-button.light{background:#1977ce;color:#fff;border-color:#1977ce}.service-actions .outline-light{background:#ffffff08;border-color:#ffffff40;color:white} +.service-actions .light:hover{background:#1566b2;border-color:#1566b2}.service-actions .outline-light:hover{background:#ffffff15;border-color:#ffffff70} +.financial-service{display:flex;flex-direction:column;min-width:0;padding:24px;border-radius:22px;transition:border-color .18s,box-shadow .18s,transform .18s} +.financial-service h2{margin:20px 0 6px;font-size:17px;font-weight:900}.financial-service p{line-height:1.8}.financial-service .cs-icon{width:23px;height:23px} +.service-link{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-top:auto;padding-top:20px;color:#08376b;font-size:14px;font-weight:700}.service-link .cs-icon{width:18px;height:18px} +.financial-service:hover{border-color:#91bfdd;box-shadow:0 15px 35px -25px #08376b70;transform:translateY(-3px)} +.chaharsu .pay-button{gap:8px;transition:background .15s,border-color .15s}.chaharsu .pay-button:hover{background:#0b4c8c;color:#fff;box-shadow:none;transform:none}.chaharsu .pay-button.secondary:hover{background:#e5f4ff;color:#08376b} +.chaharsu .pay-button:disabled{cursor:not-allowed;opacity:.5}.chaharsu .pay-button:disabled:hover{background:#08376b} +.chaharsu-dock a.active{background:#0b4c8c}.chaharsu-dock a{min-height:52px;color:#cadbea} + +/* Staff payment workspace: one reading order across desktop and mobile. */ +.pay-workspace{max-width:1200px}.pay-page-head{display:flex;justify-content:space-between;align-items:center;gap:24px;flex-wrap:wrap;margin-bottom:24px} +.pay-page-title{display:flex;align-items:center;gap:14px;min-width:0;flex:1}.pay-page-title>div{min-width:0}.pay-page-title h1{font-size:24px;line-height:1.6;margin:0}.pay-page-title p{margin:4px 0 0;color:#526c86;font-size:14px} +.pay-page-icon{display:grid;place-items:center;width:52px;height:52px;flex-shrink:0;border:1px solid #cde6f7;border-radius:16px;background:#e5f4ff;color:#08376b}.pay-page-icon .cs-icon{width:26px;height:26px} +.pay-head-actions{display:flex;gap:8px;flex-wrap:wrap}.pay-head-actions .pay-button{white-space:nowrap} +.pay-desk-tabs{display:flex;gap:4px;padding:5px;margin:0 0 16px;background:#e3e9ef;border:1px solid #d5dfe8;border-radius:16px;width:fit-content;max-width:100%} +.pay-desk-tabs a{display:flex;align-items:center;justify-content:center;padding:10px 18px;font-size:14px;font-weight:700;border-radius:12px;color:#526c86;text-align:center}.pay-desk-tabs a[aria-current]{background:#fff;color:#08376b;box-shadow:0 2px 6px #08376b08}.pay-desk-tabs a:hover{color:#08376b;background:#ffffff80} +.pay-filter-panel{margin:0 0 20px;padding:20px}.pay-filter-grid{display:grid;grid-template-columns:minmax(220px,2fr) repeat(2,minmax(140px,1fr)) auto;align-items:end;gap:12px} +.pay-bank-filters{grid-template-columns:minmax(220px,2fr) repeat(3,minmax(115px,1fr)) auto} +.pay-filter-grid label,.pay-extra-filters label,.pay-bulk-bar label{display:flex;flex-direction:column;gap:6px;font-size:14px;font-weight:700;min-width:0} +.pay-workspace input:not([type=checkbox]):not([type=hidden]),.pay-workspace select{width:100%;min-width:0;min-height:44px;border:1px solid #c6d3df;border-radius:12px;padding:9px 12px;background:#fff;color:#08376b;font:inherit;font-size:14px;box-shadow:none} +.pay-workspace input::placeholder{color:#526c86;opacity:1}.pay-workspace input:focus,.pay-workspace select:focus{border-color:#1977ce;box-shadow:0 0 0 3px #1977ce12} +.pay-search-input{display:flex;align-items:center;gap:8px;background:#f6f8fa;border:1px solid #c6d3df;border-radius:12px;padding-inline:12px;min-height:44px}.pay-search-input .cs-icon{color:#526c86;width:20px;height:20px}.pay-workspace .pay-search-input input[type=search]{border:0;background:transparent;padding-inline:0}.pay-workspace .pay-search-input input[type=search]:focus{box-shadow:none}.pay-search-input:focus-within{outline:3px solid #1977ce;outline-offset:2px} +.pay-filter-bottom{display:flex;justify-content:space-between;align-items:flex-start;gap:16px;margin-top:12px}.pay-more-filters{flex:1;min-width:0}.pay-more-filters summary{display:flex;align-items:center;gap:6px;min-height:44px;width:fit-content;cursor:pointer;font-weight:700;color:#08376b;list-style:none}.pay-more-filters summary::-webkit-details-marker{display:none}.pay-more-filters summary::after{content:'⌄';margin-inline-start:4px;transition:transform .15s}.pay-more-filters[open] summary::after{transform:rotate(180deg)}.pay-more-filters summary:focus-visible{outline:3px solid #1977ce;outline-offset:3px} +.pay-extra-filters{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;padding-top:8px}.pay-extra-filters label small{display:contents;font-size:12px;font-weight:400}.pay-extra-filters input[type=date]{direction:ltr;text-align:right}.pay-reset{display:flex;align-items:center;min-height:44px;white-space:nowrap;color:#526c86;text-decoration:underline;text-underline-offset:4px} +.pay-records,.pay-bank-panel{border:1px solid #dce5ed;border-radius:20px;background:#fff;overflow:hidden}.pay-list-head{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:64px;padding:16px 20px;border-bottom:1px solid #e7edf2}.pay-list-head h2{font-size:16px;margin:0;font-weight:800}.pay-list-head>span{font-size:12px;color:#526c86;background:#f0f4f7;border-radius:20px;padding:4px 10px;white-space:nowrap} +.pay-record{display:grid;grid-template-columns:44px minmax(0,1fr) minmax(155px,auto) minmax(165px,auto);align-items:center;gap:16px;padding:20px;border-bottom:1px solid #e7edf2}.pay-record:last-child{border-bottom:0}.pay-record:hover{background:#fbfdff} +.pay-record-icon{display:grid;place-items:center;width:44px;height:44px;flex-shrink:0;border-radius:14px;background:#f0f5f9;color:#1977ce}.pay-record-main{display:flex;flex-direction:column;gap:4px;min-width:0}.pay-record-main h3{font-size:16px;margin:0;font-weight:800}.pay-record-main h3 a:hover{text-decoration:underline;text-underline-offset:4px}.pay-record-meta{font-size:12px;color:#526c86}.pay-record-customer{display:flex;align-items:center;gap:6px;font-size:14px;color:#526c86}.pay-record-customer .cs-icon{width:16px;height:16px} +.pay-record-amount{display:flex;flex-direction:column;gap:6px}.pay-record-amount strong{font-size:18px;line-height:1.7;font-weight:800;white-space:nowrap}.pay-record-amount small{font-size:12px;font-weight:400}.pay-record-amount time{font-size:12px;color:#526c86}.pay-record-side{display:flex;flex-direction:column;align-items:flex-end;gap:8px} +.pay-status{display:inline-flex;align-items:center;gap:6px;min-height:28px;max-width:100%;padding:4px 10px;border-radius:20px;font-size:12px;font-weight:700;background:#f0f3f6;color:#526c86;white-space:nowrap}.pay-status::before{content:'';width:5px;height:5px;flex-shrink:0;border-radius:50%;background:currentColor}.pay-status.pending{background:#fff3da;color:#805300}.pay-status.partial,.pay-status.info{background:#e5f4ff;color:#125587}.pay-status.approved{background:#eaf5ef;color:#216345}.pay-status.rejected{background:#fff0ee;color:#9f1f1f} +.pay-open{display:flex;align-items:center;justify-content:center;gap:8px;min-height:44px;color:#08376b;font-size:14px;font-weight:700}.pay-open .cs-icon{width:18px;height:18px}.pay-open:hover{color:#1977ce;text-decoration:underline;text-underline-offset:4px} +.pay-empty{text-align:center;padding:40px 20px;color:#526c86}.pay-empty-icon{display:grid;place-items:center;width:56px;height:56px;border-radius:18px;background:#f0f5f9;color:#526c86;margin:0 auto 12px}.pay-empty-icon .cs-icon{width:26px;height:26px}.pay-empty h3{font-size:18px;color:#08376b;margin:0}.pay-empty p{margin:6px 0 0} +.pay-bank-table-head,.pay-bank-row{display:grid;grid-template-columns:44px minmax(190px,1fr) repeat(3,100px) 90px;align-items:center;gap:12px}.pay-bank-table-head{padding:12px 20px;background:#f6f8fa;color:#526c86;font-size:12px;border-bottom:1px solid #e7edf2}.pay-select-all{display:flex;align-items:center;gap:8px}.pay-select-all span{display:none}.pay-bank-row{padding:20px;border-bottom:1px solid #e7edf2}.pay-bank-select{display:flex;align-items:center;min-height:44px;cursor:pointer}.pay-bank-panel input[type=checkbox]{width:20px;height:20px;accent-color:#08376b;cursor:pointer;margin:0;flex-shrink:0} +.pay-bank-identity{display:flex;gap:12px;align-items:center;min-width:0}.pay-bank-identity h3{font-size:16px;margin:0}.pay-bank-identity p{font-size:14px;color:#526c86;margin:3px 0}.pay-identifier-badge{display:inline-block;font-size:12px;color:#805300;background:#fff3da;border:1px solid #f3ddae;border-radius:8px;padding:2px 7px;margin-top:4px} +.pay-bank-edit{display:flex;align-items:center;justify-content:center;gap:6px;min-height:44px;border:1px solid #dce5ed;border-radius:12px;font-size:14px;font-weight:700;color:#08376b}.pay-bank-edit:hover{background:#e5f4ff;border-color:#91bfdd}.pay-bank-edit .cs-icon{width:17px;height:17px}.pay-mobile-label{display:none} +.pay-bulk-bar{display:grid;grid-template-columns:minmax(180px,1fr) minmax(155px,auto) minmax(100px,auto) auto;align-items:end;gap:12px;padding:20px;background:#f6f8fa}.pay-bulk-label{display:flex;flex-direction:column;gap:6px;align-self:center}.pay-bulk-label strong{font-size:14px}.pay-bulk-label span{font-size:12px;color:#526c86}.pay-bank-hint{padding:0 20px 14px;margin:0!important;background:#f6f8fa;color:#526c86;font-size:12px} +.pay-pagination{justify-content:center;flex-wrap:wrap;margin-top:20px}.pay-pagination a{display:flex;align-items:center;justify-content:center;gap:8px;min-width:90px;min-height:44px;background:#fff;border:1px solid #dce5ed;border-radius:12px;font-weight:700}.pay-pagination span{font-size:14px;color:#526c86} +.pay-staff-dock{grid-template-columns:repeat(2,minmax(0,1fr));gap:4px}.pay-staff-dock.has-banks{grid-template-columns:repeat(3,minmax(0,1fr))}.pay-staff-dock a{gap:5px;min-height:54px;font-size:12px;color:#cadbea}.pay-staff-dock a.active{background:#0b4c8c}.pay-staff-dock .cs-icon{width:20px;height:20px} +@media(min-width:900px){.chaharsu-home .service-grid>*{min-height:232px}} +@media(max-width:1199px){.pay-bank-filters{grid-template-columns:repeat(3,minmax(0,1fr))}.pay-bank-filters .pay-search-field{grid-column:span 2}.pay-bank-filters button{width:100%}.pay-page-head{gap:16px}.pay-bank-table-head,.pay-bank-row{grid-template-columns:32px minmax(160px,1fr) repeat(3,85px) 80px;gap:8px}.pay-bulk-bar{grid-template-columns:repeat(3,minmax(0,1fr))}.pay-bulk-label{grid-column:1/-1}} +@media(max-width:899px){.pay-filter-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.pay-search-field{grid-column:1/-1}.pay-bank-filters .pay-search-field{grid-column:1/-1}.pay-record{grid-template-columns:44px minmax(0,1fr) auto}.pay-record-side{grid-column:2/-1;flex-direction:row;justify-content:space-between;align-items:center}.pay-head-actions{width:100%}.pay-page-title h1{font-size:22px}.cs-nav{gap:4px}.cs-nav a{padding-inline:10px}.cs-header-inner{gap:12px}.cs-profile span{display:none}} +@media(max-width:767px){.pay-bank-table-head{display:flex;padding:10px 16px}.pay-bank-table-head>span{display:none}.pay-select-all span{display:inline}.pay-bank-row{grid-template-columns:24px repeat(3,minmax(0,1fr));gap:12px;padding:16px}.pay-bank-select{grid-column:1;grid-row:1;align-self:start}.pay-bank-identity{grid-column:2/-1}.pay-bank-flag{display:flex;flex-direction:column;gap:6px;align-items:flex-start}.pay-bank-flag:nth-of-type(2){grid-column:2}.pay-mobile-label{display:block;font-size:12px;color:#526c86}.pay-bank-edit{grid-column:2/-1;justify-self:end;padding-inline:14px}.pay-bank-identity .pay-record-icon{display:none}.pay-bulk-bar{grid-template-columns:repeat(2,minmax(0,1fr));padding:16px}.pay-bulk-bar button{grid-column:1/-1}.pay-bank-hint{padding-inline:16px}} +@media(max-width:639px){ + .cs-header-inner{min-height:64px;padding-inline:16px;gap:8px}.cs-brand{gap:10px}.cs-brand img{width:64px}.cs-brand-divider{height:28px}.cs-brand b{font-size:18px}.cs-brand small{font-size:11px}.cs-nav{display:none}.cs-profile{padding:0;width:40px;min-height:40px;border-radius:12px}.cs-profile .cs-icon{margin:auto;width:20px;height:20px}.cs-header-actions{gap:4px}.cs-logout{font-size:12px;padding-inline:6px} + .customer-main:has(.chaharsu){padding:18px 12px 12px}.customer-main:has(.chaharsu-home){padding:12px 16px 8px}.chaharsu-home .chaharsu-heading{margin-bottom:12px}.chaharsu-home h1{font-size:22px}.chaharsu-home .chaharsu-heading p{display:none} + .expert-service{padding:12px 14px;border-radius:20px}.expert-service-head{grid-template-columns:minmax(0,1fr) 30px;gap:0 8px}.expert-service-head h2{grid-column:1;grid-row:1;font-size:18px}.expert-service-head .service-icon{grid-column:2;grid-row:1;width:30px;height:30px;border-radius:10px}.expert-service-head .cs-icon{width:18px;height:18px}.expert-service-head p{grid-column:1/-1;margin-top:6px;line-height:1.65}.service-actions{padding-top:10px;gap:8px}.service-actions .pay-button{padding-inline:6px;gap:5px}.service-actions .cs-icon{width:18px;height:18px} + .financial-service{padding:11px 12px;border-radius:20px}.financial-service .service-icon{width:30px;height:30px;border-radius:10px}.financial-service .cs-icon{width:20px;height:20px}.financial-service h2{font-size:14px;margin:7px 0 4px}.financial-service p{font-size:14px;line-height:1.65}.service-link{display:none}.cs-footer{padding:0 16px calc(96px + env(safe-area-inset-bottom));margin-top:16px} + .pay-page-head{margin-bottom:16px;gap:12px}.pay-page-title{gap:10px;align-items:flex-start}.pay-page-icon{width:40px;height:40px;border-radius:12px}.pay-page-icon .cs-icon{width:22px;height:22px}.pay-page-title h1{font-size:20px}.pay-page-title p{line-height:1.7;margin-top:4px}.pay-head-actions .pay-button{flex:1;padding-inline:10px}.pay-page-head>.pay-button{width:100%} + .pay-desk-tabs{width:100%;gap:2px}.pay-desk-tabs a{flex:1;min-width:0;padding:8px 6px;line-height:1.6;font-size:14px}.pay-filter-panel{padding:14px;margin-bottom:16px}.pay-filter-grid{gap:10px}.pay-filter-grid button{width:100%;align-self:end}.pay-extra-filters{grid-template-columns:1fr}.pay-filter-bottom{gap:8px;flex-wrap:wrap}.pay-reset{font-size:12px}.pay-list-head{padding:14px 16px}.pay-list-head h2{font-size:14px} + .pay-record{grid-template-columns:minmax(0,1fr);gap:10px;padding:16px}.pay-record>.pay-record-icon{display:none}.pay-record-main h3{font-size:14px}.pay-record-amount{flex-direction:row;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:6px}.pay-record-amount strong{font-size:16px}.pay-record-side{grid-column:auto;gap:8px;flex-wrap:wrap;border-top:1px solid #e7edf2;padding-top:8px}.pay-record-meta{font-size:12px}.pay-empty{padding:30px 16px}.pay-pagination{gap:8px} +} +@media(max-width:359px){.cs-header-inner{padding-inline:12px}.cs-brand img{width:54px}.cs-brand{gap:8px}.cs-brand small{font-size:10px}.customer-main:has(.chaharsu-home){padding-inline:12px}.service-actions .cs-icon{display:none}.pay-bank-row{gap:10px}.pay-bank-flag .pay-status{font-size:12px;padding-inline:7px}} +@media(max-width:639px) and (max-height:650px){.customer-main:has(.chaharsu-home)+.cs-footer{margin-top:96px}} +@media(prefers-reduced-motion:reduce){.financial-service,.chaharsu .pay-button{transition:none}.financial-service:hover{transform:none}} + +/* Every staff payment form/detail uses the same local component family. */ +.pay-form-workspace{max-width:1000px}.pay-detail-workspace{max-width:1200px} +.pay-back{display:inline-flex;align-items:center;gap:6px;min-height:44px;margin-bottom:8px;font-size:14px;font-weight:700;color:#526c86}.pay-back .cs-icon{width:18px;height:18px}.pay-back:hover{color:#08376b} +.pay-staff-form{max-width:none!important;margin:0;padding:24px}.pay-form-section{margin:0;padding:0 0 24px;border:0;min-width:0}.pay-form-section+.pay-form-section{border-top:1px solid #e7edf2;padding-top:24px}.pay-form-section legend{float:right;width:100%;font-size:18px;font-weight:800;color:#08376b;margin-bottom:12px}.pay-form-section::after{content:'';display:block;clear:both} +.pay-section-help,.pay-field-hint{font-size:14px;font-weight:400;color:#526c86;line-height:1.8}.pay-section-help{clear:both;margin:0 0 12px!important}.pay-field-hint{margin:6px 0 0!important;font-size:12px}.pay-form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px 24px;clear:both}.pay-form-grid .pay-field{margin:0;min-width:0}.pay-field-wide{grid-column:1/-1}.pay-staff-form .pay-field label{display:flex;align-items:center;flex-wrap:wrap;gap:8px;font-size:14px;margin-bottom:8px}.pay-required{font-size:12px;font-weight:400;color:#526c86}.pay-optional{font-size:12px;font-weight:400;color:#526c86} +.pay-staff-form input:not([type=checkbox]):not([type=radio]):not([type=hidden]),.pay-staff-form select,.pay-staff-form textarea{min-height:48px;padding:10px 12px;border:1px solid #b8c9d8;border-radius:12px;font-size:16px;box-shadow:none}.pay-staff-form textarea{min-height:120px;resize:vertical}.pay-staff-form input[id=id_iban],.pay-staff-form input[id=id_card_number],.pay-staff-form input[id=id_account_number],.pay-staff-form input[id=id_amount],.pay-staff-form input[id=id_deposit_id]{direction:ltr;text-align:right}.pay-staff-form .has-error input,.pay-staff-form .has-error select,.pay-staff-form .has-error textarea{border-color:#9f1f1f}.chaharsu .errorlist{list-style:none;padding:0;margin:6px 0;color:#9f1f1f;font-size:14px;line-height:1.8}.pay-staff-form .pay-field .errorlist{margin:0}.pay-inline-note{clear:both;background:#f6fbff;border:1px solid #cde6f7;border-radius:12px;padding:12px 14px;color:#526c86;font-size:14px;line-height:1.8;margin:16px 0 0!important} +.pay-check-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;clear:both}.pay-check-card{display:flex;align-items:flex-start;gap:12px;padding:16px;border:1px solid #dce5ed;border-radius:14px;background:#fafcfe;cursor:pointer;min-width:0}.pay-check-card:has(input:checked){border-color:#91bfdd;background:#f0f8fd}.pay-check-card input[type=checkbox]{width:20px;height:20px;margin:3px 0 0;flex-shrink:0;accent-color:#08376b}.pay-check-card strong{display:block;font-size:14px;font-weight:800}.pay-check-card small{display:block;font-size:12px;line-height:1.8;font-weight:400;color:#526c86;margin-top:5px}.pay-sort-field{max-width:300px;margin-top:20px}.pay-sort-field .pay-field{margin:0} +.pay-form-footer{display:flex;align-items:center;gap:12px;flex-wrap:wrap;border-top:1px solid #e7edf2;padding-top:20px;margin-top:4px}.pay-form-footer .pay-button{min-width:120px}.pay-delete-panel{border:1px solid #ecd4d2;background:#fff;border-radius:16px;margin-top:20px;color:#9f1f1f}.pay-delete-panel summary{padding:14px 20px;cursor:pointer;min-height:48px;font-size:14px;font-weight:700}.pay-delete-panel form{padding:0 20px 20px;color:#526c86;font-size:14px}.chaharsu .pay-danger,.chaharsu .pay-danger.secondary{background:#fff4f3;border-color:#d9a4a0;color:#9f1f1f}.chaharsu .pay-danger:hover{background:#9f1f1f;border-color:#9f1f1f;color:#fff} +.pay-receipt-grid{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:18px 24px;margin-top:20px}.pay-staff-form .pay-receipt-grid>.pay-field{grid-column:span 3;margin:0;min-width:0;display:block;width:auto}.pay-staff-form .pay-receipt-grid>[data-field=receipt_mode],.pay-staff-form .pay-receipt-grid>[data-field=notice_id],.pay-staff-form .pay-receipt-grid>[data-field=description],.pay-staff-form .pay-receipt-grid>[data-field=receipt_image]{grid-column:1/-1}.pay-staff-form .pay-receipt-grid>[data-field=paid_day],.pay-staff-form .pay-receipt-grid>[data-field=paid_month],.pay-staff-form .pay-receipt-grid>[data-field=paid_year]{grid-column:span 2}.pay-date-heading{grid-column:1/-1;display:flex;gap:8px;flex-wrap:wrap;margin-top:8px!important;border-top:1px solid #e7edf2;padding-top:20px}.pay-staff-form #id_receipt_mode{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.pay-staff-form #id_receipt_mode label{min-height:52px;padding:12px;margin:0;border:1px solid #dce5ed;border-radius:12px;cursor:pointer;background:#fafcfe;flex-wrap:nowrap;align-items:flex-start}.pay-staff-form #id_receipt_mode label:has(input:checked){background:#e5f4ff;border-color:#91bfdd}.pay-staff-form #id_receipt_mode input{margin:2px 0 0;flex-shrink:0;accent-color:#08376b}.pay-staff-form input[type=file]{padding:18px!important;background:#f6fbff;border:1px dashed #91bfdd!important}.pay-staff-form #amount-words{font-size:14px;color:#526c86}.pay-staff-form .pay-today{font-size:12px;min-height:36px;padding:4px 10px;margin-top:8px}.pay-staff-form #date-preview{font-size:12px;color:#526c86} +.pay-detail-grid{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(300px,1fr);gap:24px;align-items:start}.pay-detail-grid>div,.pay-detail-aside{min-width:0}.pay-detail-grid .pay-card{margin:0 0 20px}.pay-detail-grid .pay-card h2{font-size:18px;margin-bottom:12px}.pay-detail-grid .pay-form{max-width:none}.pay-notice-main .payment-notice summary{padding-bottom:16px;border-bottom:1px solid #e7edf2;margin-bottom:16px}.pay-notice-main .payment-values>div{grid-template-columns:125px minmax(0,1fr) auto}.pay-notice-main .payment-values dd{font-size:16px}.pay-notice-main .pay-copy{padding-inline:12px;min-height:40px;font-size:14px}.pay-linked-record{display:flex;align-items:center;gap:8px;flex-wrap:wrap;border-top:1px solid #e7edf2;padding:12px 0;min-height:48px;font-size:14px;font-weight:700}.pay-linked-record>.cs-icon{margin-inline-start:auto;width:18px;height:18px}.pay-notification-row{display:flex;flex-direction:column;gap:4px;padding:12px 0;border-top:1px solid #e7edf2}.pay-notification-row span{font-size:12px;color:#526c86} +.pay-warning{background:#fff3da;border:1px solid #f3ddae;color:#805300;padding:14px 16px;border-radius:14px;font-size:14px;line-height:1.8;margin:0 0 16px!important}.pay-receipt-total{background:#f6fbff;border:1px solid #cde6f7;border-radius:14px;padding:16px;margin-bottom:16px}.pay-receipt-total>span{font-size:12px;color:#526c86}.pay-receipt-total h2{font-size:26px!important;margin:4px 0!important;line-height:1.6}.pay-receipt-total h2 small{font-size:14px;font-weight:400}.pay-receipt-total p{font-size:14px;color:#526c86;margin:0}.pay-facts>div{display:grid;grid-template-columns:150px minmax(0,1fr);gap:12px;padding:10px 0;border-bottom:1px solid #e7edf2}.pay-facts dt{color:#526c86;font-size:14px}.pay-facts dd{font-size:14px;font-weight:700;min-width:0;overflow-wrap:anywhere}.pay-facts dd[dir=ltr]{text-align:right}.pay-section-title{font-size:14px!important;margin:20px 0 8px;font-weight:800}.pay-card-title{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:16px}.pay-card-title h2{margin:0!important}.pay-image-card .receipt-preview{margin:0 auto 12px;max-height:650px;width:auto;border-radius:12px;border:1px solid #e7edf2;background:#f6f8fa}.pay-decision-form{border-top:3px solid #1977ce}.pay-decision-form label{font-size:14px}.pay-decision-form textarea{margin-top:8px;min-height:110px}.pay-decision-actions{display:flex;flex-direction:column;gap:10px;margin-top:16px}.pay-decision-actions .pay-button{width:100%}.pay-timeline-item{position:relative;padding:0 16px 20px 0;border-inline-start:2px solid #dce5ed;display:flex;flex-direction:column;gap:4px}.pay-timeline-item:before{content:'';position:absolute;inset-inline-start:-5px;top:5px;background:#1977ce;width:8px;height:8px;border-radius:50%}.pay-timeline-item strong{font-size:14px}.pay-timeline-item span,.pay-timeline-item time{font-size:12px;color:#526c86}.pay-timeline-item p{font-size:14px;margin:4px 0}.pay-version-row{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;padding:10px 0;border-bottom:1px solid #e7edf2}.pay-version-row a{font-size:14px;font-weight:700}.pay-version-row time{font-size:12px;color:#526c86}.pay-reviewer{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:14px}.pay-reviewer>.cs-icon{width:18px;height:18px;color:#526c86} +@media(max-width:899px){.pay-detail-grid{grid-template-columns:minmax(0,1fr);gap:0}.pay-detail-grid .pay-card{margin-bottom:16px}} +@media(max-width:639px){.pay-staff-form{padding:16px}.pay-form-grid{grid-template-columns:minmax(0,1fr);gap:18px}.pay-form-section{padding-bottom:20px}.pay-form-section+.pay-form-section{padding-top:20px}.pay-form-section legend{font-size:16px}.pay-check-grid{grid-template-columns:minmax(0,1fr);gap:10px}.pay-check-card{padding:14px}.pay-sort-field{max-width:none}.pay-form-footer{gap:8px}.pay-form-footer .pay-button{flex:1;min-width:0}.pay-receipt-grid{gap:16px 8px}.pay-staff-form .pay-receipt-grid>.pay-field{grid-column:1/-1}.pay-staff-form .pay-receipt-grid>[data-field=paid_day],.pay-staff-form .pay-receipt-grid>[data-field=paid_month],.pay-staff-form .pay-receipt-grid>[data-field=paid_year]{grid-column:span 2}.pay-staff-form .pay-receipt-grid select{padding-inline:6px}.pay-staff-form #id_receipt_mode{grid-template-columns:minmax(0,1fr);gap:8px}.pay-staff-form [data-field=paid_day] .field-error{min-width:0}.pay-facts>div{grid-template-columns:1fr;gap:4px}.pay-receipt-total h2{font-size:24px!important}.pay-notice-main .payment-values>div{grid-template-columns:minmax(0,1fr) auto}.pay-notice-main .payment-values dt{grid-column:1/-1}.pay-detail-grid .pay-card h2{font-size:16px}.pay-delete-panel summary{padding-inline:16px}.pay-delete-panel form{padding-inline:16px}.pay-card-title .pay-button{min-height:40px;padding-inline:12px}} +/* Native radios must not inherit the workspace text-input width and height. */ +.pay-staff-form #id_receipt_mode input[type=radio]{width:20px;height:20px;min-height:20px;padding:0;border:0;border-radius:50%;background:transparent;box-shadow:none;flex:0 0 20px} +.pay-staff-form textarea{height:128px;min-height:128px}.pay-date-status{grid-column:1/-1;margin:-6px 0 0!important} +.pay-customer-picker{min-width:0}.pay-customer-picker>label{display:flex;align-items:center;gap:8px;font-weight:700;font-size:14px;margin-bottom:8px}.pay-workspace .pay-customer-picker .pay-search-input input[type=search]{border:0;background:transparent;padding-inline:0}.pay-customer-results{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:16px;max-height:520px;overflow-y:auto;padding:4px}.pay-customer-result{padding:16px;border:1px solid #dce5ed;border-radius:14px;background:#fafcfe;min-width:0;display:flex;flex-direction:column;gap:10px}.pay-customer-identity{display:flex;flex-direction:column;gap:4px;min-width:0}.pay-customer-identity strong{font-size:16px;font-weight:800}.pay-customer-identity bdi{font-size:16px;font-weight:700;text-align:right}.pay-customer-identity span{font-size:14px}.pay-customer-identity small{font-size:12px;color:#526c86}.pay-customer-result>.pay-button{margin-top:auto}.pay-customer-history{border-top:1px solid #dce5ed;padding-top:6px}.pay-customer-history summary{font-size:14px;cursor:pointer;min-height:40px;display:list-item;list-style-position:inside}.pay-customer-history div{display:flex;flex-direction:column;gap:3px;padding:8px 0;border-top:1px solid #e7edf2}.pay-customer-history a{font-size:14px;color:#08376b;text-decoration:underline;text-underline-offset:3px}.pay-customer-history small,.pay-customer-history p{font-size:12px;color:#526c86}.pay-selected-customer{margin-top:16px;padding:16px 20px;border:1px solid #91bfdd;border-radius:16px;background:#f0f8fd;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px 16px}.pay-selected-label{font-size:12px;color:#526c86;grid-column:1/-1}.pay-selected-customer .pay-customer-identity{grid-column:1;grid-row:2}.pay-selected-customer>.pay-button{grid-column:2;grid-row:2;align-self:start}.pay-selected-customer .pay-customer-history{grid-column:1/-1}.pay-customer-history summary:focus-visible{outline:3px solid #1977ce;outline-offset:2px} +@media(max-width:639px){.pay-customer-results{grid-template-columns:1fr;max-height:440px}.pay-selected-customer{padding:14px;gap:12px}.pay-selected-customer>.pay-button{grid-column:1/-1;grid-row:auto}.pay-customer-result{padding:14px}} + +/* Customer financial pages follow the standalone prototypes and STYLE_GUIDE. + All rules are scoped: staff forms and the ticket composer keep their styles. */ +.customer-finance .pay-breadcrumb{display:flex;gap:10px;align-items:center;flex-wrap:wrap;font-size:12px;color:#526c86;margin-bottom:16px} +.customer-finance-title{display:flex;align-items:center;gap:16px;margin-bottom:24px}.customer-finance-title h1{margin:0}.customer-finance-title p{color:#526c86;margin:6px 0 0}.customer-finance .pay-page-icon{width:56px;height:56px;display:grid;place-items:center;background:#e5f4ff;border:1px solid #cde6f7;border-radius:18px;flex-shrink:0}.customer-finance .pay-page-icon svg{width:28px;height:28px} +.customer-finance .finance-guidance{background:#f0f8fd;border:1px solid #cde6f7;border-radius:12px;padding:12px 14px;font-size:14px;line-height:1.8;color:#526c86;margin:12px 0 20px}.customer-finance .pay-text-link{display:inline-flex;align-items:center;gap:6px;min-height:44px;font-size:14px;font-weight:700;text-decoration:underline;text-underline-offset:4px}.customer-finance .pay-text-link svg{width:18px;height:18px}.customer-service-footer{display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;margin:20px 0} +.customer-receipt-form.pay-form{max-width:none;margin:0;padding:0;border:0;background:transparent;box-shadow:none}.customer-receipt-form .pay-card{margin:0 0 20px;padding:22px;border-radius:23px}.customer-receipt-form h2{margin:0 0 16px}.customer-receipt-form .pay-field{min-width:0}.customer-receipt-form .pay-field label{font-size:14px}.customer-receipt-form .pay-optional{margin-inline-start:8px}.customer-receipt-form input:not([type=hidden]):not([type=radio]):not([type=file]),.customer-receipt-form select,.customer-receipt-form textarea{padding:10px 12px;font-size:16px;min-height:48px;box-shadow:none;border-color:#b8c9d8}.customer-receipt-form textarea{height:112px;min-height:112px;resize:vertical}.customer-field-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px 24px;margin-top:20px}.customer-field-grid .pay-field{margin:0}.customer-receipt-form .pay-field-hint{font-size:12px;line-height:1.8}.customer-receipt-form #amount-words{font-size:14px;color:#526c86;line-height:1.8} +.customer-receipt-modes{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.customer-mode{border:1px solid #dce5ed;border-radius:14px;padding:12px 14px;background:#fafcfe}.customer-mode.recommended-mode{border-color:#91bfdd}.customer-mode:has(input:checked){background:#e5f4ff;border-color:#1977ce}.customer-receipt-form .customer-mode label{display:flex;align-items:center;gap:10px;cursor:pointer;min-height:36px;margin:0;padding:0}.customer-mode small{display:block;margin-inline-start:30px;font-size:12px;color:#526c86}.customer-receipt-form .customer-mode input[type=radio]{width:20px;height:20px;min-height:20px;margin:0;padding:0;flex:0 0 20px;accent-color:#08376b;box-shadow:none}.customer-receipt-form .receipt-mode-field{padding:0;border:0}.customer-receipt-form #selected-payment h3{font-size:16px;margin:0}.customer-receipt-form #selected-payment p{font-size:14px;color:#526c86} +.customer-date{padding:20px 0 0;margin-top:24px;border:0;border-top:1px solid #e7edf2;min-width:0}.customer-date legend{font-size:14px;font-weight:700;padding-inline:0 10px}.customer-date-selects{display:grid;grid-template-columns:.7fr 1.3fr 1fr;gap:12px}.customer-receipt-form .customer-date-selects>.pay-field{display:block;width:auto;margin:0;min-width:0}.customer-receipt-form [data-field=paid_day] .field-error{min-width:0}.customer-date-tools{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:8px}.customer-date-tools .pay-today{margin:0;font-size:12px;min-height:44px;padding:4px 12px}.customer-receipt-form #date-preview{font-size:12px;color:#526c86;min-height:22px} +.customer-upload-zone{position:relative;border:1px dashed #91bfdd;background:#f6fbff;border-radius:16px;display:flex;flex-direction:column;align-items:center;padding:24px 16px;text-align:center;gap:12px}.customer-upload-zone>.cs-icon{width:32px;height:32px;color:#1977ce}.customer-receipt-form .customer-file-button{display:inline-flex;align-items:center;gap:8px;min-height:44px;padding:8px 16px;border-radius:12px;background:#08376b;color:#fff;cursor:pointer;margin:0}.customer-file-button span{font-size:12px;font-weight:400}.customer-upload-zone p{font-size:12px;color:#526c86;margin:0}.customer-receipt-form .customer-upload-zone input[type=file]{position:absolute;width:1px;height:1px;min-height:0;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.customer-upload-zone:focus-within .customer-file-button{outline:3px solid #1977ce;outline-offset:3px}.customer-file-selection{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:12px;margin-top:12px}.customer-file-selection p{overflow-wrap:anywhere}.customer-file-selection button{min-height:44px;flex-shrink:0;background:#fff;border:1px solid #dce5ed;border-radius:10px;font:inherit;color:#9f1f1f;padding:8px 12px}.customer-receipt-form #receipt-file-preview{display:block;width:100%;max-height:240px;object-fit:contain;border:1px solid #dce5ed;border-radius:12px;background:#fafcfe;margin-top:12px}.customer-receipt-form [hidden]{display:none!important}.customer-receipt-actions{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-top:20px}.customer-receipt-actions .pay-button{min-width:180px} +.customer-list-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:24px 0 12px}.customer-list-heading h2{margin:0}.customer-list-heading>span{font-size:12px;color:#526c86;background:#fff;border-radius:20px;padding:4px 12px}.customer-search-box{display:flex;align-items:center;gap:10px;background:#fff;border:1px solid #b8c9d8;border-radius:12px;padding-inline:12px;flex:1;min-width:0}.customer-notice-search{margin-bottom:20px;align-items:center}.customer-finance .customer-search-box input{border:0;background:transparent;min-width:0;padding-inline:0;box-shadow:none}.customer-search-box:focus-within{outline:3px solid #1977ce;outline-offset:2px}.customer-empty{text-align:center;padding:32px 20px}.customer-empty>.cs-icon{width:36px;height:36px;color:#526c86}.customer-empty p{color:#526c86} +.customer-accounts .payment-notice{padding:0;overflow:hidden;border-radius:23px;margin:16px 0}.customer-accounts .payment-notice summary{position:relative;display:grid;grid-template-columns:minmax(0,1fr) auto 22px;gap:8px 16px;padding:20px 22px;list-style:none}.payment-notice summary::-webkit-details-marker{display:none}.customer-accounts .payment-notice summary strong{min-width:0;grid-column:1;grid-row:1;font-size:16px}.customer-accounts .payment-notice summary b{grid-column:2;grid-row:1;font-size:20px;white-space:nowrap}.customer-accounts .payment-notice summary b span{font-size:12px;font-weight:400}.customer-accounts .payment-notice summary small{grid-column:1;grid-row:2;width:auto}.customer-accounts .notice-state{grid-column:2;grid-row:2;font-size:12px;color:#1977ce;justify-self:end}.customer-accounts .payment-notice summary>.cs-icon{grid-column:3;grid-row:1/3;width:20px;align-self:center}.customer-accounts .payment-notice[open] summary>.cs-icon{transform:rotate(180deg)}.customer-accounts .payment-notice[open] summary{border-bottom:1px solid #e7edf2}.customer-accounts .pay-details{padding:0 22px 6px}.customer-accounts .notice-bank{font-size:14px;color:#526c86;margin:16px 0}.customer-accounts .payment-values{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.customer-accounts .payment-values>div{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;border:1px solid #dce5ed;border-radius:12px;padding:12px 14px;background:#fafcfe;align-items:center}.customer-accounts .payment-values dt{grid-column:1;grid-row:1;font-size:12px;color:#526c86;font-weight:400}.customer-accounts .payment-values dd{grid-column:1/-1;grid-row:2;font-size:16px;margin:0;line-height:1.8;letter-spacing:.3px}.customer-accounts .pay-copy{grid-column:2;grid-row:1;font-size:12px;padding:4px 8px;min-height:44px;border-color:#dce5ed;gap:6px}.customer-accounts .pay-copy svg{width:16px;height:16px}.customer-accounts .pay-details>p:not(.notice-bank){font-size:12px;color:#526c86}.customer-accounts .pay-supplement{font-size:14px;line-height:1.9}.customer-accounts .pay-supplement h3{margin:0 0 8px}.customer-accounts .pay-actions{gap:12px} +@media(max-width:639px){.customer-finance-title{gap:12px;margin-bottom:20px;align-items:flex-start}.customer-finance-title h1{font-size:24px}.customer-finance .pay-page-icon{width:48px;height:48px;border-radius:14px}.customer-receipt-form .pay-card{padding:16px;border-radius:20px;margin-bottom:16px}.customer-receipt-form h2{font-size:18px}.customer-field-grid{grid-template-columns:minmax(0,1fr);gap:18px}.customer-receipt-modes{grid-template-columns:minmax(0,1fr);gap:8px}.customer-date-selects{gap:8px}.customer-receipt-form .customer-date-selects select{padding-inline:6px}.customer-receipt-actions{gap:8px}.customer-receipt-actions .pay-button{width:100%}.customer-service-footer{flex-direction:column;gap:0}.customer-accounts .payment-notice{border-radius:20px}.customer-accounts .payment-notice summary{grid-template-columns:minmax(0,1fr) 20px;padding:16px;gap:6px 8px}.customer-accounts .payment-notice summary strong{grid-column:1;grid-row:1}.customer-accounts .payment-notice summary b{grid-column:1;grid-row:2;justify-self:start;font-size:20px}.customer-accounts .payment-notice summary small{grid-column:1;grid-row:3}.customer-accounts .notice-state{grid-column:1;grid-row:4;justify-self:start}.customer-accounts .payment-notice summary>.cs-icon{grid-column:2;grid-row:1/5}.customer-accounts .pay-details{padding:0 16px 4px}.customer-accounts .payment-values{grid-template-columns:minmax(0,1fr)}.customer-accounts .pay-actions>.pay-button{width:100%}} +/* Review identity and history: names identify actual decisions, not access lists. */ +.pay-customer-summary{display:flex;flex-direction:column;gap:3px;margin-top:8px;min-width:0}.pay-customer-summary strong{font-size:14px;font-weight:800}.pay-customer-summary>span{font-size:14px;color:#526c86;line-height:1.8}.pay-record-review{display:flex;flex-direction:column;gap:3px;margin-top:6px;font-size:12px;color:#526c86;line-height:1.8}.pay-linked-receipts{border-top:1px solid #dce5ed;margin-top:20px;padding-top:16px}.pay-linked-receipts>h3{font-size:16px;margin:0 0 12px}.pay-linked-receipt{padding:14px 16px;background:#fafcfe;border:1px solid #dce5ed;border-radius:14px;margin:12px 0}.pay-linked-receipt-head{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}.pay-linked-receipt-meta{font-size:14px;color:#526c86}.pay-linked-decision{display:flex;flex-direction:column;gap:4px;padding:10px 0;border-top:1px solid #e7edf2;font-size:14px}.pay-linked-decision time{font-size:12px;color:#526c86}.customer-receipt-detail .pay-page-head{gap:16px;margin-bottom:20px}.customer-receipt-detail .pay-card{padding:22px;border-radius:23px}.customer-receipt-detail .pay-detail-aside .pay-timeline-item{margin-top:12px}.customer-receipt-detail .pay-timeline-item p{overflow-wrap:anywhere}.customer-receipt-detail .pay-page-icon{width:48px;height:48px}.customer-receipt-detail .pay-version-row>div{text-align:end}.pay-version-row .pay-field-hint{margin:4px 0!important} +@media(max-width:639px){.customer-receipt-detail .pay-card{padding:16px;border-radius:20px}.customer-receipt-detail h1{font-size:24px}.pay-linked-receipt{padding:12px}.customer-receipt-detail .pay-version-row>div{text-align:start;width:100%}} + +@media(max-width:359px){.customer-accounts .payment-values dd{font-size:14px;letter-spacing:0}} +.pay-notice-permalink{margin:0 12px 12px}.customer-notice-detail .payment-notice>summary{cursor:pointer}.staff-receipt-workspace .customer-finance-title{align-items:flex-start}.staff-receipt-workspace .pay-customer-summary{padding:10px 14px;margin-top:12px;background:#fff;border:1px solid #dce5ed;border-radius:12px}.staff-receipt-workspace .customer-receipt-form{max-width:900px;margin:auto} +.customer-accounts .pay-notice-entry{background:#fff;border:1px solid #dce5ed;border-radius:23px;margin:16px 0;overflow:hidden}.customer-accounts .pay-notice-entry .payment-notice{border:0;border-radius:0;margin:0}.customer-accounts .pay-notice-entry .pay-notice-permalink{display:flex;justify-content:space-between;border-top:1px solid #e7edf2;margin:0;padding:12px 20px;min-height:48px;text-decoration:none;background:#fafcfe} +.pay-identifier-control{display:flex;align-items:stretch;gap:12px}.pay-identifier-control>input{flex:1;min-width:0}.pay-identifier-control>.pay-button{gap:8px;flex-shrink:0;white-space:nowrap;font-size:14px}.pay-identifier-control>.pay-button:disabled{opacity:.6;cursor:not-allowed}.pay-staff-form #id_deposit_id{letter-spacing:.5px}.pay-staff-form #identifier-replacement p{font-size:14px}.pay-staff-form #identifier-replacement .pay-text-link{font:inherit;background:transparent;border:0;color:#526c86;min-height:44px;padding:8px 12px;cursor:pointer}.pay-staff-form #identifier-replacement[hidden]{display:none} +@media(max-width:639px){.pay-identifier-control{flex-direction:column;gap:8px}.pay-identifier-control>.pay-button{align-self:stretch}} diff --git a/static/css/magic-link-confirmation.css b/static/css/magic-link-confirmation.css new file mode 100644 index 0000000..b86cc41 --- /dev/null +++ b/static/css/magic-link-confirmation.css @@ -0,0 +1,24 @@ +.magic-confirmation { color: var(--color-ink, #08376b); background: var(--color-cream, #eeeeee); } +.magic-confirmation-shell { width: min(100%, 36rem); margin-inline: auto; padding: clamp(1.5rem, 7vh, 4rem) 1rem max(1.5rem, env(safe-area-inset-bottom)); } +.magic-confirmation-brand { display: flex; justify-content: center; margin-bottom: 1.5rem; } +.magic-confirmation-brand .auth-brand { margin: 0; } +.magic-confirmation-brand img { width: 8.5rem; height: auto; object-fit: contain; } +.magic-confirmation-card { padding: 1.5rem; border: 1px solid #dce5ed; border-top: 3px solid var(--color-coral, #fa9f00); border-radius: 1.25rem; background: #fff; } +.magic-confirmation-icon { display: grid; place-items: center; width: 3rem; height: 3rem; margin-bottom: 1rem; border-radius: .875rem; background: var(--color-mint, #e5f4ff); } +.magic-confirmation-icon svg { width: 1.5rem; height: 1.5rem; } +.magic-confirmation-card h1 { margin: 0; font-size: 1.5rem; font-weight: 900; line-height: 1.6; letter-spacing: normal; } +.magic-confirmation-card p { margin: 1rem 0 0; font-size: 1rem; font-weight: 400; line-height: 1.9; color: #526c86; } +.magic-confirmation-actions { display: grid; gap: .75rem; margin-top: 1.5rem; } +.magic-confirmation-primary, .magic-confirmation-cancel { display: flex; align-items: center; justify-content: center; min-height: 3rem; min-width: 2.75rem; padding: .75rem 1rem; border: 1px solid transparent; border-radius: .75rem; font-size: .875rem; font-weight: 800; line-height: 1.7; text-align: center; white-space: normal; overflow-wrap: anywhere; text-decoration: none; box-shadow: none; transform: none; } +.magic-confirmation-primary { color: #fff; background: var(--color-ink, #08376b); } +.magic-confirmation-primary:hover { background: var(--color-ink-soft, #0b4c8c); box-shadow: none; transform: none; } +.magic-confirmation-cancel { color: var(--color-ink, #08376b); background: #fff; border-color: #dce5ed; } +.magic-confirmation-cancel:hover { background: var(--color-mint, #e5f4ff); border-color: #91bfdd; } +.magic-confirmation-primary:focus-visible, .magic-confirmation-cancel:focus-visible { outline: 3px solid var(--color-sea, #0693e3); outline-offset: 4px; } +.magic-confirmation-primary:disabled { background: var(--color-ink-soft, #0b4c8c); color: #fff; opacity: 1; cursor: wait; } +.magic-confirmation-status:empty { display: none; } +.magic-confirmation-status { color: #526c86; font-size: .875rem; line-height: 1.7; text-align: center; } +.magic-confirmation .auth-footer { margin-top: 1.5rem; color: #526c86; font-size: .75rem; line-height: 1.9; text-align: center; overflow-wrap: anywhere; } +@media (min-width: 640px) { .magic-confirmation-card { padding: 2rem; } .magic-confirmation-card h1 { font-size: 1.75rem; } } +@media (max-width: 359px) { .magic-confirmation-shell { padding-inline: .75rem; } .magic-confirmation-card { padding: 1.25rem; } } +@media (prefers-reduced-motion: reduce) { .magic-confirmation-actions > * { transition: none; } } diff --git a/static/css/operations.css b/static/css/operations.css index 01aa106..8c51be2 100644 --- a/static/css/operations.css +++ b/static/css/operations.css @@ -99,3 +99,21 @@ body>.voip-call-status{position:fixed!important;z-index:2147483001;right:auto!im .contact-history__empty strong{color:#274b65;font-size:.82rem} .contact-history__empty p{margin:0;color:#78909f;font-size:.68rem} @media(max-width:640px){.contact-history__heading{flex-wrap:wrap;padding:1rem}.contact-history__heading p{line-height:1.7}.contact-history__total{margin-inline-start:0;margin-inline-end:auto}.contact-history__timeline{padding:1rem .9rem 1rem .7rem}.contact-history__event{padding-right:1.25rem}.contact-history__summary{gap:.55rem;padding:.7rem}.contact-history__change{grid-template-columns:1fr;gap:.35rem}.contact-history__changes{padding:.7rem}.contact-history__values{gap:.3rem}.contact-history__value{padding:.4rem .45rem}} +/* Contextual financial action; local SVG, full customer identity passed by UUID. */ +.payment-notice-shortcut{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:44px;padding:9px 12px;border:1px solid #b8cfe3;border-radius:12px;background:#e5f4ff;color:#08376b;font-size:14px;font-weight:700;text-decoration:none}.payment-notice-shortcut svg{width:18px;height:18px;flex-shrink:0}.payment-notice-shortcut:hover{background:#d8ecfa;border-color:#91bfdd}.payment-notice-shortcut:focus-visible{outline:3px solid #1977ce;outline-offset:3px}.customer-side .payment-notice-shortcut{display:flex;width:100%;margin-top:16px}.contact-card footer .payment-notice-shortcut{width:100%;font-size:14px;min-height:44px} +.contact-card footer:has(.payment-notice-shortcut){flex-wrap:wrap;gap:8px}.contact-card footer .payment-notice-shortcut{flex-basis:100%;color:#08376b;font-weight:700} +.ticket-notices{margin-top:16px;border:1px solid #cbdce9;border-radius:14px;background:#f8fbfe;overflow:hidden} +.ticket-notices>summary{display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:48px;padding:12px;color:#08376b;font-size:14px;font-weight:700;cursor:pointer;list-style:none;background:#e5f4ff} +.ticket-notices>summary::-webkit-details-marker{display:none} +.ticket-notices-title{display:flex;align-items:center;gap:8px}.ticket-notices-title svg{flex-shrink:0} +.ticket-notices-chevron{font-size:20px;line-height:1}.ticket-notices[open] .ticket-notices-chevron{transform:rotate(180deg)} +.ticket-notices>summary:hover{background:#d8ecfa}.ticket-notices>summary:focus-visible,.ticket-notice-item:focus-visible,.ticket-notices-more:focus-visible{outline:3px solid #1977ce;outline-offset:-3px} +.ticket-notices-content{padding:12px}.customer-side .ticket-notices .payment-notice-shortcut{margin-top:0;padding:8px;font-size:12px;gap:6px}.ticket-notices .payment-notice-shortcut span{white-space:nowrap} +.ticket-notices-caption{margin:14px 0 8px;font-size:12px;color:#536a80} +.ticket-notices-list{display:grid;gap:8px;margin:0;padding:0;list-style:none} +.ticket-notice-item{display:grid;gap:8px;padding:12px 10px;border:1px solid #dce6ee;border-radius:10px;background:#fff;text-decoration:none;color:#08376b} +.ticket-notice-item:hover{border-color:#91bfdd;background:#f0f8ff}.ticket-notice-item strong{font-size:13px;overflow-wrap:anywhere} +.ticket-notice-meta{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:6px;font-size:11px;color:#536a80}.ticket-notice-meta bdi{font-size:10px} +.ticket-notice-status{padding:3px 7px;border-radius:6px;background:#fff3d8;color:#805b13;font-weight:700}.ticket-notice-status.is-settled{background:#e4f5eb;color:#216544}.ticket-notice-status.is-cancelled{background:#f0f1f4;color:#657080} +.ticket-notices-empty{padding:12px 0;font-size:12px;color:#536a80;line-height:1.8} +.ticket-notices-more{display:flex;align-items:center;flex-wrap:wrap;gap:6px;min-height:44px;margin-top:8px;color:#1977ce;font-size:13px;font-weight:700;text-decoration:none}.ticket-notices-more small{flex:1;font-size:10px;font-weight:400}.ticket-notices-more:hover{text-decoration:underline} diff --git a/static/css/payment-link.css b/static/css/payment-link.css new file mode 100644 index 0000000..1612951 --- /dev/null +++ b/static/css/payment-link.css @@ -0,0 +1,10 @@ +/* Temporary payment access follows the Chaharsu style guide. */ +.pay-share-url{overflow-wrap:anywhere;font-size:14px;line-height:1.8;padding:12px;border:1px solid #dce5ed;border-radius:12px;background:#f6fbff}.pay-detail-aside .pay-card:has(.pay-share-url) form{margin-top:12px} +.pay-link-status{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px 16px;margin-bottom:24px;padding:14px 18px;border:1px solid #cde6f7;border-radius:14px;background:#f6fbff;color:#08376b;line-height:1.8} +.pay-link-status>span{display:flex;align-items:center;gap:8px;font-size:14px;font-weight:700}.pay-link-status .cs-icon{width:20px;height:20px;flex-shrink:0}.pay-link-status small{color:#526c86;font-size:14px}.pay-link-session-label{font-size:14px;color:#526c86} +.payment-values .pay-deposit-required,.customer-accounts .payment-values .pay-deposit-required{background:#fff8f8;border-color:#edc5c5} +.payment-values .pay-deposit-required dt,.payment-values .pay-deposit-required dd,.customer-accounts .payment-values .pay-deposit-required dt,.customer-accounts .payment-values .pay-deposit-required dd{color:#9f1f1f;font-weight:700} +.pay-deposit-warning,.customer-accounts .pay-details>p.pay-deposit-warning{padding:12px 14px;border:1px solid #edc5c5;border-radius:12px;background:#fff8f8;color:#9f1f1f;font-size:14px;line-height:1.9} +.customer-receipt-form #selected-payment .pay-deposit-warning{margin-top:12px;color:#9f1f1f;font-size:14px}.customer-receipt-form #id_deposit_id.pay-deposit-emphasis{color:#9f1f1f;border-color:#d39090;font-weight:700} +.pay-link-message{max-width:620px;margin:48px auto}.pay-link-message .pay-card{padding:32px;border-radius:23px}.pay-link-message h1{margin:20px 0 12px;font-size:24px}.pay-link-message p{color:#526c86;font-size:16px;line-height:1.9;margin-bottom:24px}.pay-link-message .pay-actions{gap:12px;flex-wrap:wrap} +@media(max-width:639px){.pay-link-message{margin:24px auto}.pay-link-message .pay-card{padding:24px 18px}.pay-link-status{padding:12px 14px;margin-bottom:18px}.pay-link-session-label{display:none}.pay-link-message .pay-button{width:100%}} diff --git a/static/js/chaharsu/bank-selection.js b/static/js/chaharsu/bank-selection.js new file mode 100644 index 0000000..132adc9 --- /dev/null +++ b/static/js/chaharsu/bank-selection.js @@ -0,0 +1,25 @@ +/* Selection changes presentation only; permissions and updates remain server-side. */ +(() => { + 'use strict'; + const form = document.querySelector('[data-bank-selection]'); + if (!form) return; + const boxes = Array.from(form.querySelectorAll('input[name="accounts"]')); + const all = form.querySelector('[data-select-all]'); + const count = form.querySelector('[data-selection-count]'); + const submit = form.querySelector('[data-bulk-submit]'); + if (!all || !count || !submit) return; + const update = () => { + const selected = boxes.filter(box => box.checked).length; + all.checked = selected > 0 && selected === boxes.length; + all.indeterminate = selected > 0 && selected < boxes.length; + count.textContent = selected ? `${selected.toLocaleString('fa-IR')} حساب انتخاب شده` : 'حساب‌های موردنظر را انتخاب کنید.'; + submit.disabled = selected === 0; + }; + all.addEventListener('change', () => { + boxes.forEach(box => { box.checked = all.checked; }); + update(); + }); + boxes.forEach(box => box.addEventListener('change', update)); + window.addEventListener('pageshow', update); + update(); +})(); diff --git a/static/js/chaharsu/customer-picker.js b/static/js/chaharsu/customer-picker.js new file mode 100644 index 0000000..06d5c2a --- /dev/null +++ b/static/js/chaharsu/customer-picker.js @@ -0,0 +1,94 @@ +/* Local, server-filtered search. Explicit choice only; no customer data in storage. */ +(() => { + 'use strict'; + const root = document.querySelector('[data-payment-customer-picker]'); + if (!root) return; + const search = root.querySelector('input[type=search]'); + const value = root.querySelector('#id_customer'); + const results = root.querySelector('[data-customer-results]'); + const selected = root.querySelector('[data-selected-customer]'); + const status = root.querySelector('[role=status]'); + let timer, controller, version = 0; + const node = (tag, text, className) => { + const element = document.createElement(tag); + if (text !== undefined) element.textContent = text; + if (className) element.className = className; + return element; + }; + const history = customer => { + const details = node('details', undefined, 'pay-customer-history'); + details.append(node('summary', `سوابق قابل مشاهده · ${customer.ticket_count.toLocaleString('fa-IR')} تیکت`)); + if (!customer.history.length) details.append(node('p', 'سابقه تیکت قابل مشاهده‌ای وجود ندارد.')); + customer.history.forEach(ticket => { + const link = node('a', `${ticket.number} · ${ticket.subject}`); + link.href = ticket.url; link.target = '_blank'; link.rel = 'noopener'; + const row = node('div'); row.append(link, node('small', ticket.status)); details.append(row); + }); + return details; + }; + const identity = customer => { + const container = node('div', undefined, 'pay-customer-identity'); + container.append(node('strong', customer.name), node('bdi', customer.mobile)); + if (customer.organization) container.append(node('span', customer.organization)); + container.append(node('small', customer.location || 'موقعیت ثبت نشده')); + return container; + }; + const choose = (customer, focus = true) => { + clearTimeout(timer); version++; controller?.abort(); + value.value = customer.value; value.dispatchEvent(new Event('change', {bubbles:true})); + search.value = ''; results.hidden = true; results.replaceChildren(); + selected.replaceChildren(node('span', 'مشتری انتخاب‌شده', 'pay-selected-label'), identity(customer), history(customer)); + const change = node('button', 'تغییر مشتری', 'pay-button secondary'); change.type = 'button'; + change.addEventListener('click', () => { + value.value = ''; value.dispatchEvent(new Event('change', {bubbles:true})); selected.hidden = true; search.focus(); + status.textContent = 'مشتری جدید را جست‌وجو و انتخاب کنید.'; + }); + selected.append(change); selected.hidden = false; + status.textContent = 'قبل از صدور، نام و شماره تماس مشتری انتخاب‌شده را بررسی کنید.'; + if (focus) change.focus({preventScroll:true}); + }; + const lookup = async () => { + const query = search.value.trim(), current = ++version; + controller?.abort(); controller = new AbortController(); + if (query.length < 2) { results.hidden = true; status.textContent = 'حداقل دو حرف یا رقم وارد کنید.'; return; } + status.textContent = 'در حال جست‌وجو…'; + try { + const response = await fetch(`${root.dataset.searchUrl}?q=${encodeURIComponent(query)}`, { + credentials: 'same-origin', cache: 'no-store', signal: controller.signal + }); + if (!response.ok) throw new Error('lookup'); + const data = await response.json(); + if (current !== version) return; + results.replaceChildren(); + data.results.forEach(customer => { + const card = node('article', undefined, 'pay-customer-result'); + const button = node('button', 'انتخاب این مشتری', 'pay-button secondary'); button.type = 'button'; + button.addEventListener('click', () => choose(customer)); + card.append(identity(customer), history(customer), button); results.append(card); + }); + results.hidden = !data.results.length; + status.textContent = data.results.length ? `${data.results.length.toLocaleString('fa-IR')} نتیجه${data.has_more ? '؛ برای نتایج دقیق‌تر نام یا شماره کامل‌تری وارد کنید.' : '؛ مشتری موردنظر را انتخاب کنید.'}` : 'مشتری فعالی پیدا نشد؛ نام یا شماره تماس را بررسی کنید.'; + } catch (error) { + if (error.name === 'AbortError' || current !== version) return; + results.hidden = true; + status.textContent = 'جست‌وجو انجام نشد؛ اتصال را بررسی کنید و دوباره جست‌وجو کنید.'; + } + }; + search.addEventListener('input', () => { + clearTimeout(timer); version++; controller?.abort(); + value.value = ''; value.dispatchEvent(new Event('change', {bubbles:true})); selected.hidden = true; results.hidden = true; + if (search.value.trim().length < 2) { status.textContent = 'حداقل دو حرف یا رقم وارد کنید.'; return; } + timer = setTimeout(lookup, 350); + }); + search.addEventListener('keydown', event => { + if (event.key === 'Enter') { event.preventDefault(); clearTimeout(timer); lookup(); } + }); + root.closest('form').addEventListener('submit', event => { + if (!value.value) { + event.preventDefault(); status.textContent = 'مشتری را از نتایج جست‌وجو انتخاب کنید.'; + search.focus(); search.scrollIntoView({block:'center'}); + } + }); + const initial = JSON.parse(document.querySelector('#payment-selected-customer').textContent); + if (initial && String(initial.value) === value.value) choose(initial, false); +})(); diff --git a/static/js/chaharsu/customer-receipt.js b/static/js/chaharsu/customer-receipt.js new file mode 100644 index 0000000..b1d06ff --- /dev/null +++ b/static/js/chaharsu/customer-receipt.js @@ -0,0 +1,44 @@ +/* Customer-only presentation. Financial and image validation remains in receipt-form.js. + Object URLs preview local files without uploading or persisting them. */ +(() => { + const form = document.querySelector('.customer-receipt-form'); + if (!form) return; + const input = form.elements.namedItem('receipt_image'); + const preview = document.querySelector('#receipt-file-preview'); + const selection = document.querySelector('#receipt-file-selection'); + const status = document.querySelector('#receipt-file-status'); + let previewUrl = null; + function releasePreview() { + if (previewUrl) URL.revokeObjectURL(previewUrl); + previewUrl = null; + preview.removeAttribute('src'); + preview.hidden = true; + } + function updateFile() { + releasePreview(); + const file = input.files[0]; + selection.hidden = !file; + status.textContent = file ? file.name + ' · ' + new Intl.NumberFormat('fa-IR', {maximumFractionDigits: 1}).format(file.size / 1024) + ' کیلوبایت' : ''; + if (file && /^image\/(jpeg|png|gif|webp)$/.test(file.type)) { + previewUrl = URL.createObjectURL(file); + preview.src = previewUrl; + preview.hidden = false; + } + } + input.addEventListener('change', updateFile); + document.querySelector('#receipt-clear-file').addEventListener('click', () => { + input.value = ''; + input.dispatchEvent(new Event('change', {bubbles: true})); + input.focus(); + }); + window.addEventListener('pagehide', releasePreview); + const origin = form.elements.namedItem('origin_bank'); + const other = form.elements.namedItem('origin_other'); + function updateOrigin() { + const visible = origin.selectedOptions[0]?.textContent.trim() === 'سایر'; + other.closest('[data-field]').hidden = !visible; + other.disabled = !visible; + } + origin.addEventListener('change', updateOrigin); + updateOrigin(); +})(); diff --git a/static/js/chaharsu/deposit-identifier.js b/static/js/chaharsu/deposit-identifier.js new file mode 100644 index 0000000..f928f1f --- /dev/null +++ b/static/js/chaharsu/deposit-identifier.js @@ -0,0 +1,65 @@ +/* Server calculates the identifier from the selected customer's stored mobile. + Manual values remain editable. Changing customer clears an unchanged auto value. */ +(() => { + const button = document.querySelector('#generate-deposit-identifier'); + if (!button) return; + const form = button.closest('form'); + const customer = form.elements.namedItem('customer'); + const input = form.elements.namedItem('deposit_id'); + const status = document.querySelector('#identifier-generation-status'); + const replacement = document.querySelector('#identifier-replacement'); + const candidate = document.querySelector('#identifier-candidate'); + const originalLabel = button.innerHTML; + let activeCustomer = customer.value, generated = '', pending = '', controller = null; + function customerChanged() { + if (activeCustomer !== customer.value) { + controller?.abort(); controller = null; + if (generated && input.value === generated) input.value = ''; + generated = ''; pending = ''; replacement.hidden = true; + activeCustomer = customer.value; + } + button.disabled = !customer.value; + button.innerHTML = originalLabel; + status.textContent = customer.value ? 'شناسه بر اساس شماره موبایل مشتری انتخاب‌شده تولید می‌شود.' : 'ابتدا مشتری را انتخاب کنید؛ شناسه بر اساس شماره موبایل او تولید می‌شود.'; + } + function applyIdentifier(value) { + input.value = value; generated = value; pending = ''; + replacement.hidden = true; + input.dispatchEvent(new Event('input', {bubbles:true})); + input.dispatchEvent(new Event('change', {bubbles:true})); + input.focus(); + status.textContent = 'شناسه تولید و در فیلد وارد شد؛ قبل از صدور اعلامیه آن را بررسی کنید.'; + } + customer.addEventListener('change', customerChanged); + button.addEventListener('click', async () => { + if (!customer.value) return; + const selectedCustomer = customer.value; + const request = new AbortController(); controller = request; + button.disabled = true; button.textContent = 'در حال تولید…'; + replacement.hidden = true; + status.textContent = 'در حال دریافت شناسه از سیستم…'; + let failureMessage = 'شناسه دریافت نشد؛ اتصال را بررسی کنید و دوباره تلاش کنید.'; + try { + const response = await fetch(button.dataset.url, { + method: 'POST', credentials: 'same-origin', cache: 'no-store', signal: request.signal, + headers: {'X-CSRFToken': form.elements.namedItem('csrfmiddlewaretoken').value}, + body: new URLSearchParams({customer: selectedCustomer}) + }); + const data = await response.json(); + if (request !== controller || customer.value !== selectedCustomer) return; + if (!response.ok) {failureMessage = data.error || 'شناسه تولید نشد؛ دوباره تلاش کنید.'; throw new Error('generation');} + if (data.customer !== selectedCustomer || !/^[0-9]{13}$/.test(data.identifier)) {failureMessage = 'پاسخ سیستم معتبر نیست؛ دوباره تلاش کنید.'; throw new Error('generation');} + if (input.value.trim() && input.value !== data.identifier) { + pending = data.identifier; candidate.textContent = pending; replacement.hidden = false; + status.textContent = 'شناسه آماده است؛ برای جایگزینی مقدار فعلی، دکمه جایگزینی را بزنید.'; + } else applyIdentifier(data.identifier); + } catch (error) { + if (error.name !== 'AbortError' && request === controller) status.textContent = failureMessage; + } finally { + if (request === controller) {controller = null; button.disabled = !customer.value; button.innerHTML = originalLabel;} + } + }); + document.querySelector('#apply-deposit-identifier').addEventListener('click', () => {if (pending) applyIdentifier(pending);}); + document.querySelector('#cancel-deposit-identifier').addEventListener('click', () => {pending = ''; replacement.hidden = true; status.textContent = 'شناسه فعلی حفظ شد.'; button.focus();}); + customerChanged(); +})(); diff --git a/static/js/chaharsu/payments.js b/static/js/chaharsu/payments.js new file mode 100644 index 0000000..f51d452 --- /dev/null +++ b/static/js/chaharsu/payments.js @@ -0,0 +1,20 @@ +/* All assets and UI behavior are local. No CDN or financial data in persistent browser storage. */ +(() => { +'use strict'; +let toast; +function status(text){if(!toast){toast=document.createElement('div');toast.className='pay-copy-status';toast.setAttribute('role','status');document.body.append(toast);}toast.textContent=text;toast.hidden=false;setTimeout(()=>{toast.hidden=true;},3000);} +async function copy(text){ + try{await navigator.clipboard.writeText(text);status('کپی شد');} + catch{ + const dialog=document.createElement('dialog');dialog.className='pay-manual-copy';dialog.setAttribute('aria-label','کپی اطلاعات'); + const title=document.createElement('h2');title.textContent='متن را انتخاب و کپی کنید';const area=document.createElement('textarea');area.readOnly=true;area.value=text; + const select=document.createElement('button');select.className='pay-button';select.textContent='انتخاب متن';select.onclick=()=>{area.focus();area.select();}; + const close=document.createElement('button');close.className='pay-button secondary';close.textContent='بستن';close.onclick=()=>dialog.close();dialog.append(title,area,select,close);document.body.append(dialog);dialog.addEventListener('close',()=>dialog.remove());dialog.showModal();area.focus();area.select(); + } +} +document.querySelectorAll('[data-copy]').forEach(button=>button.addEventListener('click',()=>copy(button.dataset.copy))); +const identifiers=document.querySelector('#identifier-accounts'),account=document.querySelector('#id_account'),deposit=document.querySelector('#id_deposit_id'); +if(identifiers && account && deposit){const ids=JSON.parse(identifiers.textContent).map(String);function update(){deposit.required=ids.includes(account.value);document.querySelector('#identifier-help').textContent=deposit.required?'این حساب شناسه‌دار است؛ شناسه واریز مشتری الزامی است.':'شناسه واریز در صورت اعلام بانک وارد شود.';}account.addEventListener('change',update);update();} +const badge=document.querySelector('[data-payment-count]'); +if(badge){const update=async()=>{if(document.hidden)return;try{const response=await fetch(badge.dataset.countUrl,{credentials:'same-origin',cache:'no-store'});if(!response.ok)return;const data=await response.json();document.querySelectorAll('[data-payment-count]').forEach(node=>{node.textContent=String(data.pending_receipts);});}catch{}};document.addEventListener('visibilitychange',update);setInterval(update,60000);} +})(); diff --git a/static/js/chaharsu/receipt-amount.js b/static/js/chaharsu/receipt-amount.js new file mode 100644 index 0000000..647ef14 --- /dev/null +++ b/static/js/chaharsu/receipt-amount.js @@ -0,0 +1,78 @@ +/* Local amount presentation: never convert rials to tomans for the server. + Exact BigInt arithmetic; preserve fractional toman (one rial = one tenth toman). + Shared with notice amounts; visible grouping does not replace server validation. */ +(() => { +'use strict'; +const input=document.querySelector('#id_paid_amount, #id_amount');if(!input)return; +input.inputMode='numeric'; +const words=document.querySelector('#amount-words'); +const small=['صفر','یک','دو','سه','چهار','پنج','شش','هفت','هشت','نه','ده','یازده','دوازده','سیزده','چهارده','پانزده','شانزده','هفده','هجده','نوزده']; +const tens=['','','بیست','سی','چهل','پنجاه','شصت','هفتاد','هشتاد','نود']; +const hundreds=['','صد','دویست','سیصد','چهارصد','پانصد','ششصد','هفتصد','هشتصد','نهصد']; +const scales=['','هزار','میلیون','میلیارد','تریلیون','کوادریلیون']; +const ascii=value=>value.replace(/[۰-۹]/g,digit=>String(digit.charCodeAt(0)-1776)).replace(/[٠-٩]/g,digit=>String(digit.charCodeAt(0)-1632)); +const digitCount=value=>(ascii(value).match(/\d/g) || []).length; +const separators=/[,٬ \u00a0]/g; +const grouped=value=>value.replace(/\B(?=(\d{3})+(?!\d))/g,'٬').replace(/\d/g,digit=>'۰۱۲۳۴۵۶۷۸۹'[digit]); +function threeDigits(value){ + const parts=[]; + if(value>=100)parts.push(hundreds[Math.floor(value/100)]); + const rest=value%100; + if(rest && rest<20)parts.push(small[rest]); + if(rest>=20){parts.push(tens[Math.floor(rest/10)]);if(rest%10)parts.push(small[rest%10]);} + return parts.join(' و '); +} +function numberWords(value){ + if(value===0n)return 'صفر'; + const parts=[];let index=0; + while(value>0n){ + const group=Number(value%1000n); + if(group){const text=group===1 && index===1?'هزار':threeDigits(group)+(scales[index]?' '+scales[index]:'');parts.unshift(text);} + value/=1000n;index++; + } + return parts.join(' و '); +} +function tomanWords(rials){ + const whole=rials/10n,fraction=rials%10n; + if(!fraction)return numberWords(whole)+' تومان'; + return (whole?numberWords(whole)+' و ':'')+numberWords(fraction)+' دهم تومان'; +} +function caretAfterDigits(value,count){ + if(!count)return 0; + for(let index=0;index16 || BigInt(digits)>BigInt(Number.MAX_SAFE_INTEGER)){if(words)words.textContent='';return;} + const leading=normalized.replace(separators,'').length-digits.length; + const left=Math.max(0,digitCount(raw.slice(0,start))-leading),right=Math.max(0,digitCount(raw.slice(0,end))-leading); + input.value=grouped(digits); + input.setSelectionRange(caretAfterDigits(input.value,left),caretAfterDigits(input.value,right)); + if(words)words.textContent='معادل: '+tomanWords(BigInt(digits)); +} +input.addEventListener('input',formatAmount); +input.addEventListener('compositionend',()=>formatAmount()); +input.addEventListener('beforeinput',event=>{ + const start=input.selectionStart,end=input.selectionEnd; + if(start!==end || event.isComposing)return; + const backward=event.inputType==='deleteContentBackward',forward=event.inputType==='deleteContentForward'; + const adjacent=backward?start-1:start; + if((!backward && !forward) || !/[٬,]/.test(input.value[adjacent] || ''))return; + // Deleting a separator removes the adjacent digit, so grouping cannot trap the caret. + const target=backward?adjacent-1:adjacent+1; + const count=digitCount(input.value.slice(0,target)); + const raw=input.value.slice(0,target)+input.value.slice(target+1); + event.preventDefault();input.value=ascii(raw).replace(separators,''); + input.setSelectionRange(count,count);input.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:event.inputType})); +}); +input.addEventListener('blur',()=>formatAmount()); +formatAmount(); +})(); diff --git a/static/js/chaharsu/receipt-form.js b/static/js/chaharsu/receipt-form.js new file mode 100644 index 0000000..019b851 --- /dev/null +++ b/static/js/chaharsu/receipt-form.js @@ -0,0 +1,70 @@ +/* Server remains authoritative. Display Jalali, submit an ASCII Gregorian date-only. + No dates, bank details or receipts are placed in localStorage/service-worker caches. */ +(() => { +'use strict'; +const form=document.querySelector('#receipt-form');if(!form)return; +const q=name=>form.elements.namedItem(name),button=document.querySelector('#submit-receipt'); +const notices=JSON.parse(document.querySelector('#notice-data').textContent); +const day=q('paid_day'),month=q('paid_month'),year=q('paid_year'); +const digits=value=>String(value).replace(/[۰-۹]/g,x=>String(x.charCodeAt(0)-1776)).replace(/[٠-٩]/g,x=>String(x.charCodeAt(0)-1632)); +const formatter=new Intl.DateTimeFormat('en-US',{calendar:'persian',numberingSystem:'latn',timeZone:'Asia/Tehran',year:'numeric',month:'numeric',day:'numeric'}); +const parts=date=>Object.fromEntries(formatter.formatToParts(date).map(p=>[p.type,p.value])); +function newYear(y){for(let d=18;d<=24;d++){const date=new Date(Date.UTC(y+621,2,d,12)),p=parts(date);if(Number(p.year)===y && Number(p.month)===1 && Number(p.day)===1)return date;}throw Error('تاریخ معتبر نیست.');} +function dateValue(){ + const output=document.querySelector('#paid-date-iso');output.value=''; + if(!day.value || !month.value || !year.value)return ''; + try{const y=Number(year.value),m=Number(month.value),d=Number(day.value),offset=m<=6?(m-1)*31:186+(m-7)*30;const date=new Date(newYear(y).getTime()+(offset+d-1)*86400000),p=parts(date);if(Number(p.year)!==y || Number(p.month)!==m || Number(p.day)!==d)return '';output.value=date.toISOString().slice(0,10);return output.value;}catch{return '';} +} +function dateUpdate(){const iso=dateValue();document.querySelector('#date-preview').textContent=iso?'تاریخ انتخاب‌شده: '+day.options[day.selectedIndex].text+' '+month.options[month.selectedIndex].text+' '+year.value:'روز، ماه و سال پرداخت را کامل انتخاب کنید.';} +function monthUpdate(){ + let length=31; + if(month.value && year.value){const m=Number(month.value),y=Number(year.value);length=m<=6?31:m<=11?30:(newYear(y+1)-newYear(y))/86400000===366?30:29;} + [...day.options].forEach(option=>{option.disabled=Number(option.value)>length;});if(Number(day.value)>length)day.value='';dateUpdate(); +} +[month,year].forEach(control=>control.addEventListener('change',monthUpdate));day.addEventListener('change',dateUpdate); +document.querySelector('#date-today').addEventListener('click',()=>{const p=parts(new Date());year.value=p.year;month.value=p.month;monthUpdate();day.value=p.day;dateUpdate();day.focus();});monthUpdate(); +function sync(){ + const linked=q('receipt_mode').value==='linked';const select=q('notice_id');form.querySelector('[data-field=notice_id]').hidden=!linked;select.disabled=!linked;select.required=linked; + if(!linked)select.value=''; + const notice=linked?notices.find(n=>n.id===select.value):null;const summary=document.querySelector('#selected-payment');summary.hidden=!notice; + const summaryText=notice?'مبلغ اعلام‌شده: '+new Intl.NumberFormat('fa-IR').format(BigInt(notice.amount))+' ریال · شناسه واریز: '+(notice.deposit || 'اعلام نشده')+(notice.invoice?' · فاکتور: '+notice.invoice:''):''; + const title=summary.querySelector('[data-selected-notice-title]'); + if(title){title.textContent=notice?notice.subject:'';summary.querySelector('[data-selected-notice-summary]').textContent=summaryText;summary.querySelector('[data-selected-notice-link]').href=notice?notice.url:'#';}else summary.textContent=summaryText; + const finalSummary=document.querySelector('#receipt-summary');if(finalSummary)finalSummary.textContent=notice?'این فیش برای «'+notice.subject+'» ارسال می‌شود.':linked?'اعلامیه مرتبط را انتخاب کنید.':'این فیش به صورت واریز مستقل برای بررسی ارسال می‌شود.'; + const depositWarning=summary.querySelector('[data-selected-deposit-warning]'); + depositWarning.hidden=!notice?.deposit; + q('deposit_id').classList.toggle('pay-deposit-emphasis',Boolean(notice?.deposit)); + q('deposit_id').required=false; +} +form.querySelectorAll('[name=receipt_mode]').forEach(control=>control.addEventListener('change',sync));q('notice_id').addEventListener('change',()=>{const notice=notices.find(n=>n.id===q('notice_id').value);if(notice){q('destination_account').value=String(notice.account);q('deposit_id').value=notice.deposit;}sync();});q('destination_account').addEventListener('change',sync);sync(); +const fields=[...form.querySelectorAll('[data-field]')]; +fields.forEach(field=>{const controls=field.querySelectorAll('input,select,textarea');controls.forEach(control=>{control.setAttribute('aria-describedby','error-'+field.dataset.field);if(field.querySelector('.field-error').textContent.trim())control.setAttribute('aria-invalid','true');});}); +let busy=false; +function showErrors(errors){ + fields.forEach(field=>{const name=field.dataset.field,message=errors.get(name)||'',error=field.querySelector('.field-error');error.textContent=message;error.hidden=!message;field.querySelectorAll('input,select,textarea').forEach(control=>control.setAttribute('aria-invalid',String(Boolean(message))));}); + const summary=document.querySelector('#receipt-errors'),list=summary.querySelector('ul');list.replaceChildren();errors.forEach((message,name)=>{const item=document.createElement('li'),link=document.createElement('a');link.href='#id_'+name;link.textContent=message;link.onclick=event=>{event.preventDefault();q(name).focus();q(name).scrollIntoView({block:'center'});};item.append(link);list.append(item);});summary.hidden=!errors.size; + if(errors.size){const first=q(errors.keys().next().value);first.focus();first.scrollIntoView({block:'center'});} +} +async function validateImage(file){ + if(!file)return 'تصویر فیش واریز را انتخاب کنید.'; + if(!file.size || file.size>Number(form.dataset.maxBytes))return 'حجم تصویر بیش از حد مجاز است یا تصویر خالی است.'; + const extension=file.name.split('.').pop().toLowerCase();if(!['jpg','jpeg','png','gif','webp'].includes(extension))return 'فقط تصویر JPG، PNG، GIF یا WEBP انتخاب کنید.'; + const readable=await new Promise(resolve=>{const picture=new Image(),url=URL.createObjectURL(file);picture.onload=()=>{URL.revokeObjectURL(url);resolve(picture.naturalWidth*picture.naturalHeight<=25000000);};picture.onerror=()=>{URL.revokeObjectURL(url);resolve(false);};picture.src=url;});return readable?'':'تصویر باز نمی‌شود یا ابعاد آن بیش از حد مجاز است.'; +} +let revision=0;form.addEventListener('input',()=>revision++);form.addEventListener('change',()=>revision++); +form.addEventListener('submit',async event=>{ + event.preventDefault();if(busy)return;busy=true;button.disabled=true;const version=revision,errors=new Map(); + try{ + const value=digits(q('paid_amount').value).trim();if(!/^\d+$/.test(value) && !/^\d{1,3}(?:[,٬ ]\d{3})+$/.test(value))errors.set('paid_amount','مبلغ را به ریال و با عدد صحیح وارد کنید.');else{const amount=BigInt(value.replace(/[,٬ ]/g,''));if(amount<=0n || amount>9007199254740991n)errors.set('paid_amount','مبلغ باید مثبت و در محدوده مجاز باشد.');} + if(q('receipt_mode').value==='linked' && !q('notice_id').value)errors.set('notice_id','اعلامیه را انتخاب کنید یا واریز مستقل را بزنید.'); + if(!q('destination_account').value)errors.set('destination_account','حساب مقصد را انتخاب کنید.'); + const iso=dateValue();if(!iso)errors.set('paid_day','تاریخ را کامل و معتبر انتخاب کنید؛ روز، ماه و سال لازم‌اند.'); + else{const now=Object.fromEntries(new Intl.DateTimeFormat('en-US',{timeZone:'Asia/Tehran',year:'numeric',month:'2-digit',day:'2-digit'}).formatToParts(new Date()).map(p=>[p.type,p.value]));if(iso>[now.year,now.month,now.day].join('-'))errors.set('paid_day','تاریخ واریز نمی‌تواند بعد از امروز باشد.');} + const imageError=await validateImage(q('receipt_image').files[0]);if(imageError)errors.set('receipt_image',imageError); + if(version!==revision){document.querySelector('#form-status').textContent='اطلاعات تغییر کرده است؛ دوباره ارسال را بزنید.';return;} + showErrors(errors);if(errors.size)return; + button.textContent='در حال ثبت…';HTMLFormElement.prototype.submit.call(form);return; + }finally{busy=false;if(button.textContent!=='در حال ثبت…')button.disabled=false;} +}); +if(document.querySelector('#receipt-errors').hidden===false){const field=form.querySelector('[aria-invalid=true]');if(field){field.focus();field.scrollIntoView({block:'center'});}} +})(); diff --git a/static/js/magic-link-confirmation.js b/static/js/magic-link-confirmation.js new file mode 100644 index 0000000..e980c56 --- /dev/null +++ b/static/js/magic-link-confirmation.js @@ -0,0 +1,21 @@ +const confirmationForm = document.querySelector('[data-magic-confirmation]'); +if (confirmationForm) { + confirmationForm.addEventListener('submit', (event) => { + const button = confirmationForm.querySelector('button[type="submit"]'); + if (button.disabled) { + event.preventDefault(); + return; + } + button.disabled = true; + button.setAttribute('aria-busy', 'true'); + button.textContent = 'در حال باز کردن تیکت…'; + confirmationForm.querySelector('[role="status"]').textContent = button.textContent; + }); + window.addEventListener('pageshow', () => { + const button = confirmationForm.querySelector('button[type="submit"]'); + button.disabled = false; + button.removeAttribute('aria-busy'); + button.textContent = 'خروج از حساب و مشاهده تیکت'; + confirmationForm.querySelector('[role="status"]').textContent = ''; + }); +} diff --git a/static/js/sms-template-tags.js b/static/js/sms-template-tags.js new file mode 100644 index 0000000..2bebe1b --- /dev/null +++ b/static/js/sms-template-tags.js @@ -0,0 +1,16 @@ +(() => { + const events = document.querySelector('#id_event_type'); + const tags = document.querySelector('#sms-allowed-tags'); + const data = document.querySelector('#sms-tag-map'); + if (!events || !tags || !data) return; + const mapping = JSON.parse(data.textContent); + function showAllowedTags() { + tags.replaceChildren(...(mapping[events.value] || []).map(tag => { + const code = document.createElement('code'); + code.textContent = '{' + tag + '}'; + return code; + })); + } + events.addEventListener('change', showAllowedTags); + showAllowedTags(); +})(); diff --git a/templates/accounts/magic_link_confirmation.html b/templates/accounts/magic_link_confirmation.html new file mode 100644 index 0000000..a954dd0 --- /dev/null +++ b/templates/accounts/magic_link_confirmation.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% load static %} +{% block title %}این لینک مربوط به حساب دیگری است | {{ site_branding.site_title }}{% endblock %} +{% block extra_head %} + + + + +{% endblock %} +{% block body %} +
+
{% include 'includes/auth_brand.html' with hide_copy=True %}
+
+ +

این لینک مربوط به حساب دیگری است

+

شما با حساب دیگری وارد سامانه شده‌اید. برای مشاهده تیکت مرتبط با این لینک، از حساب فعلی خارج شوید. پس از خروج، تیکت به‌صورت خودکار باز می‌شود و نیازی به ورود مجدد ندارید.

+
+ {% csrf_token %} + + + + انصراف از مشاهده این تیکت/پیام + +
+
+ {% include 'includes/auth_footer.html' %} +
+{% endblock %} diff --git a/templates/base.html b/templates/base.html index b10f6c8..e2f5cc1 100644 --- a/templates/base.html +++ b/templates/base.html @@ -13,8 +13,10 @@ +{% block extra_head %}{% endblock %} +{% block body %} {% if user.is_authenticated and user.account_type == 'STAFF' %}
{{ user.staff_profile.display_name|default:user.username|slice:':1' }}
{{ user.staff_profile.display_name|default:user.username }}{{ user.staff_profile.job_title|default:'کارشناس فروش' }}
{{ staff_sidebar_credit }}
@@ -40,7 +44,7 @@
{{ site_branding.staff_panel_label }}

{% if active_tab_title %}{{ active_tab_title }}{% else %}{{ site_branding.staff_brand_subtitle }}{% endif %}

{% csrf_token %}
{% else %} - +{% block customer_header %}{% endblock %} {% endif %}
{% for message in messages %}
{{ message }}
{% endfor %}{% block content %}{% endblock %}
@@ -48,7 +52,7 @@ {% if user.is_authenticated and user.account_type == 'STAFF' %}
- {% endblock %} {% elif user.is_authenticated %} - +{% if financial_page %}{% else %} {% endif %} {% endif %} {% block pwa_install_prompt %}{% endblock %} @@ -67,4 +73,5 @@ {% if user.is_authenticated and user.account_type == 'STAFF' %}{% endif %} {% block extra_scripts %}{% endblock %} {% block otp_script %}{% endblock %} +{% endblock %} diff --git a/templates/payments/_customer_identity.html b/templates/payments/_customer_identity.html new file mode 100644 index 0000000..777ebe6 --- /dev/null +++ b/templates/payments/_customer_identity.html @@ -0,0 +1,4 @@ +
+{% if customer.customer_profile.first_name or customer.customer_profile.last_name %}{{ customer.customer_profile.first_name }} {{ customer.customer_profile.last_name }}{% else %}{{ customer.get_full_name|default:'نام ثبت نشده' }}{% endif %} +{{ customer.local_mobile }}{% if customer.customer_profile.province %} · {{ customer.customer_profile.province }}{% endif %}{% if customer.customer_profile.city %} · {{ customer.customer_profile.city }}{% endif %} +
diff --git a/templates/payments/_customer_picker.html b/templates/payments/_customer_picker.html new file mode 100644 index 0000000..56205bd --- /dev/null +++ b/templates/payments/_customer_picker.html @@ -0,0 +1,12 @@ +
+ {{ form.customer }} + +
{% include 'payments/_icon.html' with name='search' %}
+

نام و شماره تماس را تطبیق دهید؛ هیچ نتیجه‌ای خودکار انتخاب نمی‌شود.

+ {% if form.customer.errors %}{% endif %} +

برای جست‌وجو حداقل دو حرف یا رقم وارد کنید.

+ + + {{ customer_selection|json_script:'payment-selected-customer' }} + +
diff --git a/templates/payments/_customer_receipt_form.html b/templates/payments/_customer_receipt_form.html new file mode 100644 index 0000000..f6baff8 --- /dev/null +++ b/templates/payments/_customer_receipt_form.html @@ -0,0 +1,39 @@ +
+ {% include 'payments/_link_status.html' %} + +
{% include 'payments/_icon.html' with name='receipt' %}

{% if staff_form %}ثبت فیش برای مشتری{% elif revision_version %}اصلاح فیش واریزی{% else %}ارسال فیش واریزی{% endif %}

فیش را مستقل یا برای یکی از واریزهای اعلام‌شده ارسال کنید.

{% if staff_form %}{% include 'payments/_customer_identity.html' with customer=customer %}{% endif %}
+
+ {% csrf_token %}{{ form.submission_key }}{% if revision_version %}{% endif %} + +
+

این فیش مربوط به کدام واریز است؟

+
نوع واریز +
{% for radio in form.receipt_mode %}
{% if radio.data.value == 'linked' %}با اعلامیه واریز کارشناس{% else %}بدون اعلامیه واریز{% endif %}
{% endfor %}
+

{% for error in form.receipt_mode.errors %}{{ error }}{% endfor %}

+
+

{% if staff_form %}اعلامیه مشتری را انتخاب کنید تا مبلغ و حساب مقصد قابل تطبیق باشد؛ ثبت واریز مستقل نیز امکان‌پذیر است.{% else %}برای ثبت دقیق‌تر، از کارشناس بخواهید اعلامیه واریز ایجاد کند؛ این کار توصیه می‌شود و اجباری نیست.{% endif %}

+ {% if staff_form %}{% include 'payments/_receipt_field.html' with field=form.notice_id hint='موضوع، مبلغ و شماره فاکتور را با فیش مشتری تطبیق دهید.' %}{% elif payment_link_access %}{% include 'payments/_receipt_field.html' with field=form.notice_id hint='این لینک به اعلامیه مربوط به خودش دسترسی دارد؛ ارسال واریز مستقل نیز ممکن است.' %}{% else %}{% include 'payments/_receipt_field.html' with field=form.notice_id hint='۱۰ واریز اخیرِ تسویه‌نشده؛ برای واریزهای قدیمی‌تر، از صفحه شماره حساب‌ها وارد شوید.' %}{% endif %} + +
+
+

اطلاعات فیش پرداخت

مبلغ، حساب مقصد، تاریخ کامل و تصویر فیش الزامی‌اند.

+
+ {% include 'payments/_receipt_field.html' with field=form.paid_amount hint='مبلغی که واقعاً پرداخت کرده‌اید؛ ممکن است با مبلغ اعلام‌شده متفاوت باشد.' %} + {% include 'payments/_receipt_field.html' with field=form.tracking_number hint='شماره درج‌شده روی فیش بانکی' %} + {% include 'payments/_receipt_field.html' with field=form.origin_bank %} + {% include 'payments/_receipt_field.html' with field=form.destination_account hint='نام حسابی را که وجه به آن واریز کرده‌اید انتخاب کنید.' %} + {% include 'payments/_receipt_field.html' with field=form.origin_other %} + {% include 'payments/_receipt_field.html' with field=form.deposit_id hint='در صورت تمایل، شناسه‌ای را که هنگام پرداخت وارد کرده‌اید بنویسید؛ ارسال فیش بدون تکمیل این فیلد نیز امکان‌پذیر است.' %} +
+
تاریخ واریز شمسی · الزامی
{% include 'payments/_receipt_field.html' with field=form.paid_day %}{% include 'payments/_receipt_field.html' with field=form.paid_month %}{% include 'payments/_receipt_field.html' with field=form.paid_year %}

تاریخ درج‌شده روی فیش را کامل انتخاب کنید.

+
{% include 'payments/_icon.html' with name='upload' %}{{ form.receipt_image }}

تصویر JPG، PNG، GIF یا WEBP؛ حداکثر {{ max_bytes|filesizeformat }}.

{% for error in form.receipt_image.errors %}{{ error }}{% endfor %}

+ {% include 'payments/_receipt_field.html' with field=form.description %} +

+
{% if staff_form %}انصراف و بازگشت{% elif payment_link_access.notice_id %}مشاهده اعلامیه واریز{% elif not payment_link_access %}مشاهده واریزهای اعلام‌شده {% include 'payments/_icon.html' with name='arrow' %}{% endif %}
+
+
+{% if not staff_form %} + +{% endif %} + {{ notice_data|json_script:'notice-data' }}{{ account_requirements|json_script:'account-requirements' }} +
diff --git a/templates/payments/_field.html b/templates/payments/_field.html new file mode 100644 index 0000000..dba9d78 --- /dev/null +++ b/templates/payments/_field.html @@ -0,0 +1,7 @@ +
+ + {{ field }} + {% if hint %}

{{ hint }}

{% endif %} + {% if field.help_text %}

{{ field.help_text }}

{% endif %} + {% if field.errors %}{% endif %} +
diff --git a/templates/payments/_icon.html b/templates/payments/_icon.html new file mode 100644 index 0000000..479a229 --- /dev/null +++ b/templates/payments/_icon.html @@ -0,0 +1,17 @@ +{# Local inline SVG paths: no icon font, CDN or external request. #} + diff --git a/templates/payments/_link_status.html b/templates/payments/_link_status.html new file mode 100644 index 0000000..3e2eb2b --- /dev/null +++ b/templates/payments/_link_status.html @@ -0,0 +1,2 @@ +{% load jalali %} +{% if payment_link_access %}{% endif %} diff --git a/templates/payments/_linked_receipts.html b/templates/payments/_linked_receipts.html new file mode 100644 index 0000000..368106c --- /dev/null +++ b/templates/payments/_linked_receipts.html @@ -0,0 +1,8 @@ +{% load jalali humanize %} +

فیش‌های این واریز

+{% for linked_receipt in notice.related_receipts %}
+ +

{{ linked_receipt.current_amount|intcomma:False }} ریال · واریز {{ linked_receipt.current_paid_date|jalali_date }}

+{% for vote in linked_receipt.review_decisions %}

{{ vote.get_action_display }} · {{ vote.actor_name }}{% if vote.reason %}{{ vote.reason|truncatechars:160 }}{% endif %}

{% empty %}

فیش در انتظار بررسی است.

{% endfor %} +جزئیات فیش و سوابق بررسی +
{% empty %}

هنوز فیشی برای این واریز ارسال نکرده‌اید.

{% endfor %}
diff --git a/templates/payments/_notice.html b/templates/payments/_notice.html new file mode 100644 index 0000000..3af4395 --- /dev/null +++ b/templates/payments/_notice.html @@ -0,0 +1,19 @@ +{% load jalali humanize %} +{% if not staff_notice and not notice_page %}
{% endif %} +
+{{ notice.subject }}{{ notice.amount|intcomma:False }} ریال{{ notice.number }} · {{ notice.created_at|jalali_date }}{{ notice.settlement_label }}{% if notice.cancelled_at %} · لغو شده{% endif %}{% include 'payments/_icon.html' with name='chevron-down' %} +

{% if notice.account_snapshot.name %}{{ notice.account_snapshot.name }} · {% endif %}{{ notice.account_snapshot.bank }} · {{ notice.account_snapshot.holder }}

+
+
مبلغ (ریال)
{{ notice.amount|intcomma:False }}
+{% if notice.account_snapshot.account_number %}
شماره حساب
{{ notice.account_snapshot.account_number }}
{% endif %} +{% if notice.account_snapshot.iban %}
شبا
{{ notice.account_snapshot.iban }}
{% endif %} +{% if notice.account_snapshot.card_number %}
شماره کارت
{{ notice.account_snapshot.card_number }}
{% endif %} +{% if notice.deposit_id %}
شناسه واریز · ضروری
{{ notice.deposit_id }}
{% endif %} +
+{% if notice.deposit_id %}

هنگام واریز، حتماً شناسه واریز ارائه‌شده را در فرم بانکی، موبایل‌بانک یا اینترنت‌بانک وارد کنید؛ واریز بدون این شناسه معتبر نخواهد بود.

{% endif %} + +

تأییدشده: {{ notice.approved_amount|intcomma:False }} ریال · باقی‌مانده: {{ notice.remaining_amount|intcomma:False }} ریال

اطلاعات تکمیلی و توضیحات

{% if notice.invoice_number %}

فاکتور: {{ notice.invoice_number }}

{% endif %}

{{ notice.description|default:'توضیح دیگری اعلام نشده است.'|linebreaksbr }}

+
{% if not notice.cancelled_at and not staff_notice and notice.remaining_amount > 0 %}ارسال فیش این واریز{% endif %}
+{% if not staff_notice %}{% include 'payments/_linked_receipts.html' %}{% endif %} +
+{% if not staff_notice and not notice_page %}جزئیات واریز و فیش‌ها {% include 'payments/_icon.html' with name='arrow' %}
{% endif %} diff --git a/templates/payments/_notice_shortcut.html b/templates/payments/_notice_shortcut.html new file mode 100644 index 0000000..0e4c7d3 --- /dev/null +++ b/templates/payments/_notice_shortcut.html @@ -0,0 +1 @@ +{% if payment_can_create and customer.is_active %}ایجاد اعلامیه واریز{% endif %} diff --git a/templates/payments/_pagination.html b/templates/payments/_pagination.html new file mode 100644 index 0000000..c40126f --- /dev/null +++ b/templates/payments/_pagination.html @@ -0,0 +1 @@ +{% if page_obj.paginator.num_pages > 1 %}{% endif %} diff --git a/templates/payments/_receipt_field.html b/templates/payments/_receipt_field.html new file mode 100644 index 0000000..bd529ff --- /dev/null +++ b/templates/payments/_receipt_field.html @@ -0,0 +1,6 @@ +
+ {{ field }} + {% if field.name == 'paid_amount' %}

{% endif %} + {% if hint %}

{{ hint }}

{% endif %} +

{% for error in field.errors %}{{ error }}{% endfor %}

+
diff --git a/templates/payments/_ticket_notices.html b/templates/payments/_ticket_notices.html new file mode 100644 index 0000000..74af829 --- /dev/null +++ b/templates/payments/_ticket_notices.html @@ -0,0 +1,20 @@ +{% load jalali humanize %} +{% if payment_can_create or customer_notices %} +
+ اعلامیه واریز + +
+{% endif %} diff --git a/templates/payments/banks.html b/templates/payments/banks.html new file mode 100644 index 0000000..c99e757 --- /dev/null +++ b/templates/payments/banks.html @@ -0,0 +1,45 @@ +{% extends 'payments/base.html' %} +{% load static %} +{% block title %}بانک‌ها و حساب‌های شرکت{% endblock %} +{% block content %} +
+
+
{% include 'payments/_icon.html' with name='bank' %}

بانک‌ها و حساب‌های شرکت

حساب‌های قابل انتخاب برای کارشناسان و مشتریان را مدیریت کنید.

+ {% include 'payments/_icon.html' with name='plus' %}ایجاد حساب +
+
+
+ + {% for flag in bank_flags %}{% endfor %} + +
+ {% if request.GET.q or request.GET.is_active or request.GET.available_to_staff or request.GET.available_to_customers %}{% endif %} +
+
+ {% csrf_token %} +

حساب‌های شرکت

{{ page_obj.paginator.count }} حساب
+ {% if page_obj %} +
حساب و بانکوضعیت کلیکارشناسانمشتریانمدیریت
+ {% for account in page_obj %} +
+ +
{% include 'payments/_icon.html' with name='bank' %}

{{ account.name }}

{{ account.bank.name }} · {{ account.holder }}

{% if account.requires_deposit_id %}شناسه واریز الزامی{% endif %}
+
وضعیت کلی{{ account.is_active|yesno:'فعال,غیرفعال' }}
+
کارشناسان{% if not account.is_active %}غیرفعال{% else %}{{ account.available_to_staff|yesno:'فعال,غیرفعال' }}{% endif %}
+
مشتریان{% if not account.is_active %}غیرفعال{% else %}{{ account.available_to_customers|yesno:'فعال,غیرفعال' }}{% endif %}
+ {% include 'payments/_icon.html' with name='edit' %}ویرایش +
+ {% endfor %} +
+
تغییر گروهی وضعیتحساب‌های موردنظر را انتخاب کنید.
+ + + +
+

حساب غیرفعال کلی در هیچ پنلی قابل انتخاب نیست؛ تنظیم هر پنل جداگانه حفظ می‌شود.

+ {% else %}
{% include 'payments/_icon.html' with name='bank' %}

{% if request.GET %}حسابی با این فیلترها پیدا نشد{% else %}هنوز حسابی تعریف نشده است{% endif %}

{% if request.GET %}فیلترها را تغییر دهید یا پاک کنید.{% else %}برای شروع، اولین حساب شرکت را ایجاد کنید.{% endif %}

{% endif %} +
+ {% include 'payments/_pagination.html' %} +
+{% endblock %} +{% block extra_scripts %}{{ block.super }}{% endblock %} diff --git a/templates/payments/base.html b/templates/payments/base.html new file mode 100644 index 0000000..9164a6b --- /dev/null +++ b/templates/payments/base.html @@ -0,0 +1,36 @@ +{% extends 'base.html' %}{% load static %} +{% block extra_head %}{% if payment_link_access %}{% endif %}{% endblock %} +{% block customer_header %} +
+
+ + لوگوی شرکت + + چهارسومرکز خدمات مشتریان + + +
+ {% if payment_link_access %}دسترسی موقت پرداخت{% else %} + {% include 'payments/_icon.html' with name='user' %}پروفایل من +
{% csrf_token %}
+ {% endif %} +
+
+
+{% endblock %} +{% block staff_mobile_navigation %} + +{% endblock %} +{% block footer %}
{% if payment_link_access %}{{ site_branding.developer_credit_text }}{% else %}{{ developer_credit }}{% endif %}
{% endblock %} +{% block extra_scripts %}{% endblock %} diff --git a/templates/payments/customer_notice_detail.html b/templates/payments/customer_notice_detail.html new file mode 100644 index 0000000..cbc3ba3 --- /dev/null +++ b/templates/payments/customer_notice_detail.html @@ -0,0 +1,9 @@ +{% extends 'payments/base.html' %} +{% block title %}جزئیات واریز {{ notice.number }}{% endblock %} +{% block content %}
+{% include 'payments/_link_status.html' %} +{% if not payment_link_access %}{% endif %} +
{% include 'payments/_icon.html' with name='bank' %}

جزئیات واریز

اطلاعات حساب، فیش‌های ارسالی و سوابق بررسی این واریز

+{% include 'payments/_notice.html' with notice_page=True %} + +
{% endblock %} diff --git a/templates/payments/desk.html b/templates/payments/desk.html new file mode 100644 index 0000000..cff1edf --- /dev/null +++ b/templates/payments/desk.html @@ -0,0 +1,59 @@ +{% extends 'payments/base.html' %} +{% load jalali humanize %} +{% block title %}اعلامیه‌ها و فیش‌های واریزی{% endblock %} +{% block content %} +
+
+
{% include 'payments/_icon.html' with name='receipt' %}

اعلامیه‌ها و فیش‌های واریزی

اعلام اطلاعات واریز به مشتریان و بررسی فیش‌های پرداخت

+ {% if can_create %}{% endif %} +
+ +
+ + {% if request.GET.mine %}{% endif %} +
+ + + {% if tab != 'notices' %}{% else %}{% endif %} + +
+
+
+ {% include 'payments/_icon.html' with name='filter' %}فیلترهای بیشتر +
+ {% if tab != 'notices' %}{% endif %} + + +
+
+ پاک کردن فیلترها +
+
+
+

{% if tab == 'notices' %}اعلامیه‌های واریز{% elif request.GET.mine %}فیش‌های نیازمند اقدام شما{% else %}فیش‌های واریزی{% endif %}

{{ page_obj.paginator.count }} مورد
+ {% for item in page_obj %} +
+ {% include 'payments/_icon.html' with name='receipt' %} +
+ {% if tab == 'notices' %}

{{ item.subject }}

{{ item.number }}{% if item.invoice_number %} · فاکتور {{ item.invoice_number }}{% endif %} + {% else %}

{{ item.number }}

{% if item.notice %}مرتبط با {{ item.notice.subject }}{% else %}واریز مستقل{% endif %}{% endif %} + {% include 'payments/_customer_identity.html' with customer=item.customer %} +
{% if tab == 'notices' %}ایجادکننده: {{ item.created_by.staff_profile.display_name|default:item.created_by.get_full_name|default:item.created_by.username }}{% for linked in item.related_receipts %}{% if linked.status == 'APPROVED' %}{% for vote in linked.review_decisions %}تأیید فیش {{ linked.number }}: {{ vote.actor_name }}{% endfor %}{% endif %}{% endfor %}{% else %}{% if item.notice %}ایجادکننده اعلامیه: {{ item.notice.created_by.staff_profile.display_name|default:item.notice.created_by.get_full_name|default:item.notice.created_by.username }}{% endif %}{% if item.status == 'APPROVED' %}{% for vote in item.review_decisions %}تأییدکننده: {{ vote.actor_name }}{% endfor %}{% endif %}{% endif %}
+
+
{% if tab == 'notices' %}{{ item.amount|intcomma:False }}{% else %}{{ item.current_amount|intcomma:False }}{% endif %} ریال
+
+ {% if tab == 'notices' %}{% if item.cancelled_at %}لغو شده{% else %}{{ item.settlement_label }}{% endif %}مشاهده اعلامیه{% include 'payments/_icon.html' with name='arrow' %} + {% else %}{{ item.get_status_display }}مشاهده و بررسی{% include 'payments/_icon.html' with name='arrow' %}{% endif %} +
+
+ {% empty %} +
{% include 'payments/_icon.html' with name='search' %}

موردی پیدا نشد

{% if request.GET.q or request.GET.status or request.GET.notice_state or request.GET.account or request.GET.kind or request.GET.from or request.GET.to %}فیلترها را تغییر دهید یا پاک کنید.{% elif request.GET.mine %}در حال حاضر فیشی برای اقدام شما وجود ندارد.{% elif tab == 'notices' %}اعلامیه‌های صادرشده برای مشتریان اینجا نمایش داده می‌شوند.{% else %}فیش‌های ارسالی مشتریان برای بررسی اینجا نمایش داده می‌شوند.{% endif %}

+ {% endfor %} +
+ {% include 'payments/_pagination.html' %} +
+{% endblock %} diff --git a/templates/payments/home.html b/templates/payments/home.html new file mode 100644 index 0000000..a081d85 --- /dev/null +++ b/templates/payments/home.html @@ -0,0 +1,25 @@ +{% extends 'payments/base.html' %} +{% block title %}چهارسو · مرکز خدمات مشتریان{% endblock %} +{% block content %} +
+

چهارسو

خدمت موردنیازتان را انتخاب کنید.

+
+{% endblock %} diff --git a/templates/payments/link_confirmation.html b/templates/payments/link_confirmation.html new file mode 100644 index 0000000..9b5b5fc --- /dev/null +++ b/templates/payments/link_confirmation.html @@ -0,0 +1,4 @@ +{% extends 'payments/base.html' %} +{% block title %}این لینک پرداخت مربوط به حساب دیگری است{% endblock %} +{% block extra_head %}{{ block.super }}{% endblock %} +{% block body %}
{{ site_branding.developer_credit_text }}
{% endblock %} diff --git a/templates/payments/link_unavailable.html b/templates/payments/link_unavailable.html new file mode 100644 index 0000000..ab76e56 --- /dev/null +++ b/templates/payments/link_unavailable.html @@ -0,0 +1,5 @@ +{% extends 'payments/base.html' %} +{% block title %}لینک پرداخت معتبر نیست{% endblock %} +{% block customer_header %}
لوگوی شرکتچهارسومرکز خدمات مشتریان
{% endblock %} +{% block content %}{% endblock %} +{% block footer %}
{{ site_branding.developer_credit_text }}
{% endblock %} diff --git a/templates/payments/notice_detail.html b/templates/payments/notice_detail.html new file mode 100644 index 0000000..d61a790 --- /dev/null +++ b/templates/payments/notice_detail.html @@ -0,0 +1,17 @@ +{% extends 'payments/base.html' %} +{% load jalali %} +{% block title %}اعلامیه {{ notice.number }}{% endblock %} +{% block content %} +
+ {% include 'payments/_icon.html' with name='chevron' %}اعلامیه‌های واریز +
{% include 'payments/_icon.html' with name='receipt' %}

اعلامیه {{ notice.number }}

مشتری: {{ notice.customer.local_mobile }} · {{ notice.created_at|jalali_datetime }}

{% include 'payments/_icon.html' with name='plus' %}ثبت فیش برای مشتری
+
+
{% include 'payments/_notice.html' with staff_notice=True %}
+ +
+
+{% endblock %} diff --git a/templates/payments/notices.html b/templates/payments/notices.html new file mode 100644 index 0000000..3670cda --- /dev/null +++ b/templates/payments/notices.html @@ -0,0 +1,9 @@ +{% extends 'payments/base.html' %}{% block title %}شماره حساب‌ها{% endblock %} +{% block content %}
+ +
{% include 'payments/_icon.html' with name='bank' %}

شماره حساب‌ها

اطلاعات هر واریز را ببینید، کپی کنید و فیش آن را ارسال کنید.

+

از کارشناس بخواهید شماره حساب و شناسه واریز مرتبط را برای شما ارسال کند.

+

واریزهای شما

{{ page_obj.paginator.count }} مورد
+ +{% for notice in page_obj %}{% include 'payments/_notice.html' %}{% empty %}
{% include 'payments/_icon.html' with name='bank' %}

{% if query %}واریزی پیدا نشد{% else %}هنوز اعلامیه‌ای ندارید{% endif %}

{% if query %}عبارت جست‌وجو را تغییر دهید.{% else %}پس از اعلام کارشناس، اطلاعات هر واریز اینجا نمایش داده می‌شود.{% endif %}

{% endfor %} +{% include 'payments/_pagination.html' %}
{% endblock %} diff --git a/templates/payments/receipt_detail.html b/templates/payments/receipt_detail.html new file mode 100644 index 0000000..ca22104 --- /dev/null +++ b/templates/payments/receipt_detail.html @@ -0,0 +1,29 @@ +{% extends 'payments/base.html' %} +{% load jalali humanize %} +{% block title %}فیش {{ receipt.number }}{% endblock %} +{% block content %} +
+ {% include 'payments/_link_status.html' %} + {% if not staff_detail %}{% endif %} + {% if staff_detail %}{% include 'payments/_icon.html' with name='chevron' %}فیش‌های واریزی{% endif %} +
{% include 'payments/_icon.html' with name='receipt' %}

فیش {{ receipt.number }}

{% if staff_detail %}{% include 'payments/_customer_identity.html' with customer=receipt.customer %}

نسخه {{ revision.version }}

{% endif %}
{{ receipt.get_status_display }}
+ {% if revision.destination_mismatch %}

حساب مقصد با اعلامیه متفاوت است؛ بررسی مغایرت لازم است.

{% endif %} + {% if duplicate_image %}

تصویر مشابه روی فیش دیگری ثبت شده است؛ پیش از تصمیم بررسی کنید.

{% endif %} +
+
+
مبلغ پرداخت‌شده

{{ revision.amount|intcomma:False }} ریال

تاریخ پرداخت: {{ revision.paid_date|jalali_date }}

+
حساب مقصد
{{ revision.account_snapshot.name }}
بانک مقصد
{{ revision.account_snapshot.bank }}
صاحب حساب
{{ revision.account_snapshot.holder }}
شناسه واریز
{{ revision.deposit_id|default:'اعلام نشده' }}
{% if revision.origin_bank %}
بانک مبدأ
{{ revision.origin_bank.name }}{% if revision.origin_other %} · {{ revision.origin_other }}{% endif %}
{% endif %}{% if revision.tracking_number %}
شماره پیگیری
{{ revision.tracking_number }}
{% endif %}
+ {% if staff_detail %}

اطلاعات مقصد ثبت‌شده

{% if revision.account_snapshot.account_number %}
شماره حساب مقصد ثبت‌شده
{{ revision.account_snapshot.account_number }}
{% endif %}{% if revision.account_snapshot.iban %}
شبای مقصد ثبت‌شده
{{ revision.account_snapshot.iban }}
{% endif %}{% if revision.account_snapshot.card_number %}
کارت مقصد ثبت‌شده
{{ revision.account_snapshot.card_number }}
{% endif %}

شماره‌های ثبت‌شده را با تصویر فیش تطبیق دهید.

{% endif %} +
{% if receipt.notice %}

اعلامیه مرتبط

{% if staff_detail %}{{ receipt.notice.number }}{% else %}{{ receipt.notice.number }}{% endif %} · مبلغ اعلام‌شده: {{ receipt.notice.amount|intcomma:False }} ریال{% if receipt.notice.invoice_number %} · فاکتور {{ receipt.notice.invoice_number }}{% endif %}

{% else %}

واریز مستقل

{% endif %}{% if revision.description %}

توضیحات پرداخت

{{ revision.description|linebreaksbr }}

{% endif %}
+
+

تصویر فیش واریز

دریافت تصویر
تصویر فیش واریز

برای مشاهده تصویر در اندازه اصلی، روی آن بزنید.

+
+ +
+
+{% endblock %} diff --git a/templates/payments/receipt_form.html b/templates/payments/receipt_form.html new file mode 100644 index 0000000..60f9f0d --- /dev/null +++ b/templates/payments/receipt_form.html @@ -0,0 +1,3 @@ +{% extends 'payments/base.html' %}{% load static %}{% block title %}{% if staff_form %}ثبت فیش برای مشتری{% else %}ارسال فیش واریزی{% endif %}{% endblock %} +{% block content %}{% include 'payments/_customer_receipt_form.html' %}{% endblock %} +{% block extra_scripts %}{{ block.super }}{% endblock %} diff --git a/templates/payments/receipt_list.html b/templates/payments/receipt_list.html new file mode 100644 index 0000000..3f4fe92 --- /dev/null +++ b/templates/payments/receipt_list.html @@ -0,0 +1,3 @@ +{% extends 'payments/base.html' %}{% load jalali humanize %} +{% block content %}
{% include 'payments/_link_status.html' %}

{% if payment_link_access %}فیش‌های این لینک{% else %}فیش‌های من{% endif %}

ارسال فیش جدید
+{% for receipt in page_obj %}{% empty %}

هنوز فیشی ارسال نکرده‌اید.

{% endfor %}{% include 'payments/_pagination.html' %}
{% endblock %} diff --git a/templates/payments/staff_form.html b/templates/payments/staff_form.html new file mode 100644 index 0000000..92f1c37 --- /dev/null +++ b/templates/payments/staff_form.html @@ -0,0 +1,38 @@ +{% extends 'payments/base.html' %}{% load static %} +{% block title %}{{ heading }}{% endblock %} +{% block content %} +
+ {% include 'payments/_icon.html' with name='chevron' %}{% if form.bank %}بانک‌ها و حساب‌ها{% else %}اعلامیه‌ها و فیش‌ها{% endif %} +
{% if form.bank %}{% include 'payments/_icon.html' with name='bank' %}{% else %}{% include 'payments/_icon.html' with name='receipt' %}{% endif %}

{{ heading }}

{% if form.bank %}اطلاعات حساب و دسترسی هر پنل را مشخص کنید.{% elif form.subject %}حساب مقصد و جزئیات واریز را برای مشتری اعلام کنید.{% else %}ابتدا مشتری را انتخاب کنید؛ در مرحله بعد اطلاعات فیش را وارد می‌کنید.{% endif %}

+
+ {% csrf_token %} + {% if form.non_field_errors %}{% endif %} + {% for field in form.hidden_fields %}{% if field.name != 'customer' %}{{ field }}{% if field.errors %}{% endif %}{% endif %}{% endfor %} + {% if form.bank %} +
مشخصات حساب

نام نمایشی، همان نامی است که کارشناسان و مشتریان هنگام انتخاب حساب می‌بینند.

+ {% include 'payments/_field.html' with field=form.name %}{% include 'payments/_field.html' with field=form.bank %} + {% include 'payments/_field.html' with field=form.holder wide=True %} + {% include 'payments/_field.html' with field=form.account_number %}{% include 'payments/_field.html' with field=form.card_number %} + {% include 'payments/_field.html' with field=form.iban wide=True hint='شبا را با IR و بدون فاصله وارد کنید.' %} +

حداقل یکی از شماره حساب، شبا یا شماره کارت را وارد کنید.

+
تنظیمات و دسترسی‌ها

فعال‌بودن کلی حساب، شرط استفاده از آن در هر دو پنل است.

+ + + + +
{% include 'payments/_field.html' with field=form.sort_order label='ترتیب نمایش' hint='عدد کوچک‌تر، حساب را بالاتر در فهرست قرار می‌دهد.' %}
+ {% elif form.subject %} +
مشتری و حساب مقصد
{% include 'payments/_customer_picker.html' %}{% include 'payments/_field.html' with field=form.account wide=True %}
+
جزئیات واریز
+ {% include 'payments/_field.html' with field=form.subject wide=True %}{% include 'payments/_field.html' with field=form.amount %}{% include 'payments/_field.html' with field=form.invoice_number %} +
{{ form.deposit_id }}

ابتدا مشتری را انتخاب کنید؛ شناسه بر اساس شماره موبایل او تولید می‌شود.

برای حساب‌های شناسه‌دار، شناسه واریز مشتری الزامی است.

{% if form.deposit_id.errors %}{% endif %}
+ {% include 'payments/_field.html' with field=form.description wide=True hint='توضیحات این بخش برای مشتری قابل مشاهده است.' %} +

پس از صدور اعلامیه، پیامک اطلاع‌رسانی برای مشتری در صف ارسال قرار می‌گیرد.

+ {% else %}
انتخاب مشتری
{% include 'payments/_customer_picker.html' %}
{% endif %} + +
+ {% if bank_account %}
حذف حساب
{% csrf_token %}

حساب «{{ bank_account.name }}» فقط در صورت نداشتن سابقه مالی حذف می‌شود. برای توقف استفاده، آن را غیرفعال کنید.

{% endif %} + {% if identifier_accounts %}{{ identifier_accounts|json_script:'identifier-accounts' }}{% endif %} +
+{% endblock %} +{% block extra_scripts %}{{ block.super }}{% if form.subject %}{% endif %}{% if form.amount %}{% endif %}{% if form.customer %}{% endif %}{% endblock %} diff --git a/templates/tickets/staff_contacts.html b/templates/tickets/staff_contacts.html index ee7ca4f..a53e894 100644 --- a/templates/tickets/staff_contacts.html +++ b/templates/tickets/staff_contacts.html @@ -4,5 +4,5 @@

مرکز یکپارچه ارتباطات

مخاطبان و مشتریان

پرونده ارتباطی مشتریان، تأمین‌کنندگان و باربری‌ها همراه با گروه‌ها و وضعیت تیکت‌های قابل مشاهده در یک نما.

{% if staff_operations_access.contact_groups %}مدیریت گروه‌ها{% endif %}{% if can_add_contact %}+ مخاطب جدید{% endif %}
◎
{{ metrics.total }}نتیجه قابل مشاهده
♙
{{ metrics.customers }}مشتری
▣
{{ metrics.suppliers }}تأمین‌کننده
⇄
{{ metrics.carriers }}باربری
!
{{ metrics.needs_action }}تیکت نیازمند اقدام
{% if request.GET %}پاک‌کردن{% endif %}
-
{{ contacts|length }} مخاطبمرتب‌شده بر اساس تیکت‌های قابل مشاهده و آخرین ثبت
{% if staff_operations_access.contact_groups %}{{ groups|length }} گروه قابل دسترس ←{% endif %}
{% for contact in contacts %}{% with profile=contact.customer_profile %}
{{ profile.first_name|default:contact.local_mobile|slice:':1' }}

{{ profile|default:contact.local_mobile }}

{% if profile %}{{ profile.organization_name|default:profile.get_stakeholder_type_display }}{% else %}مشتری{% endif %}

{{ profile.get_stakeholder_type_display }}
☎ {{ contact.local_mobile }}⌖ {% if profile.city or profile.province %}{{ profile.city }}{% if profile.city and profile.province %}، {% endif %}{{ profile.province }}{% else %}موقعیت ثبت نشده{% endif %}
{% for group in profile.visible_groups %}{{ group.name }}{% empty %}بدون گروه قابل مشاهده{% endfor %}
{{ contact.ticket_count }}تیکت قابل مشاهده{{ contact.active_count }}فعال{{ contact.waiting_staff_count }}نیازمند اقدام{{ contact.resolved_count }}مختومه
{% endwith %}{% empty %}
⌕

مخاطبی پیدا نشد

فیلترها را تغییر دهید یا مخاطب جدیدی ثبت کنید.

{% if can_add_contact %}ثبت مخاطب جدید{% endif %}
{% endfor %}
{% endblock %} +
{{ contacts|length }} مخاطبمرتب‌شده بر اساس تیکت‌های قابل مشاهده و آخرین ثبت
{% if staff_operations_access.contact_groups %}{{ groups|length }} گروه قابل دسترس ←{% endif %}
{% for contact in contacts %}{% with profile=contact.customer_profile %}
{{ profile.first_name|default:contact.local_mobile|slice:':1' }}

{{ profile|default:contact.local_mobile }}

{% if profile %}{{ profile.organization_name|default:profile.get_stakeholder_type_display }}{% else %}مشتری{% endif %}

{{ profile.get_stakeholder_type_display }}
☎ {{ contact.local_mobile }}⌖ {% if profile.city or profile.province %}{{ profile.city }}{% if profile.city and profile.province %}، {% endif %}{{ profile.province }}{% else %}موقعیت ثبت نشده{% endif %}
{% for group in profile.visible_groups %}{{ group.name }}{% empty %}بدون گروه قابل مشاهده{% endfor %}
{{ contact.ticket_count }}تیکت قابل مشاهده{{ contact.active_count }}فعال{{ contact.waiting_staff_count }}نیازمند اقدام{{ contact.resolved_count }}مختومه
ویرایش پرونده{% include 'tickets/_voip_call_button.html' with customer=contact %}{% if staff_operations_access.create_ticket %}+ ثبت تیکت{% endif %}{% include 'payments/_notice_shortcut.html' with customer=contact %}
{% endwith %}{% empty %}
⌕

مخاطبی پیدا نشد

فیلترها را تغییر دهید یا مخاطب جدیدی ثبت کنید.

{% if can_add_contact %}ثبت مخاطب جدید{% endif %}
{% endfor %}
{% endblock %} {% block extra_scripts %}{% endblock %} diff --git a/templates/tickets/staff_detail.html b/templates/tickets/staff_detail.html index 0227e6d..02fdd2e 100644 --- a/templates/tickets/staff_detail.html +++ b/templates/tickets/staff_detail.html @@ -42,7 +42,7 @@ {% endif %} - + {% include 'tickets/_image_viewer.html' %} diff --git a/templates/tickets/staff_sms_templates.html b/templates/tickets/staff_sms_templates.html index a60b723..a789405 100644 --- a/templates/tickets/staff_sms_templates.html +++ b/templates/tickets/staff_sms_templates.html @@ -1,7 +1,8 @@ -{% extends 'base.html' %} +{% extends 'base.html' %}{% load static %} {% block title %}{{ page_title }}{% endblock %} {% block content %}

پیام‌رسانی استاندارد

الگوهای پیامک واحدها

متن‌های تاییدشده دیتابیس را برای هر رویداد مدیریت و با متغیرهای امن شخصی‌سازی کنید.

✦
متغیرهای کنترل‌شدهاز ورود تگ نامعتبر جلوگیری می‌شود
کتابخانه واحدهای شما و fallback سیستم

الگوهای موجود در دیتابیس

{{ templates|length }} الگو
{% for item in templates %}
✉

{{ item.title }}

{{ item.get_event_type_display }}

{% if item.unit %}{{ item.unit }}{% else %}الگوی عمومی سیستم{% endif %} · بروزرسانی {{ item.updated_at|date:'Y/m/d' }}
{% if item.is_active %}فعال{% else %}غیرفعال{% endif %}{% if item.unit %}ویرایش{% else %}Fallback{% endif %}
{% empty %}
✉

هیچ الگویی در دیتابیس وجود ندارد

migration داده‌های پیش‌فرض را ایجاد می‌کند؛ وضعیت migration را بررسی کنید.

{% endfor %}
-
+
{% endblock %} +{% block extra_scripts %}{{ block.super }}{{ sms_tag_map|json_script:'sms-tag-map' }}{% endblock %} diff --git a/tests/e2e/pages/auth.py b/tests/e2e/pages/auth.py index abb5fc9..dd688fd 100644 --- a/tests/e2e/pages/auth.py +++ b/tests/e2e/pages/auth.py @@ -10,7 +10,7 @@ class AuthenticationPage(BasePage): self.page.locator('input[name="mobile"]').fill(customer.local_mobile) self.page.locator('input[name="password"]').fill(password) self.page.get_by_role("button", name="ورود به حساب").click() - expect(self.page).to_have_url(self.url(reverse("tickets:list"))) + expect(self.page).to_have_url(self.url(reverse("payments:home"))) def login_staff(self, staff, password): self.goto(reverse("accounts:staff_login")) diff --git a/tests/e2e/pages/magic_link.py b/tests/e2e/pages/magic_link.py new file mode 100644 index 0000000..141750e --- /dev/null +++ b/tests/e2e/pages/magic_link.py @@ -0,0 +1,44 @@ +from django.urls import reverse +from playwright.sync_api import expect + +from tests.e2e.pages.base import BasePage + + +class MagicLinkConfirmationPage(BasePage): + def open(self, token): + self.goto(reverse("accounts:magic", args=[token])) + expect(self.page.get_by_role("heading", name="این لینک مربوط به حساب دیگری است")).to_be_visible() + + @property + def continue_button(self): + return self.page.get_by_role("button", name="خروج از حساب و مشاهده تیکت", exact=True) + + @property + def cancel_link(self): + return self.page.get_by_role("link", name="انصراف از مشاهده این تیکت/پیام", exact=True) + + def continue_to_ticket(self, ticket): + self.continue_button.click() + expect(self.page).to_have_url(self.url(reverse("tickets:detail", args=[ticket.public_id]))) + expect(self.page.get_by_role("heading", name=ticket.subject, exact=True)).to_be_visible() + expect(self.page.get_by_text("فقط به همین تیکت دسترسی دارید", exact=False)).to_be_visible() + + def cancel(self): + self.cancel_link.click() + expect(self.page).to_have_url(self.url(reverse("payments:home"))) + + def expect_readable_layout(self): + expect(self.continue_button).to_be_visible() + expect(self.cancel_link).to_be_visible() + metrics = self.page.evaluate("""() => ({ + width: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + textSize: parseFloat(getComputedStyle(document.querySelector('#confirmation-description')).fontSize), + background: getComputedStyle(document.querySelector('.magic-confirmation-primary')).backgroundColor, + color: getComputedStyle(document.querySelector('.magic-confirmation-primary')).color, + })""") + assert metrics["scrollWidth"] <= metrics["width"] + assert metrics["textSize"] >= 16 + assert metrics["background"] == "rgb(8, 55, 107)" and metrics["color"] == "rgb(255, 255, 255)" + for control in (self.continue_button, self.cancel_link): + assert control.bounding_box()["height"] >= 44 diff --git a/tests/e2e/pages/payment_link.py b/tests/e2e/pages/payment_link.py new file mode 100644 index 0000000..aa98572 --- /dev/null +++ b/tests/e2e/pages/payment_link.py @@ -0,0 +1,40 @@ +from playwright.sync_api import expect + +from .payments import PaymentsPage + + +class PaymentLinkPage(PaymentsPage): + def open_link(self, path): + self.goto(path) + expect(self.page.get_by_role('heading', name='جزئیات واریز', exact=True)).to_be_visible() + + def copy_all(self): + self.page.get_by_role('button', name='کپی کامل اطلاعات', exact=True).click() + expect(self.page.get_by_text('کپی شد', exact=True).or_( + self.page.get_by_role('heading', name='متن را انتخاب و کپی کنید'))).to_be_visible() + if self.page.get_by_role('dialog').is_visible(): + self.page.get_by_role('button', name='بستن', exact=True).click() + + def open_receipt_form(self, linked): + if linked: + self.page.get_by_role('link', name='ارسال فیش این واریز', exact=True).click() + else: + self.page.get_by_role('link', name='ارسال فیش واریز مستقل', exact=True).click() + expect(self.page.get_by_role('heading', name='ارسال فیش واریزی', exact=True)).to_be_visible() + + def show_required_errors(self): + self.page.get_by_role('button', name='ثبت و ارسال فیش', exact=True).click() + expect(self.page.locator('#receipt-errors')).to_be_visible() + expect(self.page.locator('#id_paid_amount')).to_be_focused() + + def submit_payment(self, png, account_id=None): + if account_id is not None: + self.page.locator('#id_destination_account').select_option(str(account_id)) + self.page.get_by_label('شناسه واریز که با آن پرداخت انجام داده‌اید (اختیاری)', exact=True).fill('') + expect(self.page.locator('#id_deposit_id')).not_to_have_attribute('required', '') + self.page.locator('#id_paid_amount').fill('۱۲۳۴۵') + expect(self.page.locator('#id_paid_amount')).to_have_value('۱۲٬۳۴۵') + self.page.get_by_role('button', name='امروز', exact=True).click() + self.page.locator('#id_receipt_image').set_input_files({'name': 'payment.png', 'mimeType': 'image/png', 'buffer': png}) + self.page.get_by_role('button', name='ثبت و ارسال فیش', exact=True).click() + expect(self.page.locator('.flash')).to_contain_text('فیش ثبت شد و در انتظار بررسی است.') diff --git a/tests/e2e/pages/payments.py b/tests/e2e/pages/payments.py new file mode 100644 index 0000000..f49c589 --- /dev/null +++ b/tests/e2e/pages/payments.py @@ -0,0 +1,33 @@ +from django.urls import reverse +from playwright.sync_api import expect +from .base import BasePage + + +class PaymentsPage(BasePage): + def select_customer(self, mobile): + self.page.get_by_label('جست‌وجو و انتخاب مشتری', exact=False).fill(mobile) + result = self.page.locator('.pay-customer-result').filter(has_text=mobile) + result.get_by_role('button', name='انتخاب این مشتری', exact=True).click() + expect(self.page.locator('[data-selected-customer]')).to_contain_text(mobile) + + def open_hub(self): + self.goto(reverse('payments:home')) + expect(self.page.get_by_role('heading',name='چهارسو',exact=True)).to_be_visible() + + def send_receipt(self, account_id, png, linked=False): + self.goto(reverse('payments:receipt_new')) + if not linked: + self.page.get_by_label('واریز مستقل',exact=True).check() + self.page.locator('#id_paid_amount').fill('۱۲۳۴۵') + self.page.locator('#id_destination_account').select_option(str(account_id)) + self.page.get_by_role('button',name='امروز',exact=True).click() + self.page.locator('#id_receipt_image').set_input_files({'name':'receipt.png','mimeType':'image/png','buffer':png}) + self.page.get_by_role('button',name='ثبت و ارسال فیش').click() + expect(self.page.locator('.flash')).to_contain_text('فیش ثبت شد و در انتظار بررسی است.') + + def approve(self, receipt_id, senior=False): + self.goto(reverse('staff_payments:receipt_detail',args=[receipt_id])) + if senior: + self.page.locator('#decision-reason').fill('تطبیق پرداخت بررسی شد') + self.page.get_by_role('button',name='تأیید نهایی ارشد' if senior else 'ثبت تأیید من',exact=True).click() + expect(self.page.locator('.flash')).to_contain_text('تصمیم ثبت شد.') diff --git a/tests/e2e/pages/ticket_notices.py b/tests/e2e/pages/ticket_notices.py new file mode 100644 index 0000000..abd0d10 --- /dev/null +++ b/tests/e2e/pages/ticket_notices.py @@ -0,0 +1,26 @@ +from playwright.sync_api import expect + +from .base import BasePage + + +class TicketNoticesPage(BasePage): + def open_customer_panel(self): + if self.page.get_by_role('button', name='اطلاعات مشتری', exact=True).is_visible(): + self.page.get_by_role('button', name='اطلاعات مشتری', exact=True).click() + expect(self.page.locator('#customer-info')).to_be_in_viewport() + + def toggle_notices(self): + self.page.locator('.ticket-notices > summary').click() + + def open_notice(self, subject): + self.page.get_by_role('link').filter(has=self.page.get_by_text(subject, exact=True)).click() + + def open_all(self): + self.page.get_by_role('link', name='بیشتر همه اعلامیه‌های این مشتری').click() + + def expect_closed(self): + expect(self.page.get_by_role('link', name='ایجاد اعلامیه واریز', exact=True)).to_be_hidden() + + def expect_open(self): + expect(self.page.get_by_role('link', name='ایجاد اعلامیه واریز', exact=True)).to_be_visible() + expect(self.page.get_by_role('list', name='اعلامیه‌های اخیر مشتری').get_by_role('link')).to_have_count(3) diff --git a/tests/e2e/test_external_environment_smoke.py b/tests/e2e/test_external_environment_smoke.py index 0b172d4..11a7417 100644 --- a/tests/e2e/test_external_environment_smoke.py +++ b/tests/e2e/test_external_environment_smoke.py @@ -26,7 +26,7 @@ def test_external_customer_login_and_public_routing_are_usable( page.locator('input[name="mobile"]').fill(mobile) page.locator('input[name="password"]').fill(password) page.get_by_role("button", name="ورود به حساب").click() - expect(page).to_have_url(f"{application_base_url}/tickets/") + expect(page).to_have_url(f"{application_base_url}/chaharsu/") page.goto(f"{application_base_url}/tickets/new/", wait_until="domcontentloaded") expect(page.locator("[data-ticket-unit]")).to_be_visible() expect(page.locator("[data-ticket-category]")).to_be_visible() diff --git a/tests/e2e/test_magic_link_confirmation_e2e.py b/tests/e2e/test_magic_link_confirmation_e2e.py new file mode 100644 index 0000000..376c339 --- /dev/null +++ b/tests/e2e/test_magic_link_confirmation_e2e.py @@ -0,0 +1,75 @@ +from pathlib import Path + +import pytest +from django.urls import reverse +from playwright.sync_api import expect + +from apps.accounts.models import TicketMagicLink, User +from apps.customers.models import CustomerProfile +from apps.tickets.models import TicketCategory +from apps.tickets.services import create_ticket +from tests.e2e.pages.auth import AuthenticationPage +from tests.e2e.pages.magic_link import MagicLinkConfirmationPage +from tests.e2e.pages.tickets import TicketConversationPage + + +pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)] +PASSWORD = "Magic-link-test-password-2026" +MESSAGE = "شما با حساب دیگری وارد سامانه شده‌اید. برای مشاهده تیکت مرتبط با این لینک، از حساب فعلی خارج شوید. پس از خروج، تیکت به‌صورت خودکار باز می‌شود و نیازی به ورود مجدد ندارید." + + +@pytest.fixture +def browser_magic_case(): + category = TicketCategory.objects.create(title="پشتیبانی لینک", slug="browser-magic") + owner = User.objects.create_user(username="browser-link-owner", mobile="09123000101", account_type="CUSTOMER") + other = User.objects.create_user(username="browser-link-other", mobile="09123000102", account_type="CUSTOMER", password=PASSWORD) + CustomerProfile.objects.create(user=owner) + CustomerProfile.objects.create(user=other) + ticket = create_ticket(customer=owner, category=category, subject="درخواست مرتبط با لینک", body="محتوای تیکت مرتبط") + token = TicketMagicLink.issue(ticket) + return other, ticket, token + + +@pytest.mark.parametrize("width", [320, 390, 1280]) +def test_confirmed_logout_opens_ticket_with_readable_responsive_page(browser_page, live_server, browser_magic_case, width): + other, ticket, token = browser_magic_case + with browser_page() as page: + page.set_viewport_size({"width": width, "height": 844 if width < 640 else 900}) + AuthenticationPage(page, live_server.url).login_customer(other, PASSWORD) + confirmation = MagicLinkConfirmationPage(page, live_server.url) + confirmation.open(token) + expect(page.locator("#confirmation-description")).to_have_text(MESSAGE) + expect(page.get_by_text("محتوای تیکت مرتبط", exact=True)).to_have_count(0) + confirmation.expect_readable_layout() + confirmation.continue_button.focus() + expect(confirmation.continue_button).to_be_focused() + assert confirmation.continue_button.evaluate("node => getComputedStyle(node).outlineStyle") == "solid" + artifact = Path("test-results/e2e") / f"magic-confirmation-{width}.png" + artifact.parent.mkdir(parents=True, exist_ok=True) + page.screenshot(path=str(artifact), full_page=True) + confirmation.continue_to_ticket(ticket) + TicketConversationPage(page, live_server.url).send_reply("پاسخ مرورگر پس از خروج") + confirmation.goto(reverse("accounts:profile")) + expect(page).to_have_url(confirmation.url(reverse("accounts:login")) + "?next=" + reverse("accounts:profile")) + + +def test_cancel_returns_to_logged_in_account(browser_page, live_server, browser_magic_case): + other, ticket, token = browser_magic_case + with browser_page() as page: + AuthenticationPage(page, live_server.url).login_customer(other, PASSWORD) + confirmation = MagicLinkConfirmationPage(page, live_server.url) + confirmation.open(token) + confirmation.cancel() + confirmation.goto(reverse("accounts:profile")) + expect(page.get_by_role("heading", name="پروفایل من")).to_be_visible() + expect(page.locator("main")).to_contain_text(other.mobile) + + +def test_confirmation_form_works_without_its_javascript(browser_page, live_server, browser_magic_case): + other, ticket, token = browser_magic_case + with browser_page() as page: + AuthenticationPage(page, live_server.url).login_customer(other, PASSWORD) + page.route("**/magic-link-confirmation.js*", lambda route: route.abort()) + confirmation = MagicLinkConfirmationPage(page, live_server.url) + confirmation.open(token) + confirmation.continue_to_ticket(ticket) diff --git a/tests/e2e/test_payment_links_e2e.py b/tests/e2e/test_payment_links_e2e.py new file mode 100644 index 0000000..f769083 --- /dev/null +++ b/tests/e2e/test_payment_links_e2e.py @@ -0,0 +1,41 @@ +from urllib.parse import urlsplit + +import pytest +from playwright.sync_api import expect + +from apps.core.models import SiteBranding +from apps.payments.access import issue_payment_link +from apps.payments import services +from tests.e2e.pages.payment_link import PaymentLinkPage +from tests.e2e.test_payments_e2e import payment_case, PNG + +pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)] + + +@pytest.mark.parametrize('width,linked', [(1440, True), (320, True), (390, False)]) +def test_sms_payment_without_login_preserves_copy_and_submission(live_server, browser_page, payment_case, width, linked): + customer, account, reviewers = payment_case + notice = services.issue_notice(reviewers[0], {'customer': customer, 'account': account, + 'subject': 'اعلامیه بدون ورود', 'amount': 12345, 'deposit_id': '000123', 'description': 'اطلاعات کامل اعلامیه'}) + _, url = issue_payment_link(notice=notice) + branding = SiteBranding.load() + branding.developer_credit_text = 'امضای ثابت توسعه‌دهنده' + branding.save() + with browser_page() as page: + page.set_viewport_size({'width': width, 'height': 900}) + view = PaymentLinkPage(page, live_server.url) + view.open_link(urlsplit(url).path) + expect(page.locator('.pay-deposit-required dd')).to_have_text('000123') + expect(page.get_by_text('واریز بدون این شناسه معتبر نخواهد بود.', exact=False)).to_be_visible() + expect(page.locator('.cs-footer')).to_have_text(branding.developer_credit_text) + expect(page.get_by_role('link', name='پروفایل من')).to_have_count(0) + assert page.locator('.pay-deposit-required dd').evaluate('(element) => getComputedStyle(element).color') == 'rgb(159, 31, 31)' + view.copy_all() + view.open_receipt_form(linked) + view.show_required_errors() + view.submit_payment(PNG, account_id=account.pk if not linked else None) + expect(page.get_by_text('در انتظار بررسی', exact=False).first).to_be_visible() + expect(page.get_by_role('button', name='ثبت تأیید من', exact=True)).to_have_count(0) + if not linked: + expect(page.get_by_text('واریز مستقل', exact=True)).to_be_visible() + assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1') diff --git a/tests/e2e/test_payments_e2e.py b/tests/e2e/test_payments_e2e.py new file mode 100644 index 0000000..6c80a78 --- /dev/null +++ b/tests/e2e/test_payments_e2e.py @@ -0,0 +1,361 @@ +import base64 +import pytest +from django.contrib.auth.models import Permission +from django.urls import reverse +from playwright.sync_api import expect +from apps.accounts.models import User +from apps.staff.models import StaffProfile +from apps.attachments.models import AttachmentPolicy +from apps.payments.models import BankInstitution, CompanyBankAccount, PaymentApprovalPolicy +from apps.payments import services +from tests.e2e.pages.auth import AuthenticationPage +from tests.e2e.pages.payments import PaymentsPage + +pytestmark=[pytest.mark.e2e,pytest.mark.django_db(transaction=True)] +PASSWORD='PaymentE2e-safe-2026' +PNG=base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=') + + +@pytest.fixture +def payment_case(settings,tmp_path): + settings.MEDIA_ROOT=tmp_path + bank,_=BankInstitution.objects.get_or_create(code='melli',defaults={'name':'ملی'}) + account=CompanyBankAccount.objects.create(name='حساب فروش شرکت',holder='شرکت',bank=bank,account_number='000123') + customer=User.objects.create_user(username='payment-e2e-customer',mobile='09120009021',password=PASSWORD,account_type='CUSTOMER') + reviewers=[] + for index in range(2): + user=User.objects.create_user(username=f'payment-e2e-reviewer-{index}',password=PASSWORD,account_type='STAFF') + StaffProfile.objects.create(user=user,display_name=f'تأییدکننده {index}') + user.user_permissions.add(Permission.objects.get(content_type__app_label='payments',codename='approve_paymentreceipt')) + reviewers.append(user) + policy=PaymentApprovalPolicy.objects.create(name='تأیید هر دو نفر') + policy.reviewers.add(*reviewers) + AttachmentPolicy.objects.update_or_create(attachment_type='IMAGE',defaults={'max_file_size':10*1024*1024,'allowed_extensions':['.png'],'allowed_mime_types':['image/png']}) + return customer,account,reviewers + + +def test_customer_hub_no_history_and_small_mobile_services(live_server,browser_page,payment_case): + customer,account,reviewers=payment_case + with browser_page() as page: + page.set_viewport_size({'width':320,'height':568}) + AuthenticationPage(page,live_server.url).login_customer(customer,PASSWORD) + PaymentsPage(page,live_server.url).open_hub() + expect(page.get_by_role('heading',name='درخواست‌های اخیر شما')).to_have_count(0) + for name in ['ارتباط با کارشناسان','ارسال فیش واریزی','شماره حساب‌ها']: + expect(page.get_by_role('heading',name=name,exact=True)).to_be_in_viewport() + assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1') + page.get_by_role('link',name='ثبت درخواست',exact=True).click() + expect(page).to_have_url(live_server.url+reverse('tickets:new')) + + +def test_independent_receipt_and_two_person_approval(live_server,browser_page,payment_case): + customer,account,reviewers=payment_case + with browser_page() as page: + AuthenticationPage(page,live_server.url).login_customer(customer,PASSWORD) + PaymentsPage(page,live_server.url).send_receipt(account.pk,PNG) + expect(page.get_by_text('واریز مستقل',exact=True)).to_be_visible() + # Read the public identifier from real navigation; no ORM inside Playwright's loop. + receipt_id=page.url.rstrip('/').rsplit('/',1)[-1] + page.goto(live_server.url+reverse('accounts:staff_login')) + # Explicit logout using the real page control preserves session/CSRF behavior. + page.get_by_role('button',name='خروج',exact=True).first.click() + AuthenticationPage(page,live_server.url).login_staff(reviewers[0],PASSWORD) + PaymentsPage(page,live_server.url).approve(receipt_id) + expect(page.get_by_text('تأیید ناقص',exact=True)).to_be_visible() + page.get_by_role('button',name='خروج',exact=True).first.click() + AuthenticationPage(page,live_server.url).login_staff(reviewers[1],PASSWORD) + PaymentsPage(page,live_server.url).approve(receipt_id) + expect(page.get_by_text('تأیید شده',exact=True)).to_be_visible() + expect(page.get_by_role('button',name='ثبت تأیید من')).to_have_count(0) + + +def test_form_errors_focus_and_full_copy(live_server,browser_page,payment_case): + customer,account,reviewers=payment_case + notice=services.issue_notice(reviewers[0],{'customer':customer,'account':account,'amount':12345,'subject':'فاکتور نمونه','description':'توضیح ثابت و کامل','deposit_id':'00099'}) + with browser_page() as page: + AuthenticationPage(page,live_server.url).login_customer(customer,PASSWORD) + page.goto(live_server.url+reverse('payments:receipt_new')) + page.get_by_role('button',name='ثبت و ارسال فیش').click() + expect(page.locator('#receipt-errors')).to_be_visible() + expect(page.locator('#id_paid_amount')).to_be_focused() + page.goto(live_server.url+reverse('payments:notices')+'?notice='+str(notice.public_id)) + expect(page.get_by_text('توضیح ثابت و کامل',exact=True)).to_be_visible() + page.get_by_role('button',name='کپی کامل اطلاعات').click() + # Browser may deny Clipboard on LAN HTTP; accessible manual fallback is valid. + expect(page.get_by_text('کپی شد',exact=True).or_(page.get_by_role('heading',name='متن را انتخاب و کپی کنید'))).to_be_visible() + + +def test_amount_grouping_and_jalali_leap_calendar(live_server,browser_page,payment_case): + customer,account,reviewers=payment_case + with browser_page() as page: + assets=[] + page.on('request',lambda r:assets.append(r.url) if r.resource_type in {'script','stylesheet','font','image'} else None) + AuthenticationPage(page,live_server.url).login_customer(customer,PASSWORD) + page.goto(live_server.url+reverse('payments:receipt_new')) + amount=page.locator('#id_paid_amount') + amount.fill('12345') + expect(amount).to_have_value('۱۲٬۳۴۵') + expect(page.locator('#amount-words')).to_contain_text('دهم تومان') + amount.fill('12.5') + expect(amount).to_have_value('12.5') + page.get_by_role('button',name='ثبت و ارسال فیش').click() + expect(amount).to_be_focused() + expect(page.locator('#error-paid_amount')).to_contain_text('عدد صحیح') + page.locator('#id_paid_year').select_option('1403') + page.locator('#id_paid_month').select_option('12') + page.locator('#id_paid_day').select_option('30') + expect(page.locator('#paid-date-iso')).to_have_value('2025-03-20') + page.locator('#id_paid_year').select_option('1402') + expect(page.locator('#id_paid_day')).to_have_value('') + expect(page.locator('#paid-date-iso')).to_have_value('') + assert assets and all(url.startswith(live_server.url+'/') or url.startswith('data:') for url in assets) + + +def test_staff_issues_identifier_notice_and_customer_sends_linked_receipt(live_server,browser_page,payment_case): + customer,account,reviewers=payment_case + account.requires_deposit_id=True;account.save() + with browser_page() as page: + AuthenticationPage(page,live_server.url).login_staff(reviewers[0],PASSWORD) + page.goto(live_server.url+reverse('staff_payments:desk')) + page.get_by_role('link',name='ایجاد اعلامیه واریز',exact=True).click() + PaymentsPage(page,live_server.url).select_customer(customer.local_mobile) + page.locator('#id_account').select_option(str(account.pk)) + page.locator('#id_subject').fill('پرداخت فاکتور اختصاصی') + page.locator('#id_amount').fill('100000') + expect(page.locator('#id_amount')).to_have_value('۱۰۰٬۰۰۰') + expect(page.locator('#id_deposit_id')).to_have_attribute('required','') + page.locator('#id_deposit_id').fill('۰۰۱۲۳') + page.locator('#id_description').fill('شناسه فقط برای همین واریز') + page.get_by_role('button',name='صدور و اطلاع‌رسانی').click() + expect(page.locator('.flash')).to_contain_text('اعلامیه صادر شد') + notice_id=page.url.rstrip('/').rsplit('/',1)[-1] + page.get_by_role('button',name='خروج',exact=True).first.click() + AuthenticationPage(page,live_server.url).login_customer(customer,PASSWORD) + page.goto(live_server.url+reverse('payments:notices')+'?notice='+notice_id) + expect(page.get_by_text('شناسه فقط برای همین واریز',exact=True)).to_be_visible() + page.get_by_role('link',name='ارسال فیش این واریز',exact=True).click() + expect(page.get_by_label('واریز مرتبط (پیشنهادی)',exact=True)).to_be_checked() + expect(page.locator('#id_destination_account')).to_have_value(str(account.pk)) + expect(page.locator('#id_deposit_id')).to_have_value('00123') + page.locator('#id_paid_amount').fill('100000') + page.get_by_role('button',name='امروز',exact=True).click() + page.locator('#id_receipt_image').set_input_files({'name':'receipt.png','mimeType':'image/png','buffer':PNG}) + page.get_by_role('button',name='ثبت و ارسال فیش').click() + expect(page.locator('.flash')).to_contain_text('فیش ثبت شد') + expect(page.get_by_text('واریز مستقل',exact=True)).to_have_count(0) + page.goto(live_server.url+reverse('payments:receipt_list')) + expect(page.locator('article.pay-card').filter(has_text='پرداخت فاکتور اختصاصی')).to_contain_text('در انتظار بررسی') + + +def test_staff_payment_filters_and_bank_selection(live_server, browser_page, payment_case): + customer, account, reviewers = payment_case + reviewers[0].user_permissions.add(Permission.objects.get( + content_type__app_label='payments', codename='manage_company_bankaccounts')) + CompanyBankAccount.objects.create(name='حساب دوم شرکت', holder='شرکت', bank=account.bank, + account_number='000456', is_active=False) + services.issue_notice(reviewers[0], {'customer': customer, 'account': account, + 'amount': 100000, 'subject': 'پرداخت نمونه برای بررسی میز مالی', 'description': '', 'deposit_id': ''}) + with browser_page() as page: + AuthenticationPage(page, live_server.url).login_staff(reviewers[0], PASSWORD) + page.goto(live_server.url + reverse('staff_payments:desk')) + page.get_by_role('link', name='اعلامیه‌های واریز', exact=True).click() + expect(page.get_by_role('link', name='پرداخت نمونه برای بررسی میز مالی')).to_be_visible() + page.get_by_role('link', name='نیازمند اقدام من', exact=True).click() + page.get_by_role('button', name='جست‌وجو', exact=True).click() + expect(page.get_by_role('link', name='نیازمند اقدام من', exact=True)).to_have_attribute('aria-current', 'page') + expect(page.get_by_text('در حال حاضر فیشی برای اقدام شما وجود ندارد.', exact=True)).to_be_visible() + page.goto(live_server.url + reverse('staff_payments:banks')) + page.set_viewport_size({'width': 375, 'height': 812}) + submit = page.get_by_role('button', name='اعمال بر انتخاب‌شده‌ها', exact=True) + expect(submit).to_be_disabled() + page.get_by_role('checkbox', name='انتخاب همه حساب‌های این صفحه', exact=True).check() + expect(submit).to_be_enabled() + expect(page.get_by_text('۲ حساب انتخاب شده', exact=True)).to_be_visible() + page.get_by_role('checkbox', name='انتخاب حساب دوم شرکت', exact=True).uncheck() + expect(page.get_by_text('۱ حساب انتخاب شده', exact=True)).to_be_visible() + expect(page.get_by_role('link', name='ویرایش', exact=True)).to_have_count(2) + assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1') + + +def test_staff_bank_forms_and_receipt_customer_steps(live_server, browser_page, payment_case): + customer, account, reviewers = payment_case + reviewers[0].user_permissions.add(Permission.objects.get( + content_type__app_label='payments', codename='manage_company_bankaccounts')) + with browser_page() as page: + AuthenticationPage(page, live_server.url).login_staff(reviewers[0], PASSWORD) + page.set_viewport_size({'width': 375, 'height': 812}) + page.goto(live_server.url + reverse('staff_payments:bank_new')) + page.get_by_label('نام نمایشی حساب', exact=False).fill('حساب طراحی فرم') + page.locator('#id_bank').select_option(str(account.bank_id)) + page.locator('#id_holder').fill('شرکت نمونه') + page.locator('#id_account_number').fill('123456789') + page.get_by_role('checkbox', name='شناسه واریز الزامی است', exact=False).check() + assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1') + page.get_by_role('button', name='ذخیره حساب', exact=True).click() + page.get_by_role('link', name='حساب طراحی فرم', exact=True).click() + expect(page.get_by_role('checkbox', name='شناسه واریز الزامی است', exact=False)).to_be_checked() + page.get_by_role('checkbox', name='قابل انتخاب کارشناسان', exact=False).uncheck() + page.get_by_role('button', name='ذخیره حساب', exact=True).click() + expect(page.locator('.pay-bank-row').filter(has_text='حساب طراحی فرم').get_by_text('غیرفعال', exact=True)).to_be_visible() + page.goto(live_server.url + reverse('staff_payments:receipt_customer')) + PaymentsPage(page,live_server.url).select_customer(customer.local_mobile) + page.get_by_role('button', name='ادامه', exact=True).click() + expect(page.get_by_role('heading', name='ثبت فیش برای مشتری', exact=True)).to_be_visible() + expect(page.locator('#id_paid_amount')).to_be_visible() + page.get_by_role('button', name='ثبت و ارسال فیش', exact=True).click() + expect(page.locator('#receipt-errors')).to_be_visible() + expect(page.locator('#id_paid_amount')).to_be_focused() + assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1') + + +def test_customer_search_disambiguation_and_context_shortcuts(live_server, browser_page, payment_case): + from tests.e2e.pages.ticket_notices import TicketNoticesPage + from apps.customers.models import CustomerProfile + from apps.tickets.models import Ticket, TicketCategory + from django.utils import timezone + customer, account, reviewers = payment_case + CustomerProfile.objects.create(user=customer, first_name='محمد', last_name='رضایی', organization_name='شرکت اول') + other = User.objects.create_user(username='other-payment-customer', mobile='09120009022', account_type='CUSTOMER') + CustomerProfile.objects.create(user=other, first_name='محمد', last_name='رضایی', organization_name='شرکت دوم') + ticket = Ticket.objects.create(customer=customer, + category=TicketCategory.objects.create(title='پرونده مالی', slug='finance-picker-e2e'), + subject='سابقه خرید برای انتخاب دقیق', owner=reviewers[0], selected_expert=reviewers[0], + origin_visibility='SELECTED', last_activity_at=timezone.now()) + with browser_page() as page: + AuthenticationPage(page, live_server.url).login_staff(reviewers[0], PASSWORD) + page.goto(live_server.url + reverse('staff_payments:notice_new')) + page.get_by_label('جست‌وجو و انتخاب مشتری', exact=False).fill('محمد رضایی') + expect(page.locator('.pay-customer-result')).to_have_count(2) + result = page.locator('.pay-customer-result').filter(has_text=customer.local_mobile) + result.locator('summary').click() + expect(result.get_by_role('link', name=ticket.ticket_number + ' · ' + ticket.subject)).to_be_visible() + result.get_by_role('button', name='انتخاب این مشتری', exact=True).click() + expect(page.locator('[data-selected-customer]')).to_contain_text('شرکت اول') + expect(page.locator('[data-selected-customer]')).to_contain_text(customer.local_mobile) + page.locator('#id_account').select_option(str(account.pk)) + page.locator('#id_subject').fill('انتخاب مشتری باید دوباره انجام شود') + page.locator('#id_amount').fill('100000') + page.get_by_label('جست‌وجو و انتخاب مشتری', exact=False).fill('0912') + page.get_by_role('button', name='صدور و اطلاع‌رسانی', exact=True).click() + expect(page.get_by_label('جست‌وجو و انتخاب مشتری', exact=False)).to_be_focused() + page.goto(live_server.url + reverse('staff_tickets:detail', args=[ticket.public_id])) + TicketNoticesPage(page, live_server.url).toggle_notices() + page.locator('#customer-info').get_by_role('link', name='ایجاد اعلامیه واریز', exact=True).click() + expect(page).to_have_url(live_server.url + reverse('staff_payments:notice_new') + '?customer=' + str(customer.public_id)) + expect(page.locator('[data-selected-customer]')).to_contain_text(customer.local_mobile) + page.goto(live_server.url + reverse('staff_tickets:contacts')) + page.locator('.contact-card').filter(has_text=customer.local_mobile).get_by_role('link', name='ایجاد اعلامیه واریز', exact=True).click() + expect(page.locator('[data-selected-customer]')).to_contain_text(customer.local_mobile) + + +def test_customer_finance_preview_and_notice_context(live_server, browser_page, payment_case): + customer, account, reviewers = payment_case + notice = services.issue_notice(reviewers[0], {'customer': customer, 'account': account, + 'amount': 120000, 'subject': 'واریز مرتبط نمونه', 'description': 'توضیحات همیشه نمایان', + 'invoice_number': 'INV-120', 'deposit_id': '00055'}) + with browser_page() as page: + page.set_viewport_size({'width': 320, 'height': 568}) + AuthenticationPage(page, live_server.url).login_customer(customer, PASSWORD) + page.goto(live_server.url + reverse('payments:notices') + '?notice=' + str(notice.public_id)) + expect(page.get_by_text('توضیحات همیشه نمایان', exact=True)).to_be_visible() + assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1') + page.get_by_role('link', name='ارسال فیش این واریز').click() + expect(page.locator('[data-selected-notice-title]')).to_have_text(notice.subject) + expect(page.locator('[data-selected-notice-summary]')).to_contain_text('INV-120') + expect(page.locator('[data-selected-notice-link]')).to_have_attribute('href', reverse('payments:notice_detail', args=[notice.public_id])) + page.locator('#id_receipt_image').set_input_files({'name': 'receipt.png', 'mimeType': 'image/png', 'buffer': PNG}) + expect(page.get_by_alt_text('پیش‌نمایش تصویر فیش')).to_be_visible() + expect(page.locator('#receipt-file-status')).to_contain_text('receipt.png') + page.get_by_role('button', name='حذف انتخاب').click() + expect(page.get_by_alt_text('پیش‌نمایش تصویر فیش')).to_be_hidden() + page.locator('#id_paid_amount').fill('120000') + page.get_by_role('button', name='امروز', exact=True).click() + page.get_by_role('button', name='ثبت و ارسال فیش').click() + expect(page.locator('#error-receipt_image')).to_be_visible() + assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1') + + +def test_customer_linked_receipt_status_and_reason_without_access_roster(live_server, browser_page, payment_case): + customer, account, reviewers = payment_case + notice = services.issue_notice(reviewers[0], {'customer': customer, 'account': account, + 'amount': 12345, 'subject': 'بررسی فیش مرتبط', 'description': 'جزئیات واریز'}) + with browser_page() as page: + AuthenticationPage(page, live_server.url).login_customer(customer, PASSWORD) + page.goto(live_server.url + reverse('payments:receipt_new') + '?notice=' + str(notice.public_id)) + page.locator('#id_paid_amount').fill('12345') + page.get_by_role('button', name='امروز', exact=True).click() + page.locator('#id_receipt_image').set_input_files({'name': 'receipt.png', 'mimeType': 'image/png', 'buffer': PNG}) + page.get_by_role('button', name='ثبت و ارسال فیش').click() + expect(page.locator('.flash')).to_contain_text('فیش ثبت شد') + receipt_id = page.url.rstrip('/').rsplit('/', 1)[-1] + page.get_by_role('button', name='خروج', exact=True).first.click() + AuthenticationPage(page, live_server.url).login_staff(reviewers[1], PASSWORD) + page.goto(live_server.url + reverse('staff_payments:receipt_detail', args=[receipt_id])) + expect(page.get_by_role('button', name='ثبت تأیید من', exact=True)).to_be_visible() + expect(page.get_by_role('heading', name='افراد تعیین‌شده', exact=True)).to_have_count(0) + page.locator('#decision-reason').fill('تصویر فیش خوانا نیست') + page.get_by_role('button', name='رد فیش', exact=True).click() + expect(page.get_by_text('تصویر فیش خوانا نیست', exact=True)).to_be_visible() + page.get_by_role('button', name='خروج', exact=True).first.click() + AuthenticationPage(page, live_server.url).login_customer(customer, PASSWORD) + page.goto(live_server.url + reverse('payments:notices') + '?notice=' + str(notice.public_id)) + expect(page.get_by_text('تصویر فیش خوانا نیست', exact=True)).to_be_visible() + page.get_by_role('link', name='جزئیات فیش و سوابق بررسی', exact=True).click() + expect(page.get_by_text('تصویر فیش خوانا نیست', exact=True)).to_be_visible() + expect(page.get_by_role('heading', name='افراد تعیین‌شده', exact=True)).to_have_count(0) + expect(page.get_by_role('heading', name='سوابق ارسال و اصلاح', exact=True)).to_be_visible() + + +def test_notice_permalink_and_staff_receipt_preview(live_server, browser_page, payment_case): + customer, account, reviewers = payment_case + notice = services.issue_notice(reviewers[0], {'customer': customer, 'account': account, + 'amount': 12000, 'subject': 'واریز با لینک مستقل', 'description': 'اطلاعات واریز'}) + with browser_page() as page: + page.set_viewport_size({'width': 375, 'height': 812}) + AuthenticationPage(page, live_server.url).login_customer(customer, PASSWORD) + page.goto(live_server.url + reverse('payments:notices')) + page.get_by_role('link', name='جزئیات واریز و فیش‌ها', exact=True).click() + expect(page).to_have_url(live_server.url + reverse('payments:notice_detail', args=[notice.public_id])) + expect(page.get_by_text('اطلاعات واریز', exact=True)).to_be_visible() + page.get_by_role('link', name='ارسال فیش این واریز', exact=True).click() + page.locator('[data-selected-notice-link]').click() + expect(page).to_have_url(live_server.url + reverse('payments:notice_detail', args=[notice.public_id])) + page.get_by_role('button', name='خروج', exact=True).first.click() + AuthenticationPage(page, live_server.url).login_staff(reviewers[0], PASSWORD) + page.goto(live_server.url + reverse('staff_payments:receipt_new', args=[customer.public_id]) + '?notice=' + str(notice.public_id)) + expect(page.get_by_role('heading', name='ثبت فیش برای مشتری', exact=True)).to_be_visible() + expect(page.locator('[data-selected-notice-link]')).to_have_attribute('href', reverse('staff_payments:notice_detail', args=[notice.public_id])) + page.locator('#id_paid_amount').fill('12000') + page.get_by_role('button', name='امروز', exact=True).click() + page.locator('#id_receipt_image').set_input_files({'name': 'receipt.png', 'mimeType': 'image/png', 'buffer': PNG}) + expect(page.get_by_alt_text('پیش‌نمایش تصویر فیش')).to_be_visible() + assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1') + page.get_by_role('button', name='ثبت و ارسال فیش', exact=True).click() + expect(page.locator('.flash')).to_contain_text('فیش ثبت شد') + + +def test_notice_identifier_generation_preserves_manual_value_and_customer_context(live_server, browser_page, payment_case): + customer, account, reviewers = payment_case + account.requires_deposit_id = True + account.save() + with browser_page() as page: + page.set_viewport_size({'width': 375, 'height': 812}) + AuthenticationPage(page, live_server.url).login_staff(reviewers[0], PASSWORD) + page.goto(live_server.url + reverse('staff_payments:notice_new')) + generate = page.get_by_role('button', name='تولید خودکار شناسه', exact=True) + expect(generate).to_be_disabled() + PaymentsPage(page, live_server.url).select_customer(customer.local_mobile) + page.locator('#id_account').select_option(str(account.pk)) + expect(page.locator('#id_deposit_id')).to_have_attribute('required', '') + generate.click() + expect(page.locator('#id_deposit_id')).to_have_value('0912000902169') + page.locator('#id_deposit_id').fill('manual-123') + generate.click() + expect(page.locator('#identifier-candidate')).to_have_text('0912000902169') + expect(page.locator('#id_deposit_id')).to_have_value('manual-123') + page.get_by_role('button', name='جایگزینی شناسه', exact=True).click() + expect(page.locator('#id_deposit_id')).to_have_value('0912000902169') + page.get_by_role('button', name='تغییر مشتری', exact=True).click() + expect(page.locator('#id_deposit_id')).to_have_value('') + expect(generate).to_be_disabled() + assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1') diff --git a/tests/e2e/test_ticket_notices_e2e.py b/tests/e2e/test_ticket_notices_e2e.py new file mode 100644 index 0000000..ec28b84 --- /dev/null +++ b/tests/e2e/test_ticket_notices_e2e.py @@ -0,0 +1,53 @@ +import pytest +from django.urls import reverse +from django.utils import timezone +from playwright.sync_api import expect + +from apps.accounts.models import User +from apps.payments import services +from apps.payments.models import BankInstitution, CompanyBankAccount +from apps.staff.models import StaffProfile +from apps.tickets.models import Ticket, TicketCategory +from tests.e2e.pages.auth import AuthenticationPage +from tests.e2e.pages.ticket_notices import TicketNoticesPage + +pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)] + + +@pytest.mark.parametrize('width', [1440, 390]) +def test_ticket_notices_toggle_and_customer_navigation(live_server, browser_page, width): + password = 'TicketNotices-e2e-2026' + staff = User.objects.create_user(username='notice-staff', password=password, account_type='STAFF') + StaffProfile.objects.create(user=staff, display_name='کارشناس اعلامیه') + customer = User.objects.create_user(username='notice-customer', account_type='CUSTOMER') + bank, _ = BankInstitution.objects.get_or_create(code='melli', defaults={'name': 'ملی'}) + account = CompanyBankAccount.objects.create(name='حساب اعلامیه', holder='شرکت', bank=bank, account_number='123') + notices = [services.issue_notice(staff, {'customer': customer, 'account': account, + 'subject': f'اعلامیه نمونه {index}', 'amount': 12345}) for index in range(4)] + ticket = Ticket.objects.create(customer=customer, + category=TicketCategory.objects.create(title='مالی', slug='ticket-notices'), + subject='تیکت اعلامیه', owner=staff, selected_expert=staff, + origin_visibility='SELECTED', last_activity_at=timezone.now()) + ticket_url = reverse('staff_tickets:detail', args=[ticket.public_id]) + with browser_page() as page: + page.set_viewport_size({'width': width, 'height': 900}) + AuthenticationPage(page, live_server.url).login_staff(staff, password) + view = TicketNoticesPage(page, live_server.url) + view.goto(ticket_url) + view.open_customer_panel() + view.expect_closed() + view.toggle_notices() + view.expect_open() + expect(page.get_by_role('list', name='اعلامیه‌های اخیر مشتری')).to_contain_text('در انتظار واریز') + expect(page.get_by_role('list', name='اعلامیه‌های اخیر مشتری')).not_to_contain_text(notices[0].subject) + view.toggle_notices() + view.expect_closed() + view.toggle_notices() + view.open_notice(notices[-1].subject) + expect(page).to_have_url(live_server.url + reverse('staff_payments:notice_detail', args=[notices[-1].public_id])) + view.goto(ticket_url) + view.open_customer_panel() + view.toggle_notices() + view.open_all() + expect(page).to_have_url(live_server.url + reverse('staff_payments:desk') + f'?tab=notices&customer={customer.pk}') + expect(page.get_by_text(notices[0].subject, exact=True)).to_be_visible() diff --git a/tests/test_magic_link_confirmation.py b/tests/test_magic_link_confirmation.py new file mode 100644 index 0000000..e3e1a5b --- /dev/null +++ b/tests/test_magic_link_confirmation.py @@ -0,0 +1,211 @@ +from datetime import timedelta +from uuid import uuid4 + +import pytest +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import Client +from django.urls import reverse +from django.utils import timezone + +from apps.accounts.magic_access import MAGIC_LINK_SESSION_KEY +from apps.accounts.models import MagicLinkAccessLog, TicketMagicLink, User +from apps.attachments.models import Attachment, AttachmentPolicy +from apps.customers.models import CustomerProfile +from apps.messaging.models import Message +from apps.tickets.models import TicketCategory +from apps.tickets.services import create_ticket, reply_ticket + + +pytestmark = pytest.mark.django_db +CONFIRMATION_MESSAGE = ( + "شما با حساب دیگری وارد سامانه شده‌اید. برای مشاهده تیکت مرتبط با این لینک، " + "از حساب فعلی خارج شوید. پس از خروج، تیکت به‌صورت خودکار باز می‌شود و نیازی به ورود مجدد ندارید." +) + + +@pytest.fixture +def magic_case(settings, tmp_path): + settings.MEDIA_ROOT = tmp_path + category = TicketCategory.objects.create(title="لینک امن", slug="magic-confirmation") + owner = User.objects.create_user(username="link-owner", mobile="09121000101", account_type="CUSTOMER") + other = User.objects.create_user(username="link-other", mobile="09121000102", account_type="CUSTOMER") + CustomerProfile.objects.create(user=owner) + CustomerProfile.objects.create(user=other) + ticket = create_ticket(customer=owner, category=category, subject="موضوع محرمانه", body="پیام محرمانه") + sibling = create_ticket(customer=owner, category=category, subject="تیکت خارج از لینک", body="پیام دیگر") + token, link = TicketMagicLink.issue_with_record(ticket) + return owner, other, ticket, sibling, link, reverse("accounts:magic", args=[token]) + + +def confirmation_data(user): + return {"action": "logout_and_open", "account_id": str(user.pk)} + + +@pytest.mark.parametrize("account_type", ["CUSTOMER", "STAFF"]) +def test_mismatched_account_get_requires_confirmation_without_leaking_ticket(client, magic_case, account_type): + owner, other, ticket, sibling, link, url = magic_case + other.account_type = account_type + other.save(update_fields=["account_type"]) + client.force_login(other) + response = client.get(url) + content = response.content.decode() + assert response.status_code == 200 + assert CONFIRMATION_MESSAGE in content + assert "خروج از حساب و مشاهده تیکت" in content + assert "انصراف از مشاهده این تیکت/پیام" in content + assert ticket.subject not in content and "پیام محرمانه" not in content + assert owner.mobile not in content + assert client.session["_auth_user_id"] == str(other.pk) + assert MAGIC_LINK_SESSION_KEY not in client.session + assert MagicLinkAccessLog.objects.count() == 0 + link.refresh_from_db() + assert link.view_count == 0 and link.used_at is None + assert "no-store" in response["Cache-Control"] + assert response["Referrer-Policy"] == "same-origin" + + +@pytest.mark.parametrize("account_type", ["CUSTOMER", "STAFF"]) +def test_confirmed_logout_opens_only_linked_ticket_and_uses_owner_for_reply(client, magic_case, account_type): + owner, other, ticket, sibling, link, url = magic_case + other.account_type = account_type + other.save(update_fields=["account_type"]) + client.force_login(other) + session = client.session + session["unrelated_account_data"] = "must disappear" + session.save() + old_session_key = session.session_key + response = client.post(url, confirmation_data(other), follow=True) + assert response.status_code == 200 + assert response.redirect_chain == [(reverse("tickets:detail", args=[ticket.public_id]), 302)] + assert "پیام محرمانه" in response.content.decode() + assert "فقط به همین تیکت دسترسی دارید" in response.content.decode() + assert "_auth_user_id" not in client.session + assert "unrelated_account_data" not in client.session + assert client.session.session_key != old_session_key + assert client.session[MAGIC_LINK_SESSION_KEY] == link.pk + for destination in ( + reverse("tickets:detail", args=[sibling.public_id]), + reverse("tickets:list"), reverse("tickets:new"), reverse("accounts:profile"), reverse("payments:home"), + ): + assert client.get(destination).status_code == 302 + for name in ("messages", "poll"): + assert client.get(reverse(f"tickets:{name}", args=[ticket.public_id])).status_code == 200 + reply = client.post(reverse("tickets:reply", args=[ticket.public_id]), {"body": "پاسخ پس از خروج"}) + assert reply.status_code == 302 + assert Message.objects.get(ticket=ticket, body="پاسخ پس از خروج").author_id == owner.pk + link.refresh_from_db() + assert link.view_count == 1 + assert MagicLinkAccessLog.objects.filter(magic_link=link).count() == 1 + + +def test_cancel_preserves_current_account_and_does_not_consume_link(client, magic_case): + owner, other, ticket, sibling, link, url = magic_case + client.force_login(other) + client.get(url) + response = client.get(reverse("accounts:home"), follow=True) + assert response.status_code == 200 + assert client.session["_auth_user_id"] == str(other.pk) + assert MAGIC_LINK_SESSION_KEY not in client.session + assert MagicLinkAccessLog.objects.count() == 0 + + +@pytest.mark.parametrize("invalid_state", ["expired", "revoked", "inactive", "unknown"]) +def test_link_revalidated_before_logout(client, magic_case, invalid_state): + owner, other, ticket, sibling, link, url = magic_case + client.force_login(other) + assert client.get(url).status_code == 200 + if invalid_state == "expired": + link.expires_at = timezone.now() - timedelta(seconds=1) + link.save(update_fields=["expires_at"]) + elif invalid_state == "revoked": + link.revoked_at = timezone.now() + link.save(update_fields=["revoked_at"]) + elif invalid_state == "inactive": + owner.is_active = False + owner.save(update_fields=["is_active"]) + else: + url = reverse("accounts:magic", args=["unknown-token"]) + response = client.post(url, confirmation_data(other)) + assert response.status_code == 302 and response.url == reverse("accounts:login") + assert client.session["_auth_user_id"] == str(other.pk) + assert MAGIC_LINK_SESSION_KEY not in client.session + assert MagicLinkAccessLog.objects.count() == 0 + + +@pytest.mark.parametrize("payload", [{}, {"action": "other"}, {"action": "logout_and_open", "account_id": "wrong"}]) +def test_unconfirmed_post_does_not_logout(client, magic_case, payload): + owner, other, ticket, sibling, link, url = magic_case + client.force_login(other) + assert client.post(url, payload).status_code == 200 + assert client.session["_auth_user_id"] == str(other.pk) + assert MagicLinkAccessLog.objects.count() == 0 + + +def test_confirmation_from_previous_account_cannot_logout_new_account(client, magic_case): + owner, other, ticket, sibling, link, url = magic_case + client.force_login(other) + client.get(url) + replacement = User.objects.create_user(username="replacement", mobile="09121000103", account_type="CUSTOMER") + client.force_login(replacement) + response = client.post(url, confirmation_data(other)) + assert response.status_code == 200 + assert client.session["_auth_user_id"] == str(replacement.pk) + assert MagicLinkAccessLog.objects.count() == 0 + + +def test_logout_confirmation_requires_csrf_and_valid_post_succeeds(magic_case): + owner, other, ticket, sibling, link, url = magic_case + client = Client(enforce_csrf_checks=True) + client.force_login(other) + client.get(url) + assert client.post(url, confirmation_data(other)).status_code == 403 + assert client.session["_auth_user_id"] == str(other.pk) + response = client.post(url, {**confirmation_data(other), "csrfmiddlewaretoken": client.cookies["csrftoken"].value}) + assert response.status_code == 302 and response.url == reverse("tickets:detail", args=[ticket.public_id]) + assert "_auth_user_id" not in client.session + + +@pytest.mark.parametrize("authenticated", [False, True]) +def test_existing_anonymous_and_matching_account_flow_remains_direct(client, magic_case, authenticated): + owner, other, ticket, sibling, link, url = magic_case + if authenticated: + client.force_login(owner) + response = client.get(url) + assert response.status_code == 302 and response.url == reverse("tickets:detail", args=[ticket.public_id]) + assert client.get(response.url).status_code == 200 + assert "no-store" in response["Cache-Control"] + if authenticated: + assert client.session["_auth_user_id"] == str(owner.pk) + assert MAGIC_LINK_SESSION_KEY not in client.session + else: + assert "_auth_user_id" not in client.session + assert client.session[MAGIC_LINK_SESSION_KEY] == link.pk + + +def test_link_attachment_permissions_remain_scoped_after_logout(client, magic_case): + owner, other, ticket, sibling, link, url = magic_case + AttachmentPolicy.objects.create(attachment_type="PDF", allowed_extensions=[".pdf"], max_file_size=1024 * 1024) + file = SimpleUploadedFile("linked.pdf", b"%PDF-1.4 linked", content_type="application/pdf") + reply_ticket(ticket_id=ticket.pk, user=owner, body="پیوست مجاز", files=[file]) + attachment = ticket.ticket_messages.get(body="پیوست مجاز").attachments.get() + internal = Message.objects.create(ticket=ticket, author=other, body="یادداشت داخلی", is_internal=True) + internal_attachment = Attachment.objects.get(pk=attachment.pk) + internal_attachment.pk = None + internal_attachment.public_id = uuid4() + internal_attachment.message = internal + internal_attachment.save() + client.force_login(other) + client.post(url, confirmation_data(other)) + assert client.get(reverse("attachments:download", args=[attachment.public_id])).status_code == 200 + assert client.get(reverse("attachments:download", args=[internal_attachment.public_id])).status_code == 403 + attachment.message = sibling.ticket_messages.first() + attachment.save(update_fields=["message"]) + assert client.get(reverse("attachments:download", args=[attachment.public_id])).status_code == 403 + + +@pytest.mark.parametrize("method", ["put", "delete"]) +def test_unsupported_method_never_logs_out(client, magic_case, method): + owner, other, ticket, sibling, link, url = magic_case + client.force_login(other) + assert getattr(client, method)(url).status_code == 405 + assert client.session["_auth_user_id"] == str(other.pk) diff --git a/tests/test_payment_concurrency.py b/tests/test_payment_concurrency.py new file mode 100644 index 0000000..727f4a9 --- /dev/null +++ b/tests/test_payment_concurrency.py @@ -0,0 +1,49 @@ +"""Run against an isolated PostgreSQL test DB; SQLite cannot prove row-lock semantics. +USE_SQLITE=false POSTGRES_* ... pytest tests/test_payment_concurrency.py +Never point POSTGRES_DB at a production database. +""" +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier +import pytest +from django.db import connection, close_old_connections +from django.core.exceptions import ValidationError +from apps.accounts.models import User +from apps.notifications.models import NotificationJob +from apps.payments import services +from apps.payments.models import PaymentReceipt, ReceiptDecision +from tests.test_payments import case, submit + +pytestmark = [pytest.mark.django_db(transaction=True), + pytest.mark.skipif(connection.vendor != 'postgresql', reason='Requires PostgreSQL row locks')] + + +@pytest.mark.parametrize('actors,actions,final', [ + (('first','second'), ('APPROVE','APPROVE'), 'APPROVED'), + (('first','first'), ('APPROVE','APPROVE'), 'PARTIAL'), + (('first','second'), ('APPROVE','REJECT'), 'REJECTED'), + (('first','senior'), ('APPROVE','SENIOR_APPROVE'), 'APPROVED'), +]) +def test_concurrent_decisions_have_one_consistent_final_result(case, actors, actions, final): + receipt = submit(case) + barrier = Barrier(2) + def decide(index): + close_old_connections() + try: + actor = User.objects.get(pk=case[actors[index]].pk) + barrier.wait(timeout=10) + try: + services.decide_receipt(actor, receipt.public_id, 1, actions[index], 'بررسی پرداخت') + return 'recorded' + except ValidationError: + return 'already finalized' + finally: + connection.close() + with ThreadPoolExecutor(max_workers=2) as pool: + outcomes = list(pool.map(decide, range(2))) + receipt.refresh_from_db() + assert receipt.status == final + assert receipt.revisions.get().cycle.status == final + assert outcomes.count('recorded') >= 1 + votes = ReceiptDecision.objects.filter(cycle__revision__receipt=receipt) + assert votes.count() == (1 if actors[0] == actors[1] else outcomes.count('recorded')) + assert NotificationJob.objects.filter(event_type='PAYMENT_RECEIPT_APPROVED').count() == (1 if final == 'APPROVED' else 0) diff --git a/tests/test_payment_links.py b/tests/test_payment_links.py new file mode 100644 index 0000000..d226c12 --- /dev/null +++ b/tests/test_payment_links.py @@ -0,0 +1,260 @@ +"""Permanent regressions for SMS payment capabilities; no real SMS is sent.""" +import uuid +from datetime import timedelta +from urllib.parse import urlsplit + +import pytest +from django.core.exceptions import ValidationError +from django.core.exceptions import PermissionDenied +from django.test import Client +from django.urls import reverse +from django.utils import timezone + +from apps.accounts.models import User +from apps.core.models import SiteBranding +from apps.notifications.models import NotificationJob, SMSTemplate +from apps.payments import services +from apps.payments.access import SESSION_KEY, issue_payment_link, token_digest +from apps.payments.models import PaymentAccessLink, PaymentNotification, PaymentReceipt +from apps.payments.notifications import send_payment_sms +from apps.integrations.sms.providers import SMSResult +from tests.test_payments import case, data, upload, submit + +pytestmark = pytest.mark.django_db + + +def open_notice(client, case): + link, url = issue_payment_link(notice=case['notice']) + response = client.get(urlsplit(url).path, follow=True) + assert response.status_code == 200 + return link + + +def test_sms_link_is_temporary_reusable_and_does_not_log_in(case, client, settings): + settings.PAYMENT_LINK_TTL_DAYS = 3 + job = NotificationJob.objects.get(event_type='PAYMENT_NOTICE_CREATED') + class Provider: + def send_message(self, mobile, text): + return SMSResult(True, 'test-provider') + send_payment_sms(job, provider=Provider()) + notice_sms = PaymentNotification.objects.get(job=job) + url = notice_sms.context['payment_link'] + token = urlsplit(url).path.rstrip('/').rsplit('/', 1)[-1] + assert notice_sms.access_link.token_hash == token_digest(token) + assert notice_sms.access_link.expires_at > timezone.now() + timedelta(days=2) + first_body = notice_sms.rendered_body + send_payment_sms(job, provider=Provider()) + notice_sms.refresh_from_db() + assert notice_sms.rendered_body == first_body and PaymentAccessLink.objects.count() == 1 + for _ in range(2): + response = client.get(urlsplit(url).path, follow=True) + assert response.status_code == 200 + assert case['notice'].deposit_id in response.content.decode() + assert '_auth_user_id' not in client.session + notice_sms.access_link.refresh_from_db() + assert notice_sms.access_link.view_count == 2 + + +def test_public_notice_preserves_copy_warning_and_database_signature(case, client): + branding = SiteBranding.load() + branding.developer_credit_text = 'امضای توسعه‌دهنده از دیتابیس' + branding.save() + open_notice(client, case) + response = client.get(reverse('payments:notice_detail', args=[case['notice'].public_id])) + html = response.content.decode() + assert 'data-copy="000123"' in html + assert 'کپی کامل اطلاعات' in html and 'pay-deposit-required' in html + assert case['account'].name in html + assert 'نام حساب: ' + case['account'].name in response.context['notice'].copy_text + assert 'واریز بدون این شناسه معتبر نخواهد بود' in html + assert html.count(branding.developer_credit_text) == 1 + assert 'no-store' in response['Cache-Control'] and response['X-Robots-Tag'] == 'noindex, nofollow' + assert reverse('accounts:profile') not in html + + +@pytest.mark.parametrize('linked', [True, False]) +@pytest.mark.django_db(transaction=True) +def test_anonymous_link_can_submit_linked_and_independent_receipts(case, client, linked): + link = open_notice(client, case) + values = data(case, linked=linked, deposit_id='') + response = client.post(reverse('payments:receipt_new'), {**values, 'receipt_image': upload()}) + assert response.status_code == 302 + receipt = PaymentReceipt.objects.get() + assert receipt.customer == case['customer'] and receipt.submitted_by == case['customer'] + assert receipt.notice_id == (case['notice'].pk if linked else None) + assert link.submitted_receipts.filter(pk=receipt.pk).exists() + assert client.get(response.url).status_code == 200 + image = client.get(reverse('payments:receipt_image', args=[receipt.public_id])) + assert image.status_code == 200 and 'no-store' in image['Cache-Control'] + assert b''.join(image.streaming_content) + # A browser retry of the same upload is idempotent. + assert client.post(reverse('payments:receipt_new'), {**values, 'receipt_image': upload()}).status_code == 302 + assert PaymentReceipt.objects.count() == 1 + assert '_auth_user_id' not in client.session + + +def test_scope_blocks_other_customers_and_other_notices_of_same_customer(case, client): + unrelated = services.issue_notice(case['creator'], {'customer': case['customer'], 'account': case['account'], + 'subject': 'اعلامیه خارج از لینک', 'amount': 100, 'deposit_id': '1'}) + another = User.objects.create_user(username='link-other-customer', account_type='CUSTOMER') + foreign = services.issue_notice(case['creator'], {'customer': another, 'account': case['account'], + 'subject': 'خصوصی مشتری دیگر', 'amount': 100, 'deposit_id': '2'}) + open_notice(client, case) + for notice in [unrelated, foreign]: + assert client.get(reverse('payments:notice_detail', args=[notice.public_id])).status_code == 404 + assert client.get(reverse('payments:receipt_new'), {'notice': notice.public_id}).status_code == 404 + response = client.post(reverse('payments:receipt_new'), {**data(case, notice_id=notice.pk), 'receipt_image': upload()}) + assert response.status_code == 200 and 'notice_id' in response.context['form'].errors + assert not PaymentReceipt.objects.exists() + assert client.get(reverse('payments:home')).status_code == 302 + assert client.get(reverse('payments:notices')).status_code == 302 + form = client.get(reverse('payments:receipt_new')).context['form'] + assert list(form.fields['notice_id'].queryset.values_list('pk', flat=True)) == [case['notice'].pk] + + +@pytest.mark.parametrize('invalidator', ['expired', 'revoked', 'inactive']) +def test_expiry_revocation_and_inactive_customer_apply_to_existing_session(case, client, invalidator): + link = open_notice(client, case) + if invalidator == 'expired': + PaymentAccessLink.objects.filter(pk=link.pk).update(expires_at=timezone.now() - timedelta(seconds=1)) + elif invalidator == 'revoked': + PaymentAccessLink.objects.filter(pk=link.pk).update(revoked_at=timezone.now()) + else: + User.objects.filter(pk=case['customer'].pk).update(is_active=False) + response = client.post(reverse('payments:receipt_new'), {**data(case), 'receipt_image': upload()}) + assert response.status_code == 410 and not PaymentReceipt.objects.exists() + assert SESSION_KEY not in client.session + + +def test_invalid_link_and_uuid_are_not_public_capabilities(case, client): + assert client.get(reverse('payments:open_link', args=['not-a-token'])).status_code == 410 + assert client.get(reverse('payments:open_link', args=['A' * 43])).status_code == 410 + assert client.get(reverse('payments:notice_detail', args=[case['notice'].public_id])).status_code == 302 + + +def test_wrong_account_requires_explicit_confirmation(case, client): + _, url = issue_payment_link(notice=case['notice']) + client.force_login(case['outsider']) + path = urlsplit(url).path + assert client.get(path).status_code == 200 + assert '_auth_user_id' in client.session and SESSION_KEY not in client.session + assert client.post(path, {'action': 'logout_and_open', 'account_id': 'wrong'}).status_code == 200 + assert client.post(path, {'action': 'logout_and_open', 'account_id': case['outsider'].pk}).status_code == 302 + assert '_auth_user_id' not in client.session and SESSION_KEY in client.session + + +def test_link_forms_keep_csrf_and_field_errors(case): + client = Client(enforce_csrf_checks=True) + open_notice(client, case) + url = reverse('payments:receipt_new') + assert client.post(url, {'paid_amount': '100'}).status_code == 403 + client.get(url) + token = client.cookies['csrftoken'].value + response = client.post(url, {'csrfmiddlewaretoken': token, 'paid_amount': '۱۲۳٬۴۵۶', 'receipt_mode': 'independent', 'submission_key': uuid.uuid4()}) + assert response.status_code == 200 and response.context['form'].errors + assert response.context['form']['paid_amount'].value() == '۱۲۳٬۴۵۶' + + +def test_sms_allowed_tags_follow_the_event_even_without_model_clean(case): + template = SMSTemplate.objects.get(event_type='PAYMENT_NOTICE_CREATED', unit__isnull=True) + template.allowed_tags = ['magic_link'] + template.save(update_fields=['body']) + template.refresh_from_db() + assert 'payment_link' in template.allowed_tags and 'magic_link' not in template.allowed_tags + assert '{payment_link}' in template.supported_tags + assert 'magic_link' in SMSTemplate.tags_for_event('STAFF_REPLY') + template.body = '{magic_link}' + with pytest.raises(ValidationError): + template.clean() + template.body = '{payment_link' + with pytest.raises(ValidationError): + template.clean() + + +def test_approval_sms_link_opens_the_correct_receipt_without_login(case, client): + receipt = submit(case) + services.decide_receipt(case['first'], receipt.public_id, 1, 'APPROVE') + services.decide_receipt(case['second'], receipt.public_id, 1, 'APPROVE') + job = NotificationJob.objects.get(event_type='PAYMENT_RECEIPT_APPROVED') + class Provider: + def send_message(self, mobile, text): + return SMSResult(True, 'approval-test') + send_payment_sms(job, provider=Provider()) + notification = PaymentNotification.objects.get(job=job) + assert notification.access_link.receipt_id == receipt.pk + response = client.get(urlsplit(notification.context['payment_link']).path, follow=True) + assert response.status_code == 200 and receipt.number in response.content.decode() + assert '_auth_user_id' not in client.session + assert 'ثبت تأیید من' not in response.content.decode() + + +def test_receipt_scope_images_revocation_and_rejected_revision(case, client): + receipt = submit(case) + another_notice = services.issue_notice(case['creator'], {'customer': case['customer'], 'account': case['account'], + 'subject': 'خارج از دسترسی', 'amount': 100, 'deposit_id': '3'}) + form_data = {**data(case, notice_id=another_notice.pk), 'receipt_image': upload()} + from apps.payments.forms import ReceiptForm + form = ReceiptForm(form_data, {'receipt_image': form_data.pop('receipt_image')}, customer=case['customer']) + assert form.is_valid() + unrelated = services.submit_receipt(case['customer'], case['customer'], form.cleaned_data) + link = open_notice(client, case) + assert client.get(reverse('payments:receipt_detail', args=[unrelated.public_id])).status_code == 404 + assert client.get(reverse('payments:receipt_image', args=[unrelated.public_id])).status_code == 403 + assert client.post(reverse('staff_payments:decision', args=[receipt.public_id]), {'version': 1, 'action': 'APPROVE'}).status_code == 302 + receipt.refresh_from_db() + assert receipt.status == 'PENDING' + services.decide_receipt(case['first'], receipt.public_id, 1, 'REJECT', 'تصویر خواناتر ارسال شود') + revise = reverse('payments:receipt_revise', args=[receipt.public_id]) + assert client.get(revise).status_code == 200 + values = {**data(case), 'receipt_image': upload(), 'version': 1} + assert client.post(revise, values).status_code == 302 + receipt.refresh_from_db() + assert receipt.current_version == 2 and receipt.status == 'PENDING' + PaymentAccessLink.objects.filter(pk=link.pk).update(revoked_at=timezone.now()) + assert client.get(reverse('payments:receipt_image', args=[receipt.public_id])).status_code == 410 + + +def test_cached_unsent_sms_is_upgraded_without_rewriting_custom_text(case): + job = NotificationJob.objects.get(event_type='PAYMENT_NOTICE_CREATED') + notification = PaymentNotification.objects.get(job=job) + old_url = notification.context['payment_link'] + notification.rendered_body = 'متن محفوظ قبل و بعد ' + old_url + notification.save(update_fields=['rendered_body']) + class Provider: + def send_message(self, mobile, text): + return SMSResult(True, 'upgrade-test') + send_payment_sms(job, provider=Provider()) + notification.refresh_from_db() + assert notification.access_link_id + assert notification.rendered_body == 'متن محفوظ قبل و بعد ' + notification.context['payment_link'] + + +def test_staff_can_issue_a_fresh_copyable_link_without_sending_sms(case, client): + client.force_login(case['creator']) + url = reverse('staff_payments:notice_payment_link', args=[case['notice'].public_id]) + assert client.get(url).status_code == 405 + response = client.post(url, follow=True) + assert response.status_code == 200 + share_url = response.context['payment_share_url'] + assert f'data-copy="{share_url}"' in response.content.decode() + assert PaymentAccessLink.objects.count() == 1 + assert NotificationJob.objects.filter(event_type='PAYMENT_NOTICE_CREATED').count() == 1 + client.logout() + assert client.get(urlsplit(share_url).path, follow=True).status_code == 200 + + +def test_submission_service_enforces_link_scope_and_validity(case): + from apps.payments.forms import ReceiptForm + link, _ = issue_payment_link(notice=case['notice']) + another = services.issue_notice(case['creator'], {'customer': case['customer'], 'account': case['account'], + 'subject': 'خارج از لینک', 'amount': 100, 'deposit_id': '4'}) + form = ReceiptForm(data(case, notice_id=another.pk), {'receipt_image': upload()}, customer=case['customer']) + assert form.is_valid() + with pytest.raises(PermissionDenied): + services.submit_receipt(case['customer'], case['customer'], form.cleaned_data, access_link=link) + PaymentAccessLink.objects.filter(pk=link.pk).update(revoked_at=timezone.now()) + independent = ReceiptForm(data(case, linked=False), {'receipt_image': upload()}, customer=case['customer']) + assert independent.is_valid() + with pytest.raises(PermissionDenied): + services.submit_receipt(case['customer'], case['customer'], independent.cleaned_data, access_link=link) + assert not PaymentReceipt.objects.exists() diff --git a/tests/test_payments.py b/tests/test_payments.py new file mode 100644 index 0000000..54bfe1e --- /dev/null +++ b/tests/test_payments.py @@ -0,0 +1,718 @@ +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 '