add payment_link and banks

This commit is contained in:
javad committed 2026-10-06 10:03:22 +03:30
1 parent 2ae79b1ca4
commit de3135b588
100 files changed
+6166 -46

No files matched your search

+123 -9
View File
@@ -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
+36
View File
@@ -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}"
+2 -4
View File
@@ -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):
+41 -10
View File
@@ -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]
+12
View File
@@ -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)
+6 -1
View File
@@ -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")
@@ -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", "")
@@ -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),
),
]
@@ -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),
]
+25 -1
View File
@@ -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
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))}"})
View File
Whitespace-only changes.
+130
View File
@@ -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
+128
View File
@@ -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)
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class PaymentsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.payments'
verbose_name = 'واریزها و تأیید مالی'
+15
View File
@@ -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 {}
+77
View File
@@ -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}
+42
View File
@@ -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}"
+127
View File
@@ -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
+40
View File
@@ -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
+53
View File
@@ -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()
+269
View File
@@ -0,0 +1,269 @@
# Generated by Django 5.1.1 on 2026-10-05 10:44
import apps.payments.models
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('notifications', '0017_alter_smstemplate_event_type'),
('staff', '0012_organizationalunit_supervisor_mobile_5'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BankInstitution',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.SlugField(unique=True)),
('name', models.CharField(max_length=100)),
('is_active', models.BooleanField(default=True)),
('historical', models.BooleanField(default=False)),
],
options={
'ordering': ['historical', 'name'],
},
),
migrations.CreateModel(
name='PaymentAccessConfiguration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('singleton', models.PositiveSmallIntegerField(default=1, editable=False, unique=True)),
('open_view', models.BooleanField(default=True, verbose_name='مشاهده برای همه کارشناسان فعال')),
('open_create', models.BooleanField(default=True, verbose_name='ایجاد برای همه کارشناسان فعال')),
],
options={
'permissions': [('manage_payment_access', 'Manage payment access')],
},
),
migrations.CreateModel(
name='CompanyBankAccount',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('name', models.CharField(max_length=120, verbose_name='نام نمایشی حساب')),
('holder', models.CharField(max_length=150, verbose_name='صاحب حساب')),
('account_number', models.CharField(blank=True, max_length=40, verbose_name='شماره حساب')),
('iban', models.CharField(blank=True, max_length=26, verbose_name='شبا')),
('card_number', models.CharField(blank=True, max_length=16, verbose_name='شماره کارت')),
('requires_deposit_id', models.BooleanField(default=False, verbose_name='شناسه واریز الزامی')),
('is_active', models.BooleanField(default=True, verbose_name='فعال کلی')),
('available_to_staff', models.BooleanField(default=True, verbose_name='قابل انتخاب کارشناسان')),
('available_to_customers', models.BooleanField(default=True, verbose_name='قابل انتخاب مشتریان')),
('sort_order', models.PositiveIntegerField(default=0)),
('bank', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='payments.bankinstitution', verbose_name='بانک')),
],
options={
'ordering': ['sort_order', 'name', 'pk'],
'permissions': [('manage_company_bankaccounts', 'Manage company bank accounts')],
},
),
migrations.CreateModel(
name='PaymentAuditEvent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('actor_name', models.CharField(max_length=150)),
('action', models.CharField(max_length=50)),
('object_type', models.CharField(max_length=50)),
('object_id', models.CharField(max_length=64)),
('details', models.JSONField(default=dict)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('actor', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at', '-pk'],
'permissions': [('view_payment_audit', 'View financial audit')],
},
),
migrations.CreateModel(
name='PaymentNotice',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('account_snapshot', models.JSONField()),
('subject', models.CharField(max_length=200)),
('amount', models.BigIntegerField()),
('deposit_id', models.CharField(blank=True, max_length=100)),
('invoice_number', models.CharField(blank=True, max_length=100)),
('description', models.TextField(blank=True)),
('cancelled_at', models.DateTimeField(blank=True, null=True)),
('account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='payments.companybankaccount')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='issued_payment_notices', to=settings.AUTH_USER_MODEL)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payment_notices', to=settings.AUTH_USER_MODEL)),
('owning_unit', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='staff.organizationalunit')),
],
options={
'ordering': ['-created_at', '-pk'],
},
),
migrations.CreateModel(
name='PaymentNotification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('context', models.JSONField()),
('rendered_body', models.TextField(blank=True)),
('template_snapshot', models.JSONField(default=dict)),
('job', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='payment_notification', to='notifications.notificationjob')),
('notice', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.paymentnotice')),
('unit', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='staff.organizationalunit')),
],
),
migrations.CreateModel(
name='PaymentNotificationAttempt',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('started_at', models.DateTimeField(auto_now_add=True)),
('finished_at', models.DateTimeField(null=True)),
('success', models.BooleanField(null=True)),
('provider_code', models.CharField(blank=True, max_length=50)),
('provider_id', models.CharField(blank=True, max_length=500)),
('error', models.TextField(blank=True)),
('notification', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='attempt_log', to='payments.paymentnotification')),
],
),
migrations.CreateModel(
name='PaymentReceipt',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('status', models.CharField(choices=[('PENDING', 'در انتظار بررسی'), ('PARTIAL', 'تأیید ناقص'), ('APPROVED', 'تأیید شده'), ('REJECTED', 'رد شده')], db_index=True, default='PENDING', max_length=12)),
('submission_key', models.UUIDField(unique=True)),
('current_version', models.PositiveIntegerField(default=1)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payment_receipts', to=settings.AUTH_USER_MODEL)),
('notice', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='receipts', to='payments.paymentnotice')),
('owning_unit', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='staff.organizationalunit')),
('submitted_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='submitted_payment_receipts', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at', '-pk'],
'permissions': [('approve_paymentreceipt', 'Approve payment receipt'), ('senior_approve_paymentreceipt', 'Senior final approval'), ('submit_customer_receipt', 'Submit receipt for customer'), ('view_all_payments', 'View payments across units')],
},
),
migrations.CreateModel(
name='PaymentReceiptRevision',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('version', models.PositiveIntegerField()),
('amount', models.BigIntegerField()),
('paid_date', models.DateField()),
('account_snapshot', models.JSONField()),
('origin_other', models.CharField(blank=True, max_length=100)),
('deposit_id', models.CharField(blank=True, max_length=100)),
('tracking_number', models.CharField(blank=True, max_length=100)),
('description', models.TextField(blank=True)),
('destination_mismatch', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('image', models.FileField(max_length=500, upload_to=apps.payments.models.receipt_path)),
('mime_type', models.CharField(max_length=100)),
('sha256', models.CharField(max_length=64)),
('original_name', models.CharField(max_length=255)),
('file_size', models.PositiveBigIntegerField()),
('destination', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='payments.companybankaccount')),
('origin_bank', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.bankinstitution')),
('receipt', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='revisions', to='payments.paymentreceipt')),
],
),
migrations.CreateModel(
name='ReceiptApprovalCycle',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('policy_snapshot', models.JSONField(default=dict)),
('status', models.CharField(choices=[('PENDING', 'در انتظار بررسی'), ('PARTIAL', 'تأیید ناقص'), ('APPROVED', 'تأیید شده'), ('REJECTED', 'رد شده')], default='PENDING', max_length=12)),
('finalized_at', models.DateTimeField(blank=True, null=True)),
('finalized_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
('revision', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='cycle', to='payments.paymentreceiptrevision')),
],
),
migrations.CreateModel(
name='ReceiptApprovalAssignment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_senior', models.BooleanField(default=False)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='assignments', to='payments.receiptapprovalcycle')),
],
),
migrations.AddField(
model_name='paymentnotification',
name='cycle',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.receiptapprovalcycle'),
),
migrations.CreateModel(
name='ReceiptDecision',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action', models.CharField(choices=[('APPROVE', 'تأیید'), ('REJECT', 'رد'), ('SENIOR_APPROVE', 'تأیید ارشد')], max_length=20)),
('reason', models.TextField(blank=True)),
('actor_name', models.CharField(max_length=150)),
('created_at', models.DateTimeField(auto_now_add=True)),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='decisions', to='payments.receiptapprovalcycle')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='PaymentApprovalPolicy',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=100, verbose_name='عنوان سیاست')),
('is_active', models.BooleanField(default=True)),
('reviewers', models.ManyToManyField(related_name='payment_review_policies', to=settings.AUTH_USER_MODEL, verbose_name='افراد الزامی تأیید')),
('seniors', models.ManyToManyField(blank=True, related_name='payment_senior_policies', to=settings.AUTH_USER_MODEL, verbose_name='تأییدکنندگان ارشد')),
('unit', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='staff.organizationalunit', verbose_name='واحد (خالی: عمومی)')),
],
options={
'permissions': [('manage_payment_approval_policy', 'Manage payment approval policy')],
'constraints': [models.UniqueConstraint(condition=models.Q(('is_active', True), ('unit__isnull', False)), fields=('unit',), name='pay_active_unit_policy'), models.UniqueConstraint(condition=models.Q(('is_active', True), ('unit__isnull', True)), fields=('is_active',), name='pay_active_global_policy')],
},
),
migrations.AddIndex(
model_name='paymentnotice',
index=models.Index(fields=['customer', '-created_at'], name='payments_pa_custome_bc6e3f_idx'),
),
migrations.AddIndex(
model_name='paymentnotice',
index=models.Index(fields=['owning_unit', '-created_at'], name='payments_pa_owning__f2b6af_idx'),
),
migrations.AddConstraint(
model_name='paymentnotice',
constraint=models.CheckConstraint(condition=models.Q(('amount__gt', 0), ('amount__lte', 9007199254740991)), name='pay_notice_amount'),
),
migrations.AddIndex(
model_name='paymentreceipt',
index=models.Index(fields=['customer', '-created_at'], name='payments_pa_custome_e266cc_idx'),
),
migrations.AddIndex(
model_name='paymentreceipt',
index=models.Index(fields=['status', '-created_at'], name='payments_pa_status_5cd50b_idx'),
),
migrations.AddConstraint(
model_name='paymentreceiptrevision',
constraint=models.UniqueConstraint(fields=('receipt', 'version'), name='pay_receipt_revision'),
),
migrations.AddConstraint(
model_name='paymentreceiptrevision',
constraint=models.CheckConstraint(condition=models.Q(('amount__gt', 0), ('amount__lte', 9007199254740991)), name='pay_receipt_amount'),
),
migrations.AddConstraint(
model_name='receiptapprovalassignment',
constraint=models.UniqueConstraint(fields=('cycle', 'user', 'is_senior'), name='pay_cycle_assignment'),
),
migrations.AddConstraint(
model_name='receiptdecision',
constraint=models.UniqueConstraint(fields=('cycle', 'user'), name='pay_one_vote_per_user'),
),
]
@@ -0,0 +1,23 @@
from django.db import migrations
BANKS = [["melli","ملی ایران"],["mellat","ملت"],["saderat","صادرات ایران"],["sepah","سپه"],["tejarat","تجارت"],["refah","رفاه کارگران"],["keshavarzi","کشاورزی"],["maskan","مسکن"],["sanat-madan","صنعت و معدن"],["tosee-saderat","توسعه صادرات ایران"],["tosee-taavon","توسعه تعاون"],["post","پست بانک ایران"],["pasargad","پاسارگاد"],["parsian","پارسیان"],["saman","سامان"],["eghtesad-novin","اقتصاد نوین"],["karafarin","کارآفرین"],["sina","سینا"],["shahr","شهر"],["sarmayeh","سرمایه"],["dey","دی"],["gardeshgari","گردشگری"],["iran-zamin","ایران زمین"],["khavarmianeh","خاورمیانه"],["resalat","قرض‌الحسنه رسالت"],["mehr-iran","قرض‌الحسنه مهر ایران"],["iran-venezuela","مشترک ایران و ونزوئلا"],["melal","مؤسسه اعتباری ملل"],["ayandeh","آینده"],["ansar","انصار"],["ghavamin","قوامین"],["hekmat","حکمت ایرانیان"],["mehr-eghtesad","مهر اقتصاد"],["kosar","مؤسسه کوثر"],["noor","مؤسسه نور"],["tosee","مؤسسه توسعه"]]
TAGS = ['recipient_name','company_name','notice_number','receipt_number','amount_rial','invoice_number','payment_link']
def seed(apps, schema_editor):
Bank = apps.get_model('payments', 'BankInstitution')
Template = apps.get_model('notifications', 'SMSTemplate')
for code, name in BANKS:
Bank.objects.get_or_create(code=code, defaults={'name':name, 'historical':code in {'ayandeh','ansar','ghavamin','hekmat','mehr-eghtesad','kosar','noor','tosee'}})
Bank.objects.get_or_create(code='other', defaults={'name': 'سایر'})
for event, title, body in [
('PAYMENT_NOTICE_CREATED','اعلامیه واریز','{recipient_name}، اعلامیه واریز {notice_number} به مبلغ {amount_rial} ریال برای شما صادر شد.\nمشاهده اطلاعات: {payment_link}\n{company_name}'),
('PAYMENT_RECEIPT_APPROVED','تأیید فیش','{recipient_name}، فیش واریز شما به شماره {receipt_number} و مبلغ {amount_rial} ریال تأیید شد.\nپیگیری: {payment_link}\n{company_name}'),
]:
Template.objects.get_or_create(unit=None,event_type=event, defaults={'title':title,'body':body,'allowed_tags':TAGS,'is_active':True})
apps.get_model('payments','PaymentAccessConfiguration').objects.get_or_create(singleton=1)
class Migration(migrations.Migration):
dependencies = [('payments','0001_initial'), ('notifications','0017_alter_smstemplate_event_type')]
operations = [migrations.RunPython(seed, migrations.RunPython.noop)]
@@ -0,0 +1,19 @@
# Generated by Django 5.1.1 on 2026-10-05 11:05
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('payments', '0002_initial_configuration'),
]
operations = [
migrations.AddField(
model_name='paymentnotice',
name='issuance_key',
field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
]
@@ -0,0 +1,46 @@
# Generated by Django 5.1.1 on 2026-10-06 05:38
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('payments', '0003_paymentnotice_issuance_key'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='PaymentAccessLink',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token_hash', models.CharField(editable=False, max_length=64, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('expires_at', models.DateTimeField()),
('revoked_at', models.DateTimeField(blank=True, null=True)),
('view_count', models.PositiveIntegerField(default=0)),
('last_used_at', models.DateTimeField(blank=True, null=True)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
('notice', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.paymentnotice')),
('receipt', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.paymentreceipt')),
('submitted_receipts', models.ManyToManyField(blank=True, related_name='access_links', to='payments.paymentreceipt')),
],
options={
'verbose_name': 'لینک موقت پرداخت',
'verbose_name_plural': 'لینک\u200cهای موقت پرداخت',
'ordering': ['-created_at'],
},
),
migrations.AddField(
model_name='paymentnotification',
name='access_link',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.paymentaccesslink'),
),
migrations.AddConstraint(
model_name='paymentaccesslink',
constraint=models.CheckConstraint(condition=models.Q(('notice__isnull', False), ('receipt__isnull', False), _connector='OR'), name='payment_link_has_target'),
),
]
Whitespace-only changes.
+234
View File
@@ -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)
+71
View File
@@ -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()
+64
View File
@@ -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())
+78
View File
@@ -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
+286
View File
@@ -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
+22
View File
@@ -0,0 +1,22 @@
from django.urls import path
from . import views
app_name = 'staff_payments'
urlpatterns = [
path('', views.desk, name='desk'),
path('count/', views.counts, name='count'),
path('customers/search/', views.customer_search, name='customer_search'),
path('notices/generate-identifier/', views.generate_deposit_identifier, name='generate_identifier'),
path('notices/new/', views.notice_new, name='notice_new'),
path('notices/<uuid:public_id>/', views.notice_detail, name='notice_detail'),
path('notices/<uuid:public_id>/payment-link/', views.notice_payment_link, name='notice_payment_link'),
path('receipts/new/<uuid:customer_id>/', views.staff_receipt_new, name='receipt_new'),
path('receipts/new/', views.staff_receipt_customer, name='receipt_customer'),
path('receipts/<uuid:public_id>/', views.staff_receipt_detail, name='receipt_detail'),
path('receipts/<uuid:public_id>/decide/', views.decision, name='decision'),
path('receipts/<uuid:public_id>/assign/', views.assign_policy, name='assign_policy'),
path('banks/', views.banks, name='banks'),
path('banks/new/', views.bank_edit, name='bank_new'),
path('banks/bulk/', views.bank_bulk, name='bank_bulk'),
path('banks/<uuid:public_id>/', views.bank_edit, name='bank_edit'),
path('banks/<uuid:public_id>/delete/', views.bank_delete, name='bank_delete'),
]
+14
View File
@@ -0,0 +1,14 @@
from django.urls import path
from . import views, link_views
app_name = 'payments'
urlpatterns = [
path('link/<str:token>/', link_views.open_link, name='open_link'),
path('', views.home, name='home'),
path('bank-accounts/', views.notices, name='notices'),
path('bank-accounts/<uuid:public_id>/', views.customer_notice_detail, name='notice_detail'),
path('payment-receipt/', views.receipt_new, name='receipt_new'),
path('receipts/', views.receipt_list, name='receipt_list'),
path('receipts/<uuid:public_id>/', views.receipt_detail, name='receipt_detail'),
path('receipts/<uuid:public_id>/revise/', views.receipt_revise, name='receipt_revise'),
path('receipts/<uuid:public_id>/image/', views.receipt_image, name='receipt_image'),
]
+93
View File
@@ -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}
+466
View File
@@ -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})
+9
View File
@@ -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
+8 -2
View File
@@ -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": "الگوهای پیامک"})
+5 -4
View File
@@ -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"))
+12 -2
View File
@@ -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/<path:path>", debug_media)]
+6
View File
@@ -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;
+105
View File
@@ -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` است و داده مشتری در حافظه پایدار مرورگر ذخیره نمی‌شود.
@@ -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/<uuid>/`؛ 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 سالم باقی بمانند.
+33
View File
@@ -0,0 +1,33 @@
# لینک موقت پرداخت
لینک `{payment_link}` در پیامک صدور اعلامیه و تأیید نهایی فیش، اکنون یک مجوز مستقل است؛ کاربر با آن وارد حساب نمی‌شود. مسیر ورودی `/chaharsu/link/<token>/` پس از بررسی، به آدرس بدون توکن منتقل می‌شود.
## محدوده و عمر دسترسی
- اعتبار پیش‌فرض ۷ روز است. `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، ارسال فیش مرتبط و مستقل، اصلاح و کنترل تصویرند.
+121
View File
@@ -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{miLine truncated
@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}}
+24
View File
@@ -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; } }
+18
View File
@@ -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}
+10
View File
@@ -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%}}
+25
View File
@@ -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();
})();
+94
View File
@@ -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);
})();
+44
View File
@@ -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();
})();
+65
View File
@@ -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();
})();
+20
View File
@@ -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);}
})();
+78
View File
@@ -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;index<value.length;index++)if(/\d/.test(ascii(value[index])) && --count===0)return index+1;
return value.length;
}
function formatAmount(event){
if(event?.isComposing)return;
const raw=input.value,start=input.selectionStart ?? raw.length,end=input.selectionEnd ?? start;
const normalized=ascii(raw).trim();
const valid=/^\d+$/.test(normalized) || /^\d{1,3}(?:[,٬ \u00a0]\d{3})+$/.test(normalized);
// Re-group after genuine digit edits/deletion, but never sanitize letters, signs or decimal input.
const editing=event?.inputType?.startsWith('delete') || (event?.inputType==='insertText' && /^\d+$/.test(ascii(event.data || '')));
if(!valid && !(editing && /^[\d,٬ \u00a0]+$/.test(normalized))){if(words)words.textContent='';return;}
const digits=normalized.replace(separators,'').replace(/^0+(?=\d)/,'');
if(!digits || digits.length>16 || 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();
})();
+70
View File
@@ -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'});}}
})();
+21
View File
@@ -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 = '';
});
}
+16
View File
@@ -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();
})();
@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% load static %}
{% block title %}این لینک مربوط به حساب دیگری است | {{ site_branding.site_title }}{% endblock %}
{% block extra_head %}
<meta name="referrer" content="same-origin">
<meta name="robots" content="noindex, nofollow">
<link rel="stylesheet" href="{% static 'css/magic-link-confirmation.css' %}?v={{ static_asset_version }}">
<script defer src="{% static 'js/magic-link-confirmation.js' %}?v={{ static_asset_version }}"></script>
{% endblock %}
{% block body %}
<main class="magic-confirmation magic-confirmation-shell">
<div class="magic-confirmation-brand">{% include 'includes/auth_brand.html' with hide_copy=True %}</div>
<section class="magic-confirmation-card" aria-labelledby="confirmation-title" aria-describedby="confirmation-description">
<div class="magic-confirmation-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 17l5-5-5-5M15 12H3M14 3h5a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-5"/></svg>
</div>
<h1 id="confirmation-title">این لینک مربوط به حساب دیگری است</h1>
<p id="confirmation-description">شما با حساب دیگری وارد سامانه شده‌اید. برای مشاهده تیکت مرتبط با این لینک، از حساب فعلی خارج شوید. پس از خروج، تیکت به‌صورت خودکار باز می‌شود و نیازی به ورود مجدد ندارید.</p>
<form method="post" action="{{ request.path }}" class="magic-confirmation-actions" data-magic-confirmation>
{% csrf_token %}
<input type="hidden" name="action" value="logout_and_open">
<input type="hidden" name="account_id" value="{{ user.pk }}">
<button type="submit" class="magic-confirmation-primary">خروج از حساب و مشاهده تیکت</button>
<a class="magic-confirmation-cancel" href="{% url 'accounts:home' %}">انصراف از مشاهده این تیکت/پیام</a>
<span class="magic-confirmation-status" role="status" aria-live="polite"></span>
</form>
</section>
{% include 'includes/auth_footer.html' %}
</main>
{% endblock %}
+11 -4
View File
@@ -13,8 +13,10 @@
<link rel="stylesheet" href="{% static 'css/operations.css' %}?v={{ static_asset_version }}">
<link rel="stylesheet" href="{% static 'css/dispatches.css' %}?v={{ static_asset_version }}">
<script defer src="{% static 'vendor/htmx-2.0.3.min.js' %}?v={{ static_asset_version }}"></script><script defer src="{% static 'vendor/alpine-3.14.8.min.js' %}?v={{ static_asset_version }}"></script>
{% block extra_head %}{% endblock %}
</head>
<body>
{% block body %}
{% if user.is_authenticated and user.account_type == 'STAFF' %}
<div class="staff-shell">
<aside class="staff-sidebar">
@@ -33,6 +35,8 @@
{% if staff_operations_access.contacts %}<a class="{% if active_staff_section == 'contacts' %}active{% endif %}" href="{% url 'staff_tickets:contacts' %}" {% if active_staff_section == 'contacts' %}aria-current="page"{% endif %}><span>♧</span><b>مخاطبان</b></a>{% endif %}
{% if staff_operations_access.dispatches %}<a class="{% if active_staff_section == 'dispatches' %}active{% endif %}" href="{% url 'staff_tickets:dispatch_create' %}" {% if active_staff_section == 'dispatches' %}aria-current="page"{% endif %}><span>⇶</span><b>ارسال گروهی</b></a>{% endif %}
{% if staff_operations_access.audit %}<a class="{% if active_staff_section == 'audit' %}active{% endif %}" href="{% url 'staff_tickets:magic_audit' %}" {% if active_staff_section == 'audit' %}aria-current="page"{% endif %}><span>⌁</span><b>ممیزی لینک‌ها</b></a>{% endif %}
{% if payment_can_view %}<a class="{% if active_staff_section == 'payments' %}active{% endif %}" href="{% url 'staff_payments:desk' %}" {% if active_staff_section == 'payments' %}aria-current="page"{% endif %}><svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3h12v18l-3-2-3 2-3-2-3 2V3Zm3 5h6m-6 4h6m-6 4h3"/></svg><b>اعلامیه واریز</b><em data-payment-count data-count-url="{% url 'staff_payments:count' %}" aria-label="فیش‌های در انتظار بررسی">{{ payment_pending_count }}</em></a>{% endif %}
{% if payment_can_manage_banks %}<a class="{% if active_staff_section == 'banks' %}active{% endif %}" href="{% url 'staff_payments:banks' %}" {% if active_staff_section == 'banks' %}aria-current="page"{% endif %}><svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m3 8 9-5 9 5H3Zm3 3v7m6-7v7m6-7v7M3 21h18"/></svg><b>بانک‌ها</b></a>{% endif %}
</nav>
</div>
<div><div class="staff-profile"><span class="staff-profile-avatar">{{ user.staff_profile.display_name|default:user.username|slice:':1' }}</span><div><strong>{{ user.staff_profile.display_name|default:user.username }}</strong><span>{{ user.staff_profile.job_title|default:'کارشناس فروش' }}</span></div></div><div class="staff-developer-credit">{{ staff_sidebar_credit }}</div></div>
@@ -40,7 +44,7 @@
<div class="staff-main">
<header class="staff-topbar"><div><small>{{ site_branding.staff_panel_label }}</small><h2>{% if active_tab_title %}{{ active_tab_title }}{% else %}{{ site_branding.staff_brand_subtitle }}{% endif %}</h2></div><div class="staff-topbar-actions"><span class="hidden text-xs text-ink/40 sm:block">{{ user.username }}</span><form method="post" action="{% url 'accounts:staff_logout' %}">{% csrf_token %}<button class="logout-button" type="submit">خروج</button></form></div></header>
{% else %}
<header class="site-header"><div class="header-inner"><a class="logo" href="{% if user.is_authenticated %}{% url 'tickets:list' %}{% else %}{% url 'accounts:login' %}{% endif %}">{% block header_logo %}<img class="header-company-logo" src="{{ company_logo_url }}?v={{ static_asset_version }}-{{ branding_version }}" alt="لوگوی شرکت" width="300" height="130">{% endblock %}<span class="logo-copy">{{ site_branding.header_title }}<small>{{ site_branding.header_subtitle }}</small></span></a>{% if user.is_authenticated %}<nav class="desktop-nav"><a href="{% url 'tickets:list' %}">درخواست‌های من</a><a href="{% url 'accounts:profile' %}">پروفایل</a><form method="post" action="{% url 'accounts:logout' %}">{% csrf_token %}<button class="logout-button" type="submit">خروج</button></form></nav>{% endif %}</div></header>
{% block customer_header %}<header class="site-header"><div class="header-inner"><a class="logo" href="{% if user.is_authenticated %}{% url 'payments:home' %}{% else %}{% url 'accounts:login' %}{% endif %}">{% block header_logo %}<img class="header-company-logo" src="{{ company_logo_url }}?v={{ static_asset_version }}-{{ branding_version }}" alt="لوگوی شرکت" width="300" height="130">{% endblock %}<span class="logo-copy">{{ site_branding.header_title }}<small>{{ site_branding.header_subtitle }}</small></span></a>{% if user.is_authenticated %}<nav class="desktop-nav"><a href="{% url 'tickets:list' %}">درخواست‌های من</a><a href="{% url 'accounts:profile' %}">پروفایل</a><form method="post" action="{% url 'accounts:logout' %}">{% csrf_token %}<button class="logout-button" type="submit">خروج</button></form></nav>{% endif %}</div></header>{% endblock %}
{% endif %}
<main class="{% if user.is_authenticated and user.account_type == 'STAFF' %}staff-content{% else %}customer-main{% endif %}">{% for message in messages %}<div class="flash {{ message.tags }}" role="status">{{ message }}</div>{% endfor %}{% block content %}{% endblock %}</main>
@@ -48,7 +52,7 @@
{% if user.is_authenticated and user.account_type == 'STAFF' %}
</div>
<nav class="staff-mobile-dock" aria-label="دسترسی سریع کارشناسان">
{% block staff_mobile_navigation %}<nav class="staff-mobile-dock" aria-label="دسترسی سریع کارشناسان">
<a class="{% if request.resolver_match.url_name == 'list' and active_tab == '' %}active{% endif %}" href="{% url 'staff_tickets:list' %}" {% if request.resolver_match.url_name == 'list' and active_tab == '' %}aria-current="page"{% endif %}><span class="dock-icon">⌂</span>میز کار</a>
<a class="{% if request.resolver_match.url_name == 'list' and active_tab == 'new' %}active{% endif %}" href="{% url 'staff_tickets:list' %}?tab=new" {% if request.resolver_match.url_name == 'list' and active_tab == 'new' %}aria-current="page"{% endif %}><span class="dock-icon">◌</span>صف جدید</a>
<a class="{% if request.resolver_match.url_name == 'list' and active_tab == 'waiting_staff' %}active{% endif %}" href="{% url 'staff_tickets:list' %}?tab=waiting_staff" {% if request.resolver_match.url_name == 'list' and active_tab == 'waiting_staff' %}aria-current="page"{% endif %}><span class="dock-icon">↺</span>پاسخ من</a>
@@ -56,10 +60,12 @@
<a class="{% if request.resolver_match.url_name == 'list' and active_tab == 'shared' %}active{% endif %}" href="{% url 'staff_tickets:list' %}?tab=shared" {% if request.resolver_match.url_name == 'list' and active_tab == 'shared' %}aria-current="page"{% endif %}><span class="dock-icon">◇</span>اشتراک‌ها<em data-share-unread-count data-count-url="{% url 'staff_tickets:shares_count' %}"{% if not staff_unread_shares %} hidden{% endif %}>{{ staff_unread_shares }}</em></a>
{% if staff_operations_access.contacts %}<a class="{% if active_staff_section == 'contacts' %}active{% endif %}" href="{% url 'staff_tickets:contacts' %}" {% if active_staff_section == 'contacts' %}aria-current="page"{% endif %}><span class="dock-icon">♧</span>مخاطبان</a>{% endif %}
{% if staff_operations_access.dispatches %}<a class="{% if active_staff_section == 'dispatches' %}active{% endif %}" href="{% url 'staff_tickets:dispatch_create' %}" {% if active_staff_section == 'dispatches' %}aria-current="page"{% endif %}><span class="dock-icon">⇶</span>گروهی</a>{% endif %}
</nav>
{% if payment_can_view %}<a class="{% if active_staff_section == 'payments' %}active{% endif %}" href="{% url 'staff_payments:desk' %}" {% if active_staff_section == 'payments' %}aria-current="page"{% endif %}><svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3h12v18l-3-2-3 2-3-2-3 2V3Zm3 5h6m-6 4h6m-6 4h3"/></svg><b>اعلامیه واریز</b><em data-payment-count data-count-url="{% url 'staff_payments:count' %}" aria-label="فیش‌های در انتظار بررسی">{{ payment_pending_count }}</em></a>{% endif %}
{% if payment_can_manage_banks %}<a class="{% if active_staff_section == 'banks' %}active{% endif %}" href="{% url 'staff_payments:banks' %}" {% if active_staff_section == 'banks' %}aria-current="page"{% endif %}><svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m3 8 9-5 9 5H3Zm3 3v7m6-7v7m6-7v7M3 21h18"/></svg><b>بانک‌ها</b></a>{% endif %}
</nav>{% endblock %}
</div>
{% elif user.is_authenticated %}
<nav class="mobile-dock customer-mobile-dock" aria-label="دسترسی سریع مشتری"><a class="{% if request.resolver_match.namespace == 'tickets' and request.resolver_match.url_name != 'new' %}active{% endif %}" href="{% url 'tickets:list' %}" {% if request.resolver_match.namespace == 'tickets' and request.resolver_match.url_name != 'new' %}aria-current="page"{% endif %}><span class="dock-icon">◎</span>درخواست‌ها</a><a class="{% if request.resolver_match.url_name == 'new' %}active{% endif %}" href="{% url 'tickets:new' %}" {% if request.resolver_match.url_name == 'new' %}aria-current="page"{% endif %}><span class="dock-icon">+</span>درخواست جدید</a><a class="{% if request.resolver_match.namespace == 'accounts' and request.resolver_match.url_name == 'profile' %}active{% endif %}" href="{% url 'accounts:profile' %}" {% if request.resolver_match.namespace == 'accounts' and request.resolver_match.url_name == 'profile' %}aria-current="page"{% endif %}><span class="dock-icon">○</span>پروفایل</a><form method="post" action="{% url 'accounts:logout' %}">{% csrf_token %}<button type="submit"><span class="dock-icon">←</span>خروج</button></form></nav>
{% if financial_page %}<nav class="mobile-dock customer-mobile-dock chaharsu-dock" aria-label="دسترسی سریع مشتری"><a href="{% url 'payments:home' %}" {% if request.resolver_match.url_name == 'home' %}class="active" aria-current="page"{% endif %}><svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3h7v7H3V3Zm11 0h7v7h-7V3ZM3 14h7v7H3v-7Zm11 0h7v7h-7v-7Z"/></svg>خدمات</a><a href="{% url 'tickets:list' %}"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5h16v12H9l-5 4V5Zm4 4h8m-8 4h5"/></svg>درخواست‌ها</a><a href="{% url 'accounts:profile' %}"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M16 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0ZM4 21v-2a8 8 0 0 1 16 0v2"/></svg>پروفایل</a></nav>{% else %} <nav class="mobile-dock customer-mobile-dock" aria-label="دسترسی سریع مشتری"><a class="{% if request.resolver_match.namespace == 'tickets' and request.resolver_match.url_name != 'new' %}active{% endif %}" href="{% url 'tickets:list' %}" {% if request.resolver_match.namespace == 'tickets' and request.resolver_match.url_name != 'new' %}aria-current="page"{% endif %}><span class="dock-icon">◎</span>درخواست‌ها</a><a class="{% if request.resolver_match.url_name == 'new' %}active{% endif %}" href="{% url 'tickets:new' %}" {% if request.resolver_match.url_name == 'new' %}aria-current="page"{% endif %}><span class="dock-icon">+</span>درخواست جدید</a><a class="{% if request.resolver_match.namespace == 'accounts' and request.resolver_match.url_name == 'profile' %}active{% endif %}" href="{% url 'accounts:profile' %}" {% if request.resolver_match.namespace == 'accounts' and request.resolver_match.url_name == 'profile' %}aria-current="page"{% endif %}><span class="dock-icon">○</span>پروفایل</a><form method="post" action="{% url 'accounts:logout' %}">{% csrf_token %}<button type="submit"><span class="dock-icon">←</span>خروج</button></form></nav>{% endif %}
{% endif %}
{% block pwa_install_prompt %}{% endblock %}
<script src="{% static 'js/voice-recorder.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/ui.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/chat-coach.js' %}?v={{ static_asset_version }}"></script>
@@ -67,4 +73,5 @@
{% if user.is_authenticated and user.account_type == 'STAFF' %}<link rel="stylesheet" href="{% static 'css/staff-sharing.css' %}?v={{ static_asset_version }}"><script defer src="{% static 'js/staff-sharing.js' %}?v={{ static_asset_version }}"></script>{% endif %}
{% block extra_scripts %}{% endblock %}
{% block otp_script %}{% endblock %}
{% endblock %}
</body></html>
@@ -0,0 +1,4 @@
<div class="pay-customer-summary">
<strong>{% 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 %}</strong>
<span><bdi>{{ customer.local_mobile }}</bdi>{% if customer.customer_profile.province %} · {{ customer.customer_profile.province }}{% endif %}{% if customer.customer_profile.city %} · {{ customer.customer_profile.city }}{% endif %}</span>
</div>
+12
View File
@@ -0,0 +1,12 @@
<section class="pay-customer-picker pay-field-wide" data-payment-customer-picker data-search-url="{% url 'staff_payments:customer_search' %}" aria-label="انتخاب مشتری">
{{ form.customer }}
<label for="payment-customer-search">جست‌وجو و انتخاب مشتری <span class="pay-required">الزامی</span></label>
<div class="pay-search-input">{% include 'payments/_icon.html' with name='search' %}<input id="payment-customer-search" type="search" autocomplete="off" placeholder="نام، موبایل، سازمان یا شماره و موضوع تیکت" aria-describedby="payment-customer-help payment-customer-status"></div>
<p id="payment-customer-help" class="pay-field-hint">نام و شماره تماس را تطبیق دهید؛ هیچ نتیجه‌ای خودکار انتخاب نمی‌شود.</p>
{% if form.customer.errors %}<div class="field-error" role="alert">{{ form.customer.errors }}</div>{% endif %}
<p id="payment-customer-status" class="pay-field-hint" role="status" aria-live="polite">برای جست‌وجو حداقل دو حرف یا رقم وارد کنید.</p>
<div class="pay-customer-results" data-customer-results aria-label="نتایج جست‌وجوی مشتری" hidden></div>
<div class="pay-selected-customer" data-selected-customer hidden></div>
{{ customer_selection|json_script:'payment-selected-customer' }}
<noscript>{% if customer_selection %}<p class="pay-inline-note">مشتری انتخاب‌شده: {{ customer_selection.name }} · <bdi>{{ customer_selection.mobile }}</bdi></p>{% else %}<p class="pay-inline-note">برای جست‌وجوی مشتری JavaScript مرورگر را فعال کنید. می‌توانید از صفحه مخاطبان یا تیکت مشتری، فرم اعلامیه را با مشتری انتخاب‌شده باز کنید.</p>{% endif %}</noscript>
</section>
@@ -0,0 +1,39 @@
<section class="chaharsu customer-finance customer-receipt {% if staff_form %}pay-workspace pay-form-workspace staff-receipt-workspace{% endif %}">
{% include 'payments/_link_status.html' %}
<nav class="pay-breadcrumb" aria-label="مسیر صفحه">{% if staff_form %}<a href="{% url 'staff_payments:desk' %}">اعلامیه‌ها و فیش‌ها</a>{% elif payment_link_access.notice_id %}<a href="{% url 'payments:notice_detail' payment_link_access.notice.public_id %}">اعلامیه واریز</a>{% elif not payment_link_access %}<a href="{% url 'payments:home' %}">خدمات چهارسو</a>{% endif %}<span aria-hidden="true">/</span><span aria-current="page">ارسال فیش واریزی</span></nav>
<header class="customer-finance-title"><span class="pay-page-icon">{% include 'payments/_icon.html' with name='receipt' %}</span><div><h1>{% if staff_form %}ثبت فیش برای مشتری{% elif revision_version %}اصلاح فیش واریزی{% else %}ارسال فیش واریزی{% endif %}</h1><p>فیش را مستقل یا برای یکی از واریزهای اعلام‌شده ارسال کنید.</p>{% if staff_form %}{% include 'payments/_customer_identity.html' with customer=customer %}{% endif %}</div></header>
<form id="receipt-form" class="customer-receipt-form pay-form" method="post" enctype="multipart/form-data" data-max-bytes="{{ max_bytes }}" novalidate>
{% csrf_token %}{{ form.submission_key }}{% if revision_version %}<input type="hidden" name="version" value="{{ revision_version }}">{% endif %}
<div id="receipt-errors" class="pay-errors" role="alert" {% if not form.errors %}hidden{% endif %}><strong>لطفاً این موارد را اصلاح کنید</strong><ul>{% for field in form %}{% for error in field.errors %}<li><a href="#{{ field.id_for_label }}">{{ error }}</a></li>{% endfor %}{% endfor %}{% for error in form.non_field_errors %}<li>{{ error }}</li>{% endfor %}</ul></div>
<section class="pay-card" aria-labelledby="association-title">
<h2 id="association-title">این فیش مربوط به کدام واریز است؟</h2>
<fieldset class="pay-field receipt-mode-field" data-field="receipt_mode"><legend class="sr-only">نوع واریز</legend>
<div class="customer-receipt-modes">{% for radio in form.receipt_mode %}<div class="customer-mode {% if radio.data.value == 'linked' %}recommended-mode{% endif %}"><label for="{{ radio.id_for_label }}">{{ radio.tag }}<strong>{{ radio.choice_label }}</strong></label><small>{% if radio.data.value == 'linked' %}با اعلامیه واریز کارشناس{% else %}بدون اعلامیه واریز{% endif %}</small></div>{% endfor %}</div>
<p class="field-error" id="error-receipt_mode" {% if not form.receipt_mode.errors %}hidden{% endif %}>{% for error in form.receipt_mode.errors %}{{ error }}{% endfor %}</p>
</fieldset>
<p class="finance-guidance">{% if staff_form %}اعلامیه مشتری را انتخاب کنید تا مبلغ و حساب مقصد قابل تطبیق باشد؛ ثبت واریز مستقل نیز امکان‌پذیر است.{% else %}برای ثبت دقیق‌تر، از کارشناس بخواهید اعلامیه واریز ایجاد کند؛ این کار توصیه می‌شود و اجباری نیست.{% endif %}</p>
{% 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 %}
<div id="selected-payment" class="pay-supplement" hidden><h3 data-selected-notice-title></h3><p data-selected-notice-summary></p><p class="pay-deposit-warning" data-selected-deposit-warning hidden>هنگام واریز، حتماً شناسه واریز ارائه‌شده را در فرم بانکی، موبایل‌بانک یا اینترنت‌بانک وارد کنید؛ واریز بدون این شناسه معتبر نخواهد بود.</p><a class="pay-text-link" data-selected-notice-link>مشاهده اطلاعات و کپی شماره‌ها {% include 'payments/_icon.html' with name='arrow' %}</a></div>
</section>
<section class="pay-card" aria-labelledby="receipt-title">
<h2 id="receipt-title">اطلاعات فیش پرداخت</h2><p class="pay-section-help">مبلغ، حساب مقصد، تاریخ کامل و تصویر فیش الزامی‌اند.</p>
<div class="customer-field-grid">
{% 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='در صورت تمایل، شناسه‌ای را که هنگام پرداخت وارد کرده‌اید بنویسید؛ ارسال فیش بدون تکمیل این فیلد نیز امکان‌پذیر است.' %}
</div>
<fieldset class="customer-date"><legend>تاریخ واریز <span class="pay-optional">شمسی · الزامی</span></legend><div class="customer-date-selects">{% 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 %}</div><div class="customer-date-tools"><p class="pay-field-hint">تاریخ درج‌شده روی فیش را کامل انتخاب کنید.</p><button id="date-today" type="button" class="pay-today">امروز</button></div><p id="date-preview" aria-live="polite"></p><input id="paid-date-iso" type="hidden" name="paid_date" value=""></fieldset>
<div class="pay-field customer-upload-field" data-field="receipt_image"><div class="customer-upload-zone">{% include 'payments/_icon.html' with name='upload' %}<label class="customer-file-button" for="{{ form.receipt_image.id_for_label }}">انتخاب تصویر فیش <span>الزامی</span></label>{{ form.receipt_image }}<p>تصویر JPG، PNG، GIF یا WEBP؛ حداکثر {{ max_bytes|filesizeformat }}.</p></div><p class="field-error" id="error-receipt_image" {% if not form.receipt_image.errors %}hidden{% endif %}>{% for error in form.receipt_image.errors %}{{ error }}{% endfor %}</p><div class="customer-file-selection" id="receipt-file-selection" hidden><p id="receipt-file-status" role="status" aria-live="polite"></p><button type="button" id="receipt-clear-file">حذف انتخاب</button></div><img id="receipt-file-preview" alt="پیش‌نمایش تصویر فیش" hidden></div>
{% include 'payments/_receipt_field.html' with field=form.description %}
<p id="receipt-summary" class="finance-guidance" role="status"></p><p role="status" id="form-status"></p>
<div class="customer-receipt-actions"><button class="pay-button" id="submit-receipt" type="submit">ثبت و ارسال فیش</button>{% if staff_form %}<a class="pay-text-link" href="{% url 'staff_payments:desk' %}">انصراف و بازگشت</a>{% elif payment_link_access.notice_id %}<a class="pay-text-link" href="{% url 'payments:notice_detail' payment_link_access.notice.public_id %}">مشاهده اعلامیه واریز</a>{% elif not payment_link_access %}<a class="pay-text-link" href="{% url 'payments:notices' %}">مشاهده واریزهای اعلام‌شده {% include 'payments/_icon.html' with name='arrow' %}</a>{% endif %}</div>
</section>
</form>
{% if not staff_form %}
<div class="customer-service-footer">{% if not payment_link_access %}<a class="pay-text-link" href="{% url 'payments:home' %}">بازگشت به خدمات چهارسو</a>{% endif %}<a class="pay-text-link" href="{% url 'payments:receipt_list' %}">فیش‌های من و وضعیت بررسی</a></div>
{% endif %}
{{ notice_data|json_script:'notice-data' }}{{ account_requirements|json_script:'account-requirements' }}
</section>
+7
View File
@@ -0,0 +1,7 @@
<div class="pay-field {% if field.errors %}has-error{% endif %} {% if wide %}pay-field-wide{% endif %}">
<label for="{{ field.id_for_label }}">{{ label|default:field.label }}{% if field.field.required %}<span class="pay-required">الزامی</span>{% else %}<span class="pay-optional">اختیاری</span>{% endif %}</label>
{{ field }}
{% if hint %}<p class="pay-field-hint">{{ hint }}</p>{% endif %}
{% if field.help_text %}<p class="pay-field-hint">{{ field.help_text }}</p>{% endif %}
{% if field.errors %}<div class="field-error" role="alert">{{ field.errors }}</div>{% endif %}
</div>
+17
View File
@@ -0,0 +1,17 @@
{# Local inline SVG paths: no icon font, CDN or external request. #}
<svg class="cs-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
{% if name == 'chat' %}<path d="M5 4h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9l-6 3V6a2 2 0 0 1 2-2Z M7 9h10M7 13h6"/>
{% elif name == 'receipt' %}<path d="M5 3h14v18l-3-2-4 2-4-2-3 2V3ZM9 8h6M9 12h6"/>
{% elif name == 'bank' %}<path d="m3 7 9-4 9 4v3H3ZM5 10v7M10 10v7M14 10v7M19 10v7M3 17h18v4H3"/>
{% elif name == 'plus' %}<path d="M12 5v14M5 12h14"/>
{% elif name == 'upload' %}<path d="M12 16V3m-5 5 5-5 5 5M4 16v5h16v-5"/>
{% elif name == 'copy' %}<rect x="8" y="8" width="12" height="13" rx="2"/><path d="M16 8V3H3v13h5"/>
{% elif name == 'chevron-down' %}<path d="m6 9 6 6 6-6"/>
{% elif name == 'history' %}<path d="M3 11a9 9 0 1 1 2 7M3 5v6h6M12 7v5l3 2"/>
{% elif name == 'search' %}<circle cx="10" cy="10" r="6"/><path d="m15 15 6 6"/>
{% elif name == 'filter' %}<path d="M4 7h16M7 12h10M10 17h4"/>
{% elif name == 'user' %}<circle cx="12" cy="8" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/>
{% elif name == 'edit' %}<path d="m15 4 5 5M4 20l5-1L21 7l-4-4L5 15l-1 5Z"/>
{% elif name == 'chevron' %}<path d="m9 5 7 7-7 7"/>
{% else %}<path d="M20 12H4m6-6-6 6 6 6"/>{% endif %}
</svg>
+2
View File
@@ -0,0 +1,2 @@
{% load jalali %}
{% if payment_link_access %}<div class="pay-link-status" role="note"><span>{% include 'payments/_icon.html' with name='receipt' %}مشاهده و ارسال فیش بدون نیاز به ورود</span><small>اعتبار دسترسی تا <time datetime="{{ payment_link_access.expires_at|date:'c' }}">{{ payment_link_access.expires_at|jalali_datetime }}</time></small></div>{% endif %}
+8
View File
@@ -0,0 +1,8 @@
{% load jalali humanize %}
<section class="pay-linked-receipts" aria-label="فیش‌های این واریز"><h3>فیش‌های این واریز</h3>
{% for linked_receipt in notice.related_receipts %}<article class="pay-linked-receipt">
<div class="pay-linked-receipt-head"><a class="pay-text-link" href="{% url 'payments:receipt_detail' linked_receipt.public_id %}"><bdi>{{ linked_receipt.number }}</bdi>{% include 'payments/_icon.html' with name='arrow' %}</a><span class="pay-status {{ linked_receipt.status|lower }}">{{ linked_receipt.get_status_display }}</span></div>
<p class="pay-linked-receipt-meta">{{ linked_receipt.current_amount|intcomma:False }} ریال · واریز {{ linked_receipt.current_paid_date|jalali_date }}</p>
{% for vote in linked_receipt.review_decisions %}<p class="pay-linked-decision"><strong>{{ vote.get_action_display }} · {{ vote.actor_name }}</strong><time>{{ vote.created_at|jalali_datetime }}</time>{% if vote.reason %}<span>{{ vote.reason|truncatechars:160 }}</span>{% endif %}</p>{% empty %}<p class="pay-field-hint">فیش در انتظار بررسی است.</p>{% endfor %}
<a class="pay-text-link" href="{% url 'payments:receipt_detail' linked_receipt.public_id %}">جزئیات فیش و سوابق بررسی</a>
</article>{% empty %}<p class="pay-field-hint">هنوز فیشی برای این واریز ارسال نکرده‌اید.</p>{% endfor %}</section>
+19
View File
@@ -0,0 +1,19 @@
{% load jalali humanize %}
{% if not staff_notice and not notice_page %}<article class="pay-notice-entry">{% endif %}
<details class="pay-card payment-notice" {% if open_notice == notice.public_id|stringformat:'s' or staff_notice %}open{% endif %}>
<summary><strong>{{ notice.subject }}</strong><b>{{ notice.amount|intcomma:False }} <span>ریال</span></b><small>{{ notice.number }} · {{ notice.created_at|jalali_date }}</small><span class="notice-state">{{ notice.settlement_label }}{% if notice.cancelled_at %} · لغو شده{% endif %}</span>{% include 'payments/_icon.html' with name='chevron-down' %}</summary>
<div class="pay-details"><p class="notice-bank">{% if notice.account_snapshot.name %}{{ notice.account_snapshot.name }} · {% endif %}{{ notice.account_snapshot.bank }} · {{ notice.account_snapshot.holder }}</p>
<dl class="payment-values">
<div><dt>مبلغ (ریال)</dt><dd dir="ltr">{{ notice.amount|intcomma:False }}</dd><button type="button" data-copy="{{ notice.amount }}" class="pay-copy" aria-label="کپی مبلغ">{% include 'payments/_icon.html' with name='copy' %} کپی</button></div>
{% if notice.account_snapshot.account_number %}<div><dt>شماره حساب</dt><dd dir="ltr">{{ notice.account_snapshot.account_number }}</dd><button type="button" data-copy="{{ notice.account_snapshot.account_number }}" class="pay-copy" aria-label="کپی شماره حساب">{% include 'payments/_icon.html' with name='copy' %} کپی</button></div>{% endif %}
{% if notice.account_snapshot.iban %}<div><dt>شبا</dt><dd dir="ltr">{{ notice.account_snapshot.iban }}</dd><button type="button" data-copy="{{ notice.account_snapshot.iban }}" class="pay-copy" aria-label="کپی شبا">{% include 'payments/_icon.html' with name='copy' %} کپی</button></div>{% endif %}
{% if notice.account_snapshot.card_number %}<div><dt>شماره کارت</dt><dd dir="ltr">{{ notice.account_snapshot.card_number }}</dd><button type="button" data-copy="{{ notice.account_snapshot.card_number }}" class="pay-copy" aria-label="کپی کارت">{% include 'payments/_icon.html' with name='copy' %} کپی</button></div>{% endif %}
{% if notice.deposit_id %}<div class="pay-deposit-required"><dt>شناسه واریز · ضروری</dt><dd dir="ltr">{{ notice.deposit_id }}</dd><button type="button" data-copy="{{ notice.deposit_id }}" class="pay-copy" aria-label="کپی شناسه واریز">{% include 'payments/_icon.html' with name='copy' %} کپی</button></div>{% endif %}
</dl>
{% if notice.deposit_id %}<p class="pay-deposit-warning" role="note">هنگام واریز، حتماً شناسه واریز ارائه‌شده را در فرم بانکی، موبایل‌بانک یا اینترنت‌بانک وارد کنید؛ واریز بدون این شناسه معتبر نخواهد بود.</p>{% endif %}
<!-- Supplementary details stay visible within the expanded notice. Complete copy uses ALL snapshot fields. -->
<p>تأییدشده: {{ notice.approved_amount|intcomma:False }} ریال · باقی‌مانده: {{ notice.remaining_amount|intcomma:False }} ریال</p><section class="pay-supplement"><h3>اطلاعات تکمیلی و توضیحات</h3>{% if notice.invoice_number %}<p>فاکتور: {{ notice.invoice_number }}</p>{% endif %}<p>{{ notice.description|default:'توضیح دیگری اعلام نشده است.'|linebreaksbr }}</p></section>
<div class="pay-actions">{% if not notice.cancelled_at and not staff_notice and notice.remaining_amount > 0 %}<a class="pay-button" href="{% url 'payments:receipt_new' %}?notice={{ notice.public_id }}">ارسال فیش این واریز</a>{% endif %}<button type="button" class="pay-button secondary" data-copy="{{ notice.copy_text }}">کپی کامل اطلاعات</button></div>
{% if not staff_notice %}{% include 'payments/_linked_receipts.html' %}{% endif %}
</div></details>
{% if not staff_notice and not notice_page %}<a class="pay-notice-permalink pay-text-link" href="{% url 'payments:notice_detail' notice.public_id %}">جزئیات واریز و فیش‌ها {% include 'payments/_icon.html' with name='arrow' %}</a></article>{% endif %}
+1
View File
@@ -0,0 +1 @@
{% if payment_can_create and customer.is_active %}<a class="payment-notice-shortcut" href="{% url 'staff_payments:notice_new' %}?customer={{ customer.public_id }}"><svg aria-hidden="true" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3h14v18l-3-2-4 2-4-2-3 2V3ZM9 8h6M9 12h6"/></svg><span>ایجاد اعلامیه واریز</span></a>{% endif %}
+1
View File
@@ -0,0 +1 @@
{% if page_obj.paginator.num_pages > 1 %}<nav class="pay-pagination" aria-label="صفحه‌بندی">{% if page_obj.has_previous %}<a href="?{{ query_string }}&page={{ page_obj.previous_page_number }}">قبلی</a>{% endif %}<span>{{ page_obj.number }} از {{ page_obj.paginator.num_pages }}</span>{% if page_obj.has_next %}<a href="?{{ query_string }}&page={{ page_obj.next_page_number }}">بعدی</a>{% endif %}</nav>{% endif %}
+6
View File
@@ -0,0 +1,6 @@
<div class="pay-field" data-field="{{ field.name }}">
<label for="{{ field.id_for_label }}">{{ field.label }}{% if field.field.required %}<span class="pay-optional">الزامی</span>{% endif %}</label>{{ field }}
{% if field.name == 'paid_amount' %}<p id="amount-words" aria-live="polite"></p>{% endif %}
{% if hint %}<p class="pay-field-hint">{{ hint }}</p>{% endif %}
<p class="field-error" id="error-{{ field.name }}" {% if not field.errors %}hidden{% endif %}>{% for error in field.errors %}{{ error }}{% endfor %}</p>
</div>
+20
View File
@@ -0,0 +1,20 @@
{% load jalali humanize %}
{% if payment_can_create or customer_notices %}
<details class="ticket-notices">
<summary><span class="ticket-notices-title"><svg aria-hidden="true" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3h14v18l-3-2-4 2-4-2-3 2V3ZM9 8h6M9 12h6"/></svg>اعلامیه واریز</span><span class="ticket-notices-chevron" aria-hidden="true">⌄</span></summary>
<div class="ticket-notices-content">
{% include 'payments/_notice_shortcut.html' %}
<p class="ticket-notices-caption">آخرین اعلامیه‌های این مشتری</p>
<ul class="ticket-notices-list" aria-label="اعلامیه‌های اخیر مشتری">
{% for notice in customer_notices %}
<li><a class="ticket-notice-item" href="{% url 'staff_payments:notice_detail' notice.public_id %}">
<strong>{{ notice.subject }}</strong>
<span class="ticket-notice-meta"><bdi>{{ notice.number }}</bdi><time datetime="{{ notice.created_at|date:'c' }}">{{ notice.created_at|jalali_date }}</time></span>
<span class="ticket-notice-meta"><span>{{ notice.amount|intcomma }} ریال</span><span class="ticket-notice-status {% if notice.cancelled_at %}is-cancelled{% elif notice.approved_total >= notice.amount %}is-settled{% endif %}">{% if notice.cancelled_at %}لغو شده{% elif notice.approved_total >= notice.amount %}تسویه شده{% elif notice.approved_total %}پرداخت جزئی{% else %}در انتظار واریز{% endif %}</span></span>
</a></li>
{% empty %}<li class="ticket-notices-empty">اعلامیه واریزی برای نمایش وجود ندارد.</li>{% endfor %}
</ul>
<a class="ticket-notices-more" href="{% url 'staff_payments:desk' %}?tab=notices&amp;customer={{ customer.pk }}"><span>بیشتر</span><small>همه اعلامیه‌های این مشتری</small><span aria-hidden="true">←</span></a>
</div>
</details>
{% endif %}
+45
View File
@@ -0,0 +1,45 @@
{% extends 'payments/base.html' %}
{% load static %}
{% block title %}بانک‌ها و حساب‌های شرکت{% endblock %}
{% block content %}
<section class="chaharsu pay-workspace">
<header class="pay-page-head">
<div class="pay-page-title"><span class="pay-page-icon">{% include 'payments/_icon.html' with name='bank' %}</span><div><h1>بانک‌ها و حساب‌های شرکت</h1><p>حساب‌های قابل انتخاب برای کارشناسان و مشتریان را مدیریت کنید.</p></div></div>
<a class="pay-button" href="{% url 'staff_payments:bank_new' %}">{% include 'payments/_icon.html' with name='plus' %}ایجاد حساب</a>
</header>
<form class="pay-card pay-filter-panel" method="get">
<div class="pay-filter-grid pay-bank-filters">
<label class="pay-search-field">جست‌وجوی حساب<span class="pay-search-input">{% include 'payments/_icon.html' with name='search' %}<input type="search" name="q" value="{{ request.GET.q }}" placeholder="نام حساب، بانک یا صاحب حساب"></span></label>
{% for flag in bank_flags %}<label>{{ flag.label }}<select name="{{ flag.key }}"><option value="">همه</option><option value="1" {% if flag.value == '1' %}selected{% endif %}>فعال</option><option value="0" {% if flag.value == '0' %}selected{% endif %}>غیرفعال</option></select></label>{% endfor %}
<button class="pay-button" type="submit">جست‌وجو</button>
</div>
{% if request.GET.q or request.GET.is_active or request.GET.available_to_staff or request.GET.available_to_customers %}<div class="pay-filter-bottom"><a class="pay-reset" href="{% url 'staff_payments:banks' %}">پاک کردن فیلترها</a></div>{% endif %}
</form>
<form class="pay-bank-panel" method="post" action="{% url 'staff_payments:bank_bulk' %}" data-bank-selection>
{% csrf_token %}
<div class="pay-list-head"><h2>حساب‌های شرکت</h2><span>{{ page_obj.paginator.count }} حساب</span></div>
{% if page_obj %}
<div class="pay-bank-table-head"><label class="pay-select-all"><input type="checkbox" data-select-all aria-label="انتخاب همه حساب‌های این صفحه"><span>انتخاب همه</span></label><span>حساب و بانک</span><span>وضعیت کلی</span><span>کارشناسان</span><span>مشتریان</span><span>مدیریت</span></div>
{% for account in page_obj %}
<article class="pay-bank-row">
<label class="pay-bank-select"><input type="checkbox" name="accounts" value="{{ account.pk }}" aria-label="انتخاب {{ account.name }}"></label>
<div class="pay-bank-identity"><span class="pay-record-icon">{% include 'payments/_icon.html' with name='bank' %}</span><div><h3><a href="{% url 'staff_payments:bank_edit' account.public_id %}">{{ account.name }}</a></h3><p>{{ account.bank.name }} · {{ account.holder }}</p>{% if account.requires_deposit_id %}<span class="pay-identifier-badge">شناسه واریز الزامی</span>{% endif %}</div></div>
<div class="pay-bank-flag"><span class="pay-mobile-label">وضعیت کلی</span><span class="pay-status {% if account.is_active %}approved{% else %}neutral{% endif %}">{{ account.is_active|yesno:'فعال,غیرفعال' }}</span></div>
<div class="pay-bank-flag"><span class="pay-mobile-label">کارشناسان</span><span class="pay-status {% if account.is_active and account.available_to_staff %}approved{% else %}neutral{% endif %}">{% if not account.is_active %}غیرفعال{% else %}{{ account.available_to_staff|yesno:'فعال,غیرفعال' }}{% endif %}</span></div>
<div class="pay-bank-flag"><span class="pay-mobile-label">مشتریان</span><span class="pay-status {% if account.is_active and account.available_to_customers %}approved{% else %}neutral{% endif %}">{% if not account.is_active %}غیرفعال{% else %}{{ account.available_to_customers|yesno:'فعال,غیرفعال' }}{% endif %}</span></div>
<a class="pay-bank-edit" href="{% url 'staff_payments:bank_edit' account.public_id %}">{% include 'payments/_icon.html' with name='edit' %}ویرایش</a>
</article>
{% endfor %}
<div class="pay-bulk-bar">
<div class="pay-bulk-label"><strong>تغییر گروهی وضعیت</strong><span data-selection-count role="status" aria-live="polite">حساب‌های موردنظر را انتخاب کنید.</span></div>
<label>بخش موردنظر<select name="field"><option value="is_active">وضعیت کلی حساب</option><option value="available_to_staff">پنل کارشناسان</option><option value="available_to_customers">پنل مشتریان</option></select></label>
<label>وضعیت جدید<select name="value"><option value="1">فعال</option><option value="0">غیرفعال</option></select></label>
<button class="pay-button" type="submit" data-bulk-submit>اعمال بر انتخاب‌شده‌ها</button>
</div>
<p class="pay-bank-hint">حساب غیرفعال کلی در هیچ پنلی قابل انتخاب نیست؛ تنظیم هر پنل جداگانه حفظ می‌شود.</p>
{% else %}<div class="pay-empty"><span class="pay-empty-icon">{% include 'payments/_icon.html' with name='bank' %}</span><h3>{% if request.GET %}حسابی با این فیلترها پیدا نشد{% else %}هنوز حسابی تعریف نشده است{% endif %}</h3><p>{% if request.GET %}فیلترها را تغییر دهید یا پاک کنید.{% else %}برای شروع، اولین حساب شرکت را ایجاد کنید.{% endif %}</p></div>{% endif %}
</form>
{% include 'payments/_pagination.html' %}
</section>
{% endblock %}
{% block extra_scripts %}{{ block.super }}<script defer src="{% static 'js/chaharsu/bank-selection.js' %}?v={{ static_asset_version }}"></script>{% endblock %}
+36
View File
@@ -0,0 +1,36 @@
{% extends 'base.html' %}{% load static %}
{% block extra_head %}<link rel="stylesheet" href="{% static 'css/chaharsu.css' %}?v={{ static_asset_version }}"><link rel="stylesheet" href="{% static 'css/payment-link.css' %}?v={{ static_asset_version }}">{% if payment_link_access %}<meta name="robots" content="noindex, nofollow"><meta name="referrer" content="same-origin">{% endif %}{% endblock %}
{% block customer_header %}
<header class="cs-header">
<div class="cs-header-inner">
<a class="cs-brand" href="{% if payment_link_access.notice_id %}{% url 'payments:notice_detail' payment_link_access.notice.public_id %}{% elif payment_link_access.receipt_id %}{% url 'payments:receipt_detail' payment_link_access.receipt.public_id %}{% else %}{% url 'payments:home' %}{% endif %}" aria-label="چهارسو، خدمات پرداخت">
<img src="{{ company_logo_url }}?v={{ static_asset_version }}-{{ branding_version }}" alt="لوگوی شرکت" width="300" height="130">
<span class="cs-brand-divider" aria-hidden="true"></span>
<span><b>چهارسو</b><small>مرکز خدمات مشتریان</small></span>
</a>
<nav class="cs-nav" aria-label="منوی اصلی">
{% if payment_link_access %}
<a href="{% url 'payments:receipt_new' %}">ارسال فیش واریزی</a><a href="{% url 'payments:receipt_list' %}">فیش‌ها و وضعیت بررسی</a>
{% else %}
<a href="{% url 'payments:home' %}" {% if request.resolver_match.url_name == 'home' %}aria-current="page"{% endif %}>خدمات چهارسو</a>
<a href="{% url 'tickets:list' %}">درخواست‌های من</a>
{% endif %}
</nav>
<div class="cs-header-actions">
{% if payment_link_access %}<span class="pay-link-session-label">دسترسی موقت پرداخت</span>{% else %}
<a class="cs-profile" href="{% url 'accounts:profile' %}" aria-label="پروفایل من">{% include 'payments/_icon.html' with name='user' %}<span>پروفایل من</span></a>
<form method="post" action="{% url 'accounts:logout' %}">{% csrf_token %}<button class="cs-logout" type="submit">خروج</button></form>
{% endif %}
</div>
</div>
</header>
{% endblock %}
{% block staff_mobile_navigation %}
<nav class="staff-mobile-dock pay-staff-dock {% if payment_can_manage_banks %}has-banks{% endif %}" aria-label="دسترسی سریع پرداخت‌ها">
<a href="{% url 'staff_tickets:list' %}">{% include 'payments/_icon.html' with name='chat' %}میز کار</a>
<a href="{% url 'staff_payments:desk' %}" {% if active_staff_section == 'payments' %}class="active" aria-current="page"{% endif %}>{% include 'payments/_icon.html' with name='receipt' %}واریزها</a>
{% if payment_can_manage_banks %}<a href="{% url 'staff_payments:banks' %}" {% if active_staff_section == 'banks' %}class="active" aria-current="page"{% endif %}>{% include 'payments/_icon.html' with name='bank' %}بانک‌ها</a>{% endif %}
</nav>
{% endblock %}
{% block footer %}<footer class="cs-footer">{% if payment_link_access %}{{ site_branding.developer_credit_text }}{% else %}{{ developer_credit }}{% endif %}</footer>{% endblock %}
{% block extra_scripts %}<script defer src="{% static 'js/chaharsu/payments.js' %}?v={{ static_asset_version }}"></script>{% endblock %}
@@ -0,0 +1,9 @@
{% extends 'payments/base.html' %}
{% block title %}جزئیات واریز {{ notice.number }}{% endblock %}
{% block content %}<section class="chaharsu customer-finance customer-accounts customer-notice-detail">
{% include 'payments/_link_status.html' %}
{% if not payment_link_access %}<nav class="pay-breadcrumb" aria-label="مسیر صفحه"><a href="{% url 'payments:notices' %}">شماره حساب‌ها</a><span aria-hidden="true">/</span><span aria-current="page">جزئیات واریز</span></nav>{% endif %}
<header class="customer-finance-title"><span class="pay-page-icon">{% include 'payments/_icon.html' with name='bank' %}</span><div><h1>جزئیات واریز</h1><p>اطلاعات حساب، فیش‌های ارسالی و سوابق بررسی این واریز</p></div></header>
{% include 'payments/_notice.html' with notice_page=True %}
<div class="customer-service-footer">{% if payment_link_access %}<a class="pay-text-link" href="{% url 'payments:receipt_new' %}?mode=independent">ارسال فیش واریز مستقل</a>{% else %}<a class="pay-text-link" href="{% url 'payments:notices' %}">بازگشت به واریزهای شما</a>{% endif %}<a class="pay-text-link" href="{% url 'payments:receipt_list' %}">فیش‌های من و وضعیت بررسی</a></div>
</section>{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
{% extends 'payments/base.html' %}
{% load jalali humanize %}
{% block title %}اعلامیه‌ها و فیش‌های واریزی{% endblock %}
{% block content %}
<section class="chaharsu pay-workspace">
<header class="pay-page-head">
<div class="pay-page-title"><span class="pay-page-icon">{% include 'payments/_icon.html' with name='receipt' %}</span><div><h1>اعلامیه‌ها و فیش‌های واریزی</h1><p>اعلام اطلاعات واریز به مشتریان و بررسی فیش‌های پرداخت</p></div></div>
{% if can_create %}<div class="pay-head-actions"><a class="pay-button" href="{% url 'staff_payments:notice_new' %}">{% include 'payments/_icon.html' with name='plus' %}ایجاد اعلامیه واریز</a><a class="pay-button secondary" href="{% url 'staff_payments:receipt_customer' %}">ثبت فیش برای مشتری</a></div>{% endif %}
</header>
<nav class="pay-tabs pay-desk-tabs" aria-label="نمای پرداخت‌ها">
<a href="?tab=receipts" {% if tab != 'notices' and not request.GET.mine %}aria-current="page"{% endif %}>فیش‌های واریزی</a>
<a href="?tab=notices" {% if tab == 'notices' %}aria-current="page"{% endif %}>اعلامیه‌های واریز</a>
<a href="?tab=receipts&mine=1" {% if tab != 'notices' and request.GET.mine %}aria-current="page"{% endif %}>نیازمند اقدام من</a>
</nav>
<form method="get" class="pay-card pay-filter-panel">
<input type="hidden" name="tab" value="{{ tab }}">
{% if request.GET.mine %}<input type="hidden" name="mine" value="1">{% endif %}
<div class="pay-filter-grid">
<label class="pay-search-field">جست‌وجو<span class="pay-search-input">{% include 'payments/_icon.html' with name='search' %}<input type="search" name="q" value="{{ request.GET.q }}" placeholder="نام، موبایل، استان، شهر یا فاکتور"></span></label>
<label>حساب مقصد<select name="account"><option value="">همه حساب‌ها</option>{% for bank in banks %}<option value="{{ bank.pk }}" {% if request.GET.account == bank.pk|stringformat:'s' %}selected{% endif %}>{{ bank.name }}</option>{% endfor %}</select></label>
{% if tab != 'notices' %}<label>وضعیت فیش<select name="status"><option value="">همه وضعیت‌ها</option>{% for value,label in statuses %}<option value="{{ value }}" {% if request.GET.status == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>{% else %}<label>وضعیت واریز<select name="notice_state"><option value="">همه واریزها</option><option value="pending" {% if request.GET.notice_state == 'pending' %}selected{% endif %}>تسویه‌نشده</option><option value="settled" {% if request.GET.notice_state == 'settled' %}selected{% endif %}>تسویه شده</option><option value="cancelled" {% if request.GET.notice_state == 'cancelled' %}selected{% endif %}>لغو شده</option></select></label>{% endif %}
<button class="pay-button" type="submit">جست‌وجو</button>
</div>
<div class="pay-filter-bottom">
<details class="pay-more-filters" {% if request.GET.kind or request.GET.from or request.GET.to %}open{% endif %}>
<summary>{% include 'payments/_icon.html' with name='filter' %}فیلترهای بیشتر</summary>
<div class="pay-extra-filters">
{% if tab != 'notices' %}<label>نوع واریز<select name="kind"><option value="">همه فیش‌ها</option><option value="independent" {% if request.GET.kind == 'independent' %}selected{% endif %}>واریز مستقل</option><option value="linked" {% if request.GET.kind == 'linked' %}selected{% endif %}>واریز مرتبط</option></select></label>{% endif %}
<label>از تاریخ <small>(میلادی)</small><input type="date" name="from" value="{{ request.GET.from }}"></label>
<label>تا تاریخ <small>(میلادی)</small><input type="date" name="to" value="{{ request.GET.to }}"></label>
</div>
</details>
<a class="pay-reset" href="?tab={{ tab }}{% if request.GET.mine %}&mine=1{% endif %}">پاک کردن فیلترها</a>
</div>
</form>
<section class="pay-records" aria-labelledby="pay-results-title">
<div class="pay-list-head"><h2 id="pay-results-title">{% if tab == 'notices' %}اعلامیه‌های واریز{% elif request.GET.mine %}فیش‌های نیازمند اقدام شما{% else %}فیش‌های واریزی{% endif %}</h2><span>{{ page_obj.paginator.count }} مورد</span></div>
{% for item in page_obj %}
<article class="pay-record">
<span class="pay-record-icon">{% include 'payments/_icon.html' with name='receipt' %}</span>
<div class="pay-record-main">
{% if tab == 'notices' %}<h3><a href="{% url 'staff_payments:notice_detail' item.public_id %}">{{ item.subject }}</a></h3><span class="pay-record-meta"><bdi>{{ item.number }}</bdi>{% if item.invoice_number %} · فاکتور {{ item.invoice_number }}{% endif %}</span>
{% else %}<h3><a href="{% url 'staff_payments:receipt_detail' item.public_id %}"><bdi>{{ item.number }}</bdi></a></h3><span class="pay-record-meta">{% if item.notice %}مرتبط با {{ item.notice.subject }}{% else %}واریز مستقل{% endif %}</span>{% endif %}
{% include 'payments/_customer_identity.html' with customer=item.customer %}
<div class="pay-record-review">{% if tab == 'notices' %}<span>ایجادکننده: {{ item.created_by.staff_profile.display_name|default:item.created_by.get_full_name|default:item.created_by.username }}</span>{% for linked in item.related_receipts %}{% if linked.status == 'APPROVED' %}{% for vote in linked.review_decisions %}<span>تأیید فیش {{ linked.number }}: {{ vote.actor_name }}</span>{% endfor %}{% endif %}{% endfor %}{% else %}{% if item.notice %}<span>ایجادکننده اعلامیه: {{ item.notice.created_by.staff_profile.display_name|default:item.notice.created_by.get_full_name|default:item.notice.created_by.username }}</span>{% endif %}{% if item.status == 'APPROVED' %}{% for vote in item.review_decisions %}<span>تأییدکننده: {{ vote.actor_name }}</span>{% endfor %}{% endif %}{% endif %}</div>
</div>
<div class="pay-record-amount"><strong>{% if tab == 'notices' %}{{ item.amount|intcomma:False }}{% else %}{{ item.current_amount|intcomma:False }}{% endif %} <small>ریال</small></strong><time>{% if tab == 'notices' %}{{ item.created_at|jalali_datetime }}{% else %}واریز {{ item.current_paid_date|jalali_date }}{% endif %}</time></div>
<div class="pay-record-side">
{% if tab == 'notices' %}<span class="pay-status {% if item.cancelled_at %}neutral{% elif item.approved_amount >= item.amount %}approved{% else %}info{% endif %}">{% if item.cancelled_at %}لغو شده{% else %}{{ item.settlement_label }}{% endif %}</span><a class="pay-open" href="{% url 'staff_payments:notice_detail' item.public_id %}">مشاهده اعلامیه{% include 'payments/_icon.html' with name='arrow' %}</a>
{% else %}<span class="pay-status {{ item.status|lower }}">{{ item.get_status_display }}</span><a class="pay-open" href="{% url 'staff_payments:receipt_detail' item.public_id %}">مشاهده و بررسی{% include 'payments/_icon.html' with name='arrow' %}</a>{% endif %}
</div>
</article>
{% empty %}
<div class="pay-empty"><span class="pay-empty-icon">{% include 'payments/_icon.html' with name='search' %}</span><h3>موردی پیدا نشد</h3><p>{% 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 %}</p></div>
{% endfor %}
</section>
{% include 'payments/_pagination.html' %}
</section>
{% endblock %}
+25
View File
@@ -0,0 +1,25 @@
{% extends 'payments/base.html' %}
{% block title %}چهارسو · مرکز خدمات مشتریان{% endblock %}
{% block content %}
<section class="chaharsu chaharsu-home" aria-labelledby="services-title">
<header class="chaharsu-heading"><h1 id="services-title">چهارسو</h1><p>خدمت موردنیازتان را انتخاب کنید.</p></header>
<div class="service-grid">
<section class="expert-service" aria-labelledby="expert-title">
<div class="expert-service-head"><span class="service-icon">{% include 'payments/_icon.html' with name='chat' %}</span><h2 id="expert-title">ارتباط با کارشناسان</h2><p>سؤال بپرسید، درخواست ثبت کنید یا برای پیگیری و هماهنگی با کارشناسان در ارتباط باشید.</p></div>
<div class="service-actions">
<a class="pay-button light" href="{% url 'tickets:new' %}">{% include 'payments/_icon.html' with name='plus' %}ثبت درخواست</a>
<a class="pay-button outline-light" href="{% url 'tickets:list' %}">{% include 'payments/_icon.html' with name='history' %}درخواست‌های من</a>
</div>
</section>
<a class="financial-service" href="{% url 'payments:receipt_new' %}">
<span class="service-icon">{% include 'payments/_icon.html' with name='receipt' %}</span>
<h2>ارسال فیش واریزی</h2><p>ثبت تصویر پرداخت برای بررسی و تأیید</p>
<span class="service-link">صفحه ارسال فیش{% include 'payments/_icon.html' with name='arrow' %}</span>
</a>
<a class="financial-service" href="{% url 'payments:notices' %}">
<span class="service-icon">{% include 'payments/_icon.html' with name='bank' %}</span>
<h2>شماره حساب‌ها</h2><p>حساب، شبا و شناسه واریز اعلام‌شده به شما</p>
<span class="service-link">مشاهده حساب‌ها{% include 'payments/_icon.html' with name='arrow' %}</span>
</a>
</div></section>
{% endblock %}
@@ -0,0 +1,4 @@
{% extends 'payments/base.html' %}
{% block title %}این لینک پرداخت مربوط به حساب دیگری است{% endblock %}
{% block extra_head %}{{ block.super }}<meta name="robots" content="noindex, nofollow"><meta name="referrer" content="same-origin">{% endblock %}
{% block body %}<main class="customer-main"><section class="chaharsu pay-link-message"><article class="pay-card"><h1>این لینک پرداخت مربوط به حساب دیگری است</h1><p>شما با حساب دیگری وارد شده‌اید. برای مشاهده این اعلامیه و ارسال فیش بدون ورود، ابتدا از حساب فعلی خارج شوید.</p><form method="post" action="{{ request.path }}" class="pay-actions">{% csrf_token %}<input type="hidden" name="action" value="logout_and_open"><input type="hidden" name="account_id" value="{{ user.pk }}"><button class="pay-button" type="submit">خروج از حساب و مشاهده پرداخت</button><a class="pay-text-link" href="{% url 'accounts:home' %}">انصراف</a></form></article></section></main><footer class="cs-footer">{{ site_branding.developer_credit_text }}</footer>{% endblock %}
+5
View File
@@ -0,0 +1,5 @@
{% extends 'payments/base.html' %}
{% block title %}لینک پرداخت معتبر نیست{% endblock %}
{% block customer_header %}<header class="cs-header"><div class="cs-header-inner"><div class="cs-brand"><img src="{{ company_logo_url }}?v={{ static_asset_version }}-{{ branding_version }}" alt="لوگوی شرکت" width="300" height="130"><span><b>چهارسو</b><small>مرکز خدمات مشتریان</small></span></div></div></header>{% endblock %}
{% block content %}<section class="chaharsu pay-link-message"><article class="pay-card"><span class="pay-page-icon">{% include 'payments/_icon.html' with name='bank' %}</span><h1>این لینک پرداخت معتبر نیست</h1><p>ممکن است زمان اعتبار لینک تمام شده باشد یا لینک ابطال شده باشد. برای دریافت لینک تازه با کارشناس تماس بگیرید، یا با حساب خود وارد شوید.</p><a class="pay-button" href="{% url 'accounts:login' %}?next={% url 'payments:notices' %}">ورود با شماره موبایل</a></article></section>{% endblock %}
{% block footer %}<footer class="cs-footer">{{ site_branding.developer_credit_text }}</footer>{% endblock %}
+17
View File
@@ -0,0 +1,17 @@
{% extends 'payments/base.html' %}
{% load jalali %}
{% block title %}اعلامیه {{ notice.number }}{% endblock %}
{% block content %}
<section class="chaharsu pay-workspace pay-detail-workspace">
<a class="pay-back" href="{% url 'staff_payments:desk' %}?tab=notices">{% include 'payments/_icon.html' with name='chevron' %}اعلامیه‌های واریز</a>
<header class="pay-page-head"><div class="pay-page-title"><span class="pay-page-icon">{% include 'payments/_icon.html' with name='receipt' %}</span><div><h1>اعلامیه <bdi>{{ notice.number }}</bdi></h1><p>مشتری: <bdi>{{ notice.customer.local_mobile }}</bdi> · {{ notice.created_at|jalali_datetime }}</p></div></div><a class="pay-button" href="{% url 'staff_payments:receipt_new' notice.customer.public_id %}?notice={{ notice.public_id }}">{% include 'payments/_icon.html' with name='plus' %}ثبت فیش برای مشتری</a></header>
<div class="pay-detail-grid">
<div class="pay-notice-main">{% include 'payments/_notice.html' with staff_notice=True %}</div>
<aside class="pay-detail-aside">
{% if can_share_payment_link %}<section class="pay-card"><h2>لینک موقت پرداخت</h2><p class="pay-section-help">برای مشاهده اعلامیه و ارسال فیش، مشتری نیازی به ورود ندارد. پس از پایان اعتبار، می‌توانید لینک تازه بسازید.</p>{% if payment_share_url %}<p class="pay-share-url" dir="ltr">{{ payment_share_url }}</p><button type="button" class="pay-button secondary" data-copy="{{ payment_share_url }}">کپی لینک پرداخت</button>{% endif %}<form method="post" action="{% url 'staff_payments:notice_payment_link' notice.public_id %}">{% csrf_token %}<button class="pay-button" type="submit">ساخت لینک تازه پرداخت</button></form></section>{% endif %}
<section class="pay-card"><h2>فیش‌های این اعلامیه</h2>{% for receipt in receipts %}<a class="pay-linked-record" href="{% url 'staff_payments:receipt_detail' receipt.public_id %}"><bdi>{{ receipt.number }}</bdi><span class="pay-status {{ receipt.status|lower }}">{{ receipt.get_status_display }}</span>{% include 'payments/_icon.html' with name='arrow' %}</a>{% empty %}<p class="pay-section-help">هنوز فیشی برای این اعلامیه ثبت نشده است.</p>{% endfor %}</section>
<section class="pay-card"><h2>اطلاع‌رسانی پیامک</h2>{% for notification in notifications %}<div class="pay-notification-row"><strong>{{ notification.job.get_status_display }}</strong><span>وضعیت دریافت: {{ notification.job.get_delivery_status_display }}</span></div>{% empty %}<p class="pay-section-help">اطلاع‌رسانی ثبت‌شده‌ای وجود ندارد.</p>{% endfor %}</section>
</aside>
</div>
</section>
{% endblock %}
+9
View File
@@ -0,0 +1,9 @@
{% extends 'payments/base.html' %}{% block title %}شماره حساب‌ها{% endblock %}
{% block content %}<section class="chaharsu customer-finance customer-accounts">
<nav class="pay-breadcrumb" aria-label="مسیر صفحه"><a href="{% url 'payments:home' %}">خدمات چهارسو</a><span aria-hidden="true">/</span><span aria-current="page">شماره حساب‌ها</span></nav>
<header class="customer-finance-title"><span class="pay-page-icon">{% include 'payments/_icon.html' with name='bank' %}</span><div><h1>شماره حساب‌ها</h1><p>اطلاعات هر واریز را ببینید، کپی کنید و فیش آن را ارسال کنید.</p></div></header>
<p class="finance-guidance">از کارشناس بخواهید شماره حساب و شناسه واریز مرتبط را برای شما ارسال کند.</p>
<div class="customer-list-heading"><h2>واریزهای شما</h2><span>{{ page_obj.paginator.count }} مورد</span></div>
<form class="pay-search customer-notice-search" method="get"><label class="customer-search-box">{% include 'payments/_icon.html' with name='search' %}<input name="q" value="{{ query }}" placeholder="موضوع یا شماره فاکتور" aria-label="جست‌وجوی اعلامیه"></label><button class="pay-button">جست‌وجو</button>{% if query %}<a class="pay-text-link" href="{% url 'payments:notices' %}">حذف جست‌وجو</a>{% endif %}</form>
{% for notice in page_obj %}{% include 'payments/_notice.html' %}{% empty %}<div class="pay-card customer-empty">{% include 'payments/_icon.html' with name='bank' %}<h2>{% if query %}واریزی پیدا نشد{% else %}هنوز اعلامیه‌ای ندارید{% endif %}</h2><p>{% if query %}عبارت جست‌وجو را تغییر دهید.{% else %}پس از اعلام کارشناس، اطلاعات هر واریز اینجا نمایش داده می‌شود.{% endif %}</p></div>{% endfor %}
{% include 'payments/_pagination.html' %}<div class="customer-service-footer"><a class="pay-text-link" href="{% url 'payments:home' %}">بازگشت به خدمات چهارسو</a><a class="pay-text-link" href="{% url 'payments:receipt_list' %}">فیش‌های من و وضعیت بررسی</a></div></section>{% endblock %}
+29
View File
@@ -0,0 +1,29 @@
{% extends 'payments/base.html' %}
{% load jalali humanize %}
{% block title %}فیش {{ receipt.number }}{% endblock %}
{% block content %}
<section class="chaharsu {% if staff_detail %}pay-workspace pay-detail-workspace{% else %}customer-finance customer-receipt-detail{% endif %}">
{% include 'payments/_link_status.html' %}
{% if not staff_detail %}<nav class="pay-breadcrumb" aria-label="مسیر صفحه"><a href="{% if payment_link_access %}{% url 'payments:receipt_list' %}{% else %}{% url 'payments:notices' %}{% endif %}">{% if payment_link_access %}فیش‌های این لینک{% else %}واریزهای شما{% endif %}</a><span>/</span><span aria-current="page">جزئیات فیش</span></nav>{% endif %}
{% if staff_detail %}<a class="pay-back" href="{% url 'staff_payments:desk' %}">{% include 'payments/_icon.html' with name='chevron' %}فیش‌های واریزی</a>{% endif %}
<header class="pay-page-head"><div class="pay-page-title"><span class="pay-page-icon">{% include 'payments/_icon.html' with name='receipt' %}</span><div><h1>فیش <bdi>{{ receipt.number }}</bdi></h1>{% if staff_detail %}{% include 'payments/_customer_identity.html' with customer=receipt.customer %}<p class="pay-field-hint">نسخه {{ revision.version }}</p>{% endif %}</div></div><span class="pay-status {{ receipt.status|lower }}">{{ receipt.get_status_display }}</span></header>
{% if revision.destination_mismatch %}<p class="pay-warning">حساب مقصد با اعلامیه متفاوت است؛ بررسی مغایرت لازم است.</p>{% endif %}
{% if duplicate_image %}<p class="pay-warning">تصویر مشابه روی فیش دیگری ثبت شده است؛ پیش از تصمیم بررسی کنید.</p>{% endif %}
<div class="{% if staff_detail %}pay-detail-grid{% endif %}">
<div>
<article class="pay-card"><div class="pay-receipt-total"><span>مبلغ پرداخت‌شده</span><h2>{{ revision.amount|intcomma:False }} <small>ریال</small></h2><p>تاریخ پرداخت: {{ revision.paid_date|jalali_date }}</p></div>
<dl class="pay-facts"><div><dt>حساب مقصد</dt><dd>{{ revision.account_snapshot.name }}</dd></div><div><dt>بانک مقصد</dt><dd>{{ revision.account_snapshot.bank }}</dd></div><div><dt>صاحب حساب</dt><dd>{{ revision.account_snapshot.holder }}</dd></div><div><dt>شناسه واریز</dt><dd><bdi>{{ revision.deposit_id|default:'اعلام نشده' }}</bdi></dd></div>{% if revision.origin_bank %}<div><dt>بانک مبدأ</dt><dd>{{ revision.origin_bank.name }}{% if revision.origin_other %} · {{ revision.origin_other }}{% endif %}</dd></div>{% endif %}{% if revision.tracking_number %}<div><dt>شماره پیگیری</dt><dd><bdi>{{ revision.tracking_number }}</bdi></dd></div>{% endif %}</dl>
{% if staff_detail %}<h3 class="pay-section-title">اطلاعات مقصد ثبت‌شده</h3><dl class="pay-facts">{% if revision.account_snapshot.account_number %}<div><dt>شماره حساب مقصد ثبت‌شده</dt><dd dir="ltr">{{ revision.account_snapshot.account_number }}</dd></div>{% endif %}{% if revision.account_snapshot.iban %}<div><dt>شبای مقصد ثبت‌شده</dt><dd dir="ltr">{{ revision.account_snapshot.iban }}</dd></div>{% endif %}{% if revision.account_snapshot.card_number %}<div><dt>کارت مقصد ثبت‌شده</dt><dd dir="ltr">{{ revision.account_snapshot.card_number }}</dd></div>{% endif %}</dl><p class="pay-section-help">شماره‌های ثبت‌شده را با تصویر فیش تطبیق دهید.</p>{% endif %}
<section class="pay-supplement">{% if receipt.notice %}<h3>اعلامیه مرتبط</h3><p>{% if staff_detail %}<a href="{% url 'staff_payments:notice_detail' receipt.notice.public_id %}">{{ receipt.notice.number }}</a>{% else %}<a href="{% url 'payments:notice_detail' receipt.notice.public_id %}">{{ receipt.notice.number }}</a>{% endif %} · مبلغ اعلام‌شده: {{ receipt.notice.amount|intcomma:False }} ریال{% if receipt.notice.invoice_number %} · فاکتور {{ receipt.notice.invoice_number }}{% endif %}</p>{% else %}<p>واریز مستقل</p>{% endif %}{% if revision.description %}<h3>توضیحات پرداخت</h3><p>{{ revision.description|linebreaksbr }}</p>{% endif %}</section>
</article>
<article class="pay-card pay-image-card"><div class="pay-card-title"><h2>تصویر فیش واریز</h2><a class="pay-button secondary" href="{% url 'payments:receipt_image' receipt.public_id %}?version={{ revision.version }}&download=1">دریافت تصویر</a></div><a href="{% url 'payments:receipt_image' receipt.public_id %}?version={{ revision.version }}" target="_blank" rel="noopener"><img class="receipt-preview" src="{% url 'payments:receipt_image' receipt.public_id %}?version={{ revision.version }}" alt="تصویر فیش واریز"></a><p class="pay-section-help">برای مشاهده تصویر در اندازه اصلی، روی آن بزنید.</p></article>
</div>
<aside class="pay-detail-aside">
{% if can_assign %}<form method="post" action="{% url 'staff_payments:assign_policy' receipt.public_id %}" class="pay-card">{% csrf_token %}<h2>تعیین تأییدکنندگان</h2><p class="pay-section-help">سیاست فعال تأیید را به این فیش اختصاص دهید.</p><button class="pay-button">اعمال سیاست فعال تأیید</button></form>{% endif %}
{% if receipt.status == 'PENDING' or receipt.status == 'PARTIAL' %}{% if can_approve or can_senior %}<form class="pay-card pay-form pay-decision-form" method="post" action="{% url 'staff_payments:decision' receipt.public_id %}">{% csrf_token %}<input type="hidden" name="version" value="{{ revision.version }}"><h2>ثبت نتیجه بررسی</h2><p class="pay-section-help">پس از تطبیق مبلغ، حساب و تصویر فیش، تصمیم خود را ثبت کنید.</p><div class="pay-field"><label for="decision-reason">توضیحات و دلیل تأیید یا رد</label><textarea name="reason" id="decision-reason" aria-describedby="decision-help"></textarea><p id="decision-help" class="pay-field-hint">برای تأیید می‌توانید توضیح بنویسید؛ برای رد، تأیید ارشد و مغایرت حساب، دلیل الزامی است.</p></div><p class="pay-inline-note">تأیید من تا پایان بررسی‌های لازم، تأیید نهایی نیست.</p><div class="pay-decision-actions">{% if can_approve %}<button class="pay-button" name="action" value="APPROVE">ثبت تأیید من</button>{% endif %}{% if can_approve or can_senior %}<button class="pay-button pay-danger secondary" name="action" value="REJECT">رد فیش</button>{% endif %}{% if can_senior %}<button class="pay-button" name="action" value="SENIOR_APPROVE">تأیید نهایی ارشد</button>{% endif %}</div></form>{% endif %}{% endif %}
{% if receipt.status == 'REJECTED' and not staff_detail %}<a class="pay-button" href="{% url 'payments:receipt_revise' receipt.public_id %}">اصلاح و ارسال دوباره فیش</a>{% endif %}
<article class="pay-card"><h2>سوابق بررسی</h2><div class="pay-timeline">{% for vote in decisions %}<div class="pay-timeline-item"><strong>{{ vote.actor_name }}</strong><span>{{ vote.get_action_display }} · نسخه {{ vote.cycle.revision.version }}</span><time>{{ vote.created_at|jalali_datetime }}</time>{% if vote.reason %}<p>{{ vote.reason|linebreaksbr }}</p>{% else %}<p class="pay-field-hint">توضیحی ثبت نشده است.</p>{% endif %}</div>{% empty %}<p class="pay-section-help">هنوز تصمیمی ثبت نشده است.</p>{% endfor %}</div><h3 class="pay-section-title">نسخه‌های مدرک</h3>{% for item in versions %}<div class="pay-version-row"><a href="{% url 'payments:receipt_image' receipt.public_id %}?version={{ item.version }}">نسخه {{ item.version }}</a><div><time>{{ item.created_at|jalali_datetime }}</time><p class="pay-field-hint">{{ item.amount|intcomma:False }} ریال · واریز {{ item.paid_date|jalali_date }} · {{ item.cycle.get_status_display }}</p></div></div>{% endfor %}{% if revision_events %}<h3 class="pay-section-title">سوابق ارسال و اصلاح</h3>{% for event in revision_events %}<div class="pay-timeline-item"><strong>{{ event.actor_name }}</strong><span>{% if event.action == 'RECEIPT_REVISED' %}اصلاح و ارسال نسخه {{ event.details.version }}{% else %}ارسال فیش{% endif %}</span><time>{{ event.created_at|jalali_datetime }}</time></div>{% endfor %}{% endif %}{% if cycle.finalized_at %}<div class="pay-supplement"><h3>نهایی‌کننده</h3><p>{{ cycle.finalized_by.username }}</p><time>{{ cycle.finalized_at|jalali_datetime }}</time></div>{% endif %}</article>
</aside>
</div>
</section>
{% endblock %}
+3
View File
@@ -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 }}<script defer src="{% static 'js/chaharsu/receipt-amount.js' %}?v={{ static_asset_version }}"></script><script defer src="{% static 'js/chaharsu/receipt-form.js' %}?v={{ static_asset_version }}"></script><script defer src="{% static 'js/chaharsu/customer-receipt.js' %}?v={{ static_asset_version }}"></script>{% endblock %}
+3
View File
@@ -0,0 +1,3 @@
{% extends 'payments/base.html' %}{% load jalali humanize %}
{% block content %}<section class="chaharsu">{% include 'payments/_link_status.html' %}<div class="pay-actions"><h1>{% if payment_link_access %}فیش‌های این لینک{% else %}فیش‌های من{% endif %}</h1><a class="pay-button" href="{% url 'payments:receipt_new' %}">ارسال فیش جدید</a></div>
{% for receipt in page_obj %}<article class="pay-card"><h2><a href="{% url 'payments:receipt_detail' receipt.public_id %}">{{ receipt.number }}</a></h2><p>{{ receipt.current_amount|intcomma:False }} ریال · {{ receipt.current_paid_date|jalali_date }}</p><p>{{ receipt.get_status_display }} · {% if receipt.notice %}{{ receipt.notice.subject }}{% else %}واریز مستقل{% endif %}</p><a href="{% url 'payments:receipt_detail' receipt.public_id %}">مشاهده فیش و سوابق بررسی</a></article>{% empty %}<p class="pay-card">هنوز فیشی ارسال نکرده‌اید.</p>{% endfor %}{% include 'payments/_pagination.html' %}</section>{% endblock %}
+38
View File
@@ -0,0 +1,38 @@
{% extends 'payments/base.html' %}{% load static %}
{% block title %}{{ heading }}{% endblock %}
{% block content %}
<section class="chaharsu pay-workspace pay-form-workspace">
<a class="pay-back" href="{% if form.bank %}{% url 'staff_payments:banks' %}{% else %}{% url 'staff_payments:desk' %}{% endif %}">{% include 'payments/_icon.html' with name='chevron' %}{% if form.bank %}بانک‌ها و حساب‌ها{% else %}اعلامیه‌ها و فیش‌ها{% endif %}</a>
<header class="pay-page-head"><div class="pay-page-title"><span class="pay-page-icon">{% if form.bank %}{% include 'payments/_icon.html' with name='bank' %}{% else %}{% include 'payments/_icon.html' with name='receipt' %}{% endif %}</span><div><h1>{{ heading }}</h1><p>{% if form.bank %}اطلاعات حساب و دسترسی هر پنل را مشخص کنید.{% elif form.subject %}حساب مقصد و جزئیات واریز را برای مشتری اعلام کنید.{% else %}ابتدا مشتری را انتخاب کنید؛ در مرحله بعد اطلاعات فیش را وارد می‌کنید.{% endif %}</p></div></div></header>
<form method="post" class="pay-card pay-form pay-staff-form">
{% csrf_token %}
{% if form.non_field_errors %}<div class="pay-errors" role="alert"><strong>اطلاعات را دوباره بررسی کنید</strong>{{ form.non_field_errors }}</div>{% endif %}
{% for field in form.hidden_fields %}{% if field.name != 'customer' %}{{ field }}{% if field.errors %}<div class="field-error" role="alert">{{ field.errors }}</div>{% endif %}{% endif %}{% endfor %}
{% if form.bank %}
<fieldset class="pay-form-section"><legend>مشخصات حساب</legend><p class="pay-section-help">نام نمایشی، همان نامی است که کارشناسان و مشتریان هنگام انتخاب حساب می‌بینند.</p><div class="pay-form-grid">
{% 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 و بدون فاصله وارد کنید.' %}
</div><p class="pay-inline-note">حداقل یکی از شماره حساب، شبا یا شماره کارت را وارد کنید.</p></fieldset>
<fieldset class="pay-form-section"><legend>تنظیمات و دسترسی‌ها</legend><p class="pay-section-help">فعال‌بودن کلی حساب، شرط استفاده از آن در هر دو پنل است.</p><div class="pay-check-grid">
<label class="pay-check-card">{{ form.is_active }}<span><strong>فعال‌بودن کلی حساب</strong><small>با غیرفعال‌کردن، حساب در هیچ پنلی قابل انتخاب نیست.</small>{{ form.is_active.errors }}</span></label>
<label class="pay-check-card">{{ form.requires_deposit_id }}<span><strong>شناسه واریز الزامی است</strong><small>کارشناس باید شناسه واریز مشتری را هنگام صدور اعلامیه وارد کند.</small>{{ form.requires_deposit_id.errors }}</span></label>
<label class="pay-check-card">{{ form.available_to_staff }}<span><strong>قابل انتخاب کارشناسان</strong><small>این حساب برای ایجاد اعلامیه واریز نمایش داده شود.</small>{{ form.available_to_staff.errors }}</span></label>
<label class="pay-check-card">{{ form.available_to_customers }}<span><strong>قابل انتخاب مشتریان</strong><small>این حساب برای ثبت واریز مستقل در چهارسو نمایش داده شود.</small>{{ form.available_to_customers.errors }}</span></label>
</div><div class="pay-sort-field">{% include 'payments/_field.html' with field=form.sort_order label='ترتیب نمایش' hint='عدد کوچک‌تر، حساب را بالاتر در فهرست قرار می‌دهد.' %}</div></fieldset>
{% elif form.subject %}
<fieldset class="pay-form-section"><legend>مشتری و حساب مقصد</legend><div class="pay-form-grid">{% include 'payments/_customer_picker.html' %}{% include 'payments/_field.html' with field=form.account wide=True %}</div></fieldset>
<fieldset class="pay-form-section"><legend>جزئیات واریز</legend><div class="pay-form-grid">
{% 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 %}
<div class="pay-field pay-field-wide {% if form.deposit_id.errors %}has-error{% endif %}"><label for="{{ form.deposit_id.id_for_label }}">{{ form.deposit_id.label }}</label><div class="pay-identifier-control">{{ form.deposit_id }}<button type="button" class="pay-button secondary" id="generate-deposit-identifier" data-url="{% url 'staff_payments:generate_identifier' %}" aria-describedby="identifier-generation-status" disabled>{% include 'payments/_icon.html' with name='plus' %}تولید خودکار شناسه</button></div><p id="identifier-generation-status" class="pay-field-hint" role="status" aria-live="polite">ابتدا مشتری را انتخاب کنید؛ شناسه بر اساس شماره موبایل او تولید می‌شود.</p><div id="identifier-replacement" class="pay-supplement" hidden><p>شناسه پیشنهادی: <bdi id="identifier-candidate"></bdi></p><p class="pay-field-hint">شناسه فعلی حفظ شده است. برای جایگزینی، تأیید کنید.</p><div class="pay-actions"><button type="button" class="pay-button secondary" id="apply-deposit-identifier">جایگزینی شناسه</button><button type="button" class="pay-text-link" id="cancel-deposit-identifier">انصراف</button></div></div><p id="identifier-help" class="pay-field-hint" role="status">برای حساب‌های شناسه‌دار، شناسه واریز مشتری الزامی است.</p>{% if form.deposit_id.errors %}<div class="field-error" role="alert">{{ form.deposit_id.errors }}</div>{% endif %}</div>
{% include 'payments/_field.html' with field=form.description wide=True hint='توضیحات این بخش برای مشتری قابل مشاهده است.' %}
</div><p class="pay-inline-note">پس از صدور اعلامیه، پیامک اطلاع‌رسانی برای مشتری در صف ارسال قرار می‌گیرد.</p></fieldset>
{% else %}<fieldset class="pay-form-section"><legend>انتخاب مشتری</legend><div class="pay-form-grid">{% include 'payments/_customer_picker.html' %}</div></fieldset>{% endif %}
<div class="pay-form-footer"><button class="pay-button" type="submit">{{ submit_label }}</button><a class="pay-button secondary" href="{% if form.bank %}{% url 'staff_payments:banks' %}{% else %}{% url 'staff_payments:desk' %}{% endif %}">انصراف</a></div>
</form>
{% if bank_account %}<details class="pay-delete-panel"><summary>حذف حساب</summary><form method="post" action="{% url 'staff_payments:bank_delete' bank_account.public_id %}">{% csrf_token %}<p>حساب «{{ bank_account.name }}» فقط در صورت نداشتن سابقه مالی حذف می‌شود. برای توقف استفاده، آن را غیرفعال کنید.</p><button class="pay-button pay-danger" type="submit">حذف حساب بدون سابقه</button></form></details>{% endif %}
{% if identifier_accounts %}{{ identifier_accounts|json_script:'identifier-accounts' }}{% endif %}
</section>
{% endblock %}
{% block extra_scripts %}{{ block.super }}{% if form.subject %}<script defer src="{% static 'js/chaharsu/deposit-identifier.js' %}?v={{ static_asset_version }}"></script>{% endif %}{% if form.amount %}<script defer src="{% static 'js/chaharsu/receipt-amount.js' %}?v={{ static_asset_version }}"></script>{% endif %}{% if form.customer %}<script defer src="{% static 'js/chaharsu/customer-picker.js' %}?v={{ static_asset_version }}"></script>{% endif %}{% endblock %}
+1 -1
View File
@@ -4,5 +4,5 @@
<section class="operations-hero"><div><p class="eyebrow">مرکز یکپارچه ارتباطات</p><h1>مخاطبان و مشتریان</h1><p>پرونده ارتباطی مشتریان، تأمین‌کنندگان و باربری‌ها همراه با گروه‌ها و وضعیت تیکت‌های قابل مشاهده در یک نما.</p></div><div class="operations-hero-actions">{% if staff_operations_access.contact_groups %}<a class="button button-secondary" href="{% url 'staff_tickets:groups' %}">مدیریت گروه‌ها</a>{% endif %}{% if can_add_contact %}<a class="button" href="{% url 'staff_tickets:contact_create' %}">+ مخاطب جدید</a>{% endif %}</div></section>
<section class="operations-kpis" aria-label="آمار مخاطبان"><article><span class="kpi-icon">◎</span><div><b>{{ metrics.total }}</b><small>نتیجه قابل مشاهده</small></div></article><article><span class="kpi-icon is-blue">♙</span><div><b>{{ metrics.customers }}</b><small>مشتری</small></div></article><article><span class="kpi-icon is-amber">▣</span><div><b>{{ metrics.suppliers }}</b><small>تأمین‌کننده</small></div></article><article><span class="kpi-icon is-violet">⇄</span><div><b>{{ metrics.carriers }}</b><small>باربری</small></div></article><article><span class="kpi-icon is-coral">!</span><div><b>{{ metrics.needs_action }}</b><small>تیکت نیازمند اقدام</small></div></article></section>
<section class="operations-panel"><form method="get" class="contact-filter-bar"><label class="contact-search"><span>⌕</span><input type="search" name="q" value="{{ request.GET.q }}" placeholder="جست‌وجو با نام، سازمان یا شماره همراه"></label><label><span>نوع مخاطب</span><select name="stakeholder_type"><option value="">همه انواع</option>{% for value,label in stakeholder_types %}<option value="{{ value }}" {% if request.GET.stakeholder_type == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><label><span>گروه</span><select name="group"><option value="">همه گروه‌ها</option>{% for group in groups %}<option value="{{ group.pk }}" {% if request.GET.group == group.pk|stringformat:'s' %}selected{% endif %}>{{ group.name }} ({{ group.contact_count }})</option>{% endfor %}</select></label><label><span>استان</span><select name="province"><option value="">همه استان‌ها</option>{% for province in provinces %}<option value="{{ province }}" {% if request.GET.province == province %}selected{% endif %}>{{ province }}</option>{% endfor %}</select></label><button type="submit">اعمال فیلتر</button>{% if request.GET %}<a class="filter-reset" href="{% url 'staff_tickets:contacts' %}">پاک‌کردن</a>{% endif %}</form>
<div class="contact-result-meta"><div><b>{{ contacts|length }} مخاطب</b><small>مرتب‌شده بر اساس تیکت‌های قابل مشاهده و آخرین ثبت</small></div>{% if staff_operations_access.contact_groups %}<a href="{% url 'staff_tickets:groups' %}">{{ groups|length }} گروه قابل دسترس ←</a>{% endif %}</div><div class="contact-card-grid">{% for contact in contacts %}{% with profile=contact.customer_profile %}<article class="contact-card"><header><span class="contact-avatar">{{ profile.first_name|default:contact.local_mobile|slice:':1' }}</span><div class="contact-identity"><h2>{{ profile|default:contact.local_mobile }}</h2><p>{% if profile %}{{ profile.organization_name|default:profile.get_stakeholder_type_display }}{% else %}مشتری{% endif %}</p></div><span class="contact-type type-{{ profile.stakeholder_type|lower }}">{{ profile.get_stakeholder_type_display }}</span></header><div class="contact-meta"><span dir="ltr">☎ {{ contact.local_mobile }}</span><span>⌖ {% if profile.city or profile.province %}{{ profile.city }}{% if profile.city and profile.province %}، {% endif %}{{ profile.province }}{% else %}موقعیت ثبت نشده{% endif %}</span></div><div class="contact-groups">{% for group in profile.visible_groups %}<span>{{ group.name }}</span>{% empty %}<small>بدون گروه قابل مشاهده</small>{% endfor %}</div><div class="contact-ticket-stats"><span><b>{{ contact.ticket_count }}</b>تیکت قابل مشاهده</span><span><b>{{ contact.active_count }}</b>فعال</span><span class="needs-action"><b>{{ contact.waiting_staff_count }}</b>نیازمند اقدام</span><span><b>{{ contact.resolved_count }}</b>مختومه</span></div><footer><a href="{% url 'staff_tickets:contact_edit' contact.public_id %}">ویرایش پرونده</a>{% include 'tickets/_voip_call_button.html' with customer=contact %}{% if staff_operations_access.create_ticket %}<a class="primary-action" href="{% url 'staff_tickets:create' %}?customer={{ contact.public_id }}">+ ثبت تیکت</a>{% endif %}</footer></article>{% endwith %}{% empty %}<div class="empty-state contact-empty"><span>⌕</span><h2>مخاطبی پیدا نشد</h2><p>فیلترها را تغییر دهید یا مخاطب جدیدی ثبت کنید.</p>{% if can_add_contact %}<a class="button" href="{% url 'staff_tickets:contact_create' %}">ثبت مخاطب جدید</a>{% endif %}</div>{% endfor %}</div></section></div>{% endblock %}
<div class="contact-result-meta"><div><b>{{ contacts|length }} مخاطب</b><small>مرتب‌شده بر اساس تیکت‌های قابل مشاهده و آخرین ثبت</small></div>{% if staff_operations_access.contact_groups %}<a href="{% url 'staff_tickets:groups' %}">{{ groups|length }} گروه قابل دسترس ←</a>{% endif %}</div><div class="contact-card-grid">{% for contact in contacts %}{% with profile=contact.customer_profile %}<article class="contact-card"><header><span class="contact-avatar">{{ profile.first_name|default:contact.local_mobile|slice:':1' }}</span><div class="contact-identity"><h2>{{ profile|default:contact.local_mobile }}</h2><p>{% if profile %}{{ profile.organization_name|default:profile.get_stakeholder_type_display }}{% else %}مشتری{% endif %}</p></div><span class="contact-type type-{{ profile.stakeholder_type|lower }}">{{ profile.get_stakeholder_type_display }}</span></header><div class="contact-meta"><span dir="ltr">☎ {{ contact.local_mobile }}</span><span>⌖ {% if profile.city or profile.province %}{{ profile.city }}{% if profile.city and profile.province %}، {% endif %}{{ profile.province }}{% else %}موقعیت ثبت نشده{% endif %}</span></div><div class="contact-groups">{% for group in profile.visible_groups %}<span>{{ group.name }}</span>{% empty %}<small>بدون گروه قابل مشاهده</small>{% endfor %}</div><div class="contact-ticket-stats"><span><b>{{ contact.ticket_count }}</b>تیکت قابل مشاهده</span><span><b>{{ contact.active_count }}</b>فعال</span><span class="needs-action"><b>{{ contact.waiting_staff_count }}</b>نیازمند اقدام</span><span><b>{{ contact.resolved_count }}</b>مختومه</span></div><footer><a href="{% url 'staff_tickets:contact_edit' contact.public_id %}">ویرایش پرونده</a>{% include 'tickets/_voip_call_button.html' with customer=contact %}{% if staff_operations_access.create_ticket %}<a class="primary-action" href="{% url 'staff_tickets:create' %}?customer={{ contact.public_id }}">+ ثبت تیکت</a>{% endif %}{% include 'payments/_notice_shortcut.html' with customer=contact %}</footer></article>{% endwith %}{% empty %}<div class="empty-state contact-empty"><span>⌕</span><h2>مخاطبی پیدا نشد</h2><p>فیلترها را تغییر دهید یا مخاطب جدیدی ثبت کنید.</p>{% if can_add_contact %}<a class="button" href="{% url 'staff_tickets:contact_create' %}">ثبت مخاطب جدید</a>{% endif %}</div>{% endfor %}</div></section></div>{% endblock %}
{% block extra_scripts %}<script defer src="{% static 'js/voip-call.js' %}?v={{ static_asset_version }}"></script>{% endblock %}
+1 -1
View File
@@ -42,7 +42,7 @@
</section>
{% endif %}
</main>
<aside class="workspace-side customer-side" id="customer-info" data-workspace-panel><button type="button" class="panel-close" data-close-panel>×</button><section class="info-card"><p class="panel-index">مشتری</p><h3>{{ ticket.customer.customer_profile|default:'بدون نام' }}</h3><p class="mt-1 text-sm font-bold" dir="ltr">{{ ticket.customer.mobile }}</p><div class="info-list"><div class="info-row"><span>نوع مشتری</span><b>{{ ticket.customer.customer_profile.get_customer_type_display|default:'ثبت نشده' }}</b></div><div class="info-row"><span>موقعیت تیکت</span><b>{% if ticket.city %}{{ ticket.city }}، {{ ticket.province }}{% elif ticket.province %}{{ ticket.province }}{% else %}ثبت نشده{% endif %}</b></div><div class="info-row"><span>تعداد تیکت</span><b>{{ ticket.customer.customer_tickets.count }}</b></div></div></section><section class="info-card"><h3>سابقه اخیر</h3>{% for old in customer_history %}<div class="history-item"><b>{{ old.subject }}</b><span class="ticket-number">{{ old.ticket_number }}</span>{% if perms.tickets.view_all_tickets or old.owner == user or old.selected_expert == user %}<a class="back-link" href="{% url 'staff_tickets:detail' old.public_id %}">مشاهده ←</a>{% else %}<small class="muted">محتوای خصوصی</small>{% endif %}</div>{% empty %}<p class="muted mt-3">تیکت دیگری ثبت نشده است.</p>{% endfor %}</section></aside>
<aside class="workspace-side customer-side" id="customer-info" data-workspace-panel><button type="button" class="panel-close" data-close-panel>×</button><section class="info-card"><p class="panel-index">مشتری</p><h3>{{ ticket.customer.customer_profile|default:'بدون نام' }}</h3><p class="mt-1 text-sm font-bold" dir="ltr">{{ ticket.customer.mobile }}</p><div class="info-list"><div class="info-row"><span>نوع مشتری</span><b>{{ ticket.customer.customer_profile.get_customer_type_display|default:'ثبت نشده' }}</b></div><div class="info-row"><span>موقعیت تیکت</span><b>{% if ticket.city %}{{ ticket.city }}، {{ ticket.province }}{% elif ticket.province %}{{ ticket.province }}{% else %}ثبت نشده{% endif %}</b></div><div class="info-row"><span>تعداد تیکت</span><b>{{ ticket.customer.customer_tickets.count }}</b></div></div>{% include 'payments/_ticket_notices.html' with customer=ticket.customer %}</section><section class="info-card"><h3>سابقه اخیر</h3>{% for old in customer_history %}<div class="history-item"><b>{{ old.subject }}</b><span class="ticket-number">{{ old.ticket_number }}</span>{% if perms.tickets.view_all_tickets or old.owner == user or old.selected_expert == user %}<a class="back-link" href="{% url 'staff_tickets:detail' old.public_id %}">مشاهده ←</a>{% else %}<small class="muted">محتوای خصوصی</small>{% endif %}</div>{% empty %}<p class="muted mt-3">تیکت دیگری ثبت نشده است.</p>{% endfor %}</section></aside>
</div>
</div>
{% include 'tickets/_image_viewer.html' %}
+3 -2
View File
@@ -1,7 +1,8 @@
{% extends 'base.html' %}
{% extends 'base.html' %}{% load static %}
{% block title %}{{ page_title }}{% endblock %}
{% block content %}<div class="operations-page operations-templates">
<section class="operations-hero"><div><p class="eyebrow">پیام‌رسانی استاندارد</p><h1>الگوهای پیامک واحدها</h1><p>متن‌های تاییدشده دیتابیس را برای هر رویداد مدیریت و با متغیرهای امن شخصی‌سازی کنید.</p></div><div class="template-guide"><span>✦</span><div><b>متغیرهای کنترل‌شده</b><small>از ورود تگ نامعتبر جلوگیری می‌شود</small></div></div></section>
<div class="template-layout"><section class="operations-panel"><header class="panel-heading"><div><small>کتابخانه واحدهای شما و fallback سیستم</small><h2>الگوهای موجود در دیتابیس</h2></div><span>{{ templates|length }} الگو</span></header><div class="template-card-list">{% for item in templates %}<article class="template-card"><span class="template-icon">✉</span><div><h3>{{ item.title }}</h3><p>{{ item.get_event_type_display }}</p><small>{% if item.unit %}{{ item.unit }}{% else %}الگوی عمومی سیستم{% endif %} · بروزرسانی {{ item.updated_at|date:'Y/m/d' }}</small></div><span class="status-pill {% if item.is_active %}is-success{% else %}is-muted{% endif %}">{% if item.is_active %}فعال{% else %}غیرفعال{% endif %}</span>{% if item.unit %}<a href="{% url 'staff_tickets:sms_template_edit' item.pk %}">ویرایش</a>{% else %}<span class="status-pill is-muted">Fallback</span>{% endif %}</article>{% empty %}<div class="empty-state"><span>✉</span><h3>هیچ الگویی در دیتابیس وجود ندارد</h3><p>migration داده‌های پیش‌فرض را ایجاد می‌کند؛ وضعیت migration را بررسی کنید.</p></div>{% endfor %}</div></section>
<aside class="operations-form-side template-editor"><header><span>{% if form.instance.pk %}✎{% else %}+{% endif %}</span><div><small>{% if form.instance.pk %}ویرایش الگو{% else %}ساخت الگو{% endif %}</small><h2>{% if form.instance.pk %}{{ form.instance.title }}{% else %}الگوی اختصاصی جدید{% endif %}</h2></div></header><form method="post" class="operations-form">{% csrf_token %}{% if form.non_field_errors %}<div class="create-form-errors">{{ form.non_field_errors }}</div>{% endif %}{% for field in form %}<label><span>{{ field.label }}</span>{{ field }}{{ field.errors }}</label>{% endfor %}<div class="variable-box"><b>متغیرهای قابل استفاده</b><div dir="ltr">{% for tag in allowed_tags %}<code>{% templatetag openvariable %}{{ tag }}{% templatetag closevariable %}</code>{% endfor %}</div></div><button type="submit">ذخیره الگوی پیامک</button>{% if form.instance.pk %}<a href="{% url 'staff_tickets:sms_templates' %}">انصراف از ویرایش</a>{% endif %}</form></aside></div>
<aside class="operations-form-side template-editor"><header><span>{% if form.instance.pk %}✎{% else %}+{% endif %}</span><div><small>{% if form.instance.pk %}ویرایش الگو{% else %}ساخت الگو{% endif %}</small><h2>{% if form.instance.pk %}{{ form.instance.title }}{% else %}الگوی اختصاصی جدید{% endif %}</h2></div></header><form method="post" class="operations-form">{% csrf_token %}{% if form.non_field_errors %}<div class="create-form-errors">{{ form.non_field_errors }}</div>{% endif %}{% for field in form %}<label><span>{{ field.label }}</span>{{ field }}{{ field.errors }}</label>{% endfor %}<div class="variable-box"><b>متغیرهای قابل استفاده</b><div id="sms-allowed-tags" dir="ltr">{% for tag in allowed_tags %}<code>{% templatetag openbrace %}{{ tag }}{% templatetag closebrace %}</code>{% endfor %}</div></div><div class="variable-box"><b>متغیرهای رویدادهای مالی</b><div dir="ltr"><code>{recipient_name}</code> <code>{company_name}</code> <code>{notice_number}</code> <code>{receipt_number}</code> <code>{amount_rial}</code> <code>{invoice_number}</code> <code>{payment_link}</code></div></div><button type="submit">ذخیره الگوی پیامک</button>{% if form.instance.pk %}<a href="{% url 'staff_tickets:sms_templates' %}">انصراف از ویرایش</a>{% endif %}</form></aside></div>
</div>{% endblock %}
{% block extra_scripts %}{{ block.super }}{{ sms_tag_map|json_script:'sms-tag-map' }}<script defer src="{% static 'js/sms-template-tags.js' %}?v={{ static_asset_version }}"></script>{% endblock %}
+1 -1
View File
@@ -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"))
+44
View File
@@ -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
+40
View File
@@ -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('فیش ثبت شد و در انتظار بررسی است.')
+33
View File
@@ -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('تصمیم ثبت شد.')
+26
View File
@@ -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)
+1 -1
View File
@@ -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()
@@ -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)
+41
View File
@@ -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')
+361
View File
@@ -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')
+53
View File
@@ -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()
+211
View File
@@ -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)
+49
View File
@@ -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)
+260
View File
@@ -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()
+718
View File
@@ -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 '<select name="customer"' not in html
assert client.get(url, {'customer': 'invalid'}).status_code == 404
assert client.get(url, {'customer': str(uuid.uuid4())}).status_code == 404
case['customer'].is_active = False
case['customer'].save()
assert client.get(url, {'customer': str(case['customer'].public_id)}).status_code == 404
def test_payment_shortcuts_on_contact_and_ticket(case, client):
from apps.tickets.models import Ticket, TicketCategory
ticket = Ticket.objects.create(customer=case['customer'], category=TicketCategory.objects.create(title='میان‌بر مالی', slug='payment-shortcut'),
subject='تیکت برای ایجاد اعلامیه', owner=case['creator'], selected_expert=case['creator'],
origin_visibility='SELECTED', owning_unit=case['unit'], last_activity_at=timezone.now())
client.force_login(case['creator'])
target = reverse('staff_payments:notice_new') + '?customer=' + str(case['customer'].public_id)
for url in [reverse('staff_tickets:detail', args=[ticket.public_id]), reverse('staff_tickets:contacts')]:
response = client.get(url)
assert response.status_code == 200
assert target in response.content.decode()
configuration = PaymentAccessConfiguration.objects.first()
configuration.open_create = False
configuration.save()
assert target not in client.get(reverse('staff_tickets:detail', args=[ticket.public_id])).content.decode()
def test_ticket_recent_notices_are_customer_scoped_and_permission_filtered(case, client):
from apps.payments.models import PaymentNotice
from apps.tickets.models import Ticket, TicketCategory
ticket = Ticket.objects.create(customer=case['customer'],
category=TicketCategory.objects.create(title='اعلامیه‌ها', slug='recent-notices'),
subject='اعلامیه‌های مشتری', owner=case['creator'], selected_expert=case['creator'],
origin_visibility='SELECTED', owning_unit=case['unit'], last_activity_at=timezone.now())
notices = [services.issue_notice(case['creator'], {
'customer': case['customer'], 'account': case['account'], 'subject': f'اعلامیه اخیر {index}',
'amount': 12345, 'deposit_id': '000123',
}) for index in range(3)]
notices[-1].cancelled_at = timezone.now()
notices[-1].save(update_fields=['cancelled_at'])
other = User.objects.create_user(username='other-notice-customer', account_type='CUSTOMER')
foreign = services.issue_notice(case['creator'], {'customer': other, 'account': case['account'],
'subject': 'اعلامیه مشتری دیگر', 'amount': 100, 'deposit_id': '000123'})
receipt = submit(case)
# Only confirmed current receipt amounts count towards settlement.
PaymentReceipt.objects.filter(pk=receipt.pk).update(notice=notices[0], status='APPROVED')
client.force_login(case['creator'])
url = reverse('staff_tickets:detail', args=[ticket.public_id])
response = client.get(url)
assert response.status_code == 200
assert [notice.pk for notice in response.context['customer_notices']] == [n.pk for n in reversed(notices)]
html = response.content.decode()
assert case['notice'].number not in html
assert foreign.number not in html
assert 'لغو شده' in html and 'تسویه شده' in html and 'در انتظار واریز' in html
for notice in notices:
assert reverse('staff_payments:notice_detail', args=[notice.public_id]) in html
more = reverse('staff_payments:desk') + f'?tab=notices&customer={case["customer"].pk}'
assert more.replace('&', '&amp;') in html
assert client.get(more).context['page_obj'].paginator.count == 4
configuration = PaymentAccessConfiguration.objects.first()
configuration.open_view = False
configuration.save()
hidden = client.get(url)
assert not list(hidden.context['customer_notices'])
assert all(notice.number not in hidden.content.decode() for notice in notices)
def test_customer_notice_menu_is_ten_recent_pending_and_retains_old_link(case, client):
from apps.payments.models import PaymentNotice
for index in range(12):
services.issue_notice(case['creator'], {'customer': case['customer'], 'account': case['account'],
'subject': f'واریز {index}', 'amount': 20000, 'deposit_id': '123'})
cancelled = PaymentNotice.objects.filter(customer=case['customer']).first()
cancelled.cancelled_at = timezone.now()
cancelled.save()
client.force_login(case['customer'])
response = client.get(reverse('payments:receipt_new'))
choices = response.context['notice_data']
assert len(choices) == 10
assert str(cancelled.pk) not in {n['id'] for n in choices}
assert str(case['notice'].pk) not in {n['id'] for n in choices}
direct = client.get(reverse('payments:receipt_new'), {'notice': str(case['notice'].public_id)})
assert len(direct.context['notice_data']) == 10
assert str(case['notice'].pk) in {n['id'] for n in direct.context['notice_data']}
def test_linked_receipt_rejection_history_and_staff_identity(case, client):
from apps.customers.models import CustomerProfile
CustomerProfile.objects.update_or_create(user=case['customer'], defaults={
'first_name': 'مریم', 'last_name': 'امینی', 'province': 'اصفهان', 'city': 'گلپایگان'})
receipt = submit(case)
services.decide_receipt(case['first'], receipt.public_id, 1, 'APPROVE', 'مبلغ مطابقت دارد')
services.decide_receipt(case['second'], receipt.public_id, 1, 'REJECT', 'تصویر رسید خوانا نیست')
client.force_login(case['customer'])
overview = client.get(reverse('payments:notices'), {'notice': str(case['notice'].public_id)}).content.decode()
assert str(receipt.public_id) in overview and 'تصویر رسید خوانا نیست' in overview
detail = client.get(reverse('payments:receipt_detail', args=[receipt.public_id])).content.decode()
assert 'مبلغ مطابقت دارد' in detail and 'تصویر رسید خوانا نیست' in detail
assert 'افراد تعیین‌شده' not in detail and 'سوابق ارسال و اصلاح' in detail
client.force_login(case['second'])
staff = client.get(reverse('staff_payments:receipt_detail', args=[receipt.public_id])).content.decode()
assert all(value in staff for value in ['مریم', 'امینی', 'اصفهان', 'گلپایگان', case['customer'].local_mobile])
assert 'افراد تعیین‌شده' not in staff
listing = client.get(reverse('staff_payments:desk'), {'q': 'گلپایگان'}).content.decode()
assert 'مریم' in listing and 'ایجادکننده اعلامیه: creator' in listing
def test_settled_notice_excluded_and_approvers_shown_on_staff_list(case, client):
from apps.payments.models import PaymentNotice
receipt = submit(case)
PaymentNotice.objects.filter(pk=case['notice'].pk).update(amount=12345)
services.decide_receipt(case['first'], receipt.public_id, 1, 'APPROVE', 'تطبیق مبلغ')
services.decide_receipt(case['second'], receipt.public_id, 1, 'APPROVE', 'تطبیق تصویر')
client.force_login(case['customer'])
response = client.get(reverse('payments:receipt_new'))
assert not response.context['notice_data']
client.force_login(case['first'])
for tab in ['receipts', 'notices']:
content = client.get(reverse('staff_payments:desk'), {'tab': tab}).content.decode()
assert 'first' in content and 'second' in content and 'creator' in content
number = receipt.number if tab == 'receipts' else case['notice'].number
found = client.get(reverse('staff_payments:desk'), {'tab': tab, 'q': number})
assert found.context['page_obj'].paginator.count == 1
settled = client.get(reverse('staff_payments:desk'), {'tab': 'notices', 'notice_state': 'settled'})
assert settled.context['page_obj'].paginator.count == 1
pending = client.get(reverse('staff_payments:desk'), {'tab': 'notices', 'notice_state': 'pending'})
assert pending.context['page_obj'].paginator.count == 0
def test_assigned_senior_can_reject_with_reason_and_unassigned_cannot(case, client):
receipt = submit(case)
client.force_login(case['senior'])
page = client.get(reverse('staff_payments:receipt_detail', args=[receipt.public_id])).content.decode()
assert 'value="REJECT"' in page and 'value="SENIOR_APPROVE"' in page
with pytest.raises(ValidationError):
services.decide_receipt(case['senior'], receipt.public_id, 1, 'REJECT', '')
grant(case['outsider'], 'senior_approve_paymentreceipt')
with pytest.raises(PermissionDenied):
services.decide_receipt(case['outsider'], receipt.public_id, 1, 'REJECT', 'توضیح')
services.decide_receipt(case['senior'], receipt.public_id, 1, 'REJECT', 'اطلاعات رسید با پرداخت مطابقت ندارد')
receipt.refresh_from_db()
assert receipt.status == 'REJECTED'
assert ReceiptDecision.objects.get(cycle__revision__receipt=receipt).actor_name == 'senior'
assert not NotificationJob.objects.filter(event_type='PAYMENT_RECEIPT_APPROVED').exists()
def test_customer_notice_has_independent_owned_detail_url(case, client):
receipt = submit(case)
url = reverse('payments:notice_detail', args=[case['notice'].public_id])
client.force_login(case['customer'])
listing = client.get(reverse('payments:notices')).content.decode()
assert url in listing
detail = client.get(url)
assert detail.status_code == 200
content = detail.content.decode()
assert 'توضیح همیشه نمایان' in content and str(receipt.public_id) in content
form = client.get(reverse('payments:receipt_new'), {'notice': str(case['notice'].public_id)})
assert form.context['notice_data'][0]['url'] == url
other = User.objects.create_user(username='other-notice-customer', account_type='CUSTOMER')
client.force_login(other)
assert client.get(url).status_code == 404
client.force_login(case['creator'])
assert client.get(url).status_code == 403
staff = client.get(reverse('staff_payments:receipt_new', args=[case['customer'].public_id]), {'notice': str(case['notice'].public_id)})
assert staff.context['notice_data'][0]['url'] == reverse('staff_payments:notice_detail', args=[case['notice'].public_id])
@pytest.mark.parametrize('mobile,expected', [('09120009999','0912000999938'), ('09120009021','0912000902169'), ('09000000000','0900000000090')])
def test_deposit_identifier_reference_vectors(mobile, expected):
from apps.payments.deposit_identifier import generate_id_algorithm
assert generate_id_algorithm(mobile) == expected
def test_deposit_identifier_endpoint_uses_stored_mobile_and_permissions(case, client):
from django.test import Client
endpoint = reverse('staff_payments:generate_identifier')
client.force_login(case['creator'])
before = case['customer'].payment_notices.count()
response = client.post(endpoint, {'customer': case['customer'].pk, 'mobile': '09000000000'})
assert response.status_code == 200
assert response.json()['identifier'] == '0912000999938'
assert response['Cache-Control'] == 'no-store'
assert case['customer'].payment_notices.count() == before
assert client.get(endpoint).status_code == 405
assert client.post(endpoint, {'customer': case['outsider'].pk}).status_code == 400
csrf_client = Client(enforce_csrf_checks=True)
csrf_client.force_login(case['creator'])
assert csrf_client.post(endpoint, {'customer': case['customer'].pk}).status_code == 403
client.force_login(case['customer'])
assert client.post(endpoint, {'customer': case['customer'].pk}).status_code == 403
client.force_login(case['creator'])
PaymentAccessConfiguration.objects.update_or_create(singleton=1, defaults={'open_create':False})
assert client.post(endpoint, {'customer': case['customer'].pk}).status_code == 403
+3 -3
View File
@@ -363,7 +363,7 @@ def test_root_redirects_to_login_or_authenticated_workspace(client):
username="root-customer", mobile="09120000991", account_type="CUSTOMER"
)
client.force_login(customer)
assert client.get("/").url == reverse("tickets:list")
assert client.get("/").url == reverse("payments:home")
staff = User.objects.create_user(
username="root-staff", mobile="09120000992", account_type="STAFF"
@@ -388,7 +388,7 @@ def test_customer_can_set_password_and_login(client):
assert (customer.customer_profile.province,customer.customer_profile.city)==("خراسان رضوی","مشهد")
client.logout()
response=client.post(reverse("accounts:password_login"),{"mobile":"09128888888","password":"strong-pass-123"})
assert response.status_code==302 and response.url==reverse("tickets:list")
assert response.status_code==302 and response.url==reverse("payments:home")
@pytest.mark.django_db
def test_magic_link_is_reusable_for_seven_days_and_only_grants_one_ticket(client):
@@ -595,7 +595,7 @@ def test_authenticated_users_are_redirected_away_from_all_login_pages(client):
client.force_login(customer)
for route in ("accounts:login","accounts:password_login","accounts:staff_login"):
response=client.get(reverse(route))
assert response.status_code==302 and response.url==reverse("tickets:list")
assert response.status_code==302 and response.url==reverse("payments:home")
staff=User.objects.create_user(username="already-staff",mobile="09384999655",account_type="STAFF")
StaffProfile.objects.create(user=staff,display_name="کارشناس واردشده")
client.force_login(staff)