fix payment

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

No files matched your search

+353 -15
View File
@@ -1,14 +1,15 @@
Project Root: customerTicket
Generated At: 2026-10-05T09:09:24+00:00
Python Files: 223
Django Files: 42
JavaScript/TypeScript Files: 28
HTML Files: 45
Total Files: 951
Total Directories: 106
Generated At: 2026-10-07T07:30:58+00:00
Python Files: 287
Django Files: 49
JavaScript/TypeScript Files: 38
HTML Files: 72
Total Files: 1264
Total Directories: 131
customerTicket
├── .agents
├── .aws
├── .codex
├── apps
│ ├── accounts
@@ -73,7 +74,8 @@ customerTicket
│ │ ├── middleware.py
│ │ ├── models.py
│ │ ├── persian_text.py
│ │ └── production_seed.py
│ │ ├── production_seed.py
│ │ └── session_access.py
│ ├── customers
│ │ ├── migrations
│ │ │ ├── 0001_initial.py
@@ -113,6 +115,7 @@ customerTicket
│ │ ├── admin.py
│ │ ├── apps.py
│ │ ├── models.py
│ │ ├── read_receipts.py
│ │ ├── services.py
│ │ ├── urls.py
│ │ └── views.py
@@ -139,6 +142,8 @@ customerTicket
│ │ │ ├── 0014_backfill_safe_0098_delivery_retries.py
│ │ │ ├── 0015_announcementjob.py
│ │ │ ├── 0016_announcementjob_requested_by_announcementjob_source_and_more.py
│ │ │ ├── 0017_alter_smstemplate_event_type.py
│ │ │ ├── 0018_alter_smstemplate_body.py
│ │ │ └── __init__.py
│ │ ├── __init__.py
│ │ ├── admin.py
@@ -146,6 +151,33 @@ customerTicket
│ │ ├── apps.py
│ │ ├── models.py
│ │ └── services.py
│ ├── payments
│ │ ├── migrations
│ │ │ ├── 0001_initial.py
│ │ │ ├── 0002_initial_configuration.py
│ │ │ ├── 0003_paymentnotice_issuance_key.py
│ │ │ ├── 0004_paymentaccesslink_paymentnotification_access_link_and_more.py
│ │ │ ├── 0005_paymentnotice_deleted_at.py
│ │ │ └── __init__.py
│ │ ├── __init__.py
│ │ ├── access.py
│ │ ├── admin.py
│ │ ├── apps.py
│ │ ├── context_processors.py
│ │ ├── customer_picker.py
│ │ ├── deposit_identifier.py
│ │ ├── forms.py
│ │ ├── link_views.py
│ │ ├── middleware.py
│ │ ├── models.py
│ │ ├── notifications.py
│ │ ├── policies.py
│ │ ├── selectors.py
│ │ ├── services.py
│ │ ├── staff_urls.py
│ │ ├── urls.py
│ │ ├── validators.py
│ │ └── views.py
│ ├── phone_contacts_api
│ │ ├── migrations
│ │ │ ├── 0001_initial.py
@@ -263,9 +295,14 @@ customerTicket
│ ├── entrypoint.sh
│ └── nginx.conf
├── docs
│ ├── CHAHARSU_PAYMENTS_IMPLEMENTATION.md
│ ├── CHAHARSU_PAYMENTS_IMPLEMENTATION_PLAN.md
│ ├── DEPLOYMENT_COOLIFY.md
│ ├── MESSAGE_READ_RECEIPTS.md
│ ├── ORGANIZATIONAL_TICKETING.md
│ ├── ORGANIZATIONAL_TICKETING_GAP_ANALYSIS.md
│ ├── PAYMENT_LINKS.md
│ ├── PAYMENT_NOTICE_DELETION.md
│ ├── PHONE_CONTACTS_API.md
│ ├── PRODUCTION_SEED.md
│ ├── QA_PERMISSION_MATRIX.md
@@ -332,6 +369,13 @@ customerTicket
│ │ ├── graph.json
│ │ ├── GRAPH_REPORT.md
│ │ └── manifest.json
│ ├── 2026-10-05
│ │ ├── .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
@@ -353,16 +397,20 @@ customerTicket
│ │ │ ├── 04b84d19d132764512441a31b31294e4f19dc963ed0a6e3abdd3eca7719e8bca.json
│ │ │ ├── 057ccb6901a83922bee4265993977ceb0d0ac425c900cb2d2f3181998df71796.json
│ │ │ ├── 058b4e36240cd25d158647c300472baad5e7101ebde0319883abb0edc8c9c74d.json
│ │ │ ├── 06ad1912a277e46fe5cecaa8a59b370c18101329c055c26fa6d603f37f63c91a.json
│ │ │ ├── 07e174cf218887aa0f8747199874faf6bc8af9347e43ff9b5a2073c089637f6f.json
│ │ │ ├── 08ff4daae812563107028890c2ae7257bb5f9a376a978244c814b7a4c1d10a98.json
│ │ │ ├── 091194a653ec9ff59e50d6d6121d12c0b0238b3f5cbb0467133a224b7eda59c8.json
│ │ │ ├── 09f9369eaae9b6144d8967b80f1a3edf0b3a8a3c44ef7e7f453b48b0adaa3fac.json
│ │ │ ├── 0aa59f78fa092e41298775a4db40136ea63654d45e3ae835903c4e3dbd0578d4.json
│ │ │ ├── 0aafac1c4b289aeef9a6251e3a12fbf1710ae576dbd647d155aa3981008d50b7.json
│ │ │ ├── 0c2c38fa53a0fe1da084703272dff1c8130732eb327a788d26ec0443c72d6b96.json
│ │ │ ├── 0d276b031b1e68ce07a7e0620645994c4d80c88dbdeb4b49deba5e053ef188cb.json
│ │ │ ├── 0d5f84af41009fdecae2a46d4ad5fab38a79c1a5dde262e9c7f8d35707a35092.json
│ │ │ ├── 0ef31d053655ee64599a0246f26c8eb61f27654ae1ffbd41b87500438fc9b06a.json
│ │ │ ├── 0f0c4146a70cadafffd025d6954e9202626be74ff97539e984c8b27cda5ed662.json
│ │ │ ├── 0f28b01b2484ce84b5251ff848065a2c6f7f574665305b5ecf8cb31a755a7982.json
│ │ │ ├── 0feebff2f8ac6ee66557b8692e3a537c0834b6bc4c28a6b9da6f3edf619283c1.json
│ │ │ ├── 11d2b71feb1480622fb18ee3795cf070421b146e4273b5016fad81e7cc8e2c7e.json
│ │ │ ├── 11ff4da3afbce9cae19102862deefdf725fa363130fad753fa189fa4f6c8c4de.json
│ │ │ ├── 12a877e28c12d0651d6487751c6b746afbe8361f9ba7e1b672f09bd596fbcf82.json
@@ -375,27 +423,37 @@ customerTicket
│ │ │ ├── 18ba25713068baf53992eb4ea94f95212ecaeb931fdcf6769970fc2fd19b2f93.json
│ │ │ ├── 19ed1276256eb4d44b9cea2dddac4de3801d67a08cf381a55466e82ee6f2bbc6.json
│ │ │ ├── 1bfc839bf502b97d0b70b0bacc9781cae7511d27d078ed6ac6978fd438833dd1.json
│ │ │ ├── 1c74c48acbde13e6bc5c2f63b204a1c051f22a8f48cd7b40ac54fc95e8809018.json
│ │ │ ├── 1c9f81b7412f571aa252ed10230220684b6657e1115f75a8c3a64b792517d41c.json
│ │ │ ├── 1d0ab0c5a2f15cd1c5a78495225dc535c02b58e9da10f882f48182e39b3f8b7b.json
│ │ │ ├── 1d6b17be6b2c0e7d25b4622b4a8c09ab705cecf9db09e2f86c35a4a564485791.json
│ │ │ ├── 1db075565e2fe1027675b7ee03d055150e6b741f3e9865bcd46d9a6113c538a8.json
│ │ │ ├── 1e3bef21b169a2020643dc56fc33c665acc164b4b8f07639a9b1f58c37bb4caf.json
│ │ │ ├── 1f440edc6feb5bf8c2a462b1b4a0b44bd22b4a763286fb2b340c797c282b5bcd.json
│ │ │ ├── 1f6cfd55d13bc48aa6c68558090ceeaf24a54c7bec690d51d95243c904daf0a6.json
│ │ │ ├── 1fabd63a56e826ff351d3edca222f5f958004c01f0bd16aed30080e8b13da33f.json
│ │ │ ├── 21f38c2f6a379ab402b4d3bd1b0809129c8554ebb01202a160c42a1e9b49b717.json
│ │ │ ├── 22534d744aadf9c9b61b908eccea0e9b3eaa30bc381bace54b3221ee0861a0ec.json
│ │ │ ├── 22df765bbeaca09caaf2fbcddcdd3ef1d4c0800f0c545f963bd91e85c33fe7ec.json
│ │ │ ├── 2363fafda0db2a268722cdc649cfb520f1d2e7f888debc09528ea8dc4aab0b60.json
│ │ │ ├── 2396802d9ca82a055f431939b4cd95eea6c29556956075d1d1ff35e4bca3b7c6.json
│ │ │ ├── 254ff59f2f43fd16c2028b338ec47a892294021f1a354238db59cb2ae6cbdf4a.json
│ │ │ ├── 2609e627752bd20df923f6c69e090173a368757b3dbd2ce418e3cc9f67c697ba.json
│ │ │ ├── 26a41d5fda805c545065f2da12215d350587a61aff951937f54d64bb713f0fab.json
│ │ │ ├── 26c6832fa65fe38fa62f98bbfd8d544cf6a470cce547579e1d650390e8b9fded.json
│ │ │ ├── 26e3b3874f2dc2c3709945e2f6cf050e5c525933de9f09a8c91b244e3bfa8ca7.json
│ │ │ ├── 28cefeff27c9cb5760aead796c37afdb1311bff0a5ff759136f0473ffa0152ff.json
│ │ │ ├── 298888276da87757f59b0e0280ea46039c0ee05945961c3e00b287abcbc6a2f0.json
│ │ │ ├── 29aca949b15e953360acae5ba86aac6c34ba8efddc55636fa05c28bd259ec66e.json
│ │ │ ├── 2b7cc5d0dec7234082c1797ab637b916ac7df39277f8fbf109ae90190fc1e900.json
│ │ │ ├── 2c76016c5b2c45bddcb0aec92b2365ea389feafb0667b2367350c756c7c51d87.json
│ │ │ ├── 2c8d91fbfca457646cf60edb03fd3a89a2494a6b6612087854edeac5a119739a.json
│ │ │ ├── 2df1edd1fd32c467f237babe4a94f2ed73b67270ede6fa8298b32eab7c3eb80b.json
│ │ │ ├── 2e7903ddca9e29c53f1706f2755303e56d60436e494d066d011fdba93e701903.json
│ │ │ ├── 2fd5953e7926f320bdeac2d559967758aef4632bf7dfa26a5ee50ad386fc9b67.json
│ │ │ ├── 30a1c433d0992b236232f8d17b7e17bbcfac1fd7385c607d1d09e716ba7da9db.json
│ │ │ ├── 3142f9d4517f137483c716dd61985811bbf006b85506791e9a2ee37d58b2f53e.json
│ │ │ ├── 314eb1aeeb47d9039980ee994d0682472193ed4e187b497797cd3c51a51b18a4.json
│ │ │ ├── 32a4505d16f4fd1efc5bc0c2ea7c8900419856b21a41b06d988cc424258ea1c9.json
│ │ │ ├── 32c40d17e90ccf2511e124b4fcc5b3674a7200dd5215ee8742d319f8090b04ba.json
│ │ │ ├── 336d5e1042b05e1f144a030b9f84836283398076b9f90d8cd4dc37622ab00caf.json
@@ -412,16 +470,19 @@ customerTicket
│ │ │ ├── 3948717314f47afa835e760073df4764ac427f4858f5ec0ee6a2f298ac393d49.json
│ │ │ ├── 3b9252049dbf8ef67e64a756bb41325a6cf50190004d4442119d438620bacc72.json
│ │ │ ├── 3c4a42b0b79b95acf81967479efdc4cd0a369e3b53b771fe87478d6327ddf0eb.json
│ │ │ ├── 3ca58a3cda78344757f73fb9dc390d4bb3b45f4a711a1a058c049801282ec1f8.json
│ │ │ ├── 3cec8055583c7d94e03ced535376a8bd9d3d335cd6c7e328f38dc2a9e6b1e397.json
│ │ │ ├── 3d66d87daf2039a3fcd22d5d844e30c00443bed258dc048fbf939d97258e08d2.json
│ │ │ ├── 3f0cad4a4a6eb440a2b133bf72f2e72d6e7ef3668f4c8dbc2c759dbd1a41eb6f.json
│ │ │ ├── 401b3df2aa1a5c618d917ae2422e552c59dfbc5220b312fe7977d751168f6987.json
│ │ │ ├── 408f6eb568e45e950fdc170776dabf9087829df0db630e16643fba8995318d26.json
│ │ │ ├── 4177b5c9bcdd159b716f3e956852b337fc54e7e597b5631d0f0e9a4039011901.json
│ │ │ ├── 41b1ac2b8ad040a3aa7173910cd19c2074adfdc23c4a1df31e445b61427a330b.json
│ │ │ ├── 42a296078b0eb6bea6dd15ebc5799c252901ba9e001290df5d3a9dfae0e4e4d5.json
│ │ │ ├── 42ffbc76dd6afcd90fde30281453fcc765ed07bc0e729fae4eee28d86f840578.json
│ │ │ ├── 433946952194be24247d30e4d5ba4d67b64ee2cda27a9212f16b4160a6f3fb71.json
│ │ │ ├── 434bb9df51f8929ca10167ce360dbaa9d93fdbfbd27053bdf11f3b171189edb8.json
│ │ │ ├── 44c4bd6b78660abe3642da6dfe25e665ac7bed04e759528556f437423fbbfe36.json
│ │ │ ├── 478a054f8292cd197743a61b5e3e04487f67f8db76ae658a28b04e4aef670ab2.json
│ │ │ ├── 479992e96c0c5fac0a00bd4d9e0822dd52273158f5364c26884649b017e5b874.json
│ │ │ ├── 47fb67d01a9006c7f835ad87b75b95bb1365c27b342144f081d858e07fd309b3.json
@@ -429,10 +490,15 @@ customerTicket
│ │ │ ├── 48f2dd332e3e37756440716095b3b0ba781ebd5abae50130c6b8bfee5a0468cd.json
│ │ │ ├── 4993b53d428cbbb1e334ea396aae6397586a1295a35f39da038cfe72f0b89fd4.json
│ │ │ ├── 49dbf05487834af2e8fb3c1c4048f93565e6ca40251d04486a137b8f960adcac.json
│ │ │ ├── 4a114c79b6523fc965e3aabcaf45df383f34f3fa942efed1aab21819d5a11cee.json
│ │ │ ├── 4ae084687e4372e69f349e8bbc5cf603768a032d3d9ee4d2504e6709ed8ee440.json
│ │ │ ├── 4ae419f04a3a3b1221faef43b8526910f1958336162fbce432bab0e596ef2aa1.json
│ │ │ ├── 4ae7b7ad5faee3292f9987cf047c1b9f484bb316d9e8c177dd85345bdfc861f3.json
│ │ │ ├── 4bf46c6c0f27e14312f7c1311bca1bf5e1bfee29ea5cb35e6f94142141985d23.json
│ │ │ ├── 4c924746bf7373f2f4b09200257622c6f97c2ba7fb3edc7f6595709e3c7ce9de.json
│ │ │ ├── 4cf64fe0b79aec18c49355f7d82ab04ae66067042a6c364e89842109427ffb5d.json
│ │ │ ├── 4d244ac4c7ac7dddfbb2d8ad6e70b2f9ef0d5226d2660b2ef38ef109f19eacc4.json
│ │ │ ├── 4e56ef4c75a0ef1d175a72a6980ba047bb4aa51d49b8a17471d8f342a0b779c0.json
│ │ │ ├── 4f4799a971a5d6701087428e8c709396c0d947f7ce5bcf7a048cd542d5551c57.json
│ │ │ ├── 4fd59b7f74524c4eb0b9293df963b8aa007039c53e88fef4ca2bd9b61197a114.json
│ │ │ ├── 502d00f961089b9a706153667a4767dbf3463eabb1d9c636ab92197cfebaaf20.json
@@ -440,11 +506,13 @@ customerTicket
│ │ │ ├── 52cd74ad5803b940489587043429b741434ae931fa4d244f5b0dc53582cee050.json
│ │ │ ├── 5323b09e3c325ba8c161f5e2cab233b25cfa1b96ff042c6ae79ae1b029e2b1bc.json
│ │ │ ├── 54d567d298ddc3c274327011a29bd4f2f2cb31efaae5ecb3a7a94164a3eb3c1f.json
│ │ │ ├── 55322e8bcc443a33b31b8e43e27a92b0c89335d5669d7046d34f58d33edef673.json
│ │ │ ├── 57bc96866536f0d67300ed29e56bd0f45fa9119c787fac805f228adcab18dc6f.json
│ │ │ ├── 582c91a8ddabbea5521fc65d724e4fb3672c070f9213fae7b58595fcf62f3c85.json
│ │ │ ├── 584447cf308657a696ef8465181b12d2c06f9dc5ff69752bea66bd0b9631feec.json
│ │ │ ├── 596a13e9b322f8c3332a3ae214ca7bf368ba6158ff3adcc7db4582f102ea51dc.json
│ │ │ ├── 5a34ded8b2dd4c10c4251d287580918b88690c7058dd2299d2d670170104241d.json
│ │ │ ├── 5ab2de631b0d480df2665ade93dae4732a4d6a1f44b809b2051ccff4696515af.json
│ │ │ ├── 5d43f2c9afc3451745d2a1b5f1ed17bc138c9b19caff61beadbc40a4e970b525.json
│ │ │ ├── 5f759ce7c1bcc67b3972da0ff15616dedf748eb2a85e029802d6f0e0feb053ee.json
│ │ │ ├── 5fcd0fdc47a301cbd07130dfa7545017b5975c29b9d7458ec65938ed503506a6.json
@@ -456,6 +524,7 @@ customerTicket
│ │ │ ├── 63a85c116feeb64b68cb48c92e34e48a89fc4ed443f40594b62751711b6e13e0.json
│ │ │ ├── 63d01f1af1f4f0c9ad42a8c4d310a5b9df5547d5526c49284255fc6ff15ace9b.json
│ │ │ ├── 6506009802a6a268be82b680ace7580613f18dc702ed27aaff929398a85760f3.json
│ │ │ ├── 656466a59cda75489e13531815b0b6075b27edf7d4261322324e5c621edbe8a1.json
│ │ │ ├── 659df1da012e33aa3c92c4d36817034e6ec1a4d5d7ae94e6c2328f9aaa607b6b.json
│ │ │ ├── 65c9b0921f2ad9b0228f5685c40d5b341da9604f8cec59339fded4cf0bfa40f4.json
│ │ │ ├── 6802a4e7d920a2a5ce48a61989565f6fa769c314a01b9179cb9d44653b9467ae.json
@@ -465,11 +534,14 @@ customerTicket
│ │ │ ├── 6c69494ba124d4bc564bcd3a3df1f33ae0ee0769001a2dfc46825ef8b44dc3e2.json
│ │ │ ├── 6d3b094dc56f9b26ce7374ba28e460b85248711223cf516f8e732c5d8f4d9d75.json
│ │ │ ├── 6d5685e1562faf37f6c30c614bcc7628edd19cd8181d5791aff416daf9fd5a6c.json
│ │ │ ├── 6d824f70de5ab20115bad3abf431778360f87aace8e16f7056e6706f65346978.json
│ │ │ ├── 6dc742b24111f73be6bd5c6d49bd013066a7bf4d299d145391fb0de1f4ed6fae.json
│ │ │ ├── 6ee9154979fb497a8ec229a7177660af53a487e1aa17c7b555cdc67797615162.json
│ │ │ ├── 7093cbd0a855750457814388b6e527fd3b612163f7a2b898ed6ce470dae74843.json
│ │ │ ├── 72ae8a21c9106c639725190cc02f4e07efe7c645896cc4410923ad36751fbb2e.json
│ │ │ ├── 72de23f55766289c980e6ccf604ca5d162e0989a222271b7496775ba4d749e3a.json
│ │ │ ├── 7379dad88b1741f6f4d999fcea3eb0ac4ffe56f72c07dc11881e94615457bd52.json
│ │ │ ├── 745e730da3d08083a3c15a3a8e2d5e603c81a33d371c6e2de7f554a70c7e2e29.json
│ │ │ ├── 74c2a0c0f83087673b100b7555e506c71d2a6a941677e388a1de59371b6d9afe.json
│ │ │ ├── 75fad6124616f6d9e50ce9a475d4e989955c5fae7c5566b99992d986ef9a1f93.json
│ │ │ ├── 77916c3b035a577b24da1a1dbcf47db7307af4e0774fecc549f7c95b91994a20.json
@@ -479,6 +551,7 @@ customerTicket
│ │ │ ├── 7a886aec3fe4d432060a7ad6c88a9e0fd810a122ad1d244456af089a828af8c1.json
│ │ │ ├── 7b25ccdfeab6d57509c1b8a9745fb601ab7e8fcae0e73a22932ceb725fd38c9a.json
│ │ │ ├── 7b6916f499ae0ceae3eb3d509b51f42a7eaa98e43e161fe87c44b9a5bb01287b.json
│ │ │ ├── 7bc90a6d89ccfaea18fd0aa05f2ab506d0639360f33413bfa9385ddf49e6d2fe.json
│ │ │ ├── 7cd278b7ea82af8777e4da2f84fd1773dae3dd371744f66bbe12c0b925fd6486.json
│ │ │ ├── 7d276789b80c8d9d1677556b3fb33b2da05988e97f6bf28903a044bb9fd64894.json
│ │ │ ├── 7e136779a95d8e8e2ac6cad2192890254fd0d54d46b4f5514debba0f22618d75.json
@@ -497,14 +570,19 @@ customerTicket
│ │ │ ├── 84dedd4165eed360c4051690b2aa84f6ecc08f3f053f4129ca6f3e33c334ba35.json
│ │ │ ├── 853fba8af82971f4fa56d957282d45d335d2e794f8e11001977161265af2ec79.json
│ │ │ ├── 8595a5d566d43428818ee229e64f6b56453cc9ca61fd68be0b1c3c783d326315.json
│ │ │ ├── 885114f15b5e41946539500e8df7e848021aa7f62c5a5bf097176f7b1cc741df.json
│ │ │ ├── 88ade181c6611ec33d9bcf5fe739dc809d81875b6571b9f60f87dafb4f5941b2.json
│ │ │ ├── 89273d53a77489be59de43475ae2b053374f33b01b5400a963cffe0804427f7a.json
│ │ │ ├── 899b16856382d32c4e5b39d02cb251bdedaf35c5a81bede5c19bfaff092208e9.json
│ │ │ ├── 8ab55a4f4f776ea75d01aaafdff3502c07b5b86e359789b22c590c7f988bf5f4.json
│ │ │ ├── 8af38f9544549f250eff9b83a6c2a0e51f86d6ac51de0db24b48b7c654723a39.json
│ │ │ ├── 8e4cb8d07f4d43e171550ff2fc9423efd032b911d7ffee51259e437d4dc32d4f.json
│ │ │ ├── 91c2dc58207c4311391f22d524f461032f7ef3c9443d8f584edb2128966821bb.json
│ │ │ ├── 91d83c62f39f219ccb6125502bf8e0a71a587dfc0a66d977a9bb93c8f2dc5dc6.json
│ │ │ ├── 91f4a9afed43fafc3d99d7fe5eaa945cd5c89e29d4d6f6448b630c3e58db05e8.json
│ │ │ ├── 9301321aa7fb1a7beff5518eb54fbf1bf7145e0aa76d3ed29ae4cb38f1057da9.json
│ │ │ ├── 9326d87e36b2dc24e6173aa3346f46e7df9fc3353fdb409304af4363c286a41e.json
│ │ │ ├── 94ea5a4bac836b2d19b08e446cf90eaea49b9eddc2591d261c384b3513e36f26.json
│ │ │ ├── 9697c37cf3a88d2b4eb5419b2381951a9aca966172316db79e7b4c32557d5e87.json
│ │ │ ├── 97506546de142970349e57e8a14064280e66a4fcdbced5dac55fc263630a2247.json
│ │ │ ├── 97d8ebb860cbcd63be9ef80c6e2094e4a7ccba668b291d3c4815133d5fb8f37e.json
@@ -518,37 +596,48 @@ customerTicket
│ │ │ ├── 9cac12079d625e44ef3f62c6e4c9c7848f1cdd4d086c9251b3c81101cf4c114e.json
│ │ │ ├── 9ce16f69c6b04f703adb24ec7498a2d2ecbf6f7524c459deb25111247e550698.json
│ │ │ ├── 9d0e2a1fb14529aabd31c82c8d8f5543c0068366932934038ba93e234f291e22.json
│ │ │ ├── 9d3a68c9991464131299b924086faf1e0c474c156019d681ba41e64d8100a7b1.json
│ │ │ ├── 9ed5e291859abba38b07725bc6cf0275a1548e937888a5ba177674afaa48b96f.json
│ │ │ ├── 9ef0e904ba6d1b295c75a1d5741851f571339a8e22c7de96bf5e2508f764ee43.json
│ │ │ ├── a04a8ed5ca0ac9661dda4f6f369248937cf67e61cc982a1f17b24495263d53d6.json
│ │ │ ├── a081526ffd8f52690e0834cc71e9d2bf987f949c2d0de68a6eb024063d97e64b.json
│ │ │ ├── a130f47959c058751c2afdefa119ce88d7c59fa65883f7e0a69099a022033d4d.json
│ │ │ ├── a1bd5a32741129c967a18413ae1be6bf3e20754251066d342d652a668ef95025.json
│ │ │ ├── a2fea28bcbfaa240ed1524c5f422481c263eb6f010e0e06ddf8e45c7d1d88e6d.json
│ │ │ ├── a3c770875ffabc18c595532501255df82b1a09246ee54b831e7c2221932d4f88.json
│ │ │ ├── a3f339fe1ccb99a051c1a3bff70e7de0e2ee6b952db0aa2d6739e97331c29cd9.json
│ │ │ ├── a4a27e7b3842713c361895d6d9c5fc9e4341cce71c5842f014748e1c1c23a197.json
│ │ │ ├── a4a568f58efea076f16698bb9bb6279a92ab44167a6e97d424fff44900ff973e.json
│ │ │ ├── a5231d68fe35e6b02ff4ff70e02a3229c08d9b6c2bae774a1b3227823b95e15a.json
│ │ │ ├── a614593a975a3af1612ede797f69fd29220ee9251ab016d34d5e612a04696ce9.json
│ │ │ ├── a698382a7159e40294490d6f668a46485f1a08fafdfbc52bfc616f362dc084a9.json
│ │ │ ├── a7729f0b6bf20ded62ff7b6ee678fdaae9516dcd80c2a8f9e9f3d9299f6e22a0.json
│ │ │ ├── a7b036e5bd51876694ebd883bd1b0a527c5f8409b48d4716612311e954a8cc0f.json
│ │ │ ├── a8274003d8d4ef62b23c66e3baecf14eea260f99a4509411d58506f560c2b474.json
│ │ │ ├── a836a06de0905683ff2aee057760fe6e4a3f9161d0cb60544fb7afc9ce782671.json
│ │ │ ├── a93b84a0def4d2e0e97ff35032871b0ce64786ff468912aed21fd958b9fe782b.json
│ │ │ ├── ab1648cc3b4d4bba5754e19eeb80ea160fd20207d5530ac371647168f9ed211a.json
│ │ │ ├── abaaf1c3e88b939bf1833294f5233fa8e7c94091077495c424a0779298cc885d.json
│ │ │ ├── ac454f6bfed5913148eb8c9caaa0e30f99bac381936b44612bdee8274bb27b29.json
│ │ │ ├── ad1175b041ab42a2615856a41fbab394ae63ef9dd991901c0658c3fb0846ad3f.json
│ │ │ ├── ae4d845b7155a0e8a73ab943ab7c4524cd6e73c3f90be008edb478c968d4fd9c.json
│ │ │ ├── aeb59c5e1447023f9c49003f6bf4eb26a7b9a34ca5658f7ba2cde1b99e25ec76.json
│ │ │ ├── aee78d4e87210ef46180d47e7015345bd66a7763cd140d84cd86539e720e8e67.json
│ │ │ ├── afa89caafcdde5a4d7d85aad1db8c0abaddcebf884d84e8d23c50556b136b1a9.json
│ │ │ ├── b0f297dddb05ea13b6bade486a2e88b6746c3c727b9ddfbd640942776b2e2cfb.json
│ │ │ ├── b0f3c37fe7f5327da6c37161e405561df7eb920611513b0f7ec544e30763a312.json
│ │ │ ├── b137ac0b92c8567809fb0323cd320fbf60c45ae6fad1bbdfb10d6efb22989f0b.json
│ │ │ ├── b390f7bae9f80c48e703a6664d9de6ec9c141cdf8ac2f012d67ac2b4d8a25b4e.json
│ │ │ ├── b396b497b11e135cfc19ccbee84e3e67df0e401a412f51c0336c89cb3609b5f5.json
│ │ │ ├── b4446ca11a1f4f6ea45256e755ed56d6ba7c330278e15ead8cae2de28d5e18d8.json
│ │ │ ├── b6e731246dc10854a0fa0455c3c5816e29887b1a8c436d518926e1c2a5e36239.json
│ │ │ ├── b7014ccf0ecd3cdccb86d9ac37bd58c5ca00b59d2de58a194eb66ac139e14a5c.json
│ │ │ ├── b7ddd6ec7284c45f0925a557b161afd7c9d01171b06c3f723ac4da599ecf47d2.json
│ │ │ ├── b922fd1a31f3cf602f197c872f3bac6e69c79255e502ddcbf1626f846ec3baee.json
│ │ │ ├── b96845015841d34aa0edcc02a6a51a4ec5f77bd6827d5e9bf8ca6f620c8de3dd.json
│ │ │ ├── bb3d09cc5d1bc380e841e2eb19cb497391117101c73c358e6db03db4d6ae51e7.json
│ │ │ ├── bb78c0a0d3cbee2cd3238fcc9a1c14cb8bd9ce97a78de60af3aa49f132f966a8.json
│ │ │ ├── bf759ce9656625911599b351f64028b9bf0bcdcf4adc084e96e9cccc80bbe50c.json
│ │ │ ├── c1060dca2a3f437f386324975be659215fe9f7e689137dc85e823879b462d11f.json
│ │ │ ├── c10793a36312ddf2bd8ed9edbae5d67a19f54b78ac3cae01682d38c34f2bdf11.json
│ │ │ ├── c199fcf03e3df0de15c0e7fa5f89fbd9649f2be4a257c8de9a7ebc69ce0d629a.json
@@ -562,6 +651,7 @@ customerTicket
│ │ │ ├── caac8d56f4ba7b3987d86c31357c6ffd2c4125d16e4637cb5a4568ac6eee29de.json
│ │ │ ├── cb87cd70f59a8e0950529ae42a2229a259ff8b305e4319422fb1a9dbc41e63e2.json
│ │ │ ├── ccd15ad60b63cd29e2bd2762133a74e2047a2894fd67206dff7f7c7fd082b399.json
│ │ │ ├── cd137e2201aa02abbd8d57e28535e25842bbbe227f8e859dd45752d1e4217b0a.json
│ │ │ ├── cd4804092002a20d5ed88c32b577b0868a0ef39e518c749dbadec455775917fb.json
│ │ │ ├── ce52c1ffbf8db0d0b4ba85e0a07055258a8eb179db097f1df874e5be81aae49c.json
│ │ │ ├── d083dc75811629ffa77ea30c1965e67ef3bc2bf65e5b20a1a35cb0f80a46949c.json
@@ -578,10 +668,12 @@ customerTicket
│ │ │ ├── d9516c3232e0f78225764bf4f9586c88932df925c45de71f4eb1c883a9ed5b5d.json
│ │ │ ├── d9c81db9cf9f2fee8a4985367d33b1c0ab1d7e6901e85473070c3bfd6bfe32c6.json
│ │ │ ├── daa187dac6877c746c02269141a7e7db178ac32a0b3a4bfeaaa8ed046b6dcffb.json
│ │ │ ├── db0c84080aa8bbc15b1ebf23e820aa372f70076c2771e24fd7e6b1e8c663e881.json
│ │ │ ├── dbaef2f4942a9f7ecde0afc10e4eebfe3a2809af1d8b76cb036bd417f19274b1.json
│ │ │ ├── dc90ac5e8d6766f8001bc2a40d21c203717d7a9f469956c2f6d9bedf3ae51aea.json
│ │ │ ├── dd795078450fe69f61a445d3cf1275420be57381b005597cb5003859b2025800.json
│ │ │ ├── de1655ae1d42e56f0cdb8788c0f6ba1c7c6e6725e6ef5e80969b92ec3e68ac78.json
│ │ │ ├── dea57fa4a89ef99a815bd9b1bb71b8475727a3d7d88161a9a696ebf636f29ed0.json
│ │ │ ├── df2d803fc69084e91df819bda27a7e9f7303a40ff7a5d30d93fbc16daf5e2d45.json
│ │ │ ├── dfc9279b0d9023afc1dbe55536fc1cd7a556a6a437775128b9e41615431b4733.json
│ │ │ ├── e2f4f5c0bcb971ba3cb08a4ba14ed5d1ba6b5899fd36db1f5dcdbd2313cd318e.json
@@ -613,26 +705,36 @@ customerTicket
│ │ │ ├── f41de3fc65a6805cd39a47d713e1d224ba3c4eff476f0aeaf95b5e9b44cbc7ed.json
│ │ │ ├── f4436ad45c3f2a27b4461333ae2ed21af157a3945e152fca7f43d640dc39f63b.json
│ │ │ ├── f4780f9612e45a5bf32eb07fd006b874586bf10ee1528219fcad4f91304a6640.json
│ │ │ ├── f48a1f4157bafecbddf10702c6bfd0ee33addb5752a4eddcdaac7b7e0cfa06e9.json
│ │ │ ├── f4bd364e7dd2fde53b658ae0635bf5143f1f4cc5eaa22b1ad9449abcf98a9bd1.json
│ │ │ ├── f542bc3aa4ea54e09e2fb68ecc937eed71e4307283d9cf7c2d94755c6937b7ad.json
│ │ │ ├── f576af4a0cecb6cbaaf8afe5097913be0b7071e200fc924c918ea93446f00c91.json
│ │ │ ├── f7e200f94ac2080e0885bf4a03e8afdb5cea2684a31a7c09e67dbf2f4fee6f51.json
│ │ │ ├── f8769e3eeb23aebc01d72869a2bc6e635d7dc82440bc2b0b0e11e6b6b17f5109.json
│ │ │ ├── f8fc55a028176d1ea9a1b580a8653d4c45ae7466445f37e6c23867f12821c44c.json
│ │ │ ├── fa27401c5036759a68a650cf8097dd25e845aa3c385c1425fb2603455e6e9220.json
│ │ │ ├── fadb933ee40737a7c1a11722b8c07663038f72148bd0e2c435ef319958670d9b.json
│ │ │ ├── fb9736a4ef3aca74573f131c7cbd9f015d88dd755ffba3edd324aec95149175f.json
│ │ │ ├── fbe224671138a9d843def175a438533c3d6cc144b2851f8a723e505918f2c22b.json
│ │ │ ├── fca6b7e4c9f92410b2590539af80d6b499509255af4ef88282912c6c79a09330.json
│ │ │ ├── fdd30ae7a839a46eb2f57681444d93258df5cbbdc40d2c24aae4caa8adef0936.json
│ │ │ ├── fde577f1231f9b033560bfe075896efc6bf8d6079a030a159035584c7d39843b.json
│ │ │ ├── fe032050843e4e1c28f5712205df74fa1698390169658c4ddc4555b6bcce8872.json
│ │ │ ├── fe0c3eed2ff5c4b5d2116aba4494d599ae3c27004136e99f5d455f351281fa78.json
│ │ │ ├── fe2f630d6d6dda85620260656f31e78db2ffcd0a8145f28e7b1713d851bd6baa.json
│ │ │ ├── fe77fbd662d91d04f2efc69788d30e9859584c8d87d1fabd60ca66661568091e.json
│ │ │ ├── fe93d0fb278c013183276f7bf5c5e2d0e62fe9c75f6695390f5994ec0b9c8dec.json
│ │ │ └── ff809a93fdef0b7b8896c56bc9048369340da05981fc0435be179cfe930e7e4c.json
│ │ ├── semantic
│ │ │ └── pa567fc138e3a
│ │ │ ├── 00825b46909baea25186d0f8351fc2c3fae63520319b5e7bede4ed3e9f53e54d.json
│ │ │ ├── 04501034a062c75a4259223058c373b19c0b87c5ed44734076511726a012bd98.json
│ │ │ ├── 067b09f64665879ffd6e39e47f23d7d969c007079c810536f77d816af6713957.json
│ │ │ ├── 09f927cff0791d0e91365f50b4dba1a6c8cc0496ef70847d2ba9f1726092d00c.json
│ │ │ ├── 0acd6559c9a499b9d542b239c9137b8f873cd77273cb7b388c0dc57d03f47d06.json
│ │ │ ├── 0c8c80793f32ac4d0097cfee25dc9fdf0774d8ff334a3b2c4595e73bec49c4d5.json
│ │ │ ├── 1285fb34ce6f72c9ab1cb3259622f3914330beab20c8e0404dbe1f46b9fd374a.json
│ │ │ ├── 144fab3d71b01514d348460e1a00c80f30268fd6f9ac3ae9eb7ae70b4c5ce44c.json
│ │ │ ├── 1516a3814f7846c4b73dd288d7a594bbdc6c4b72a9c177d3a169036715c0d8b0.json
│ │ │ ├── 1753a19b402251a9e18e9c4113a5f1750a4168e9b0bedb06b4d6f35f2aa5980c.json
│ │ │ ├── 195617b9b33d83896a4ffa16edb1d849e0da5cb7a1aa2b64d82d1bafed070f80.json
@@ -644,26 +746,39 @@ customerTicket
│ │ │ ├── 2233df96365dd9e332d4744056d02289ae3a003a6fb8f3342a205860e03f8665.json
│ │ │ ├── 22569dac279084bd62c922dbc391f9a5a7cc2e1051ef043bde5cfc72df0ad864.json
│ │ │ ├── 23f032018f0612c58670d22539577856b5479f6107878d558e5e062bac9da30b.json
│ │ │ ├── 2715c71f1fa5ff6094b1b2044ce39de3608cc500c04fb154e62006d9a3e9d948.json
│ │ │ ├── 28f2ce9d1005a70b7f624b04888ce9c511251d8da243d6c1c57a630eb14c1220.json
│ │ │ ├── 292cc5e382e1a39fb922a669823ccc8be3955d41ebd377327d297ecdf1098a9d.json
│ │ │ ├── 29a4f053d88deb1406e9e7fd20dac4fc1a3c518382af3adcb25fc0d6415e6c31.json
│ │ │ ├── 29aca949b15e953360acae5ba86aac6c34ba8efddc55636fa05c28bd259ec66e.json
│ │ │ ├── 2aca62132d5013370c679b9953bd8152e07acadbfc431f8e2b3d4257e02c8c73.json
│ │ │ ├── 2b0ca95b4d2205396255ef9ad6880126d69de168f4349406796caf7cee711791.json
│ │ │ ├── 2db4afd2f04cb1be70a822a9dfde1cbc197dea9ecc8d39cabf5eab10b778f10d.json
│ │ │ ├── 3134c8aa643b2f1de403eb947f71f0c404194bf0c036212aed1bbf18972de75f.json
│ │ │ ├── 3451c7089bb943a2571e2fd451467aa5e18fc642a64edaee34380e7b59f54baa.json
│ │ │ ├── 362893de8fa037c65968c8d7d571986ee37da19609c9043fcae115812310fa0f.json
│ │ │ ├── 379031c1044f245cdf1ded7504608f5861a11d5110845436678be4e121251062.json
│ │ │ ├── 3a67afa45adca2d481cf2d40ef5330cd1302d052c83174c592ab5ea603989d6c.json
│ │ │ ├── 3a706195f94277c85075fa51f990e934092ba6d36d28e46516bad78f18ea6140.json
│ │ │ ├── 40fde7ee08169065b66efc00abbdc63c51cb968f8ad48fb21a32d44351878fc4.json
│ │ │ ├── 4466b520ab1a4eb44abdd915e8a1cba43d72c8d08bf93ebea81d8e8feadef93e.json
│ │ │ ├── 481e0ef213e9eb7ac3679e2fdf54cfe7bafde152b3de57b0c9306416529ab904.json
│ │ │ ├── 485a9d1cf66322f5cb2886aac1f388a2846aba6fd444b87b251edb03c51ff00a.json
│ │ │ ├── 4a72da5bbf738460d04c3c799474a4a7093821d69fff2b2efcca1dd78f947652.json
│ │ │ ├── 4bf46c6c0f27e14312f7c1311bca1bf5e1bfee29ea5cb35e6f94142141985d23.json
│ │ │ ├── 4e56ef4c75a0ef1d175a72a6980ba047bb4aa51d49b8a17471d8f342a0b779c0.json
│ │ │ ├── 527fef297bee36f729186cbfe4bee9b0a41ad7bc98896815e2058c5348b76706.json
│ │ │ ├── 5533327da089db48f0050a2d48af495de46f562ea2e29fcfdf3a527ece013770.json
│ │ │ ├── 568acfd2839a06fe34d1c68f634629e1671bbaf64f45c2e1a628827249a03e1e.json
│ │ │ ├── 57240e5b1ee2390b9f26b44f11253b97b1c6b279affdedc34cbeeadc74181504.json
│ │ │ ├── 5931c8253737ddb54edc00f7f0edb0dfcf52d0f4b34d1c94e05e918a991505ec.json
│ │ │ ├── 5aa8fb6425a50cdd0fdd3fe862960efbb179a8098df347292940f564737404ef.json
│ │ │ ├── 5b5bd6e41d3308af6e19908a392f853875cd8a1f1a10f6e195d21aeab1a6a586.json
│ │ │ ├── 647cd99ee01ccb860fc776807d8116a3849db7316920814fd004970cf61c38f1.json
│ │ │ ├── 660dafe22e562cecf6aaf4afdf658996dd024a1436c57e9815c82caa0c3e1782.json
│ │ │ ├── 68ad05b476d72c3361a7900c669d221d4b6c629e8697c360837d4faa857030e5.json
│ │ │ ├── 697c16d4c7fadca75d73c67dcfea3aefc8c3b91394718b1d09188e7e41d8811a.json
│ │ │ ├── 69ffb09e8b5b90bd7b99ae5182e4f20c9e89fa9498f5288b69931b8fdb9f4de0.json
│ │ │ ├── 6d5ac5656791048028b6bec9a94588cede8731bd7ca2151f13f7819e2819f2e7.json
│ │ │ ├── 7322f205d129029de2b4bb0be04fc6de2798fa17122738ae5d5f300e66ee5409.json
│ │ │ ├── 74b74470177ae910424d02aaef051f094b6bd2d41b2f68dca025f2c11c0cfe2c.json
@@ -679,6 +794,7 @@ customerTicket
│ │ │ ├── 8a7c3cffb298a6af2f6f497e70513330244117b87649ee61c964461dbcaa05e8.json
│ │ │ ├── 8d6f6490e7bc2e9660f5e0cc8ce68371ec092276a3eca9876d198989ed1d3ffb.json
│ │ │ ├── 8e62122fceb99e49a7dd46a86cc46cbe3218cd86773d310fe15135a6ad4d6998.json
│ │ │ ├── 8f1b537daa4cfb03cac6156351594ddc5310b32b66d3e1758f21621a7d35c491.json
│ │ │ ├── 902240b9e3adc33b9d30ac42dfffbe44368e357a3658728c8d535e47fa5650a6.json
│ │ │ ├── 908e0129cefe129d0b562243460fe20793c84876244cef2e99ca1becf8cf6a1f.json
│ │ │ ├── 9281775c35930ece9d1382446b2e7d54cb3ec777fbc045ce46cdce5287f589d9.json
@@ -689,8 +805,14 @@ customerTicket
│ │ │ ├── a7b56fb6165dabba4d71f0757463855dfca3d85741975d62820b05abee14890f.json
│ │ │ ├── a8274003d8d4ef62b23c66e3baecf14eea260f99a4509411d58506f560c2b474.json
│ │ │ ├── a9e40d5d4e9e6a566950d6e8fad456d9703444e6e6a3aaac4022462fd2a6c45d.json
│ │ │ ├── aa93e30f61d5ebc3d96127c430b5179e380e1af4aa9b620d472b3272fa8b39e0.json
│ │ │ ├── ab73eedab77367896980e569b7dc44bfc0cd32b02e172584ff2a649948a24a80.json
│ │ │ ├── aba1d8f31b0657f74255ef0de96fd7ec4b17c71de9f7d8141980dfeb103d89f3.json
│ │ │ ├── af1fde0ad9ac2361e390c1f26e8b0962bbbf815a6be52bfa1c284feb3fea89f0.json
│ │ │ ├── afebb8ab557f5b8220fa23344ed71d088e3b694f2bd38c4dc9d6102f3ed6bb56.json
│ │ │ ├── b22c98e94a1e8c2b87a433d7ed250dd2fecd29126618e1846df822e951fd9a95.json
│ │ │ ├── b40ac508988387f4983259dd227b1d883f2979f7cfa77ca18b250d18c0b25f8c.json
│ │ │ ├── b5120a5f0b8d6df0debf9ade8b271f625fc152ed18896d3941246aa727b4e0b8.json
│ │ │ ├── b631ef1705c4bbcc263ee039213e6929538d85401b648d0a675849fbeb60db11.json
│ │ │ ├── bc16443a4c12a2ca6a8a74c6378c2f66b27425fd52be8ca5c938c2141c886a53.json
│ │ │ ├── bd8de3be72dbe84774acd42c873075c4ca1cf14924cc0e20baa5b04a5853065d.json
@@ -699,6 +821,8 @@ customerTicket
│ │ │ ├── ca98ecfba0b2ad600c63c4f743e41528e2add0a2b48774b065aa0f60dcd905e3.json
│ │ │ ├── cf5a494e78799d9b7557e6021056f1f896a5e8d8378b85d5a4879eeaceb8bec7.json
│ │ │ ├── d2a670f53c70d2f82ff69615cfec3ab85a8b106dbd812f47cd3f0351c63ad69a.json
│ │ │ ├── d2e91825bbf307d7ea4572efa370ce35846db137b4399684cdb0e21bef9f3996.json
│ │ │ ├── d6edb416c765d3de28e41c59b685ce73df501e0ba7a0c0767b449c2d276c0395.json
│ │ │ ├── ddf9f939efdbd2f15fe815fd608b3048eb2c439343c9700f446739f72582a91b.json
│ │ │ ├── e00a1732e6e7df91a7aabb9bd609e2a5985d5cf7215635cde4a7495a0112efec.json
│ │ │ ├── e10d4f4e825fa6fd025ab71d55be4304798b707016c9b0b0f6cc0da6589a8435.json
@@ -707,7 +831,9 @@ customerTicket
│ │ │ ├── e5f6e9aa1524a7e8c45c4ba6043bbf5798a8f8bec760ed991d69359cd416a08c.json
│ │ │ ├── ea56445d1314587e18b1992d9599da03e1419ea7bc4929854b9039d6947de33b.json
│ │ │ ├── eb0b530666894887e1b06f70f6dc329de81cf71265ae51d4a8ec138a41d67d86.json
│ │ │ ├── eb9c8d1a9cff4e5c104daa3311dd2d2a9d410657194c46986e3e5614549d59e5.json
│ │ │ ├── ec9174249dd1b030999fd9c188e1713659650afd92dbe8fcd7a1f29666023a81.json
│ │ │ ├── ee37ba38f532af1bbe5bc8e1e9085c3be2eb5e152634b13765ee5557921a7ce5.json
│ │ │ ├── f60e55eafdca5a62a95829310f4cc0a2a33e91d90b93f95c5b88fe3d6deb13c5.json
│ │ │ └── ffc1f756026bddae9a6ad7a82aca9345a23c2762d06107b344bf35f35092653d.json
│ │ ├── last_query_stamp
@@ -770,7 +896,8 @@ customerTicket
│ │ ├── query_20260916_111348_12184b5e_دنبال_کدهای_خطا_سامانه_0098sms_بگرد_در_source_کد_ا.md
│ │ ├── query_20260917_060706_47ef388c_اصلاح_رابط_موبایل_صفحه_جزئیات_تیکت_کارشناس_و_magic.md
│ │ ├── query_20260917_063554_1824eb56_پس_از_باز_شدن_کیبورد_موبایل__کادر_نوشتن_پاسخ_تیکت.md
│ │ └── query_20260923_131543_e5730a0b_how_do_staff_ticket_sms_status__failed_delivery__a.md
│ │ ├── query_20260923_131543_e5730a0b_how_do_staff_ticket_sms_status__failed_delivery__a.md
│ │ └── query_20261006_111656_eab3ac96_how_do_payment_approval_policy_and_receipt_approva.md
│ ├── reflections
│ │ └── LESSONS.md
│ ├── .graphify_analysis.json
@@ -785,13 +912,72 @@ customerTicket
│ ├── .graphify_semantic.json
│ ├── .html_semantic_extract.json
│ ├── .vocab.txt
│ ├── chaharsu-home-mobile.png
│ ├── chaharsu-prototype_server.py
│ ├── chaharsu-ui-bank-new-desktop.png
│ ├── chaharsu-ui-bank-new-mobile.png
│ ├── chaharsu-ui-home-desktop.png
│ ├── chaharsu-ui-home-mobile.png
│ ├── chaharsu-ui-notice-new-desktop.png
│ ├── chaharsu-ui-notice-new-mobile.png
│ ├── chaharsu-ui-receipt-customer-desktop.png
│ ├── chaharsu-ui-receipt-customer-mobile.png
│ ├── chaharsu-ui-staff-banks-desktop.png
│ ├── chaharsu-ui-staff-banks-mobile.png
│ ├── chaharsu-ui-staff-payments-desktop.png
│ ├── chaharsu-ui-staff-payments-mobile.png
│ ├── chaharsu-ui-staff-receipt-desktop.png
│ ├── chaharsu-ui-staff-receipt-mobile.png
│ ├── chaharsu_merge_audit.json
│ ├── chaharsu_semantic.json
│ ├── CHAHARSU_SEMANTIC_REPORT.md
│ ├── cost.json
│ ├── customer-bank-accounts-1440.png
│ ├── customer-bank-accounts-320.png
│ ├── customer-bank-accounts-375.png
│ ├── customer-payment-receipt-1440.png
│ ├── customer-payment-receipt-320.png
│ ├── customer-payment-receipt-375.png
│ ├── db-before-announcement-fix-20261004-115635.sqlite3
│ ├── db-before-payment-links-20261006.sqlite3
│ ├── db-before-staff-sharing.sqlite3
│ ├── db-pre-chaharsu.sqlite3
│ ├── graph.html
│ ├── graph.json
│ ├── GRAPH_REPORT.md
│ ├── identifier-generation-1440.png
│ ├── identifier-generation-375.png
│ ├── manifest.json
│ ├── notice-page-customer-0-1440.png
│ ├── notice-page-customer-0-320.png
│ ├── notice-page-customer-1-1440.png
│ ├── notice-page-customer-1-320.png
│ ├── notice-page-customer-2-1440.png
│ ├── notice-page-customer-2-320.png
│ ├── notice-page-staff-0-1440.png
│ ├── notice-page-staff-0-320.png
│ ├── notice-page-staff-1-1440.png
│ ├── notice-page-staff-1-320.png
│ ├── notice-page-staff-2-1440.png
│ ├── notice-page-staff-2-320.png
│ ├── payment-link-form-1440.png
│ ├── payment-link-form-320.png
│ ├── payment-link-form-390.png
│ ├── payment-link-notice-1440.png
│ ├── payment-link-notice-320.png
│ ├── payment-link-notice-390.png
│ ├── payment-review-customer-0-1440.png
│ ├── payment-review-customer-0-320.png
│ ├── payment-review-customer-1-1440.png
│ ├── payment-review-customer-1-320.png
│ ├── payment-review-customer-2-1440.png
│ ├── payment-review-customer-2-320.png
│ ├── payment-review-staff-0-1440.png
│ ├── payment-review-staff-0-320.png
│ ├── payment-review-staff-1-1440.png
│ ├── payment-review-staff-1-320.png
│ ├── payment-review-staff-2-1440.png
│ ├── payment-review-staff-2-320.png
│ ├── shared-ticket-metadata-1280.png
│ ├── shared-ticket-metadata-390.png
│ ├── staff-inline-desktop-closed.png
@@ -806,10 +992,21 @@ customerTicket
│ ├── staff-sharing-mobile-audit.png
│ ├── staff-sharing-mobile-message.png
│ ├── staff-sharing-mobile-ticket.png
│ ├── ticket-notices-1440.png
│ ├── ticket-notices-390.png
│ ├── unified-shares-message-1280.png
│ ├── unified-shares-message-390.png
│ ├── unified-shares-queue-1280.png
│ └── unified-shares-queue-390.png
│ ├── unified-shares-queue-390.png
│ ├── verify_chaharsu_forms.py
│ ├── verify_chaharsu_live.py
│ ├── verify_chaharsu_ui.py
│ ├── verify_customer_finance.py
│ ├── verify_identifier_ui.py
│ ├── verify_notice_pages.py
│ ├── verify_payment_links.py
│ ├── verify_payment_review.py
│ └── verify_ticket_notices.py
├── production-seed
│ ├── data.json.gz
│ ├── manifest.json
@@ -817,10 +1014,16 @@ customerTicket
├── static
│ ├── css
│ │ ├── app.css
│ │ ├── chaharsu.css
│ │ ├── customer-dock.css
│ │ ├── customer-profile.css
│ │ ├── customer-ticket-wizard.css
│ │ ├── customer-tickets.css
│ │ ├── dispatches.css
│ │ ├── enhancements.css
│ │ ├── magic-link-confirmation.css
│ │ ├── operations.css
│ │ ├── payment-link.css
│ │ ├── staff-sharing.css
│ │ ├── tailwind.css
│ │ ├── ticket-composer-v2.css
@@ -836,14 +1039,25 @@ customerTicket
│ │ ├── favicon.ico
│ │ └── pwa-icon.png
│ ├── js
│ │ ├── chaharsu
│ │ │ ├── bank-selection.js
│ │ │ ├── customer-picker.js
│ │ │ ├── customer-receipt.js
│ │ │ ├── deposit-identifier.js
│ │ │ ├── payments.js
│ │ │ ├── receipt-amount.js
│ │ │ └── receipt-form.js
│ │ ├── attachment-uploader.js
│ │ ├── chat-coach.js
│ │ ├── contact-group-selector.js
│ │ ├── conversation-read.js
│ │ ├── conversation.js
│ │ ├── customer-ticket-wizard.js
│ │ ├── image-viewer.js
│ │ ├── location-picker.js
│ │ ├── magic-link-confirmation.js
│ │ ├── pwa.js
│ │ ├── sms-template-tags.js
│ │ ├── staff-customer-picker.js
│ │ ├── staff-sharing.js
│ │ ├── ticket-intent-search.js
@@ -863,6 +1077,7 @@ customerTicket
│ ├── accounts
│ │ ├── customer_login.html
│ │ ├── customer_password_login.html
│ │ ├── magic_link_confirmation.html
│ │ ├── otp.html
│ │ ├── profile.html
│ │ └── staff_login.html
@@ -871,10 +1086,37 @@ customerTicket
│ │ └── auth_footer.html
│ ├── messaging
│ │ └── revisions.html
│ ├── payments
│ │ ├── _customer_identity.html
│ │ ├── _customer_picker.html
│ │ ├── _customer_receipt_form.html
│ │ ├── _field.html
│ │ ├── _icon.html
│ │ ├── _link_status.html
│ │ ├── _linked_receipts.html
│ │ ├── _notice.html
│ │ ├── _notice_shortcut.html
│ │ ├── _pagination.html
│ │ ├── _receipt_field.html
│ │ ├── _ticket_notices.html
│ │ ├── banks.html
│ │ ├── base.html
│ │ ├── customer_notice_detail.html
│ │ ├── desk.html
│ │ ├── home.html
│ │ ├── link_confirmation.html
│ │ ├── link_unavailable.html
│ │ ├── notice_detail.html
│ │ ├── notices.html
│ │ ├── receipt_detail.html
│ │ ├── receipt_form.html
│ │ ├── receipt_list.html
│ │ └── staff_form.html
│ ├── pwa
│ │ └── service-worker.js
│ ├── tickets
│ │ ├── _announcement_status.html
│ │ ├── _customer_dock.html
│ │ ├── _image_viewer.html
│ │ ├── _immediate_announcement.html
│ │ ├── _messages.html
@@ -906,6 +1148,19 @@ customerTicket
│ │ └── staff_sms_templates.html
│ └── base.html
├── test-results
│ ├── customer-tickets-design
│ │ ├── chaharsu-1440.png
│ │ ├── chaharsu-320.png
│ │ ├── chaharsu-393.png
│ │ ├── new-1440.png
│ │ ├── new-320.png
│ │ ├── new-393.png
│ │ ├── profile-1440.png
│ │ ├── profile-320.png
│ │ ├── profile-393.png
│ │ ├── tickets-1440.png
│ │ ├── tickets-320.png
│ │ └── tickets-393.png
│ ├── e2e
│ │ ├── design-prototypes_ticket-new-wizard_test_prototype_e2e.py__test_ticket_wizard_progressive_flow_preview_edit_and_attachment_removal
│ │ │ ├── failure.png
@@ -922,6 +1177,15 @@ customerTicket
│ │ ├── tests_e2e_test_chat_coach.py__test_staff_sees_chat_guidance_after_two_rapid_replies
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_customer_profile_design.py__test_profile_design_navigation_validation_and_save[1440]
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_customer_profile_design.py__test_profile_design_navigation_validation_and_save[320]
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_customer_profile_design.py__test_profile_design_navigation_validation_and_save[393]
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_customer_unit_categories_e2e.py__test_customer_natural_language_search_selects_helpful_catalog_route[invoice]
│ │ │ ├── failure.png
│ │ │ └── trace.zip
@@ -934,12 +1198,36 @@ customerTicket
│ │ ├── tests_e2e_test_customer_unit_categories_e2e.py__test_customer_switches_unit_and_sees_its_categories
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_magic_link_confirmation_e2e.py__test_cancel_returns_to_logged_in_account
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_magic_link_confirmation_e2e.py__test_confirmation_form_works_without_its_javascript
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_magic_link_confirmation_e2e.py__test_confirmed_logout_opens_ticket_with_readable_responsive_page[1280]
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_magic_link_confirmation_e2e.py__test_confirmed_logout_opens_ticket_with_readable_responsive_page[320]
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_magic_link_confirmation_e2e.py__test_confirmed_logout_opens_ticket_with_readable_responsive_page[390]
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_message_read_receipts_e2e.py__test_revocation_wins_over_a_poll_response_already_in_flight
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_mobile_ticket_viewport.py__test_magic_link_mobile_viewport_keeps_composer_messages_and_credit_visible
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_mobile_ticket_viewport.py__test_staff_ticket_mobile_viewport_survives_open_close_and_rotation
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_notice_deletion_e2e.py__test_notice_delete_requires_review_and_confirmation[1440]
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_notice_deletion_e2e.py__test_notice_delete_requires_review_and_confirmation[320]
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_organization_permissions_e2e.py__test_e2e_02_contact_group_picker_excludes_other_units
│ │ │ ├── failure.png
│ │ │ └── trace.zip
@@ -952,6 +1240,24 @@ customerTicket
│ │ ├── tests_e2e_test_organization_permissions_e2e.py__test_unit_change_renders_experts_when_a_legacy_cached_payload_has_no_queue
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_payment_links_e2e.py__test_sms_payment_without_login_preserves_copy_and_submission[1440-True]
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_payment_links_e2e.py__test_sms_payment_without_login_preserves_copy_and_submission[320-True]
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_payment_links_e2e.py__test_sms_payment_without_login_preserves_copy_and_submission[390-False]
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_payments_e2e.py__test_customer_search_disambiguation_and_context_shortcuts
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_payments_e2e.py__test_independent_receipt_and_two_person_approval
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_payments_e2e.py__test_staff_issues_identifier_notice_and_customer_sends_linked_receipt
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_public_ticket_workflows_e2e.py__test_e2e_07_sales_general_queue_creates_verifiable_ticket
│ │ │ ├── failure.png
│ │ │ └── trace.zip
@@ -997,11 +1303,18 @@ customerTicket
│ │ ├── 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
│ │ ├── tests_e2e_test_staff_sharing_e2e.py__test_staff_can_share_one_message_track_opening_and_revoke
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_ticket_notices_e2e.py__test_ticket_notices_toggle_and_customer_navigation[390]
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── magic-confirmation-1280.png
│ │ ├── magic-confirmation-320.png
│ │ └── magic-confirmation-390.png
│ ├── customer-chat-guidance.png
│ └── staff-chat-guidance.png
│ ├── staff-chat-guidance.png
│ └── ui_design_review.py
├── tests
│ ├── e2e
│ │ ├── pages
@@ -1009,20 +1322,36 @@ customerTicket
│ │ │ ├── auth.py
│ │ │ ├── base.py
│ │ │ ├── customer_new_ticket.py
│ │ │ ├── customer_profile.py
│ │ │ ├── customer_tickets.py
│ │ │ ├── magic_link.py
│ │ │ ├── message_reads.py
│ │ │ ├── notice_deletion.py
│ │ │ ├── payment_link.py
│ │ │ ├── payments.py
│ │ │ ├── sharing.py
│ │ │ ├── ticket_notices.py
│ │ │ └── tickets.py
│ │ ├── config.py
│ │ ├── conftest.py
│ │ ├── README.md
│ │ ├── test_chat_coach.py
│ │ ├── test_customer_profile_design.py
│ │ ├── test_customer_tickets_design.py
│ │ ├── test_customer_unit_categories_e2e.py
│ │ ├── test_external_environment_smoke.py
│ │ ├── test_magic_link_confirmation_e2e.py
│ │ ├── test_message_read_receipts_e2e.py
│ │ ├── test_mobile_ticket_viewport.py
│ │ ├── test_notice_deletion_e2e.py
│ │ ├── test_organization_permissions_e2e.py
│ │ ├── test_payment_links_e2e.py
│ │ ├── test_payments_e2e.py
│ │ ├── test_public_ticket_workflows_e2e.py
│ │ ├── test_pwa_versioning.py
│ │ ├── test_staff_operations_ui.py
│ │ └── test_staff_sharing_e2e.py
│ │ ├── test_staff_sharing_e2e.py
│ │ └── test_ticket_notices_e2e.py
│ ├── js
│ │ └── chat-coach.test.mjs
│ ├── test_announcements.py
@@ -1033,7 +1362,14 @@ customerTicket
│ ├── test_domain.py
│ ├── test_env.py
│ ├── test_jalali_templates.py
│ ├── test_magic_link_confirmation.py
│ ├── test_message_read_receipts.py
│ ├── test_organizational_ticketing.py
│ ├── test_payment_concurrency.py
│ ├── test_payment_links.py
│ ├── test_payment_notice_concurrency.py
│ ├── test_payment_notice_deletion.py
│ ├── test_payments.py
│ ├── test_permission_architecture.py
│ ├── test_persian_text.py
│ ├── test_production_seed.py
@@ -1050,7 +1386,9 @@ customerTicket
├── .env.local.example
├── .gitignore
├── .npmrc
├── .test-db.sqlite3
├── AGENTS.md
├── alghortim_shenase.py
├── db.sqlite3
├── docker-compose.yml
├── Dockerfile
+20
View File
@@ -15,6 +15,26 @@ class ConsoleBaleProvider:
return NotificationResult(True, "console")
class BaleBotProvider:
def send_photo(self, chat_id, photo, caption='', *, token=None, filename='receipt.jpg', mime_type='image/jpeg'):
"""Upload private evidence bytes; do not expose a media URL or log a bot token."""
token = settings.BALE_BOT_TOKEN if token is None else token
if not token or not chat_id:
return NotificationResult(False, error='توکن یا گروه بله تنظیم نشده است.')
field, method = ('photo', 'sendPhoto') if mime_type in ('image/jpeg', 'image/png') else ('document', 'sendDocument')
try:
response = requests.post(f"{settings.BALE_API_BASE.rstrip('/')}/bot{token}/{method}",
data={'chat_id': chat_id, 'caption': caption}, files={field: (filename, photo, mime_type)},
timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS)
if not response.ok:
return NotificationResult(False, error=f'خطای HTTP بله: {response.status_code}')
data = response.json()
result = data.get('result') if isinstance(data, dict) else None
if data.get('ok') is not True or not isinstance(result, dict) or not result.get('message_id'):
return NotificationResult(False, error='بله ارسال تصویر را تأیید نکرد.')
return NotificationResult(True, str(result['message_id']))
except (requests.RequestException, ValueError, AttributeError) as error:
return NotificationResult(False, error=f'خطا در ارتباط با بله ({type(error).__name__})')
def send_message(self, chat_id, text):
if not chat_id: return NotificationResult(False, error="Bale chat id is not configured")
response = requests.post(f"{settings.BALE_API_BASE.rstrip('/')}/bot{settings.BALE_BOT_TOKEN}/sendMessage", json={"chat_id": chat_id, "text": text, "disable_web_page_preview": True}, timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS)
@@ -85,6 +85,9 @@ def _prepare_sms(job):
def _send_sms(job):
if job.event_type == 'PAYMENT_RECEIPT_RECEIVED':
from apps.payments.receipt_alerts import send_receipt_alert
return send_receipt_alert(job, sms_provider_factory=get_sms_provider), None
if job.event_type in {"PAYMENT_NOTICE_CREATED", "PAYMENT_RECEIPT_APPROVED"}:
from apps.payments.notifications import send_payment_sms
return send_payment_sms(job, provider_factory=get_sms_provider)
@@ -107,6 +110,9 @@ def _send_sms(job):
return get_sms_provider().send_message(job.customer.mobile, message), issued
def _send_bale(job):
if job.event_type == 'PAYMENT_RECEIPT_RECEIVED':
from apps.payments.receipt_alerts import send_receipt_alert
return send_receipt_alert(job)
unit_chat_id = (getattr(job.ticket.owning_unit, "bale_chat_id", "") or "").strip()
if job.event_type == "NEW_CUSTOMER_TICKET":
return get_bale_provider().send_message(unit_chat_id, _bale_ticket_text(job.ticket)) if unit_chat_id else None
@@ -349,6 +355,10 @@ class Command(BaseCommand):
for job in jobs:
issued_link = None
try:
if job.event_type == 'PAYMENT_RECEIPT_RECEIVED':
from apps.payments.receipt_alerts import prepare_receipt_alert
if not prepare_receipt_alert(job):
continue
if job.channel == NotificationJob.Channel.SMS and not _prepare_sms(job):
continue
if job.channel == NotificationJob.Channel.BALE and job.event_type == "CUSTOMER_TICKET_REPLY" and not _prepare_customer_reply_bale(job):
+19
View File
@@ -19,6 +19,7 @@ from . import policies
from .models import PaymentAccessLink, PaymentNotice
SESSION_KEY = 'payment_access_link_id'
SHARE_SESSION_KEY = 'payment_page_share'
def token_digest(token):
@@ -44,6 +45,24 @@ def issue_payment_link(*, notice=None, receipt=None):
raise RuntimeError('عدم موفقیت در تولید لینک یکتای پرداخت.')
def notice_share_url(request, notice):
"""Reuse a validated capability; only a logged-in owner can issue a new one."""
shared = request.session.get(SHARE_SESSION_KEY, {})
token = shared.get('token', '')
link = active_links().filter(pk=shared.get('link_id'), notice=notice,
token_hash=token_digest(token)).first() if token else None
grant = getattr(request, 'payment_access_link', None)
if grant and (link is None or link.pk != grant.pk):
raise PermissionDenied('برای کپی لینک، دوباره لینک پرداخت دریافتی را باز کنید.')
if link is None:
from .services import audit
link, url = issue_payment_link(notice=notice)
token = url.rstrip('/').rsplit('/', 1)[-1]
request.session[SHARE_SESSION_KEY] = {'link_id': link.pk, 'token': token}
audit(request.user, 'PAYMENT_LINK_CREATED', link)
return settings.PUBLIC_BASE_URL.rstrip('/') + reverse('payment_short_link', args=[token])
def active_links():
return PaymentAccessLink.objects.select_related('customer', 'notice', 'receipt').filter(
notice__deleted_at__isnull=True,
+88
View File
@@ -126,3 +126,91 @@ for model in [models.PaymentNotice,models.PaymentReceipt,models.PaymentReceiptRe
models.ReceiptApprovalAssignment,models.ReceiptDecision,models.PaymentAuditEvent,
models.PaymentNotification,models.PaymentNotificationAttempt]:
admin.site.register(model,EvidenceAdmin)
class ReceiptAlertRecipientInline(admin.TabularInline):
model = models.PaymentReceiptAlertRecipient
extra = 1
can_delete = False
def has_view_permission(self, request, obj=None):
return policies.has_permission(request.user, 'manage_receipt_alerts')
has_add_permission = has_view_permission
has_change_permission = has_view_permission
@admin.register(models.PaymentReceiptAlertConfiguration)
class ReceiptAlertConfigurationAdmin(AuditedConfigurationAdmin):
from .forms import ReceiptAlertConfigurationForm
form = ReceiptAlertConfigurationForm
permission = 'manage_receipt_alerts'
singleton_configuration = True
list_display = ('__str__', 'bale_enabled', 'sms_enabled', 'notify_revisions', 'updated_at')
readonly_fields = ('updated_at',)
inlines = [ReceiptAlertRecipientInline]
fieldsets = (
('بله — تصویر فیش', {'fields': ('bale_enabled', 'bale_token', 'clear_bale_token', 'bale_chat_id', 'bale_caption')}),
('پیامک مدیران', {'fields': ('sms_enabled', 'sms_template')}),
('فیش اصلاح‌شده', {'fields': ('notify_revisions', 'updated_at')}),
)
def save_related(self, request, form, formsets, change):
super().save_related(request, form, formsets, change)
services.audit(request.user, 'RECEIPT_ALERT_RECIPIENTS_CHANGED', form.instance,
{'recipients': list(form.instance.recipients.values('id', 'name', 'mobile', 'is_active'))})
@admin.register(models.PaymentReceiptAlertRecipient)
class ReceiptAlertRecipientAdmin(AuditedConfigurationAdmin):
permission = 'manage_receipt_alerts'
list_display = ('name', 'mobile', 'is_active')
list_filter = ('is_active',)
search_fields = ('name', 'mobile')
@admin.register(models.PaymentReceiptAlert)
class ReceiptAlertAdmin(EvidenceAdmin):
list_display = ('receipt_number', 'recipient_name', 'recipient_mobile', 'channel', 'status', 'attempts', 'delivery_status')
list_select_related = ('job', 'revision__receipt', 'recipient')
readonly_fields = ('channel', 'status', 'attempts', 'delivery_status', 'last_error', 'sent_at', 'delivery_description')
def has_view_permission(self, request, obj=None):
return policies.has_permission(request.user, 'manage_receipt_alerts') or super().has_view_permission(request, obj)
def get_queryset(self, request):
if policies.has_permission(request.user, 'manage_receipt_alerts'):
return admin.ModelAdmin.get_queryset(self, request)
return super().get_queryset(request)
@admin.display(description='فیش')
def receipt_number(self, obj):
return obj.revision.receipt.number
@admin.display(description='کانال')
def channel(self, obj):
return obj.job.get_channel_display()
@admin.display(description='وضعیت ارسال')
def status(self, obj):
return obj.job.get_status_display()
@admin.display(description='تعداد تلاش')
def attempts(self, obj):
return obj.job.attempts
@admin.display(description='دریافت پیامک')
def delivery_status(self, obj):
return obj.job.get_delivery_status_display() or 'استعلام ثبت نشده'
@admin.display(description='آخرین خطا')
def last_error(self, obj):
return obj.job.last_error
@admin.display(description='زمان ارسال')
def sent_at(self, obj):
return obj.job.sent_at
@admin.display(description='شرح دریافت')
def delivery_description(self, obj):
return obj.job.delivery_description
+22
View File
@@ -0,0 +1,22 @@
from string import Formatter
from django.core.exceptions import ValidationError
ALERT_TAGS = ('amount_rial', 'customer_name', 'customer_mobile', 'received_at', 'paid_date',
'receipt_number', 'receipt_link', 'revision_version', 'manager_name')
DEFAULT_RECEIPT_SMS = 'فیش واریزی به مبلغ {amount_rial} ریال توسط {customer_name} به شماره تماس {customer_mobile} ارسال شد.'
DEFAULT_RECEIPT_CAPTION = ('فیش واریزی جدید\nمشتری: {customer_name}\nشماره تماس: {customer_mobile}\n'
'مبلغ: {amount_rial} ریال\nزمان ارسال: {received_at}\nتاریخ واریز: {paid_date}\n'
'شماره فیش: {receipt_number} · نسخه {revision_version}\n{receipt_link}')
ALERT_TEMPLATE_HELP = 'تگ‌های مجاز: ' + '، '.join('{' + tag + '}' for tag in ALERT_TAGS)
def validate_alert_template(value):
if not value.strip():
raise ValidationError('متن اعلان نمی‌تواند خالی باشد.')
try:
for _, field, spec, conversion in Formatter().parse(value):
if field is not None and (field not in ALERT_TAGS or spec or conversion):
raise ValueError
except ValueError:
raise ValidationError('قالب معتبر نیست؛ فقط تگ‌های معرفی‌شده و بدون قالب‌بندی اضافی مجازند.')
+7 -1
View File
@@ -4,6 +4,12 @@ The input is the customer local mobile number, retaining its leading zero.
This is a deterministic identifier, not a random or unique-per-notice value.
"""
def normalize_deposit_identifier(value):
"""Remove leading zeroes from deposit IDs only; retain internal/check digits."""
value = str(value or '').translate(str.maketrans('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩', '01234567890123456789')).strip()
return (value.lstrip('0') or '0') if value else ''
def generate_id_algorithm(id_code: str) -> str:
"""
Calculates the verification code based on the reverse-engineered logic:
@@ -39,4 +45,4 @@ def generate_id_algorithm(id_code: str) -> str:
# فرمت‌دهی دو رقمی (مثلاً 6 می‌شود 06)
check_digit_str = f"{check_digit:02}"
return f"{id_code}{check_digit_str}"
return normalize_deposit_identifier(f"{id_code}{check_digit_str}")
+23
View File
@@ -8,6 +8,29 @@ from . import validators
from .models import BankInstitution, CompanyBankAccount, PaymentNotice
class ReceiptAlertConfigurationForm(forms.ModelForm):
clear_bale_token = forms.BooleanField(label='پاک کردن توکن ذخیره‌شده', required=False)
class Meta:
from .models import PaymentReceiptAlertConfiguration
model = PaymentReceiptAlertConfiguration
fields = '__all__'
widgets = {'bale_token': forms.PasswordInput(render_value=False)}
help_texts = {'bale_token': 'برای حفظ توکن فعلی این فیلد را خالی بگذارید؛ برای جایگزینی، توکن تازه وارد کنید.'}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.initial['bale_token'] = ''
def clean(self):
data = super().clean()
if data.get('clear_bale_token'):
data['bale_token'] = ''
elif not data.get('bale_token') and self.instance.pk:
data['bale_token'] = self.instance.bale_token
return data
class BankAccountForm(forms.ModelForm):
revision_token = forms.CharField(required=False, widget=forms.HiddenInput)
+2
View File
@@ -29,6 +29,8 @@ def open_link(request, token):
logout(request)
if not request.user.is_authenticated:
access.grant_payment_access(request, link)
# Retain the token across redirects in the session; the link model still stores only its hash.
request.session[access.SHARE_SESSION_KEY] = {'link_id': link.pk, 'token': token}
with transaction.atomic():
PaymentAccessLink.objects.filter(pk=link.pk).update(view_count=F('view_count') + 1, last_used_at=timezone.now())
services.audit(None, 'PAYMENT_LINK_OPENED', link, {
@@ -0,0 +1,17 @@
from django.db import migrations
def normalize_existing_identifiers(apps, schema_editor):
digit_map = str.maketrans('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩', '01234567890123456789')
for name in ('PaymentNotice', 'PaymentReceiptRevision'):
records = apps.get_model('payments', name).objects.using(schema_editor.connection.alias)
for pk, original in records.exclude(deposit_id='').values_list('pk', 'deposit_id').iterator(chunk_size=500):
value = original.translate(digit_map).strip()
normalized = (value.lstrip('0') or '0') if value else ''
if normalized != original:
records.filter(pk=pk).update(deposit_id=normalized)
class Migration(migrations.Migration):
dependencies = [('payments', '0005_paymentnotice_deleted_at')]
operations = [migrations.RunPython(normalize_existing_identifiers, migrations.RunPython.noop)]
@@ -0,0 +1,73 @@
# Generated by Django 5.1.1 on 2026-10-07 09:49
import apps.payments.alert_templates
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('notifications', '0018_alter_smstemplate_body'),
('payments', '0006_normalize_deposit_identifiers'),
]
operations = [
migrations.CreateModel(
name='PaymentReceiptAlertConfiguration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('bale_enabled', models.BooleanField(default=False, verbose_name='ارسال تصویر فیش به بله')),
('bale_token', models.CharField(blank=True, max_length=500, verbose_name='توکن ربات بله پرداخت')),
('bale_chat_id', models.CharField(blank=True, max_length=150, verbose_name='شناسه گروه بله')),
('bale_caption', models.TextField(default='فیش واریزی جدید\nمشتری: {customer_name}\nشماره تماس: {customer_mobile}\nمبلغ: {amount_rial} ریال\nزمان ارسال: {received_at}\nتاریخ واریز: {paid_date}\nشماره فیش: {receipt_number} · نسخه {revision_version}\n{receipt_link}', help_text='تگ\u200cهای مجاز: {amount_rial}، {customer_name}، {customer_mobile}، {received_at}، {paid_date}، {receipt_number}، {receipt_link}، {revision_version}، {manager_name}', max_length=3000, validators=[apps.payments.alert_templates.validate_alert_template], verbose_name='متن همراه تصویر')),
('sms_enabled', models.BooleanField(default=False, verbose_name='پیامک فیش جدید به مدیران')),
('sms_template', models.TextField(default='فیش واریزی به مبلغ {amount_rial} ریال توسط {customer_name} به شماره تماس {customer_mobile} ارسال شد.', help_text='تگ\u200cهای مجاز: {amount_rial}، {customer_name}، {customer_mobile}، {received_at}، {paid_date}، {receipt_number}، {receipt_link}، {revision_version}، {manager_name}', max_length=1000, validators=[apps.payments.alert_templates.validate_alert_template], verbose_name='متن پیامک مدیران')),
('notify_revisions', models.BooleanField(default=True, verbose_name='اطلاع\u200cرسانی فیش اصلاح\u200cشده مشتری')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='آخرین ویرایش')),
],
options={
'verbose_name': 'تنظیمات اطلاع\u200cرسانی فیش مشتری',
'verbose_name_plural': 'تنظیمات اطلاع\u200cرسانی فیش مشتری',
'permissions': [('manage_receipt_alerts', 'Manage receipt alert settings and recipients')],
'constraints': [models.CheckConstraint(condition=models.Q(('pk', 1)), name='pay_receipt_alert_singleton')],
},
),
migrations.CreateModel(
name='PaymentReceiptAlertRecipient',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=150, verbose_name='نام مدیر')),
('mobile', models.CharField(max_length=20, verbose_name='شماره موبایل مدیر')),
('is_active', models.BooleanField(default=True, verbose_name='دریافت پیامک فعال')),
('configuration', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='recipients', to='payments.paymentreceiptalertconfiguration')),
],
options={
'verbose_name': 'مدیر دریافت\u200cکننده پیامک فیش',
'verbose_name_plural': 'مدیران دریافت\u200cکننده پیامک فیش',
},
),
migrations.CreateModel(
name='PaymentReceiptAlert',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('recipient_name', models.CharField(blank=True, max_length=150, verbose_name='نام گیرنده هنگام ثبت')),
('recipient_mobile', models.CharField(blank=True, max_length=20, verbose_name='موبایل گیرنده هنگام ثبت')),
('bale_chat_id', models.CharField(blank=True, max_length=150, verbose_name='گروه هنگام ثبت')),
('context', models.JSONField()),
('message_template', models.TextField(verbose_name='قالب هنگام ثبت')),
('rendered_body', models.TextField(blank=True, verbose_name='متن ارسال')),
('job', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='receipt_alert', to='notifications.notificationjob')),
('revision', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='alerts', to='payments.paymentreceiptrevision')),
('recipient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.paymentreceiptalertrecipient')),
],
options={
'verbose_name': 'سابقه اعلان فیش مشتری',
'verbose_name_plural': 'سوابق اعلان فیش مشتری',
},
),
migrations.AddConstraint(
model_name='paymentreceiptalertrecipient',
constraint=models.UniqueConstraint(fields=('configuration', 'mobile'), name='pay_receipt_alert_mobile'),
),
]
+91
View File
@@ -4,6 +4,8 @@ from pathlib import Path
from django.conf import settings
from django.db import models
from apps.core.models import PublicIdModel, TimeStampedModel
from .deposit_identifier import normalize_deposit_identifier
from .alert_templates import DEFAULT_RECEIPT_SMS, DEFAULT_RECEIPT_CAPTION, ALERT_TEMPLATE_HELP, validate_alert_template
MAX_AMOUNT = 9_007_199_254_740_991
@@ -87,6 +89,10 @@ class PaymentNotice(PublicIdModel, TimeStampedModel):
cancelled_at = models.DateTimeField(null=True, blank=True)
deleted_at = models.DateTimeField(null=True, blank=True, editable=False)
def save(self, *args, **kwargs):
self.deposit_id = normalize_deposit_identifier(self.deposit_id)
super().save(*args, **kwargs)
class Meta:
ordering = ['-created_at', '-pk']
constraints = [models.CheckConstraint(condition=models.Q(amount__gt=0, amount__lte=MAX_AMOUNT), name='pay_notice_amount')]
@@ -145,6 +151,10 @@ class PaymentReceiptRevision(models.Model):
original_name = models.CharField(max_length=255)
file_size = models.PositiveBigIntegerField()
def save(self, *args, **kwargs):
self.deposit_id = normalize_deposit_identifier(self.deposit_id)
super().save(*args, **kwargs)
class Meta:
constraints = [models.UniqueConstraint(fields=['receipt', 'version'], name='pay_receipt_revision'), models.CheckConstraint(condition=models.Q(amount__gt=0, amount__lte=MAX_AMOUNT), name='pay_receipt_amount')]
@@ -233,3 +243,84 @@ class PaymentNotificationAttempt(models.Model):
provider_code = models.CharField(max_length=50, blank=True)
provider_id = models.CharField(max_length=500, blank=True)
error = models.TextField(blank=True)
class PaymentReceiptAlertConfiguration(models.Model):
"""Independent, initially disabled destinations for customer receipt uploads."""
bale_enabled = models.BooleanField('ارسال تصویر فیش به بله', default=False)
bale_token = models.CharField('توکن ربات بله پرداخت', max_length=500, blank=True)
bale_chat_id = models.CharField('شناسه گروه بله', max_length=150, blank=True)
bale_caption = models.TextField('متن همراه تصویر', default=DEFAULT_RECEIPT_CAPTION,
validators=[validate_alert_template], help_text=ALERT_TEMPLATE_HELP, max_length=3000)
sms_enabled = models.BooleanField('پیامک فیش جدید به مدیران', default=False)
sms_template = models.TextField('متن پیامک مدیران', default=DEFAULT_RECEIPT_SMS,
validators=[validate_alert_template], help_text=ALERT_TEMPLATE_HELP, max_length=1000)
notify_revisions = models.BooleanField('اطلاع‌رسانی فیش اصلاح‌شده مشتری', default=True)
updated_at = models.DateTimeField('آخرین ویرایش', auto_now=True)
class Meta:
verbose_name = 'تنظیمات اطلاع‌رسانی فیش مشتری'
verbose_name_plural = 'تنظیمات اطلاع‌رسانی فیش مشتری'
permissions = [('manage_receipt_alerts', 'Manage receipt alert settings and recipients')]
constraints = [models.CheckConstraint(condition=models.Q(pk=1), name='pay_receipt_alert_singleton')]
def __str__(self):
return 'اطلاع‌رسانی فیش مشتری'
def save(self, *args, **kwargs):
self.pk = 1
super().save(*args, **kwargs)
def clean(self):
from django.core.exceptions import ValidationError
self.pk = 1
self.bale_token = self.bale_token.strip()
self.bale_chat_id = self.bale_chat_id.strip()
if self.bale_token and any(char.isspace() or char in '/?#' for char in self.bale_token):
raise ValidationError({'bale_token': 'توکن معتبر ربات را وارد کنید.'})
if self.bale_enabled and (not self.bale_token or not self.bale_chat_id):
raise ValidationError('برای فعال کردن بله، توکن ربات و شناسه گروه را تکمیل کنید.')
class PaymentReceiptAlertRecipient(models.Model):
configuration = models.ForeignKey(PaymentReceiptAlertConfiguration, on_delete=models.PROTECT, related_name='recipients')
name = models.CharField('نام مدیر', max_length=150)
mobile = models.CharField('شماره موبایل مدیر', max_length=20)
is_active = models.BooleanField('دریافت پیامک فعال', default=True)
class Meta:
verbose_name = 'مدیر دریافت‌کننده پیامک فیش'
verbose_name_plural = 'مدیران دریافت‌کننده پیامک فیش'
constraints = [models.UniqueConstraint(fields=['configuration', 'mobile'], name='pay_receipt_alert_mobile')]
def __str__(self):
return self.name
def clean(self):
from apps.accounts.models import normalize_mobile
from django.core.exceptions import ValidationError
from .validators import digits
try:
self.mobile = normalize_mobile(digits(self.mobile))
except ValueError:
raise ValidationError({'mobile': 'شماره موبایل معتبر مدیر را وارد کنید.'})
def save(self, *args, **kwargs):
self.clean()
super().save(*args, **kwargs)
class PaymentReceiptAlert(models.Model):
job = models.OneToOneField('notifications.NotificationJob', on_delete=models.PROTECT, related_name='receipt_alert')
revision = models.ForeignKey(PaymentReceiptRevision, on_delete=models.PROTECT, related_name='alerts')
recipient = models.ForeignKey(PaymentReceiptAlertRecipient, null=True, blank=True, on_delete=models.PROTECT)
recipient_name = models.CharField('نام گیرنده هنگام ثبت', max_length=150, blank=True)
recipient_mobile = models.CharField('موبایل گیرنده هنگام ثبت', max_length=20, blank=True)
bale_chat_id = models.CharField('گروه هنگام ثبت', max_length=150, blank=True)
context = models.JSONField()
message_template = models.TextField('قالب هنگام ثبت')
rendered_body = models.TextField('متن ارسال', blank=True)
class Meta:
verbose_name = 'سابقه اعلان فیش مشتری'
verbose_name_plural = 'سوابق اعلان فیش مشتری'
+4
View File
@@ -9,6 +9,10 @@ EVENTS = {'PAYMENT_NOTICE_CREATED', 'PAYMENT_RECEIPT_APPROVED'}
TAGS = ['recipient_name', 'company_name', 'notice_number', 'receipt_number', 'amount_rial', 'invoice_number', 'payment_link']
def notice_sms_pending(notice):
return PaymentNotification.objects.filter(notice=notice, job__status__in=['PENDING', 'PROCESSING', 'RETRYING']).exists()
def context(customer, notice=None, receipt=None, amount=0):
profile = getattr(customer, 'customer_profile', None)
return {'recipient_name': str(profile) if profile else customer.username, 'company_name': settings.COMPANY_NAME,
+102
View File
@@ -0,0 +1,102 @@
"""Receipt-only outbox handlers. No network operation runs during receipt registration."""
import logging
from pathlib import Path
from django.conf import settings
from django.db import transaction
from django.urls import reverse
from apps.core.templatetags.jalali import jalali_date, jalali_datetime
from apps.integrations.sms.base import SMSResult
from apps.notifications.services import enqueue
from .alert_templates import validate_alert_template
from .models import PaymentReceiptAlert, PaymentReceiptAlertConfiguration
logger = logging.getLogger(__name__)
RECEIPT_ALERT_EVENT = 'PAYMENT_RECEIPT_RECEIVED'
def receipt_alert_context(revision):
customer = revision.receipt.customer
profile = getattr(customer, 'customer_profile', None)
name = f'{profile.first_name} {profile.last_name}'.strip() if profile else ''
return {'customer_name': name or customer.get_full_name() or 'نام ثبت نشده',
'customer_mobile': customer.local_mobile, 'amount_rial': f'{revision.amount:,}',
'received_at': jalali_datetime(revision.created_at), 'paid_date': jalali_date(revision.paid_date),
'receipt_number': revision.receipt.number, 'revision_version': str(revision.version), 'manager_name': '',
'receipt_link': settings.PUBLIC_BASE_URL.rstrip('/') + reverse('staff_payments:receipt_detail', args=[revision.receipt.public_id])}
def queue_receipt_alerts(revision, actor):
if actor.account_type != 'CUSTOMER' or actor.pk != revision.receipt.customer_id:
return
configuration = PaymentReceiptAlertConfiguration.objects.filter(pk=1).first()
if not configuration or not (configuration.bale_enabled or configuration.sms_enabled) or (revision.version > 1 and not configuration.notify_revisions):
return
context = receipt_alert_context(revision)
destinations = []
if configuration.bale_enabled:
destinations.append(('BALE', None, configuration.bale_caption))
if configuration.sms_enabled:
destinations.extend(('SMS', recipient, configuration.sms_template) for recipient in configuration.recipients.filter(is_active=True))
for channel, recipient, template in destinations:
try:
with transaction.atomic():
job = enqueue(channel, RECEIPT_ALERT_EVENT, None,
f'receipt-alert:{revision.pk}:{channel}:{recipient.pk if recipient else "group"}', customer=revision.receipt.customer)
PaymentReceiptAlert.objects.get_or_create(job=job, defaults={'revision': revision, 'recipient': recipient,
'recipient_name': recipient.name if recipient else '', 'recipient_mobile': recipient.mobile if recipient else '',
'bale_chat_id': configuration.bale_chat_id if channel == 'BALE' else '', 'message_template': template,
'context': {**context, 'manager_name': recipient.name if recipient else ''}})
except Exception as error:
logger.error('Receipt alert destination queue failed revision=%s channel=%s type=%s', revision.pk, channel, type(error).__name__)
def safe_queue_receipt_alerts(revision, actor):
# A failed configuration lookup/queue write rolls back only this savepoint.
try:
with transaction.atomic():
queue_receipt_alerts(revision, actor)
except Exception as error:
logger.error('Receipt alert queue failed revision=%s type=%s', revision.pk, type(error).__name__)
def prepare_receipt_alert(job):
configuration = PaymentReceiptAlertConfiguration.objects.filter(pk=1).first()
alert = PaymentReceiptAlert.objects.select_related('recipient').get(job=job)
if job.channel == 'BALE':
enabled = configuration and configuration.bale_enabled and configuration.bale_chat_id == alert.bale_chat_id
else:
enabled = configuration and configuration.sms_enabled and alert.recipient and alert.recipient.is_active and alert.recipient.mobile == alert.recipient_mobile
if enabled:
return True
job.status = 'CANCELLED'
job.last_error = 'اطلاع‌رسانی این کانال یا گیرنده غیرفعال شده یا مقصد تغییر کرده است.'
job.save(update_fields=['status', 'last_error'])
return False
def send_receipt_alert(job, sms_provider_factory=None):
"""A destination failure is returned to the worker's existing bounded retry policy."""
try:
alert = PaymentReceiptAlert.objects.select_related('revision').get(job=job)
if not alert.rendered_body:
validate_alert_template(alert.message_template)
alert.rendered_body = alert.message_template.format(**alert.context)
alert.save(update_fields=['rendered_body'])
if job.channel == 'SMS':
from apps.integrations.sms.providers import get_sms_provider
return (sms_provider_factory or get_sms_provider)().send_message(alert.recipient_mobile, alert.rendered_body)
from apps.integrations.bale.providers import BaleBotProvider
configuration = PaymentReceiptAlertConfiguration.objects.get(pk=1)
if not configuration.bale_token:
return SMSResult(False, error='توکن ربات بله پرداخت تنظیم نشده است.')
if len(alert.rendered_body) > 4096:
return SMSResult(False, error='متن همراه تصویر بله بیش از ۴۰۹۶ کاراکتر است.')
with alert.revision.image.open('rb') as image:
return BaleBotProvider().send_photo(alert.bale_chat_id, image, alert.rendered_body,
token=configuration.bale_token, filename=f'receipt-{alert.revision.pk}{Path(alert.revision.image.name).suffix}',
mime_type=alert.revision.mime_type)
except Exception as error:
# HTTP exception strings can contain credentials in the request URL. Never persist them.
return SMSResult(False, error=f'خطا در ارسال اعلان فیش ({type(error).__name__})')
+28
View File
@@ -5,6 +5,34 @@ from django.db.models.functions import Coalesce
from .models import PaymentReceiptRevision
def notice_sms_history(notice):
"""Separate gateway acceptance from final handset delivery; preserve every send attempt."""
from django.db.models import Prefetch
from .models import PaymentNotificationAttempt
notifications = notice.paymentnotification_set.select_related('job__staff_user__staff_profile').prefetch_related(
Prefetch('attempt_log', queryset=PaymentNotificationAttempt.objects.order_by('-started_at', '-pk'), to_attr='send_attempts')
).order_by('-job__created_at', '-pk')
for notification in notifications:
job = notification.job
notification.manual_resend = ':resend:' in job.dedupe_key
notification.delivery_class = 'info'
if job.status != 'SENT':
notification.delivery_label = 'هنوز ارسال موفق ثبت نشده است.'
elif not job.provider_message_id:
notification.delivery_label = 'شناسه رهگیری ارائه نشده؛ دریافت توسط گوشی قابل تأیید نیست.'
elif not job.delivery_status:
notification.delivery_label = 'دریافت توسط گوشی قابل تأیید نیست.'
elif job.delivery_is_final:
notification.delivery_label = job.get_delivery_status_display()
notification.delivery_class = 'approved' if job.delivery_status == 'DELIVERED' else 'rejected'
elif job.delivery_status == 'PENDING':
notification.delivery_label = 'در انتظار استعلام دریافت توسط گوشی'
else:
suffix = 'در حال پیگیری' if job.delivery_next_check_at else 'دریافت توسط گوشی تأیید نشده است'
notification.delivery_label = f'نتیجه غیرقطعی: {job.get_delivery_status_display()}؛ {suffix}'
return notifications
def notices_with_totals(queryset):
approved = PaymentReceiptRevision.objects.filter(
receipt__notice_id=OuterRef('pk'), receipt__status='APPROVED',
+42 -3
View File
@@ -17,6 +17,36 @@ def actor_name(actor):
return getattr(profile, 'display_name', '') or actor.get_full_name() or actor.username
@transaction.atomic
def resend_notice_sms(actor, notice, request_key):
from uuid import UUID
from .notifications import context, notice_sms_pending
from .models import PaymentNotification
from apps.notifications.services import enqueue
if not policies.can_create(actor, notice.owning_unit) or not policies.notice_actions(PaymentNotice.objects.filter(pk=notice.pk), actor).exists():
raise PermissionDenied
try:
key = str(UUID(str(request_key)))
except (ValueError, TypeError, AttributeError):
raise ValidationError('درخواست ارسال معتبر نیست؛ صفحه را دوباره باز کنید.')
# Same customer -> notice lock order as issuance, receipt submission and deletion.
customer = User.objects.select_for_update().get(pk=notice.customer_id)
notice = PaymentNotice.objects.select_for_update().get(pk=notice.pk)
if notice.deleted_at or notice.cancelled_at or not customer.is_active or not customer.mobile:
raise ValidationError('این اعلامیه یا مشتری امکان ارسال پیامک ندارد.')
dedupe_key = f'payment-notice:{notice.public_id}:resend:{key}'
previous = PaymentNotification.objects.filter(notice=notice, job__dedupe_key=dedupe_key).select_related('job').first()
if previous:
return previous.job
if notice_sms_pending(notice):
raise ValidationError('پیامک این اعلامیه در صف یا در حال ارسال است؛ تا پایان تلاش جاری منتظر بمانید.')
job = enqueue('SMS', 'PAYMENT_NOTICE_CREATED', None, dedupe_key, customer=customer, staff_user=actor)
PaymentNotification.objects.create(job=job, notice=notice, unit=notice.owning_unit,
context=context(customer, notice=notice, amount=notice.amount))
audit(actor, 'NOTICE_SMS_RESEND_REQUESTED', notice, {'job_id': job.pk})
return job
def audit(actor, action, obj, details=None):
return PaymentAuditEvent.objects.create(actor=actor, actor_name=actor_name(actor) if actor else 'SYSTEM',
action=action, object_type=obj._meta.label_lower, object_id=str(getattr(obj, 'public_id', obj.pk)), details=details or {})
@@ -24,7 +54,10 @@ def audit(actor, action, obj, details=None):
def configuration_snapshot(obj):
"""Serialize only the financial configuration passed here, including flags and M2M IDs."""
return json.loads(serializers.serialize('json', [obj]))[0]['fields']
snapshot = json.loads(serializers.serialize('json', [obj]))[0]['fields']
if 'bale_token' in snapshot:
snapshot['bale_token'] = '[configured]' if snapshot['bale_token'] else ''
return snapshot
@transaction.atomic
@@ -60,7 +93,8 @@ def issue_notice(actor, data):
account = CompanyBankAccount.objects.select_for_update(of=('self',)).select_related('bank').get(pk=data['account'].pk)
if not account.is_active or not account.available_to_staff:
raise ValidationError('این حساب دیگر برای صدور اعلامیه فعال نیست.')
deposit_id = validators.digits(data.get('deposit_id', ''))
from .deposit_identifier import normalize_deposit_identifier
deposit_id = normalize_deposit_identifier(data.get('deposit_id', ''))
if account.requires_deposit_id and not deposit_id:
raise ValidationError('برای این حساب شناسه واریز مشتری الزامی است.')
key = data.get('submission_key')
@@ -108,9 +142,10 @@ def assign_cycle(revision, actor):
def receipt_metadata(data):
from .deposit_identifier import normalize_deposit_identifier
return {'amount': data['paid_amount'], 'paid_date': data['paid_date'], 'destination': data['destination_account'],
'origin_bank': data.get('origin_bank'), 'origin_other': data.get('origin_other', ''),
'deposit_id': validators.digits(data.get('deposit_id', '')), 'tracking_number': data.get('tracking_number', ''),
'deposit_id': normalize_deposit_identifier(data.get('deposit_id', '')), 'tracking_number': data.get('tracking_number', ''),
'description': data.get('description', '')}
@@ -156,6 +191,8 @@ def _submit_receipt(actor, customer, data, file_info, created_files):
created_files.append((revision.image.storage, revision.image.name))
assign_cycle(revision, actor)
audit(actor, 'RECEIPT_SUBMITTED', receipt, {'version': 1, 'amount': str(revision.amount), 'sha256': revision.sha256})
from .receipt_alerts import safe_queue_receipt_alerts
safe_queue_receipt_alerts(revision, actor)
return receipt
@@ -302,6 +339,8 @@ def revise_receipt(actor, receipt_id, version, data):
receipt.status='PENDING'
receipt.save(update_fields=['current_version','status','updated_at'])
audit(actor,'RECEIPT_REVISED',receipt,{'version':new_version,'previous_version':version,'sha256':revision.sha256})
from .receipt_alerts import safe_queue_receipt_alerts
safe_queue_receipt_alerts(revision, actor)
return receipt
except Exception:
for storage,name in created_files:
+1
View File
@@ -9,6 +9,7 @@ urlpatterns = [
path('notices/new/', views.notice_new, name='notice_new'),
path('notices/<uuid:public_id>/', views.notice_detail, name='notice_detail'),
path('notices/<uuid:public_id>/delete/', views.notice_delete, name='notice_delete'),
path('notices/<uuid:public_id>/resend-sms/', views.notice_resend_sms, name='notice_resend_sms'),
path('notices/<uuid:public_id>/payment-link/', views.notice_payment_link, name='notice_payment_link'),
path('receipts/new/<uuid:customer_id>/', views.staff_receipt_new, name='receipt_new'),
path('receipts/new/', views.staff_receipt_customer, name='receipt_customer'),
+1
View File
@@ -6,6 +6,7 @@ urlpatterns = [
path('', views.home, name='home'),
path('bank-accounts/', views.notices, name='notices'),
path('bank-accounts/<uuid:public_id>/', views.customer_notice_detail, name='notice_detail'),
path('bank-accounts/<uuid:public_id>/share-link/', views.customer_notice_share_link, name='notice_share_link'),
path('payment-receipt/', views.receipt_new, name='receipt_new'),
path('receipts/', views.receipt_list, name='receipt_list'),
path('receipts/<uuid:public_id>/', views.receipt_detail, name='receipt_detail'),
+50 -3
View File
@@ -42,6 +42,8 @@ def home(request):
def complete_notice(notice):
from .deposit_identifier import normalize_deposit_identifier
notice.deposit_id = normalize_deposit_identifier(notice.deposit_id)
snapshot = notice.account_snapshot
approved = getattr(notice, 'approved_total', None)
if approved is None:
@@ -57,6 +59,8 @@ def complete_notice(notice):
('شماره حساب', snapshot.get('account_number')), ('شبا', snapshot.get('iban')),
('کارت', snapshot.get('card_number')), ('شناسه واریز', notice.deposit_id),
('فاکتور', notice.invoice_number), ('توضیحات', notice.description)] if value)
if notice.deposit_id:
notice.copy_text += '\nوارد کردن شناسه واریز هنگام پرداخت الزامی و اجباری است؛ آن را در فرم بانکی، موبایل‌بانک یا اینترنت‌بانک وارد کنید.'
return notice
@@ -84,6 +88,17 @@ def customer_notice_detail(request, public_id):
notice=complete_notice(notice), open_notice=str(notice.public_id), financial_page=True))
@require_POST
@financial_required(allow_payment_link=True)
def customer_notice_share_link(request, public_id):
notice = get_object_or_404(access.visible_payments(request, PaymentNotice.objects.all()),
public_id=public_id, customer__is_active=True)
url = access.notice_share_url(request, notice)
response = JsonResponse({'url': url})
response['Cache-Control'] = 'private, no-store'
return response
def receipt_form_page(request, customer, staff=False):
link = getattr(request, 'payment_access_link', None)
initial = access.receipt_initial(request, customer)
@@ -93,9 +108,13 @@ def receipt_form_page(request, customer, staff=False):
try:
receipt = services.submit_receipt(customer if link else request.user, customer, form.cleaned_data, access_link=link)
messages.success(request, 'فیش ثبت شد و در انتظار بررسی است.')
if request.headers.get('Accept') == 'application/json':
return JsonResponse({'redirect_url': reverse('staff_payments:receipt_detail' if staff else 'payments:receipt_detail', args=[receipt.public_id])})
return redirect('staff_payments:receipt_detail' if staff else 'payments:receipt_detail', public_id=receipt.public_id)
except ValidationError as error:
form.add_error(None, error)
if request.method == 'POST' and request.headers.get('Accept') == 'application/json':
return JsonResponse({'errors': form.errors.get_json_data()}, status=422)
selected = form['notice_id'].value()
available = list(form.fields['notice_id'].queryset) if staff else selectors.recent_pending_notice_choices(form)
notice_data = [selectors.notice_form_data(n, staff=staff) for n in available]
@@ -254,6 +273,9 @@ def filtered(queryset, request):
@financial_required(staff=True)
def desk(request):
tab = request.GET.get('tab', 'receipts')
if tab not in {'receipts', 'notices'}:
tab = 'receipts'
mine = tab == 'receipts' and (request.GET.get('mine') == '1' or ('tab' not in request.GET and 'mine' not in request.GET))
receipts = selectors.receipts_with_current_values(policies.visible(PaymentReceipt.objects.select_related('customer__customer_profile', 'notice__created_by__staff_profile'), request.user))
notice_items = selectors.notices_with_totals(policies.visible(PaymentNotice.objects.select_related('customer__customer_profile', 'created_by__staff_profile', 'account'), request.user))
if tab == 'notices':
@@ -274,7 +296,7 @@ def desk(request):
items = items.filter(notice__isnull=True)
elif request.GET.get('kind') == 'linked':
items = items.filter(notice__isnull=False)
if request.GET.get('mine'):
if mine:
my_decision = ReceiptDecision.objects.filter(cycle__revision__receipt_id=OuterRef('pk'),
cycle__revision__version=OuterRef('current_version'), user=request.user)
items = items.filter(status__in=['PENDING','PARTIAL'], revisions__version=F('current_version'),
@@ -289,7 +311,12 @@ def desk(request):
page.object_list = selectors.receipt_review_summaries(page.object_list)
query = request.GET.copy()
query.pop('page', None)
return render(request, 'payments/desk.html', staff_context(page_obj=page, tab=tab, statuses=PaymentReceipt.Status.choices,
query['tab'] = tab
if mine:
query['mine'] = '1'
else:
query.pop('mine', None)
return render(request, 'payments/desk.html', staff_context(page_obj=page, tab=tab, mine=mine, statuses=PaymentReceipt.Status.choices,
can_create=policies.can_create(request.user), banks=CompanyBankAccount.objects.all(), query_string=query.urlencode()))
@@ -337,12 +364,28 @@ def notice_detail(request, public_id):
request.session.pop('payment_link_share', None)
else:
share = {}
from uuid import uuid4
from .notifications import notice_sms_pending
return render(request, 'payments/notice_detail.html', staff_context(notice=notice,
can_delete_notice=policies.can_delete_notice(request.user, notice),
may_delete_notice=policies.may_delete_notice(request.user, notice),
can_share_payment_link=policies.can_create(request.user) and notice.customer.is_active,
payment_share_url=share.get('url', ''),
receipts=policies.visible(notice.receipts.all(), request.user), notifications=notice.paymentnotification_set.select_related('job')))
can_resend_notice_sms=policies.can_create(request.user, notice.owning_unit) and notice.customer.is_active and not notice.cancelled_at,
notice_sms_pending=notice_sms_pending(notice), notice_sms_request_key=str(uuid4()),
receipts=policies.visible(notice.receipts.all(), request.user), notifications=selectors.notice_sms_history(notice)))
@financial_required(staff=True)
@require_POST
def notice_resend_sms(request, public_id):
notice = get_object_or_404(policies.notice_actions(PaymentNotice.objects.all(), request.user), public_id=public_id)
try:
services.resend_notice_sms(request.user, notice, request.POST.get('request_key', ''))
messages.success(request, 'درخواست ارسال مجدد پیامک در صف ثبت شد؛ نتیجه ارسال و دریافت در سوابق نمایش داده می‌شود.')
except ValidationError as error:
messages.error(request, '؛ '.join(error.messages))
return redirect('staff_payments:notice_detail', public_id=public_id)
@financial_required(staff=True)
@@ -474,9 +517,13 @@ def receipt_revise(request, public_id):
try:
services.revise_receipt(request.payment_customer,receipt.public_id,int(request.POST.get('version','0')),form.cleaned_data)
messages.success(request,'نسخه اصلاحی ثبت شد و دوباره بررسی می‌شود.')
if request.headers.get('Accept') == 'application/json':
return JsonResponse({'redirect_url': reverse('payments:receipt_detail', args=[receipt.public_id])})
return redirect('payments:receipt_detail',public_id=receipt.public_id)
except (ValidationError,ValueError) as error:
form.add_error(None,error if isinstance(error,ValidationError) else 'نسخه فیش معتبر نیست.')
if request.method == 'POST' and request.headers.get('Accept') == 'application/json':
return JsonResponse({'errors': form.errors.get_json_data()}, status=422)
from apps.attachments.models import AttachmentPolicy
policy=AttachmentPolicy.objects.filter(attachment_type='IMAGE',is_enabled=True).first()
notice_data=[selectors.notice_form_data(n) for n in selectors.recent_pending_notice_choices(form, retain_original=receipt.notice_id)]
+9 -2
View File
@@ -18,11 +18,12 @@
- اعلامیه، فیش و تصمیم تأیید مستقل‌اند. ثبت تصویر یا تأیید یک نفر از دو نفر، پرداخت را نهایی نمی‌کند.
- مبلغ عدد صحیح **ریال** است؛ نمایش حروفی تومان در مرورگر با BigInt انجام می‌شود و مقدار ریال به سرور می‌رود.
- نمایش تاریخ شمسی و انتخاب سه‌قسمتی است. سرور با jdatetime روز/ماه/سال را مستقل از JS بررسی و Gregorian را در DateField ذخیره می‌کند. تاریخ ناقص، غیرواقعی، آینده و خارج از بازه مجاز رد می‌شود.
- شناسه‌ها، شماره‌ها و شبا رشته‌اند؛ صفر ابتدایی حفظ می‌شود. شبا و کارت موجود اعتبارسنجی می‌شوند.
- شماره حساب، کارت و شبا رشته‌اند و صفر ابتدایی آن‌ها حفظ می‌شود. شناسه واریز رشته است اما صفرهای ابتدایی آن پس از محاسبه رقم کنترل حذف می‌شوند؛ تولید، ذخیره و نمایش همین مقدار را استفاده می‌کنند. migration `0006_normalize_deposit_identifiers` شناسه اعلامیه‌ها و نسخه‌های فیش قبلی را هماهنگ می‌کند و snapshot بانکی یا ممیزی تاریخی را تغییر نمی‌دهد. شبا و کارت موجود اعتبارسنجی می‌شوند.
- حساب شناسه‌دار در صدور اعلامیه الزاماً شناسه می‌خواهد. در ثبت فیش، شناسه اعلامیه فقط برای **همان حساب** معتبر است؛ مقصد متفاوت شناسه خودش را لازم دارد.
- حساب فقط فعال در پنل کارشناسان، در زمینه اعلامیه اختصاصی همان مشتری قابل استفاده است؛ در واریز مستقل قابل استفاده نیست. غیرفعال کلی بودن، ثبت جدید را متوقف می‌کند و سوابق را پنهان نمی‌کند.
- snapshot حساب و شناسه اعلامیه حفظ می‌شود؛ ویرایش بانک، شماره تاریخی اعلامیه یا نسخه فیش را تغییر نمی‌دهد.
- فرم صدور و فرم فیش nonce دارند؛ ارسال تکراری همان داده رکورد یا پیامک جدید ایجاد نمی‌کند. استفاده مجدد از nonce برای اطلاعات مالی متفاوت رد می‌شود.
- فرم مشترک فیش مشتری، کارشناس، دسترسی لینک پرداخت و اصلاح فیش، تصویر را با XMLHttpRequest ارسال می‌کند: درصد و حجم واقعی درخواست، زمان سپری‌شده و تخمین زمان باقی‌مانده نمایش داده می‌شوند. پس از پایان انتقال، مرحله بررسی و ثبت سرور جداگانه نمایش داده می‌شود. دکمه ارسال و فیلدها تا پاسخ سرور قفل‌اند؛ در خطا، تصویر و nonce حفظ می‌شوند و امکان تلاش مجدد وجود دارد. درخواست دارای `Accept: application/json` مقصد ادامه را در `redirect_url` یا خطاهای فرم را با وضعیت ۴۲۲ دریافت می‌کند؛ ارسال معمولی بدون JavaScript همچنان از redirect استفاده می‌کند.
- وضعیت اعلامیه از مبلغ نسخه جاری فیش‌های تأییدشده بدون مغایرت مقصد محاسبه می‌شود؛ پرداخت جزئی و باقی‌مانده نمایش داده می‌شوند.
## تنظیمات ضروری پیش از استفاده مالی واقعی
@@ -75,7 +76,13 @@
`recipient_name`, `company_name`, `notice_number`, `receipt_number`, `amount_rial`, `invoice_number`, `payment_link`
صف فعلی `NotificationJob` و worker `process_notifications` به کار می‌روند. اعلامیه به معنی صف‌شدن پیامک است؛ دریافت پیامک نیازمند اجرای worker و تنظیم provider است. وضعیت ارسال و دلیوری مستقل از وضعیت مالی است. متن و نسخه الگو در اولین تلاش موفق آماده‌سازی snapshot می‌شود؛ retry همان متن را حفظ می‌کند. تلاش ناموفقِ آماده‌سازی قالب یا ارتباط provider نیز تاریخچه دارد. URL پیامک ورود کامل مشتری را لازم دارد.
صف فعلی `NotificationJob` و worker `process_notifications` به کار می‌روند. اعلامیه به معنی صف‌شدن پیامک است؛ دریافت پیامک نیازمند اجرای worker و تنظیم provider است. وضعیت ارسال و دلیوری مستقل از وضعیت مالی است. متن و نسخه الگو در اولین تلاش موفق آماده‌سازی snapshot می‌شود؛ retry همان متن را حفظ می‌کند. تلاش ناموفقِ آماده‌سازی قالب یا ارتباط provider نیز تاریخچه دارد. URL پیامک لینک موقت مستقل از ورود کامل مشتری است.
در جزئیات اعلامیه کارشناسان، «ارسال مجدد پیامک اعلامیه واریز» از مسیر POST `notices/<uuid>/resend-sms/` یک job و `PaymentNotification` مستقل می‌سازد؛ ارسال‌های قبلی و نتیجه دریافت آن‌ها بازنویسی نمی‌شوند. کلید UUID فرم درخواست تکراری را یکسان می‌کند و قفل مشتری سپس اعلامیه، درخواست‌های همزمان را هماهنگ می‌کند. تا پایان job در صف/پردازش/تلاش خودکار، درخواست جدید ثبت نمی‌شود. مجوز ایجاد در دامنه واحد و دسترسی به اعلامیه، فعال بودن مشتری و لغو/حذف نشدن اعلامیه بررسی می‌شوند؛ رویداد `NOTICE_SMS_RESEND_REQUESTED` ممیزی می‌شود. worker همان handler و قالب‌های فعلی را استفاده می‌کند و برای هر ارسال مجدد لینک پرداخت تازه می‌سازد.
سوابق، زمان درخواست و ارسال، درخواست‌کننده، متن آماده‌شده، تمام تلاش‌های ارسال با خطا و شناسه و کد provider، وضعیت دریافت، شرح/کد و زمان آخرین استعلام و استعلام بعدی را نمایش می‌دهند. `SENT` فقط پذیرش سرویس است؛ رسیدن به گوشی نیاز به نتیجه قطعی دلیوری دارد. پاسخ‌های غیرقطعی یا پایان پایش بدون تأیید دریافت، صریحاً غیرقطعی نمایش داده می‌شوند. سوابق با تازه‌سازی صفحه به‌روز می‌شوند.
میز پرداخت کارشناسان به‌طور پیش‌فرض فیش‌های «نیازمند اقدام من» را نمایش می‌دهد؛ `?tab=receipts` همه فیش‌های مجاز و `?tab=notices` اعلامیه‌ها را انتخاب می‌کند. صفحه‌بندی ۲۵تایی با شمارنده صفحه و محدوده نتایج، تب و فیلترها را نگه می‌دارد.
در آزمون‌ها provider mock شده است؛ پیامک واقعی برای مشتری ارسال نشده و worker روی صف واقعی توسعه اجرا نشده است.
+6
View File
@@ -23,6 +23,12 @@
شماره حساب، شبا و سایر اطلاعات از snapshot اعلامیه نمایش داده می‌شوند. شناسه واریز و تذکر آن قرمز است. امضای مسیر بدون ورود از `SiteBranding.developer_credit_text` خوانده می‌شود.
## کپی اطلاعات و اشتراک صفحه
شناسه واریز در تولید، ذخیره، نمایش و متن کپی بدون صفرهای ابتدایی است؛ رقم کنترل و صفرهای داخلی حفظ می‌شوند. در صورت وجود شناسه، متن کپی کامل صریحاً می‌گوید وارد کردن شناسه واریز هنگام پرداخت الزامی و اجباری است.
زیر دکمه کپی کامل در جزئیات واریز، «کپی لینک کوتاه این صفحه برای ارسال به دیگران» قرار دارد. درخواست POST با CSRF و کنترل مالکیت/محدوده اجرا می‌شود. مشتری واردشده می‌تواند لینک موقت همین اعلامیه را بسازد؛ پیامکی ارسال نمی‌شود. بازدیدکننده ناشناس فقط لینک معتبر دریافت‌شده را کپی می‌کند؛ انقضا یا محدوده دسترسی تمدید نمی‌شود. برای کپی پس از redirect، توکن در نشست سمت سرور نگهداری می‌شود؛ مدل لینک همچنان فقط هش توکن را ذخیره می‌کند. نشست‌های قدیمی فاقد توکن باید لینک دریافتی را دوباره باز کنند. Clipboard ناموجود، کپی دستی قابل انتخاب را نمایش می‌دهد.
## تست دائمی
از WSL و در ریشه پروژه اجرا کنید:
+41
View File
@@ -0,0 +1,41 @@
# اطلاع‌رسانی فیش مشتری
پس از ثبت موفق فیش مشتری، اپ `payments` اعلان‌ها را در صف `NotificationJob` قرار می‌دهد. ثبت از حساب مشتری و لینک کوتاه پرداخت هر دو پشتیبانی می‌شوند. فیشی که کارشناس به نمایندگی از مشتری ثبت می‌کند اعلان ندارد. تصویر هر نسخه مستقل است؛ گزینه «اطلاع‌رسانی فیش اصلاح‌شده مشتری» تعیین می‌کند نسخه‌های اصلاحی نیز اعلان داشته باشند.
## راه‌اندازی
۱. migration `payments.0007_receipt_alerts` را اجرا کنید. هر دو کانال در شروع خاموش‌اند و بدون تنظیمات هیچ اعلانی ساخته نمی‌شود.
۲. در Django Admin، بخش پرداخت‌ها، «تنظیمات اطلاع‌رسانی فیش مشتری» را باز کنید:
`/x5admin/payments/paymentreceiptalertconfiguration/`
- بله: توکن ربات، شناسه گروه و متن همراه تصویر را وارد و کانال را فعال کنید. ربات باید مجوز ارسال به گروه داشته باشد. این توکن از `BALE_BOT_TOKEN` مربوط به تیکت‌ها مستقل است.
- پیامک: کانال را فعال کنید، متن پیامک را تنظیم کنید و در جدول همان صفحه مدیران دریافت‌کننده را با نام و شماره موبایل اضافه کنید. هر مدیر گزینه فعال مستقل دارد. شماره‌ها با قالب موبایل فعلی پروژه یکسان‌سازی می‌شوند و شماره تکراری پذیرفته نمی‌شود. درگاه پیامک فعلی پروژه استفاده می‌شود.
- برای حفظ توکن ذخیره‌شده، فیلد توکن را خالی بگذارید. برای تعویض، توکن تازه وارد کنید. برای حذف، ابتدا بله را خاموش و گزینه پاک‌کردن توکن را انتخاب کنید. مقدار توکن در HTML فرم و ممیزی پرداخت درج نمی‌شود؛ توکن در دیتابیس تنظیمات نگهداری می‌شود و دسترسی دیتابیس/نسخه پشتیبان باید محدود باشد.
۳. Worker موجود `process_notifications` باید با نسخه جدید کد اجرا شود و به storage تصاویر فیش دسترسی داشته باشد. در محیط تولید از محیط مجازی همان سرور استفاده کنید. اجرای Worker قبلی را پس از انتشار بازراه‌اندازی کنید؛ Worker جدید جداگانه لازم نیست.
مجوز مدیریت تنظیمات و مدیران: `payments.manage_receipt_alerts`. مدیرعامل یا superuser فعال با حساب STAFF نیز مطابق سیاست فعلی پرداخت دسترسی دارد.
## متن و سابقه
تگ‌های متن: `{amount_rial}` (ریال)، `{customer_name}`، `{customer_mobile}`، `{received_at}` (تاریخ و ساعت شمسی)، `{paid_date}` (تاریخ واریز شمسی)، `{receipt_number}`، `{revision_version}`، `{receipt_link}` و `{manager_name}`. نام خالی مشتری با «نام ثبت نشده» جایگزین می‌شود. قالب‌بندی اضافی یا دسترسی به ویژگی‌های شیء در تگ‌ها پذیرفته نمی‌شود.
بله فایل خصوصی را مستقیماً همراه متن دریافت می‌کند؛ آدرس عمومی تصویر ایجاد نمی‌شود. لینک پیش‌فرض متن به جزئیات محافظت‌شده کارشناسان است. PNG/JPEG به‌صورت تصویر و دیگر فرمت‌های مجاز فیش به‌صورت فایل تصویری ارسال می‌شوند.
صفحه سوابق:
`/x5admin/payments/paymentreceiptalert/`
برای هر نسخه و مقصد، سابقه مستقل شامل گیرنده، متن، کانال، تعداد تلاش، خطای آخر و نتیجه دریافت پیامک وجود دارد. پذیرش سرویس پیامک با رسیدن به گوشی متفاوت است؛ نتیجه دریافت از استعلام Worker و قابلیت درگاه می‌آید. سوابق فقط خواندنی‌اند.
## رفتار هنگام خطا و تغییر تنظیمات
- ثبت فیش هیچ درخواست شبکه‌ای برای اعلان انجام نمی‌دهد. ساخت صف در همان تراکنش است؛ rollback ثبت فیش، صف آن را نیز حذف می‌کند. nonce و کلید یکتای نسخه/کانال/گیرنده جلوی ساخت اعلان تکراری را می‌گیرند.
- خرابی صف یک مقصد با savepoint جدا مدیریت می‌شود؛ مقصدهای دیگر و ثبت فیش ادامه پیدا می‌کنند. اگر خواندن تنظیمات یا ساخت صف ممکن نباشد، خطای بدون اطلاعات محرمانه در لاگ ثبت می‌شود؛ چنین خطایی صف قابل تلاش مجدد ایجاد نمی‌کند.
- خطای شبکه/درگاه/فایل در Worker به خطای اعلان تبدیل می‌شود. تلاش مجدد از سیاست محدود فعلی پیامک و بله پیروی می‌کند و ثبت یا بررسی فیش را تغییر نمی‌دهد. خطای یک اعلان پردازش مقصدهای دیگر را متوقف نمی‌کند.
- خاموش‌کردن کانال یا مدیر، یا تغییر شماره/شناسه گروه، اعلان‌های قبلی ارسال‌نشده آن مقصد را لغو می‌کند. توکن جدید برای تلاش‌های بعدی خوانده می‌شود. متن و اطلاعات مشتری/گیرنده مربوط به زمان ثبت نسخه حفظ می‌شوند؛ تغییر متن، پیام‌های جدید را تغییر می‌دهد.
- همانند صف فعلی پروژه، اگر سرویس پیام را بپذیرد ولی پاسخ شبکه گم شود، تلاش بعدی ممکن است ارسال بیرونی را تکرار کند؛ سرویس بیرونی تضمین exactly-once ندارد.
تست‌ها در `tests/test_payment_receipt_alerts.py` تمام ارسال‌ها را mock می‌کنند و به گروه یا موبایل واقعی پیام نمی‌فرستند.
+2
View File
@@ -119,3 +119,5 @@
.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}}
.pay-pagination{flex-wrap:wrap}.pay-pagination small{flex-basis:100%;text-align:center;color:#526c86}.pay-sms-panel{overflow-wrap:anywhere}.pay-sms-panel>form>.pay-button{width:100%;white-space:normal;line-height:1.8}.pay-sms-panel button:disabled{opacity:.6;cursor:not-allowed}.pay-sms-record{padding:16px 0;border-top:1px solid #e7edf2}.pay-sms-record header{display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap}.pay-sms-record p{margin:8px 0;font-size:13px;line-height:1.9}.pay-sms-record time{font-size:12px;color:#526c86}.pay-sms-panel .pay-status{white-space:normal;max-width:100%;line-height:1.8}.pay-sms-record summary{cursor:pointer;font-size:13px;padding:10px 0;color:#17435d}.pay-sms-body{font-size:13px;line-height:1.9;background:#f0f8fc;padding:12px;border-radius:10px}.pay-sms-attempt{padding:10px 0;border-top:1px dashed #dce5ed}.pay-sms-error{color:#9f1f1f}
.receipt-upload{padding:18px;margin:16px 0;border:1px solid #c7dfeb;border-radius:16px;background:#f0f8fc;color:#17435d;overflow-wrap:anywhere}.receipt-upload[hidden]{display:none}.receipt-upload p{margin:8px 0;font-size:14px}.receipt-upload small{display:block;color:#526c86}.receipt-upload-meter{display:flex;align-items:center;gap:12px;margin-top:12px}.receipt-upload-meter progress{width:100%;flex:1;min-width:0;height:12px;accent-color:#087d8b;direction:ltr}.receipt-upload-meter span{min-width:44px;text-align:center;font-weight:700;font-variant-numeric:tabular-nums}.customer-receipt-actions button:disabled{opacity:.65;cursor:wait}.customer-receipt-form.is-uploading input,.customer-receipt-form.is-uploading select,.customer-receipt-form.is-uploading textarea{opacity:.75}
+1
View File
@@ -1,4 +1,5 @@
/* Temporary payment access follows the Chaharsu style guide. */
.pay-page-share{margin:12px 0 18px;padding-top:16px;border-top:1px solid #e7edf2}.pay-page-share>.pay-button{display:flex;gap:10px;max-width:100%;min-height:48px;white-space:normal;text-align:start;line-height:1.8}.pay-page-share>.pay-button:focus-visible{outline:3px solid #1977ce;outline-offset:3px}.pay-page-share>.pay-button:disabled{opacity:.65;cursor:wait}.pay-page-share p{margin:8px 0 0;color:#526c86;font-size:13px;line-height:1.9}.pay-page-share .pay-share-feedback:empty{display:none}@media(max-width:639px){.pay-page-share>.pay-button{width:100%;justify-content:center}}
.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}
+1 -1
View File
@@ -48,7 +48,7 @@
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 (data.customer !== selectedCustomer || !/^9[0-9]{11}$/.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 = 'شناسه آماده است؛ برای جایگزینی مقدار فعلی، دکمه جایگزینی را بزنید.';
+16
View File
@@ -13,6 +13,22 @@ async function copy(text){
}
}
document.querySelectorAll('[data-copy]').forEach(button=>button.addEventListener('click',()=>copy(button.dataset.copy)));
document.querySelectorAll('[data-notice-sms]').forEach(form=>form.addEventListener('submit',event=>{
if(form.dataset.sending==='true'){event.preventDefault();return;}
form.dataset.sending='true';const button=form.querySelector('button');button.disabled=true;button.textContent='در حال ثبت درخواست ارسال…';
}));
document.querySelectorAll('[data-share-link]').forEach(form=>form.addEventListener('submit',async event=>{
event.preventDefault();
const button=form.querySelector('button'),feedback=form.querySelector('[role="status"]');
button.disabled=true;button.setAttribute('aria-busy','true');feedback.textContent='در حال آماده‌سازی لینک…';
try{
const response=await fetch(form.action,{method:'POST',body:new FormData(form),credentials:'same-origin',cache:'no-store',headers:{'Accept':'application/json'}});
if(!response.ok || !response.headers.get('Content-Type')?.includes('application/json'))throw new Error('share unavailable');
const data=await response.json();
await copy(data.url);feedback.textContent='لینک آماده است؛ می‌توانید آن را برای دیگران ارسال کنید.';
}catch{feedback.textContent='دریافت لینک ممکن نشد. دوباره تلاش کنید یا لینک پرداخت دریافتی را باز کنید.';}
finally{button.disabled=false;button.removeAttribute('aria-busy');}
}));
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]');
+8 -5
View File
@@ -42,8 +42,8 @@ fields.forEach(field=>{const controls=field.querySelectorAll('input,select,texta
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'});}
const summary=document.querySelector('#receipt-errors'),list=summary.querySelector('ul');list.replaceChildren();errors.forEach((message,name)=>{const item=document.createElement('li'),control=q(name);if(control && typeof control.focus==='function'){const link=document.createElement('a');link.href='#'+control.id;link.textContent=message;link.onclick=event=>{event.preventDefault();control.focus();control.scrollIntoView({block:'center'});};item.append(link);}else item.textContent=message;list.append(item);});summary.hidden=!errors.size;
if(errors.size){const first=q(errors.keys().next().value);if(first && typeof first.focus==='function'){first.focus();first.scrollIntoView({block:'center'});}else{summary.tabIndex=-1;summary.focus();summary.scrollIntoView({block:'center'});}}
}
async function validateImage(file){
if(!file)return 'تصویر فیش واریز را انتخاب کنید.';
@@ -53,7 +53,7 @@ async function validateImage(file){
}
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();
event.preventDefault();if(busy)return;busy=true;button.disabled=true;button.textContent='در حال بررسی تصویر…';document.querySelector('#form-status').textContent='';const version=revision,errors=new Map();let navigating=false;
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','اعلامیه را انتخاب کنید یا واریز مستقل را بزنید.');
@@ -63,8 +63,11 @@ form.addEventListener('submit',async event=>{
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;}
const result=await window.ReceiptUpload.send(form);
if(result.redirect_url){const destination=new URL(result.redirect_url,location.href);if(destination.origin!==location.origin)throw new Error('پاسخ ثبت فیش معتبر نیست.');navigating=true;location.assign(destination.href);return;}
showErrors(new Map(Object.entries(result.errors).map(([name,items])=>[name,items.map(item=>item.message).join(' ')])));
}catch(error){showErrors(new Map([['__all__',error.message || 'ارسال فیش انجام نشد؛ دوباره تلاش کنید.']]));}
finally{if(!navigating){busy=false;button.disabled=false;button.textContent='ثبت و ارسال فیش';}}
});
if(document.querySelector('#receipt-errors').hidden===false){const field=form.querySelector('[aria-invalid=true]');if(field){field.focus();field.scrollIntoView({block:'center'});}}
})();
+65
View File
@@ -0,0 +1,65 @@
/* Real multipart upload progress; registration remains authoritative on the server. */
(() => {
'use strict';
const number=new Intl.NumberFormat('fa-IR',{maximumFractionDigits:1});
function bytes(value){return value>=1048576?number.format(value/1048576)+' مگابایت':number.format(value/1024)+' کیلوبایت';}
function duration(seconds){return seconds>=60?number.format(Math.ceil(seconds/60))+' دقیقه':number.format(Math.max(1,Math.ceil(seconds)))+' ثانیه';}
function send(form){
const payload=new FormData(form),panel=document.querySelector('#receipt-upload');
const title=document.querySelector('#receipt-upload-title'),meter=document.querySelector('#receipt-upload-progress');
const percent=document.querySelector('#receipt-upload-percent'),amount=document.querySelector('#receipt-upload-bytes'),time=document.querySelector('#receipt-upload-time');
const button=document.querySelector('#submit-receipt');
const controls=[...form.querySelectorAll('input,select,textarea,button')].map(control=>[control,control.disabled]);
controls.forEach(([control])=>{control.disabled=true;});form.classList.add('is-uploading');button.setAttribute('aria-busy','true');
panel.hidden=false;title.textContent='در حال ارسال فیش…';button.textContent='در حال ارسال…';
meter.value=0;percent.textContent='۰٪';amount.textContent='در انتظار شروع ارسال';
const started=performance.now();let loaded=0,total=0,lastProgress=started,processing=false;
function updateTime(){
if(processing)return;
const elapsed=(performance.now()-started)/1000;
const estimate=loaded>0 && elapsed>=1 && total>loaded;
time.textContent='زمان سپری‌شده: '+number.format(Math.floor(elapsed))+' ثانیه'+(performance.now()-lastProgress>5000?' · منتظر ادامه ارسال؛ سرعت اتصال تغییر کرده است.':estimate?' · حدود '+duration((total-loaded)*elapsed/loaded)+' دیگر برای ارسال':' · در حال محاسبه زمان باقی‌مانده…');
}
function process(){
processing=true;meter.value=100;percent.textContent='۱۰۰٪';
title.textContent='ارسال کامل شد؛ در حال بررسی و ثبت فیش…';button.textContent='در حال ثبت…';
time.textContent='منتظر تأیید ثبت از سرور؛ زمان این مرحله به سرعت پردازش بستگی دارد.';
}
updateTime();panel.scrollIntoView({block:'nearest'});
return new Promise((resolve,reject)=>{
const xhr=new XMLHttpRequest(),timer=setInterval(updateTime,1000);let settled=false;
function finish(error,result){
if(settled)return;settled=true;clearInterval(timer);
if(!result?.redirect_url){
controls.forEach(([control,disabled])=>{control.disabled=disabled;});form.classList.remove('is-uploading');button.removeAttribute('aria-busy');
title.textContent='ثبت فیش انجام نشد';time.textContent='اطلاعات و تصویر حفظ شده‌اند؛ پس از رفع مشکل دوباره ارسال کنید.';
}
if(error)reject(error);else resolve(result);
}
xhr.upload.addEventListener('progress',event=>{
loaded=event.loaded;total=event.lengthComputable?event.total:0;lastProgress=performance.now();
amount.textContent='ارسال‌شده: '+bytes(loaded)+(total?' از '+bytes(total):'');
if(total){const value=Math.min(100,Math.floor(loaded/total*100));meter.value=value;percent.textContent=number.format(value)+'٪';}
else{meter.removeAttribute('value');percent.textContent='در حال ارسال';}
updateTime();
});
xhr.upload.addEventListener('load',process);
xhr.addEventListener('load',()=>{
const result=xhr.response;
if(xhr.status>=200 && xhr.status<300 && result?.redirect_url){finish(null,result);return;}
if(xhr.status===422 && result?.errors){finish(null,result);return;}
const message=[401,403,410].includes(xhr.status)?'دسترسی یا نشست شما معتبر نیست؛ صفحه را دوباره باز کنید.':xhr.status===413?'حجم تصویر بیش از حد مجاز سرور است؛ تصویر کوچک‌تری انتخاب کنید.':'ثبت فیش تأیید نشد؛ دوباره تلاش کنید. در صورت تکرار، وضعیت فیش‌های خود را بررسی کنید.';
finish(new Error(message));
});
xhr.addEventListener('error',()=>finish(new Error('ارتباط با سرور قطع شد؛ اتصال اینترنت را بررسی و دوباره ارسال کنید.')));
xhr.addEventListener('abort',()=>finish(new Error('ارسال متوقف شد؛ دوباره تلاش کنید.')));
try{
xhr.open('POST',form.action || location.href);xhr.responseType='json';
xhr.setRequestHeader('Accept','application/json');
xhr.setRequestHeader('X-CSRFToken',payload.get('csrfmiddlewaretoken'));
xhr.send(payload);
}catch(error){finish(error);}
});
}
window.ReceiptUpload=Object.freeze({send});
})();
@@ -29,6 +29,12 @@
<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>
<section id="receipt-upload" class="receipt-upload" aria-labelledby="receipt-upload-title" hidden>
<strong id="receipt-upload-title" role="status">در حال ارسال فیش…</strong>
<div class="receipt-upload-meter"><progress id="receipt-upload-progress" max="100" value="0" aria-label="پیشرفت ارسال فیش"></progress><span id="receipt-upload-percent">۰٪</span></div>
<p id="receipt-upload-bytes"></p><p id="receipt-upload-time"></p>
<small>لطفاً تا پایان ثبت، این صفحه را باز نگه دارید.</small>
</section>
<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>
+5
View File
@@ -14,6 +14,11 @@
<!-- 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 notice_page and not staff_notice %}<form class="pay-page-share" method="post" action="{% url 'payments:notice_share_link' notice.public_id %}" data-share-link>
{% csrf_token %}<button type="submit" class="pay-button secondary">{% include 'payments/_icon.html' with name='copy' %}<span>کپی لینک کوتاه این صفحه برای ارسال به دیگران</span></button>
<p>دارندهٔ لینک به اطلاعات همین واریز دسترسی دارد؛ لینک تا پایان اعتبار آن قابل استفاده است.</p>
<p class="pay-share-feedback" role="status" aria-live="polite"></p>
</form>{% endif %}
{% 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 %}
+39
View File
@@ -0,0 +1,39 @@
{% load jalali %}
<section class="pay-card pay-sms-panel" aria-labelledby="notice-sms-title">
<h2 id="notice-sms-title">پیامک اعلامیه و سوابق ارسال</h2>
<p class="pay-section-help">پذیرش پیامک توسط سرویس به معنی رسیدن به گوشی نیست؛ وضعیت دریافت بر اساس استعلام سرویس پیامک نمایش داده می‌شود.</p>
{% if can_resend_notice_sms %}
<form method="post" action="{% url 'staff_payments:notice_resend_sms' notice.public_id %}" data-notice-sms>
{% csrf_token %}<input type="hidden" name="request_key" value="{{ notice_sms_request_key }}">
<button class="pay-button secondary" type="submit" {% if notice_sms_pending %}disabled{% endif %}>ارسال مجدد پیامک اعلامیه واریز</button>
</form>
{% if notice_sms_pending %}<p class="pay-section-help" role="status">پیامک در صف یا در حال ارسال است. نتیجه پس از پردازش سامانه و با تازه‌سازی این صفحه نمایش داده می‌شود.</p>{% endif %}
{% endif %}
<div class="pay-sms-history">
{% for notification in notifications %}
<article class="pay-sms-record">
<header><strong>{% if notification.manual_resend %}ارسال مجدد{% else %}ارسال اولیه{% endif %}</strong><time>{{ notification.job.created_at|jalali_datetime }}</time></header>
<p>وضعیت ارسال: <strong>{% if notification.job.status == 'SENT' %}پذیرفته‌شده توسط سرویس پیامک{% else %}{{ notification.job.get_status_display }}{% endif %}</strong></p>
<p>وضعیت دریافت: <span class="pay-status {{ notification.delivery_class }}">{{ notification.delivery_label }}</span></p>
{% if notification.job.staff_user %}<p>درخواست‌کننده: {{ notification.job.staff_user.staff_profile.display_name|default:notification.job.staff_user.get_full_name|default:notification.job.staff_user.username }}</p>{% endif %}
{% if notification.job.sent_at %}<p>زمان ارسال: {{ notification.job.sent_at|jalali_datetime }}</p>{% endif %}
{% if notification.job.status == 'RETRYING' %}<p>تلاش خودکار بعدی: {{ notification.job.next_attempt_at|jalali_datetime }}</p>{% endif %}
{% if notification.job.last_error %}<p class="pay-sms-error">علت خطای ارسال: {{ notification.job.last_error }}</p>{% endif %}
{% if notification.job.delivery_checked_at %}<p>آخرین استعلام: {{ notification.job.delivery_checked_at|jalali_datetime }} · {{ notification.job.delivery_check_attempts }} نوبت</p>{% endif %}
{% if notification.job.delivery_next_check_at %}<p>استعلام بعدی: {{ notification.job.delivery_next_check_at|jalali_datetime }}</p>{% endif %}
{% if notification.job.delivery_description %}<p>شرح نتیجه دریافت: {{ notification.job.delivery_description }}</p>{% endif %}
{% if notification.job.delivery_status_code %}<p>کد وضعیت دریافت: <bdi>{{ notification.job.delivery_status_code }}</bdi></p>{% endif %}
<details><summary>جزئیات پیام و تلاش‌های ارسال ({{ notification.send_attempts|length }})</summary>
{% if notification.rendered_body %}<div class="pay-sms-body">{{ notification.rendered_body|linebreaksbr }}</div>{% endif %}
{% for attempt in notification.send_attempts %}<div class="pay-sms-attempt">
<p><time>{{ attempt.started_at|jalali_datetime }}</time> · {% if attempt.success == True %}پذیرفته‌شده توسط سرویس{% elif attempt.success == False %}تلاش ناموفق{% else %}نتیجه تلاش هنوز ثبت نشده است{% endif %}</p>
{% if attempt.finished_at %}<p>پایان تلاش: {{ attempt.finished_at|jalali_datetime }}</p>{% endif %}
{% if attempt.provider_id %}<p>شناسه رهگیری: <bdi>{{ attempt.provider_id }}</bdi></p>{% endif %}
{% if attempt.provider_code %}<p>کد پاسخ سرویس: <bdi>{{ attempt.provider_code }}</bdi></p>{% endif %}
{% if attempt.error %}<p class="pay-sms-error">{{ attempt.error }}</p>{% endif %}
{% empty %}<p>هنوز تلاشی برای ارسال ثبت نشده است.</p>{% endfor %}
</details>
</article>
{% empty %}<p class="pay-section-help">اطلاع‌رسانی ثبت‌شده‌ای وجود ندارد.</p>{% endfor %}
</div>
</section>
+1 -1
View File
@@ -1 +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 %}
{% if always or 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 %}{% if always and page_obj.paginator.count %}<small>نمایش {{ page_obj.start_index }} تا {{ page_obj.end_index }} از {{ page_obj.paginator.count }} مورد</small>{% endif %}</nav>{% endif %}
+7 -7
View File
@@ -8,13 +8,13 @@
{% 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=receipts" {% if tab != 'notices' and not 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>
<a href="?tab=receipts&mine=1" {% if 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 %}
{% if 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>
@@ -30,11 +30,11 @@
<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>
<a class="pay-reset" href="?tab={{ tab }}{% if 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>
<div class="pay-list-head"><h2 id="pay-results-title">{% if tab == 'notices' %}اعلامیه‌های واریز{% elif 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>
@@ -51,9 +51,9 @@
</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>
<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 mine %}در حال حاضر فیشی برای اقدام شما وجود ندارد.{% elif tab == 'notices' %}اعلامیه‌های صادرشده برای مشتریان اینجا نمایش داده می‌شوند.{% else %}فیش‌های ارسالی مشتریان برای بررسی اینجا نمایش داده می‌شوند.{% endif %}</p></div>
{% endfor %}
</section>
{% include 'payments/_pagination.html' %}
{% include 'payments/_pagination.html' with always=True %}
</section>
{% endblock %}
+1 -1
View File
@@ -10,7 +10,7 @@
<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>
{% include 'payments/_notice_sms.html' %}
{% if can_delete_notice %}<details class="pay-delete-panel"><summary>حذف اعلامیه واریز</summary><form method="post" action="{% url 'staff_payments:notice_delete' notice.public_id %}">{% csrf_token %}<p role="note">آیا از حذف اعلامیه «{{ notice.subject }}» با شماره {{ notice.number }} مطمئن هستید؟ پس از حذف، اعلامیه از فهرست‌ها خارج می‌شود و لینک‌های آن دیگر قابل استفاده نیستند. این اقدام از پنل قابل بازگشت نیست.</p><p>حذف فقط تا زمانی مجاز است که هیچ فیشی برای این اعلامیه ثبت نشده باشد.</p><label><input type="checkbox" name="confirm" value="delete" required> هشدار را خواندم و حذف این اعلامیه را تأیید می‌کنم.</label><div class="pay-actions"><button class="pay-button pay-danger" type="submit">تأیید و حذف اعلامیه</button></div></form></details>{% elif may_delete_notice %}<section class="pay-card"><h2>حذف اعلامیه</h2><p class="pay-section-help">برای این اعلامیه فیش ثبت شده است؛ حذف آن امکان‌پذیر نیست، حتی اگر فیش رد شده باشد.</p></section>{% endif %}
</aside>
</div>
+1 -1
View File
@@ -1,3 +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 %}
{% 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-upload.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 %}
+25
View File
@@ -4,6 +4,31 @@ from .payments import PaymentsPage
class PaymentLinkPage(PaymentsPage):
def prepare_copy(self, manual=False):
self.manual_copy = manual
if manual:
self.page.add_init_script("Object.defineProperty(navigator, 'clipboard', {value: {writeText: async () => {throw new Error('Clipboard unavailable');}}});")
else:
self.page.context.grant_permissions(['clipboard-read', 'clipboard-write'])
def copied_text(self, button_name):
self.page.get_by_role('button', name=button_name, exact=True).click()
if button_name == 'کپی لینک کوتاه این صفحه برای ارسال به دیگران':
expect(self.page.locator('.pay-share-feedback')).to_contain_text('لینک آماده است')
if self.manual_copy:
dialog = self.page.get_by_role('dialog')
expect(dialog).to_be_visible()
value = dialog.locator('textarea').input_value()
dialog.get_by_role('button', name='بستن', exact=True).click()
return value
expect(self.page.get_by_text('کپی شد', exact=True)).to_be_visible()
return self.page.evaluate('navigator.clipboard.readText()')
def copy_short_link(self):
value = self.copied_text('کپی لینک کوتاه این صفحه برای ارسال به دیگران')
expect(self.page.locator('.pay-share-feedback')).to_contain_text('لینک آماده است')
return value
def open_link(self, path):
self.goto(path)
expect(self.page.get_by_role('heading', name='جزئیات واریز', exact=True)).to_be_visible()
+79
View File
@@ -0,0 +1,79 @@
from playwright.sync_api import expect
from .base import BasePage
class ReceiptUploadPage(BasePage):
def control_transport(self):
"""Pause the browser transport while keeping the final POST real."""
self.page.add_init_script("""(() => {
const Native = window.XMLHttpRequest;
window.receiptTransport = {count: 0};
window.XMLHttpRequest = class extends Native {
open(method, url, ...rest) {
this.receiptRequest = method === 'POST' && /payment-receipt|receipts/.test(url);
super.open(method, url, ...rest);
}
send(payload) {
if (!this.receiptRequest) return super.send(payload);
window.receiptTransport.count++;
window.receiptTransport.current = this;
this.release = () => super.send(payload);
this.upload.dispatchEvent(new ProgressEvent('progress', {
lengthComputable: true, loaded: 524288, total: 1048576
}));
}
};
})();""")
def fill_receipt(self, account_id, png):
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})
def submit(self):
self.page.get_by_role('button', name='ثبت و ارسال فیش', exact=True).click()
def assert_uploading(self):
expect(self.page.locator('#receipt-upload')).to_be_visible()
expect(self.page.locator('#receipt-upload-percent')).to_have_text('۵۰٪')
expect(self.page.locator('#receipt-upload-bytes')).to_have_text('ارسال‌شده: ۵۱۲ کیلوبایت از ۱ مگابایت')
expect(self.page.locator('#receipt-upload-time')).to_contain_text('دیگر برای ارسال')
expect(self.page.locator('#submit-receipt')).to_be_disabled()
expect(self.page.locator('#id_receipt_image')).to_be_disabled()
def repeat_submission(self):
self.page.locator('#receipt-form').evaluate("form => {form.requestSubmit(); form.requestSubmit();}")
assert self.page.evaluate('window.receiptTransport.count') == 1
def processing(self):
self.page.evaluate("window.receiptTransport.current.upload.dispatchEvent(new Event('load'))")
expect(self.page.locator('#receipt-upload-percent')).to_have_text('۱۰۰٪')
expect(self.page.locator('#receipt-upload-title')).to_contain_text('در حال بررسی و ثبت فیش')
expect(self.page.locator('#submit-receipt')).to_be_disabled()
def fail_network(self):
self.page.evaluate("window.receiptTransport.current.dispatchEvent(new Event('error'))")
expect(self.page.locator('#receipt-errors')).to_contain_text('ارتباط با سرور قطع شد')
expect(self.page.locator('#submit-receipt')).to_be_enabled()
expect(self.page.locator('#id_receipt_image')).to_be_enabled()
assert self.page.locator('#id_receipt_image').evaluate('input => input.files.length') == 1
def reject_fields(self):
self.page.evaluate("""() => {
const request = window.receiptTransport.current;
Object.defineProperty(request, 'status', {value: 422});
Object.defineProperty(request, 'response', {value: {errors: {
paid_amount: [{message: 'مبلغ با پرداخت مطابقت ندارد.'}],
__all__: [{message: 'اطلاعات بانکی را دوباره بررسی کنید.'}]
}}});
request.dispatchEvent(new Event('load'));
}""")
expect(self.page.locator('#receipt-errors')).to_contain_text('اطلاعات بانکی را دوباره بررسی کنید.')
expect(self.page.locator('#error-paid_amount')).to_have_text('مبلغ با پرداخت مطابقت ندارد.')
expect(self.page.locator('#submit-receipt')).to_be_enabled()
def finish(self, revision=False):
self.page.evaluate('window.receiptTransport.current.release()')
expect(self.page.locator('.flash')).to_contain_text('نسخه اصلاحی ثبت شد' if revision else 'فیش ثبت شد و در انتظار بررسی است.')
+44
View File
@@ -0,0 +1,44 @@
from django.urls import reverse
from playwright.sync_api import expect
from .base import BasePage
class StaffPaymentsPage(BasePage):
def open_desk(self, parameters=''):
self.goto(reverse('staff_payments:desk') + parameters)
def assert_action_tab(self):
expect(self.page.get_by_role('link', name='نیازمند اقدام من', exact=True)).to_have_attribute('aria-current', 'page')
expect(self.page.get_by_role('heading', name='فیش‌های نیازمند اقدام شما')).to_be_visible()
def search(self, query):
self.page.locator('[name=q]').fill(query)
self.page.get_by_role('button', name='جست‌وجو', exact=True).click()
def next_page(self):
pagination = self.page.get_by_role('navigation', name='صفحه‌بندی', exact=True)
expect(pagination).to_contain_text('صفحه 1 از 2')
pagination.get_by_role('link', name='بعدی', exact=True).click()
expect(pagination).to_contain_text('صفحه 2 از 2')
def open_notice(self, public_id):
self.goto(reverse('staff_payments:notice_detail', args=[public_id]))
def inspect_history(self):
panel = self.page.locator('.pay-sms-panel')
expect(panel).to_contain_text('رسیده به گوشی')
expect(panel).to_contain_text('پذیرفته‌شده توسط سرویس پیامک')
expect(panel).to_contain_text('اعتبار پنل کافی نیست')
for details in panel.locator('details').all():
details.locator('summary').click()
expect(panel).to_contain_text('old-delivered-id')
expect(panel).to_contain_text('کد پاسخ سرویس: -19')
def resend_sms(self):
button = self.page.get_by_role('button', name='ارسال مجدد پیامک اعلامیه واریز', exact=True)
expect(button).to_be_enabled()
button.click()
expect(self.page.locator('.flash')).to_contain_text('درخواست ارسال مجدد پیامک در صف ثبت شد')
expect(button).to_be_disabled()
expect(self.page.locator('.pay-sms-history .pay-sms-record')).to_have_count(3)
+37 -1
View File
@@ -7,11 +7,47 @@ 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.pages.auth import AuthenticationPage
from tests.e2e.test_payments_e2e import payment_case, PNG
from tests.e2e.test_payments_e2e import PASSWORD
from django.urls import reverse
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
@pytest.mark.parametrize('width,via_link,manual', [(320, True, True), (1440, True, False),
(320, False, False), (1440, False, True)])
def test_notice_sharing_and_full_copy(live_server, browser_page, payment_case, width, via_link, manual):
customer, account, reviewers = payment_case
notice = services.issue_notice(reviewers[0], {'customer': customer, 'account': account,
'subject': 'اشتراک اطلاعات واریز', 'amount': 12345, 'deposit_id': '۰۰۰۱۲۳۰۴'})
_, original_url = issue_payment_link(notice=notice)
with browser_page() as page:
page.set_viewport_size({'width': width, 'height': 900})
view = PaymentLinkPage(page, live_server.url)
view.prepare_copy(manual)
if via_link:
view.open_link(urlsplit(original_url).path)
else:
AuthenticationPage(page, live_server.url).login_customer(customer, PASSWORD)
view.goto(reverse('payments:notice_detail', args=[notice.public_id]))
expect(page.locator('.pay-deposit-required dd')).to_have_text('12304')
full_copy = view.copied_text('کپی کامل اطلاعات')
assert 'شناسه واریز: 12304' in full_copy
assert 'وارد کردن شناسه واریز هنگام پرداخت الزامی و اجباری است' in full_copy
assert 'شماره حساب: 000123' in full_copy
shared_url = view.copy_short_link()
assert urlsplit(shared_url).path.startswith('/p/')
if via_link:
assert shared_url == original_url
assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1')
with browser_page() as recipient_page:
recipient = PaymentLinkPage(recipient_page, live_server.url)
recipient.open_link(urlsplit(shared_url).path)
expect(recipient_page.locator('.pay-deposit-required dd')).to_have_text('12304')
expect(recipient_page.get_by_role('link', name='پروفایل من')).to_have_count(0)
@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
@@ -25,7 +61,7 @@ def test_sms_payment_without_login_preserves_copy_and_submission(live_server, br
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.locator('.pay-deposit-required dd')).to_have_text('123')
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)
+4 -4
View File
@@ -150,7 +150,7 @@ def test_staff_issues_identifier_notice_and_customer_sends_linked_receipt(live_s
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')
expect(page.locator('#id_deposit_id')).to_have_value('123')
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})
@@ -362,13 +362,13 @@ def test_notice_identifier_generation_preserves_manual_value_and_customer_contex
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')
expect(page.locator('#id_deposit_id')).to_have_value('912000902169')
page.locator('#id_deposit_id').fill('manual-123')
generate.click()
expect(page.locator('#identifier-candidate')).to_have_text('0912000902169')
expect(page.locator('#identifier-candidate')).to_have_text('912000902169')
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')
expect(page.locator('#id_deposit_id')).to_have_value('912000902169')
page.get_by_role('button', name='تغییر مشتری', exact=True).click()
expect(page.locator('#id_deposit_id')).to_have_value('')
expect(generate).to_be_disabled()
+101
View File
@@ -0,0 +1,101 @@
from urllib.parse import urlsplit
import uuid
import jdatetime
import pytest
from django.contrib.auth.models import Permission
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from django.utils import timezone
from playwright.sync_api import expect
from apps.payments import services
from apps.payments.access import issue_payment_link
from apps.payments.models import PaymentReceipt
from tests.e2e.pages.auth import AuthenticationPage
from tests.e2e.pages.receipt_upload import ReceiptUploadPage
from tests.e2e.test_payments_e2e import payment_case, PNG, PASSWORD
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
@pytest.mark.parametrize('mode,width', [('independent', 320), ('linked', 1440), ('staff', 320), ('link', 320)])
def test_receipt_upload_progress_processing_and_single_submission(live_server, browser_page, payment_case, mode, width):
customer, account, reviewers = payment_case
creator = reviewers[0]
creator.user_permissions.add(Permission.objects.get(content_type__app_label='payments', codename='add_paymentnotice'))
notice = services.issue_notice(creator, {'customer': customer, 'account': account, 'subject': 'تست پیشرفت ارسال', 'amount': 12345})
_, link_url = issue_payment_link(notice=notice)
with browser_page() as page:
page.set_viewport_size({'width': width, 'height': 900})
view = ReceiptUploadPage(page, live_server.url)
view.control_transport()
if mode == 'staff':
AuthenticationPage(page, live_server.url).login_staff(creator, PASSWORD)
view.goto(reverse('staff_payments:receipt_new', args=[customer.public_id]))
elif mode == 'link':
view.goto(urlsplit(link_url).path)
page.get_by_role('link', name='ارسال فیش این واریز', exact=True).click()
else:
AuthenticationPage(page, live_server.url).login_customer(customer, PASSWORD)
view.goto(reverse('payments:receipt_new') + ('?notice=' + str(notice.public_id) if mode == 'linked' else ''))
if mode in ('independent', 'staff'):
page.get_by_label('واریز مستقل', exact=True).check()
view.fill_receipt(account.pk, PNG)
view.submit()
view.assert_uploading()
view.repeat_submission()
assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1')
view.processing()
view.finish()
assert PaymentReceipt.objects.filter(customer=customer).count() == 1
@pytest.mark.parametrize('failure', ['network', 'validation'])
def test_failed_upload_retains_image_and_can_retry(live_server, browser_page, payment_case, failure):
customer, account, _ = payment_case
with browser_page() as page:
view = ReceiptUploadPage(page, live_server.url)
view.control_transport()
AuthenticationPage(page, live_server.url).login_customer(customer, PASSWORD)
view.goto(reverse('payments:receipt_new'))
page.get_by_label('واریز مستقل', exact=True).check()
view.fill_receipt(account.pk, PNG)
key = page.locator('[name=submission_key]').input_value()
view.submit()
view.assert_uploading()
view.fail_network() if failure == 'network' else view.reject_fields()
expect(page.locator('#id_paid_amount')).to_have_value('۱۲٬۳۴۵')
assert page.locator('[name=submission_key]').input_value() == key
view.submit()
expect(page.locator('#receipt-upload-title')).to_have_text('در حال ارسال فیش…')
view.finish()
assert PaymentReceipt.objects.filter(customer=customer).count() == 1
def test_revised_receipt_uses_upload_progress(live_server, browser_page, payment_case, client):
customer, account, reviewers = payment_case
today = jdatetime.date.fromgregorian(date=timezone.localdate())
client.force_login(customer)
response = client.post(reverse('payments:receipt_new'), {
'receipt_mode': 'independent', 'paid_amount': '12345', 'destination_account': account.pk,
'paid_day': str(today.day), 'paid_month': str(today.month), 'paid_year': str(today.year),
'submission_key': str(uuid.uuid4()),
'receipt_image': SimpleUploadedFile('receipt.png', PNG, content_type='image/png'),
})
assert response.status_code == 302
receipt = PaymentReceipt.objects.get(customer=customer)
services.decide_receipt(reviewers[0], receipt.public_id, 1, 'REJECT', 'تصویر واضح‌تر لازم است')
with browser_page() as page:
view = ReceiptUploadPage(page, live_server.url)
view.control_transport()
AuthenticationPage(page, live_server.url).login_customer(customer, PASSWORD)
view.goto(reverse('payments:receipt_revise', args=[receipt.public_id]))
view.fill_receipt(account.pk, PNG)
view.submit()
view.assert_uploading()
view.repeat_submission()
view.processing()
view.finish(revision=True)
receipt.refresh_from_db()
assert receipt.current_version == 2 and receipt.revisions.count() == 2
+76
View File
@@ -0,0 +1,76 @@
import uuid
from urllib.parse import parse_qs, urlsplit
import jdatetime
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from django.utils import timezone
from apps.notifications.models import NotificationJob
from apps.payments import services
from apps.payments.models import PaymentNotification, PaymentNotificationAttempt
from tests.e2e.pages.auth import AuthenticationPage
from tests.e2e.pages.staff_payments import StaffPaymentsPage
from tests.e2e.test_payments_e2e import payment_case, PASSWORD, PNG
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
@pytest.mark.parametrize('notices', [False, True])
def test_staff_default_action_tab_and_pagination(live_server, browser_page, payment_case, client, notices):
customer, account, reviewers = payment_case
if notices:
for index in range(26):
services.issue_notice(reviewers[0], {'customer': customer, 'account': account, 'subject': f'اعلامیه صفحه بندی {index}', 'amount': 12345})
else:
client.force_login(customer)
today = jdatetime.date.fromgregorian(date=timezone.localdate())
for _ in range(26):
response = client.post(reverse('payments:receipt_new'), {'receipt_mode': 'independent',
'paid_amount': '12345', 'destination_account': account.pk, 'submission_key': str(uuid.uuid4()),
'paid_day': str(today.day), 'paid_month': str(today.month), 'paid_year': str(today.year),
'receipt_image': SimpleUploadedFile('receipt.png', PNG, content_type='image/png')})
assert response.status_code == 302
with browser_page() as page:
page.set_viewport_size({'width': 320, 'height': 900})
AuthenticationPage(page, live_server.url).login_staff(reviewers[0], PASSWORD)
view = StaffPaymentsPage(page, live_server.url)
view.open_desk()
view.assert_action_tab()
if notices:
page.get_by_role('link', name='اعلامیه‌های واریز', exact=True).click()
view.search(customer.username)
view.next_page()
query = parse_qs(urlsplit(page.url).query)
assert query['q'] == [customer.username]
assert query['tab'] == ['notices' if notices else 'receipts']
if not notices:
assert query['mine'] == ['1']
view.assert_action_tab()
assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1')
@pytest.mark.parametrize('width', [320, 1440])
def test_staff_sms_history_and_resend(live_server, browser_page, payment_case, width):
customer, account, reviewers = payment_case
notice = services.issue_notice(reviewers[0], {'customer': customer, 'account': account, 'subject': 'اعلامیه با سوابق پیامک', 'amount': 12345})
first = PaymentNotification.objects.get(notice=notice)
NotificationJob.objects.filter(pk=first.job_id).update(status='FAILED', attempts=1, last_error='اعتبار پنل کافی نیست')
PaymentNotificationAttempt.objects.create(notification=first, finished_at=timezone.now(), success=False,
provider_code='-19', error='اعتبار پنل کافی نیست')
delivered = services.resend_notice_sms(reviewers[0], notice, str(uuid.uuid4()))
NotificationJob.objects.filter(pk=delivered.pk).update(status='SENT', provider_message_id='old-delivered-id',
sent_at=timezone.now(), delivery_status='DELIVERED', delivery_is_final=True, delivery_checked_at=timezone.now())
PaymentNotificationAttempt.objects.create(notification=delivered.payment_notification, finished_at=timezone.now(),
success=True, provider_code='2', provider_id='old-delivered-id')
with browser_page() as page:
page.set_viewport_size({'width': width, 'height': 900})
AuthenticationPage(page, live_server.url).login_staff(reviewers[0], PASSWORD)
view = StaffPaymentsPage(page, live_server.url)
view.open_notice(notice.public_id)
view.inspect_history()
assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1')
view.resend_sms()
assert PaymentNotification.objects.filter(notice=notice).count() == 3
assert PaymentNotification.objects.filter(notice=notice, job__status='PENDING').count() == 1
+66 -1
View File
@@ -31,6 +31,71 @@ def open_notice(client, case):
return link
def test_customer_shares_short_link_once_without_sending_sms(case, client):
endpoint = reverse('payments:notice_share_link', args=[case['notice'].public_id])
client.force_login(case['customer'])
jobs = NotificationJob.objects.count()
assert client.get(endpoint).status_code == 405
response = client.post(endpoint)
assert response.status_code == 200
assert response['Cache-Control'] == 'private, no-store'
url = response.json()['url']
assert re.fullmatch(r'/p/[a-z0-9]{10}/', urlsplit(url).path)
assert client.post(endpoint).json()['url'] == url
assert PaymentAccessLink.objects.count() == 1
assert NotificationJob.objects.count() == jobs
recipient = Client()
page = recipient.get(urlsplit(url).path, follow=True)
assert page.status_code == 200
assert case['notice'].number in page.content.decode()
assert '_auth_user_id' not in recipient.session
assert recipient.post(endpoint).json()['url'] == url
def test_anonymous_share_reuses_original_expiry_and_revocation(case, client):
link, url = issue_payment_link(notice=case['notice'])
endpoint = reverse('payments:notice_share_link', args=[case['notice'].public_id])
client.get(urlsplit(url).path, follow=True)
before = link.expires_at
assert client.post(endpoint).json()['url'] == url
link.refresh_from_db()
assert link.expires_at == before
assert PaymentAccessLink.objects.count() == 1
link.revoked_at = timezone.now()
link.save(update_fields=['revoked_at'])
assert client.post(endpoint).status_code == 410
assert PaymentAccessLink.objects.count() == 1
def test_share_link_checks_owner_scope_and_csrf(case, client):
endpoint = reverse('payments:notice_share_link', args=[case['notice'].public_id])
other = User.objects.create_user(username='other-share-owner', account_type='CUSTOMER')
client.force_login(other)
assert client.post(endpoint).status_code == 404
client.force_login(case['creator'])
assert client.post(endpoint).status_code == 403
csrf_client = Client(enforce_csrf_checks=True)
csrf_client.force_login(case['customer'])
assert csrf_client.post(endpoint).status_code == 403
open_notice(client := Client(), case)
outside = services.issue_notice(case['creator'], {'customer': case['customer'], 'account': case['account'],
'subject': 'اعلامیه خارج از لینک اشتراک', 'amount': 100, 'deposit_id': '00044'})
assert client.post(reverse('payments:notice_share_link', args=[outside.public_id])).status_code == 404
@pytest.mark.parametrize('with_identifier', [False, True])
def test_full_copy_mentions_required_identifier_only_when_present(case, client, with_identifier):
from apps.payments.models import PaymentNotice
PaymentNotice.objects.filter(pk=case['notice'].pk).update(deposit_id='۰۰۰۱۲۳' if with_identifier else '')
client.force_login(case['customer'])
response = client.get(reverse('payments:notice_detail', args=[case['notice'].public_id]))
text = response.context['notice'].copy_text
assert ('وارد کردن شناسه واریز هنگام پرداخت الزامی و اجباری است' in text) == with_identifier
assert ('شناسه واریز: 123' in text) == with_identifier
assert 'شماره حساب: 000123456' in text
assert 'کپی لینک کوتاه این صفحه برای ارسال به دیگران' in response.content.decode()
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')
@@ -112,7 +177,7 @@ def test_public_notice_preserves_copy_warning_and_database_signature(case, clien
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 'data-copy="123"' 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
+27
View File
@@ -1,6 +1,7 @@
"""PostgreSQL-only regression for simultaneous notice deletion/receipt submission."""
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from uuid import uuid4
import pytest
from django.core.exceptions import ValidationError
@@ -9,12 +10,38 @@ from django.db import close_old_connections, connection
from apps.payments import services
from apps.payments.forms import ReceiptForm
from apps.payments.models import PaymentReceipt
from apps.payments.models import PaymentNotification
from apps.notifications.models import NotificationJob
from tests.test_payments import case, data, upload
pytestmark = [pytest.mark.django_db(transaction=True),
pytest.mark.skipif(connection.vendor != 'postgresql', reason='Requires PostgreSQL row locks')]
@pytest.mark.parametrize('same_key', [True, False])
def test_simultaneous_notice_sms_resend_creates_one_job(case, same_key):
original = PaymentNotification.objects.get(notice=case['notice'])
NotificationJob.objects.filter(pk=original.job_id).update(status='FAILED')
first_key = str(uuid4())
keys = [first_key, first_key if same_key else str(uuid4())]
barrier = Barrier(2)
def resend(key):
close_old_connections()
try:
barrier.wait(timeout=10)
try:
return services.resend_notice_sms(case['creator'], case['notice'], key).pk
except ValidationError:
return 'blocked'
finally:
connection.close()
with ThreadPoolExecutor(max_workers=2) as pool:
outcomes = list(pool.map(resend, keys))
assert PaymentNotification.objects.filter(notice=case['notice']).count() == 2
assert len(set(outcomes)) == (1 if same_key else 2)
assert outcomes.count('blocked') == (0 if same_key else 1)
def test_simultaneous_delete_and_receipt_allow_exactly_one_operation(case):
form = ReceiptForm(data(case), {'receipt_image': upload()}, customer=case['customer'])
assert form.is_valid(), form.errors
+269
View File
@@ -0,0 +1,269 @@
"""Receipt alert integration regressions; providers are always mocked."""
from datetime import timedelta
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
import requests
from django.core.exceptions import ValidationError
from django.core.management import call_command
from django.db import IntegrityError, transaction
from django.urls import reverse
from django.utils import timezone
from apps.core.templatetags.jalali import jalali_datetime
from apps.integrations.bale.providers import BaleBotProvider
from apps.integrations.sms.base import SMSDeliveryResult, SMSResult
from apps.notifications.management.commands import process_notifications as worker
from apps.notifications.models import NotificationJob
from apps.payments import receipt_alerts, services
from apps.payments.alert_templates import validate_alert_template
from apps.payments.forms import ReceiptAlertConfigurationForm, ReceiptForm
from apps.payments.models import (PaymentAuditEvent, PaymentReceipt, PaymentReceiptAlert,
PaymentReceiptAlertConfiguration, PaymentReceiptAlertRecipient)
from tests.test_payment_links import open_notice
from tests.test_payments import case, data, grant, submit, upload, PNG
pytestmark = pytest.mark.django_db
@pytest.fixture
def enabled():
config = PaymentReceiptAlertConfiguration.objects.create(bale_enabled=True, bale_token='private-payment-token',
bale_chat_id='receipt-group', sms_enabled=True)
for name, mobile, active in [('مدیر اول', '09121110001', True), ('مدیر دوم', '09121110002', True), ('خاموش', '09121110003', False)]:
PaymentReceiptAlertRecipient.objects.create(configuration=config, name=name, mobile=mobile, is_active=active)
return config
def run(job):
call_command('process_notifications', once=True, job_id=job.pk)
job.refresh_from_db()
return job
def test_initially_disabled_and_staff_uploads_do_not_notify(case, enabled, monkeypatch):
network = Mock(side_effect=AssertionError('Network must not run in registration'))
monkeypatch.setattr(requests, 'post', network)
enabled.bale_enabled = enabled.sms_enabled = False
enabled.save()
submit(case)
enabled.bale_enabled = enabled.sms_enabled = True
enabled.save()
form = ReceiptForm(data(case, linked=False), {'receipt_image': upload()}, customer=case['customer'])
assert form.is_valid(), form.errors
services.submit_receipt(case['creator'], case['customer'], form.cleaned_data)
assert not PaymentReceiptAlert.objects.exists()
network.assert_not_called()
@pytest.mark.parametrize('anonymous,linked', [(False, True), (False, False), (True, True), (True, False)])
def test_all_customer_entry_points_enqueue_once_without_network(case, enabled, client, monkeypatch, anonymous, linked):
network = Mock(side_effect=AssertionError('Network must not run in registration'))
monkeypatch.setattr(requests, 'post', network)
case['customer'].first_name = 'مشتری'
case['customer'].last_name = 'آزمایشی'
case['customer'].save()
if anonymous:
open_notice(client, case)
else:
client.force_login(case['customer'])
values = data(case, linked=linked)
endpoint = reverse('payments:receipt_new')
for _ in range(2):
response = client.post(endpoint, {**values, 'receipt_image': upload()}, HTTP_ACCEPT='application/json')
assert response.status_code == 200, response.content
receipt = PaymentReceipt.objects.get(customer=case['customer'])
alerts = list(PaymentReceiptAlert.objects.filter(revision__receipt=receipt))
assert len(alerts) == 3
assert {a.recipient_mobile for a in alerts} == {'', '+989121110001', '+989121110002'}
assert alerts[0].context['customer_name'] == 'مشتری آزمایشی'
assert alerts[0].context['customer_mobile'] == '09120009999'
assert alerts[0].context['received_at'] == jalali_datetime(receipt.revisions.get().created_at)
assert alerts[0].context['amount_rial'] == '12,345'
assert all(enabled.bale_token not in str(a.context) + a.message_template + str(a.job.payload) for a in alerts)
network.assert_not_called()
def test_customer_corrections_have_separate_idempotent_alerts_and_can_be_disabled(case, enabled):
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(), form.errors
services.revise_receipt(case['customer'], receipt.public_id, 1, form.cleaned_data)
assert PaymentReceiptAlert.objects.count() == 6
receipt_alerts.safe_queue_receipt_alerts(receipt.revisions.get(version=2), case['customer'])
assert PaymentReceiptAlert.objects.count() == 6
enabled.notify_revisions = False
enabled.save()
services.decide_receipt(case['first'], receipt.public_id, 2, 'REJECT', 'اصلاح لازم است')
form = ReceiptForm(data(case), {'receipt_image': upload()}, customer=case['customer'])
assert form.is_valid()
services.revise_receipt(case['customer'], receipt.public_id, 2, form.cleaned_data)
assert PaymentReceiptAlert.objects.count() == 6
def test_outer_rollback_removes_receipt_and_alerts(case, enabled):
with pytest.raises(RuntimeError), transaction.atomic():
submit(case)
raise RuntimeError('rollback')
assert not PaymentReceipt.objects.exists() and not PaymentReceiptAlert.objects.exists()
assert not NotificationJob.objects.filter(event_type=receipt_alerts.RECEIPT_ALERT_EVENT).exists()
def test_destination_database_failure_does_not_break_receipt_or_other_destinations(case, enabled, monkeypatch, caplog):
original = PaymentReceiptAlert.objects.get_or_create
def fail_group(**kwargs):
if kwargs['job'].channel == 'BALE':
raise IntegrityError('secret must not be logged')
return original(**kwargs)
monkeypatch.setattr(PaymentReceiptAlert.objects, 'get_or_create', fail_group)
receipt = submit(case)
assert receipt.revisions.count() == 1 and PaymentReceiptAlert.objects.count() == 2
assert not NotificationJob.objects.filter(event_type=receipt_alerts.RECEIPT_ALERT_EVENT, channel='BALE').exists()
assert 'IntegrityError' in caplog.text and 'secret must not be logged' not in caplog.text
def test_configuration_failure_isolated_from_registration(case, enabled, monkeypatch):
monkeypatch.setattr(receipt_alerts, 'queue_receipt_alerts', Mock(side_effect=IntegrityError('unavailable')))
receipt = submit(case)
assert PaymentReceipt.objects.filter(pk=receipt.pk).exists()
assert receipt.revisions.count() == 1
def test_worker_sends_private_photo_with_database_token_and_sms_to_managers(case, enabled, monkeypatch, settings):
settings.BALE_BOT_TOKEN = 'existing-ticket-token'
receipt = submit(case)
posted, sent = [], []
def post(url, **kwargs):
posted.append((url, kwargs['data'], kwargs['files']['photo'][1].read(), kwargs['timeout']))
return SimpleNamespace(ok=True, json=lambda: {'ok': True, 'result': {'message_id': 72}})
class Provider:
def send_message(self, mobile, body):
sent.append((mobile, body))
return SMSResult(True, 'manager-sms-id')
def get_delivery_status(self, provider_id):
return SMSDeliveryResult('DELIVERED', '0', 'رسیده به گوشی مدیر', True)
monkeypatch.setattr(requests, 'post', post)
monkeypatch.setattr(worker, 'get_sms_provider', lambda: Provider())
for alert in receipt.revisions.get().alerts.select_related('job'):
assert run(alert.job).status == 'SENT'
assert len(posted) == 1 and posted[0][0].endswith('/botprivate-payment-token/sendPhoto')
assert posted[0][1]['chat_id'] == 'receipt-group' and posted[0][2] == PNG
assert 'نام ثبت نشده' in posted[0][1]['caption'] and '09120009999' in posted[0][1]['caption']
assert jalali_datetime(receipt.revisions.get().created_at) in posted[0][1]['caption']
assert len(sent) == 2 and {mobile for mobile, _ in sent} == {'+989121110001', '+989121110002'}
assert all('12,345' in body and '09120009999' in body for _, body in sent)
sms = NotificationJob.objects.filter(event_type=receipt_alerts.RECEIPT_ALERT_EVENT, channel='SMS').first()
assert sms.delivery_status == 'PENDING'
NotificationJob.objects.filter(pk=sms.pk).update(delivery_next_check_at=timezone.now() - timedelta(seconds=1))
assert run(sms).delivery_status == 'DELIVERED'
assert len(sent) == 2
@pytest.mark.parametrize('channel', ['BALE', 'SMS'])
def test_outages_retry_bounded_and_preserve_receipt(case, enabled, monkeypatch, settings, channel):
settings.SMS_MAX_RETRIES = 1
settings.MAX_NOTIFICATION_ATTEMPTS = 2
receipt = submit(case)
monkeypatch.setattr(requests, 'post', Mock(side_effect=requests.Timeout('private-payment-token')))
monkeypatch.setattr(worker, 'get_sms_provider', lambda: SimpleNamespace(send_message=Mock(side_effect=RuntimeError('private-payment-token'))))
job = PaymentReceiptAlert.objects.filter(job__channel=channel).first().job
run(job)
assert job.status == ('RETRYING' if channel == 'SMS' else 'FAILED') and job.attempts == 1
assert 'private-payment-token' not in job.last_error
NotificationJob.objects.filter(pk=job.pk).update(next_attempt_at=timezone.now())
run(job)
assert job.status == 'FAILED' and job.attempts == 2
NotificationJob.objects.filter(pk=job.pk).update(next_attempt_at=timezone.now())
run(job)
assert job.attempts == 2 and receipt.revisions.get().image.storage.exists(receipt.revisions.get().image.name)
@pytest.mark.parametrize('change', ['bale_off', 'sms_off', 'group_changed', 'mobile_changed', 'manager_off'])
def test_pending_jobs_respect_switches_and_destination_changes(case, enabled, monkeypatch, change):
submit(case)
channel = 'BALE' if change in ['bale_off', 'group_changed'] else 'SMS'
alert = PaymentReceiptAlert.objects.filter(job__channel=channel).first()
if change == 'bale_off': enabled.bale_enabled = False
if change == 'sms_off': enabled.sms_enabled = False
if change == 'group_changed': enabled.bale_chat_id = 'other-group'
enabled.save()
if change in ['mobile_changed', 'manager_off']:
recipient = alert.recipient
if change == 'mobile_changed': recipient.mobile = '09124440000'
else: recipient.is_active = False
recipient.save()
post = Mock(side_effect=AssertionError('Cancelled destination'))
provider = Mock(side_effect=AssertionError('Cancelled destination'))
monkeypatch.setattr(requests, 'post', post)
monkeypatch.setattr(worker, 'get_sms_provider', provider)
assert run(alert.job).status == 'CANCELLED'
post.assert_not_called(); provider.assert_not_called()
@pytest.mark.parametrize('payload', [[], {'ok': False}, {'ok': True, 'result': {}}, {'ok': True, 'result': None}])
def test_malformed_bale_response_never_becomes_success(monkeypatch, payload):
monkeypatch.setattr(requests, 'post', lambda *a, **k: SimpleNamespace(ok=True, json=lambda: payload))
assert not BaleBotProvider().send_photo('group', upload(), token='secret').success
@pytest.mark.parametrize('template', ['{customer_name.__class__}', '{customer_name[0]}', '{amount_rial:99}', '{customer_name!r}', '{unknown}', '{}', '{broken', ''])
def test_templates_reject_unsafe_or_missing_tags(template):
with pytest.raises(ValidationError):
validate_alert_template(template)
def config_values(config, **extra):
return {'bale_enabled': config.bale_enabled, 'bale_token': '', 'bale_chat_id': config.bale_chat_id,
'bale_caption': config.bale_caption, 'sms_enabled': config.sms_enabled,
'sms_template': config.sms_template, 'notify_revisions': config.notify_revisions, **extra}
def test_configuration_form_keeps_hides_replaces_and_clears_token(enabled):
assert enabled.bale_token not in ReceiptAlertConfigurationForm(instance=enabled).as_p()
for value, expected in [('', enabled.bale_token), ('rotated-token', 'rotated-token')]:
form = ReceiptAlertConfigurationForm(config_values(enabled, bale_token=value), instance=enabled)
assert form.is_valid(), form.errors
assert form.save().bale_token == expected
form = ReceiptAlertConfigurationForm(config_values(enabled, clear_bale_token=True), instance=enabled)
assert not form.is_valid()
enabled.refresh_from_db()
form = ReceiptAlertConfigurationForm(config_values(enabled, bale_enabled=False, clear_bale_token=True), instance=enabled)
assert form.is_valid(), form.errors
assert form.save().bale_token == ''
def test_admin_can_manage_configuration_and_recipients_without_exposing_token(case, enabled, client):
user = case['creator']; user.is_staff = True; user.save()
grant(user, 'manage_receipt_alerts')
client.force_login(user)
endpoint = reverse('admin:payments_paymentreceiptalertconfiguration_change', args=[1])
page = client.get(endpoint)
assert page.status_code == 200 and enabled.bale_token not in page.content.decode()
recipients = list(enabled.recipients.all())
values = config_values(enabled, sms_template='مدیر {manager_name}: {amount_rial} ریال از {customer_mobile}')
values.update({'recipients-TOTAL_FORMS': len(recipients), 'recipients-INITIAL_FORMS': len(recipients),
'recipients-MIN_NUM_FORMS': 0, 'recipients-MAX_NUM_FORMS': 1000, '_save': 'Save'})
for i, recipient in enumerate(recipients):
values.update({f'recipients-{i}-id': recipient.pk, f'recipients-{i}-configuration': 1,
f'recipients-{i}-name': recipient.name, f'recipients-{i}-mobile': recipient.mobile})
if recipient.is_active: values[f'recipients-{i}-is_active'] = 'on'
response = client.post(endpoint, values)
assert response.status_code == 302, response.context['adminform'].form.errors if response.status_code == 200 else response.content
enabled.refresh_from_db()
assert enabled.bale_token == 'private-payment-token' and enabled.sms_template == values['sms_template']
audit = PaymentAuditEvent.objects.filter(action='CONFIG_SAVED').latest('pk')
assert enabled.bale_token not in str(audit.details)
submit(case)
assert client.get(reverse('admin:payments_paymentreceiptalert_changelist')).status_code == 200
assert client.get(reverse('admin:payments_paymentreceiptalert_change', args=[PaymentReceiptAlert.objects.first().pk])).status_code == 200
client.force_login(case['outsider'])
assert client.get(endpoint).status_code in [302, 403]
def test_recipient_invalid_mobile_is_validation_error_and_normalization_deduplicates(enabled):
with pytest.raises(ValidationError):
PaymentReceiptAlertRecipient.objects.create(configuration=enabled, name='نام', mobile='123')
with pytest.raises(IntegrityError), transaction.atomic():
PaymentReceiptAlertRecipient.objects.create(configuration=enabled, name='نام', mobile='۰۹۱۲۱۱۱۰۰۰۱')
+94 -5
View File
@@ -25,6 +25,51 @@ def upload():
return SimpleUploadedFile('receipt.png', PNG, content_type='image/png')
@pytest.mark.parametrize('staff', [False, True])
def test_receipt_upload_json_success_keeps_flash_and_deduplicates(case, client, staff):
client.force_login(case['creator'] if staff else case['customer'])
endpoint = reverse('staff_payments:receipt_new', args=[case['customer'].public_id]) if staff else reverse('payments:receipt_new')
values = data(case)
first = client.post(endpoint, {**values, 'receipt_image': upload()}, HTTP_ACCEPT='application/json')
assert first.status_code == 200
receipt = PaymentReceipt.objects.get(customer=case['customer'])
expected = reverse('staff_payments:receipt_detail' if staff else 'payments:receipt_detail', args=[receipt.public_id])
assert first.json() == {'redirect_url': expected}
repeated = client.post(endpoint, {**values, 'receipt_image': upload()}, HTTP_ACCEPT='application/json')
assert repeated.json() == first.json()
assert PaymentReceipt.objects.filter(customer=case['customer']).count() == 1
assert 'فیش ثبت شد و در انتظار بررسی است.' in client.get(expected).content.decode()
def test_receipt_upload_json_field_and_service_errors(case, client, monkeypatch):
client.force_login(case['customer'])
endpoint = reverse('payments:receipt_new')
invalid = client.post(endpoint, {**data(case), 'paid_amount': '0', 'receipt_image': upload()}, HTTP_ACCEPT='application/json')
assert invalid.status_code == 422
assert invalid.json()['errors']['paid_amount'][0]['message']
def unavailable(*args, **kwargs):
raise ValidationError('اعلامیه دیگر قابل پرداخت نیست.')
monkeypatch.setattr(services, 'submit_receipt', unavailable)
response = client.post(endpoint, {**data(case), 'receipt_image': upload()}, HTTP_ACCEPT='application/json')
assert response.status_code == 422
assert response.json()['errors']['__all__'][0]['message'] == 'اعلامیه دیگر قابل پرداخت نیست.'
assert not PaymentReceipt.objects.filter(customer=case['customer']).exists()
def test_receipt_revision_upload_json_errors_and_success(case, client):
receipt = submit(case)
services.decide_receipt(case['first'], receipt.public_id, 1, 'REJECT', 'تصویر خوانا نیست')
client.force_login(case['customer'])
endpoint = reverse('payments:receipt_revise', args=[receipt.public_id])
stale = client.post(endpoint, {**data(case), 'version': '0', 'receipt_image': upload()}, HTTP_ACCEPT='application/json')
assert stale.status_code == 422 and stale.json()['errors']['__all__']
valid = client.post(endpoint, {**data(case), 'version': '1', 'receipt_image': upload()}, HTTP_ACCEPT='application/json')
assert valid.json() == {'redirect_url': reverse('payments:receipt_detail', args=[receipt.public_id])}
receipt.refresh_from_db()
assert receipt.current_version == 2 and receipt.revisions.count() == 2
assert 'نسخه اصلاحی ثبت شد' in client.get(valid.json()['redirect_url']).content.decode()
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']:
@@ -234,7 +279,7 @@ def test_independent_receipt_and_server_date(case,client):
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'})
response = client.get(reverse('staff_payments:desk'),{'tab':'receipts','kind':'independent'})
assert receipt.number in response.content.decode()
assert client.get(reverse('staff_payments:count')).json()['pending_receipts'] == 1
@@ -414,7 +459,7 @@ def test_revision_retains_notice_snapshot_and_identifier(case):
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'
assert revision.deposit_id == '123'
def test_financial_unit_sms_template_accepts_financial_tags(case):
@@ -723,7 +768,7 @@ def test_linked_receipt_rejection_history_and_staff_identity(case, client):
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()
listing = client.get(reverse('staff_payments:desk'), {'tab': 'receipts', 'q': 'گلپایگان'}).content.decode()
assert 'مریم' in listing and 'ایجادکننده اعلامیه: creator' in listing
@@ -787,7 +832,7 @@ def test_customer_notice_has_independent_owned_detail_url(case, client):
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')])
@pytest.mark.parametrize('mobile,expected', [('09120009999','912000999938'), ('09120009021','912000902169'), ('09000000000','900000000090')])
def test_deposit_identifier_reference_vectors(mobile, expected):
from apps.payments.deposit_identifier import generate_id_algorithm
assert generate_id_algorithm(mobile) == expected
@@ -800,7 +845,7 @@ def test_deposit_identifier_endpoint_uses_stored_mobile_and_permissions(case, cl
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.json()['identifier'] == '912000999938'
assert response['Cache-Control'] == 'no-store'
assert case['customer'].payment_notices.count() == before
assert client.get(endpoint).status_code == 405
@@ -813,3 +858,47 @@ def test_deposit_identifier_endpoint_uses_stored_mobile_and_permissions(case, cl
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
@pytest.mark.parametrize('value,expected', [('', ''), ('000', '0'), ('۰۰۰۱۲۳۰۴', '12304'),
('٠٠٩١٢٣٠٦', '912306'), (' 00123 ', '123'), ('manual-123', 'manual-123')])
def test_deposit_identifier_normalization(value, expected):
from apps.payments.deposit_identifier import normalize_deposit_identifier
assert normalize_deposit_identifier(value) == expected
def test_generator_strips_zeroes_after_calculating_check_digits():
from apps.payments.deposit_identifier import generate_id_algorithm
assert generate_id_algorithm('00009120009999') == '912000999938'
assert generate_id_algorithm('000') == '0'
assert generate_id_algorithm('invalid').startswith('Error:')
def test_notice_and_receipt_store_normalized_identifiers(case):
notice = case['notice']
notice.refresh_from_db()
assert notice.deposit_id == '123'
assert notice.account_snapshot['account_number'] == '000123456'
receipt = submit(case)
assert receipt.revisions.get(version=1).deposit_id == '123'
notice.deposit_id = '٠٠١٢٣٠٤'
notice.save(update_fields=['deposit_id'])
notice.refresh_from_db()
assert notice.deposit_id == '12304'
def test_existing_identifier_migration_preserves_bank_snapshot(case):
from importlib import import_module
from django.apps import apps
from django.db import connection
from apps.payments.models import PaymentNotice, PaymentReceiptRevision
receipt = submit(case)
PaymentNotice.objects.filter(pk=case['notice'].pk).update(deposit_id='۰۰۰۱۲۳۰۴')
PaymentReceiptRevision.objects.filter(receipt=receipt).update(deposit_id='00012304')
migration = import_module('apps.payments.migrations.0006_normalize_deposit_identifiers')
from types import SimpleNamespace
migration.normalize_existing_identifiers(apps, SimpleNamespace(connection=connection))
case['notice'].refresh_from_db()
assert case['notice'].deposit_id == '12304'
assert receipt.revisions.get(version=1).deposit_id == '12304'
assert case['notice'].account_snapshot['account_number'] == '000123456'
+153
View File
@@ -0,0 +1,153 @@
import uuid
from urllib.parse import parse_qs, urlsplit
import pytest
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.management import call_command
from django.test import Client
from django.urls import reverse
from django.utils import timezone
from apps.integrations.sms.base import SMSDeliveryResult, SMSResult
from apps.notifications.models import NotificationJob
from apps.notifications.management.commands import process_notifications
from apps.payments import services
from apps.payments.models import PaymentAuditEvent, PaymentAccessConfiguration, PaymentNotification, PaymentNotice
from tests.test_payments import case, submit, grant
pytestmark = pytest.mark.django_db
def test_default_action_tab_and_explicit_all_receipts(case, client):
receipt = submit(case)
client.force_login(case['first'])
first = client.get(reverse('staff_payments:desk'))
assert first.context['mine'] is True and receipt.number in first.content.decode()
assert 'صفحه 1 از 1' in first.content.decode()
services.decide_receipt(case['first'], receipt.public_id, 1, 'APPROVE')
assert receipt.number not in client.get(reverse('staff_payments:desk')).content.decode()
assert receipt.number in client.get(reverse('staff_payments:desk'), {'tab': 'receipts'}).content.decode()
client.force_login(case['outsider'])
assert receipt.number not in client.get(reverse('staff_payments:desk')).content.decode()
assert client.get(reverse('staff_payments:desk'), {'mine': '0'}).context['mine'] is False
@pytest.mark.parametrize('tab', ['notices', 'receipts', 'mine'])
def test_desk_pagination_preserves_tab_and_filters(case, client, tab):
if tab == 'notices':
for index in range(26):
services.issue_notice(case['creator'], {'customer': case['customer'], 'account': case['account'],
'subject': f'صفحه بندی {index}', 'amount': 12345, 'deposit_id': '123'})
else:
for _ in range(26):
submit(case)
client.force_login(case['first'])
parameters = {'q': case['customer'].username, 'account': case['account'].pk}
if tab != 'mine':
parameters['tab'] = tab
first = client.get(reverse('staff_payments:desk'), parameters)
assert len(first.context['page_obj']) == 25
query = parse_qs(first.context['query_string'])
assert query['tab'] == ['notices' if tab == 'notices' else 'receipts']
assert query.get('mine') == (['1'] if tab == 'mine' else None)
assert query['q'] == [case['customer'].username]
second = client.get(reverse('staff_payments:desk'), {**parameters, 'page': 2})
assert second.context['page_obj'].number == 2 and len(second.context['page_obj']) >= 1
assert not {item.pk for item in first.context['page_obj']} & {item.pk for item in second.context['page_obj']}
def test_resend_uses_worker_keeps_attempts_and_delivery_history(case, client, monkeypatch, settings):
settings.SMS_MAX_RETRIES = 0
sent = []
class Provider:
def send_message(self, mobile, body):
sent.append((mobile, body))
return SMSResult(False, error='اعتبار پنل کافی نیست', provider_code='-19') if len(sent) == 1 else SMSResult(True, 'new-sms-id', provider_code='2')
def get_delivery_status(self, provider_id):
assert provider_id == 'new-sms-id'
return SMSDeliveryResult('DELIVERED', '0', 'پیامک به گوشی رسید', True)
monkeypatch.setattr(process_notifications, 'get_sms_provider', lambda: Provider())
original = PaymentNotification.objects.get(notice=case['notice'])
call_command('process_notifications', once=True, job_id=original.job_id)
original.refresh_from_db()
original.job.refresh_from_db()
assert original.job.status == 'FAILED'
endpoint = reverse('staff_payments:notice_resend_sms', args=[case['notice'].public_id])
detail = reverse('staff_payments:notice_detail', args=[case['notice'].public_id])
client.force_login(case['creator'])
key = str(uuid.uuid4())
assert client.get(endpoint).status_code == 405
assert client.post(endpoint, {'request_key': key}).status_code == 302
retry = PaymentNotification.objects.filter(notice=case['notice']).exclude(pk=original.pk).get()
assert retry.job.status == 'PENDING'
client.post(endpoint, {'request_key': key})
assert PaymentNotification.objects.filter(notice=case['notice']).count() == 2
client.post(endpoint, {'request_key': str(uuid.uuid4())})
assert PaymentNotification.objects.filter(notice=case['notice']).count() == 2
call_command('process_notifications', once=True, job_id=retry.job_id)
retry.refresh_from_db(); retry.job.refresh_from_db()
assert retry.job.status == 'SENT' and retry.job.delivery_status == 'PENDING'
assert original.access_link_id != retry.access_link_id
assert retry.context['payment_link'] in sent[-1][1]
awaiting = client.get(detail).content.decode()
assert 'پذیرفته‌شده توسط سرویس پیامک' in awaiting and 'در انتظار استعلام دریافت توسط گوشی' in awaiting
NotificationJob.objects.filter(pk=retry.job_id).update(delivery_next_check_at=timezone.now())
call_command('process_notifications', once=True, job_id=retry.job_id)
completed = client.get(detail).content.decode()
assert all(value in completed for value in ['رسیده به گوشی', 'پیامک به گوشی رسید', 'new-sms-id', 'اعتبار پنل کافی نیست', 'آخرین استعلام'])
assert original.attempt_log.count() == 1 and retry.attempt_log.count() == 1 and len(sent) == 2
assert PaymentAuditEvent.objects.filter(action='NOTICE_SMS_RESEND_REQUESTED').count() == 1
# Replaying the same form after completion must still not issue another message.
client.post(endpoint, {'request_key': key})
assert PaymentNotification.objects.filter(notice=case['notice']).count() == 2
@pytest.mark.parametrize('status,final,next_check,text', [
('UNDELIVERED', False, True, 'نتیجه غیرقطعی'),
('UNDELIVERED', True, False, 'نرسیده به گوشی'),
('UNKNOWN', False, False, 'دریافت توسط گوشی تأیید نشده است'),
('CHECK_FAILED', False, True, 'خطای موقت استعلام'),
('EXPIRED', True, False, 'منقضی‌شده'),
])
def test_sms_history_distinguishes_final_and_provisional_delivery(case, client, status, final, next_check, text):
notification = PaymentNotification.objects.get(notice=case['notice'])
NotificationJob.objects.filter(pk=notification.job_id).update(status='SENT', provider_message_id='tracked-id',
delivery_status=status, delivery_is_final=final, delivery_status_code='6',
delivery_next_check_at=timezone.now() if next_check else None)
client.force_login(case['creator'])
html = client.get(reverse('staff_payments:notice_detail', args=[case['notice'].public_id])).content.decode()
assert text in html
assert ('نتیجه غیرقطعی' in html) is not final
def test_sms_resend_permission_invalid_key_and_csrf(case, client):
notice = case['notice']
notification = PaymentNotification.objects.get(notice=notice)
NotificationJob.objects.filter(pk=notification.job_id).update(status='FAILED')
endpoint = reverse('staff_payments:notice_resend_sms', args=[notice.public_id])
secure = Client(enforce_csrf_checks=True)
secure.force_login(case['creator'])
assert secure.post(endpoint, {'request_key': str(uuid.uuid4())}).status_code == 403
client.force_login(case['creator'])
client.post(endpoint, {'request_key': 'invalid'})
assert PaymentNotification.objects.filter(notice=notice).count() == 1
PaymentAccessConfiguration.objects.update_or_create(pk=1, defaults={'open_create': False, 'open_view': False})
with pytest.raises(PermissionDenied):
services.resend_notice_sms(case['creator'], notice, str(uuid.uuid4()))
grant(case['outsider'], 'add_paymentnotice')
with pytest.raises(PermissionDenied):
services.resend_notice_sms(case['outsider'], notice, str(uuid.uuid4()))
with pytest.raises(PermissionDenied):
services.resend_notice_sms(case['customer'], notice, str(uuid.uuid4()))
@pytest.mark.parametrize('reason', ['cancelled', 'inactive', 'deleted'])
def test_invalid_notice_cannot_be_resent(case, reason):
notice = case['notice']
if reason == 'inactive':
case['customer'].is_active = False; case['customer'].save(update_fields=['is_active'])
else:
PaymentNotice.objects.filter(pk=notice.pk).update(**{'cancelled_at' if reason == 'cancelled' else 'deleted_at': timezone.now()})
with pytest.raises((ValidationError, PermissionDenied)):
services.resend_notice_sms(case['creator'], notice, str(uuid.uuid4()))
assert PaymentNotification.objects.filter(notice=notice).count() == 1