From bb3ddd1610a3cffddfd6bf1621237d9f4dd81899 Mon Sep 17 00:00:00 2001 From: javad Date: Wed, 7 Oct 2026 13:39:29 +0330 Subject: [PATCH] fix payment --- PROJECT_TREE.txt | 368 +++++++++++++++++- apps/integrations/bale/providers.py | 20 + .../commands/process_notifications.py | 10 + apps/payments/access.py | 19 + apps/payments/admin.py | 88 +++++ apps/payments/alert_templates.py | 22 ++ apps/payments/deposit_identifier.py | 8 +- apps/payments/forms.py | 23 ++ apps/payments/link_views.py | 2 + .../0006_normalize_deposit_identifiers.py | 17 + .../migrations/0007_receipt_alerts.py | 73 ++++ apps/payments/models.py | 91 +++++ apps/payments/notifications.py | 4 + apps/payments/receipt_alerts.py | 102 +++++ apps/payments/selectors.py | 28 ++ apps/payments/services.py | 45 ++- apps/payments/staff_urls.py | 1 + apps/payments/urls.py | 1 + apps/payments/views.py | 53 ++- docs/CHAHARSU_PAYMENTS_IMPLEMENTATION.md | 11 +- docs/PAYMENT_LINKS.md | 6 + docs/PAYMENT_RECEIPT_ALERTS.md | 41 ++ static/css/chaharsu.css | 2 + static/css/payment-link.css | 1 + static/js/chaharsu/deposit-identifier.js | 2 +- static/js/chaharsu/payments.js | 16 + static/js/chaharsu/receipt-form.js | 13 +- static/js/chaharsu/receipt-upload.js | 65 ++++ .../payments/_customer_receipt_form.html | 6 + templates/payments/_notice.html | 5 + templates/payments/_notice_sms.html | 39 ++ templates/payments/_pagination.html | 2 +- templates/payments/desk.html | 14 +- templates/payments/notice_detail.html | 2 +- templates/payments/receipt_form.html | 2 +- tests/e2e/pages/payment_link.py | 25 ++ tests/e2e/pages/receipt_upload.py | 79 ++++ tests/e2e/pages/staff_payments.py | 44 +++ tests/e2e/test_payment_links_e2e.py | 38 +- tests/e2e/test_payments_e2e.py | 8 +- tests/e2e/test_receipt_upload_e2e.py | 101 +++++ tests/e2e/test_staff_payment_sms_e2e.py | 76 ++++ tests/test_payment_links.py | 67 +++- tests/test_payment_notice_concurrency.py | 27 ++ tests/test_payment_receipt_alerts.py | 269 +++++++++++++ tests/test_payments.py | 99 ++++- tests/test_staff_payment_sms.py | 153 ++++++++ 47 files changed, 2137 insertions(+), 51 deletions(-) create mode 100644 apps/payments/alert_templates.py create mode 100644 apps/payments/migrations/0006_normalize_deposit_identifiers.py create mode 100644 apps/payments/migrations/0007_receipt_alerts.py create mode 100644 apps/payments/receipt_alerts.py create mode 100644 docs/PAYMENT_RECEIPT_ALERTS.md create mode 100644 static/js/chaharsu/receipt-upload.js create mode 100644 templates/payments/_notice_sms.html create mode 100644 tests/e2e/pages/receipt_upload.py create mode 100644 tests/e2e/pages/staff_payments.py create mode 100644 tests/e2e/test_receipt_upload_e2e.py create mode 100644 tests/e2e/test_staff_payment_sms_e2e.py create mode 100644 tests/test_payment_receipt_alerts.py create mode 100644 tests/test_staff_payment_sms.py diff --git a/PROJECT_TREE.txt b/PROJECT_TREE.txt index 16b9bc0..729f8c2 100644 --- a/PROJECT_TREE.txt +++ b/PROJECT_TREE.txt @@ -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 diff --git a/apps/integrations/bale/providers.py b/apps/integrations/bale/providers.py index 9fcd7c7..d093e55 100644 --- a/apps/integrations/bale/providers.py +++ b/apps/integrations/bale/providers.py @@ -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) diff --git a/apps/notifications/management/commands/process_notifications.py b/apps/notifications/management/commands/process_notifications.py index 748d467..3654744 100644 --- a/apps/notifications/management/commands/process_notifications.py +++ b/apps/notifications/management/commands/process_notifications.py @@ -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): diff --git a/apps/payments/access.py b/apps/payments/access.py index 9ae493b..a545273 100644 --- a/apps/payments/access.py +++ b/apps/payments/access.py @@ -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, diff --git a/apps/payments/admin.py b/apps/payments/admin.py index 33c6d2a..a50417e 100644 --- a/apps/payments/admin.py +++ b/apps/payments/admin.py @@ -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 diff --git a/apps/payments/alert_templates.py b/apps/payments/alert_templates.py new file mode 100644 index 0000000..51ddfc2 --- /dev/null +++ b/apps/payments/alert_templates.py @@ -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('قالب معتبر نیست؛ فقط تگ‌های معرفی‌شده و بدون قالب‌بندی اضافی مجازند.') diff --git a/apps/payments/deposit_identifier.py b/apps/payments/deposit_identifier.py index fc50762..3e62882 100644 --- a/apps/payments/deposit_identifier.py +++ b/apps/payments/deposit_identifier.py @@ -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}") diff --git a/apps/payments/forms.py b/apps/payments/forms.py index 374e1e3..8994629 100644 --- a/apps/payments/forms.py +++ b/apps/payments/forms.py @@ -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) diff --git a/apps/payments/link_views.py b/apps/payments/link_views.py index e2a30db..5f141e0 100644 --- a/apps/payments/link_views.py +++ b/apps/payments/link_views.py @@ -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, { diff --git a/apps/payments/migrations/0006_normalize_deposit_identifiers.py b/apps/payments/migrations/0006_normalize_deposit_identifiers.py new file mode 100644 index 0000000..8e7e05d --- /dev/null +++ b/apps/payments/migrations/0006_normalize_deposit_identifiers.py @@ -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)] diff --git a/apps/payments/migrations/0007_receipt_alerts.py b/apps/payments/migrations/0007_receipt_alerts.py new file mode 100644 index 0000000..6114feb --- /dev/null +++ b/apps/payments/migrations/0007_receipt_alerts.py @@ -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'), + ), + ] diff --git a/apps/payments/models.py b/apps/payments/models.py index 2b7a070..826ddc4 100644 --- a/apps/payments/models.py +++ b/apps/payments/models.py @@ -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 = 'سوابق اعلان فیش مشتری' diff --git a/apps/payments/notifications.py b/apps/payments/notifications.py index eb46093..8c3d6d7 100644 --- a/apps/payments/notifications.py +++ b/apps/payments/notifications.py @@ -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, diff --git a/apps/payments/receipt_alerts.py b/apps/payments/receipt_alerts.py new file mode 100644 index 0000000..2516bce --- /dev/null +++ b/apps/payments/receipt_alerts.py @@ -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__})') diff --git a/apps/payments/selectors.py b/apps/payments/selectors.py index 9ebe03c..9615345 100644 --- a/apps/payments/selectors.py +++ b/apps/payments/selectors.py @@ -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', diff --git a/apps/payments/services.py b/apps/payments/services.py index ee386a6..534cd8d 100644 --- a/apps/payments/services.py +++ b/apps/payments/services.py @@ -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: diff --git a/apps/payments/staff_urls.py b/apps/payments/staff_urls.py index d75b232..c792a60 100644 --- a/apps/payments/staff_urls.py +++ b/apps/payments/staff_urls.py @@ -9,6 +9,7 @@ urlpatterns = [ path('notices/new/', views.notice_new, name='notice_new'), path('notices//', views.notice_detail, name='notice_detail'), path('notices//delete/', views.notice_delete, name='notice_delete'), + path('notices//resend-sms/', views.notice_resend_sms, name='notice_resend_sms'), path('notices//payment-link/', views.notice_payment_link, name='notice_payment_link'), path('receipts/new//', views.staff_receipt_new, name='receipt_new'), path('receipts/new/', views.staff_receipt_customer, name='receipt_customer'), diff --git a/apps/payments/urls.py b/apps/payments/urls.py index c06447e..711fbc5 100644 --- a/apps/payments/urls.py +++ b/apps/payments/urls.py @@ -6,6 +6,7 @@ urlpatterns = [ path('', views.home, name='home'), path('bank-accounts/', views.notices, name='notices'), path('bank-accounts//', views.customer_notice_detail, name='notice_detail'), + path('bank-accounts//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//', views.receipt_detail, name='receipt_detail'), diff --git a/apps/payments/views.py b/apps/payments/views.py index 7656f8f..6bfab9d 100644 --- a/apps/payments/views.py +++ b/apps/payments/views.py @@ -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)] diff --git a/docs/CHAHARSU_PAYMENTS_IMPLEMENTATION.md b/docs/CHAHARSU_PAYMENTS_IMPLEMENTATION.md index dc2a416..77ac93f 100644 --- a/docs/CHAHARSU_PAYMENTS_IMPLEMENTATION.md +++ b/docs/CHAHARSU_PAYMENTS_IMPLEMENTATION.md @@ -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//resend-sms/` یک job و `PaymentNotification` مستقل می‌سازد؛ ارسال‌های قبلی و نتیجه دریافت آن‌ها بازنویسی نمی‌شوند. کلید UUID فرم درخواست تکراری را یکسان می‌کند و قفل مشتری سپس اعلامیه، درخواست‌های همزمان را هماهنگ می‌کند. تا پایان job در صف/پردازش/تلاش خودکار، درخواست جدید ثبت نمی‌شود. مجوز ایجاد در دامنه واحد و دسترسی به اعلامیه، فعال بودن مشتری و لغو/حذف نشدن اعلامیه بررسی می‌شوند؛ رویداد `NOTICE_SMS_RESEND_REQUESTED` ممیزی می‌شود. worker همان handler و قالب‌های فعلی را استفاده می‌کند و برای هر ارسال مجدد لینک پرداخت تازه می‌سازد. + +سوابق، زمان درخواست و ارسال، درخواست‌کننده، متن آماده‌شده، تمام تلاش‌های ارسال با خطا و شناسه و کد provider، وضعیت دریافت، شرح/کد و زمان آخرین استعلام و استعلام بعدی را نمایش می‌دهند. `SENT` فقط پذیرش سرویس است؛ رسیدن به گوشی نیاز به نتیجه قطعی دلیوری دارد. پاسخ‌های غیرقطعی یا پایان پایش بدون تأیید دریافت، صریحاً غیرقطعی نمایش داده می‌شوند. سوابق با تازه‌سازی صفحه به‌روز می‌شوند. + +میز پرداخت کارشناسان به‌طور پیش‌فرض فیش‌های «نیازمند اقدام من» را نمایش می‌دهد؛ `?tab=receipts` همه فیش‌های مجاز و `?tab=notices` اعلامیه‌ها را انتخاب می‌کند. صفحه‌بندی ۲۵تایی با شمارنده صفحه و محدوده نتایج، تب و فیلترها را نگه می‌دارد. در آزمون‌ها provider mock شده است؛ پیامک واقعی برای مشتری ارسال نشده و worker روی صف واقعی توسعه اجرا نشده است. diff --git a/docs/PAYMENT_LINKS.md b/docs/PAYMENT_LINKS.md index f55b523..322abef 100644 --- a/docs/PAYMENT_LINKS.md +++ b/docs/PAYMENT_LINKS.md @@ -23,6 +23,12 @@ شماره حساب، شبا و سایر اطلاعات از snapshot اعلامیه نمایش داده می‌شوند. شناسه واریز و تذکر آن قرمز است. امضای مسیر بدون ورود از `SiteBranding.developer_credit_text` خوانده می‌شود. +## کپی اطلاعات و اشتراک صفحه + +شناسه واریز در تولید، ذخیره، نمایش و متن کپی بدون صفرهای ابتدایی است؛ رقم کنترل و صفرهای داخلی حفظ می‌شوند. در صورت وجود شناسه، متن کپی کامل صریحاً می‌گوید وارد کردن شناسه واریز هنگام پرداخت الزامی و اجباری است. + +زیر دکمه کپی کامل در جزئیات واریز، «کپی لینک کوتاه این صفحه برای ارسال به دیگران» قرار دارد. درخواست POST با CSRF و کنترل مالکیت/محدوده اجرا می‌شود. مشتری واردشده می‌تواند لینک موقت همین اعلامیه را بسازد؛ پیامکی ارسال نمی‌شود. بازدیدکننده ناشناس فقط لینک معتبر دریافت‌شده را کپی می‌کند؛ انقضا یا محدوده دسترسی تمدید نمی‌شود. برای کپی پس از redirect، توکن در نشست سمت سرور نگهداری می‌شود؛ مدل لینک همچنان فقط هش توکن را ذخیره می‌کند. نشست‌های قدیمی فاقد توکن باید لینک دریافتی را دوباره باز کنند. Clipboard ناموجود، کپی دستی قابل انتخاب را نمایش می‌دهد. + ## تست دائمی از WSL و در ریشه پروژه اجرا کنید: diff --git a/docs/PAYMENT_RECEIPT_ALERTS.md b/docs/PAYMENT_RECEIPT_ALERTS.md new file mode 100644 index 0000000..ad31764 --- /dev/null +++ b/docs/PAYMENT_RECEIPT_ALERTS.md @@ -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 می‌کنند و به گروه یا موبایل واقعی پیام نمی‌فرستند. diff --git a/static/css/chaharsu.css b/static/css/chaharsu.css index 663655b..e76f714 100644 --- a/static/css/chaharsu.css +++ b/static/css/chaharsu.css @@ -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} diff --git a/static/css/payment-link.css b/static/css/payment-link.css index 1612951..227a7c7 100644 --- a/static/css/payment-link.css +++ b/static/css/payment-link.css @@ -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} diff --git a/static/js/chaharsu/deposit-identifier.js b/static/js/chaharsu/deposit-identifier.js index f928f1f..0186131 100644 --- a/static/js/chaharsu/deposit-identifier.js +++ b/static/js/chaharsu/deposit-identifier.js @@ -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 = 'شناسه آماده است؛ برای جایگزینی مقدار فعلی، دکمه جایگزینی را بزنید.'; diff --git a/static/js/chaharsu/payments.js b/static/js/chaharsu/payments.js index f51d452..2991215 100644 --- a/static/js/chaharsu/payments.js +++ b/static/js/chaharsu/payments.js @@ -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]'); diff --git a/static/js/chaharsu/receipt-form.js b/static/js/chaharsu/receipt-form.js index 019b851..f785bb6 100644 --- a/static/js/chaharsu/receipt-form.js +++ b/static/js/chaharsu/receipt-form.js @@ -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'});}} })(); diff --git a/static/js/chaharsu/receipt-upload.js b/static/js/chaharsu/receipt-upload.js new file mode 100644 index 0000000..e58ec3c --- /dev/null +++ b/static/js/chaharsu/receipt-upload.js @@ -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}); +})(); diff --git a/templates/payments/_customer_receipt_form.html b/templates/payments/_customer_receipt_form.html index f6baff8..469af20 100644 --- a/templates/payments/_customer_receipt_form.html +++ b/templates/payments/_customer_receipt_form.html @@ -29,6 +29,12 @@
{% include 'payments/_icon.html' with name='upload' %}{{ form.receipt_image }}

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

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

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

+
{% if staff_form %}انصراف و بازگشت{% elif payment_link_access.notice_id %}مشاهده اعلامیه واریز{% elif not payment_link_access %}مشاهده واریزهای اعلام‌شده {% include 'payments/_icon.html' with name='arrow' %}{% endif %}
diff --git a/templates/payments/_notice.html b/templates/payments/_notice.html index 3af4395..447a872 100644 --- a/templates/payments/_notice.html +++ b/templates/payments/_notice.html @@ -14,6 +14,11 @@

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

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

{% if notice.invoice_number %}

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

{% endif %}

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

{% if not notice.cancelled_at and not staff_notice and notice.remaining_amount > 0 %}ارسال فیش این واریز{% endif %}
+{% if notice_page and not staff_notice %}
+{% csrf_token %} +

دارندهٔ لینک به اطلاعات همین واریز دسترسی دارد؛ لینک تا پایان اعتبار آن قابل استفاده است.

+

+
{% endif %} {% if not staff_notice %}{% include 'payments/_linked_receipts.html' %}{% endif %} {% if not staff_notice and not notice_page %}جزئیات واریز و فیش‌ها {% include 'payments/_icon.html' with name='arrow' %}{% endif %} diff --git a/templates/payments/_notice_sms.html b/templates/payments/_notice_sms.html new file mode 100644 index 0000000..52d32f4 --- /dev/null +++ b/templates/payments/_notice_sms.html @@ -0,0 +1,39 @@ +{% load jalali %} +
+

پیامک اعلامیه و سوابق ارسال

+

پذیرش پیامک توسط سرویس به معنی رسیدن به گوشی نیست؛ وضعیت دریافت بر اساس استعلام سرویس پیامک نمایش داده می‌شود.

+ {% if can_resend_notice_sms %} +
+ {% csrf_token %} + +
+ {% if notice_sms_pending %}

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

{% endif %} + {% endif %} +
+ {% for notification in notifications %} +
+
{% if notification.manual_resend %}ارسال مجدد{% else %}ارسال اولیه{% endif %}
+

وضعیت ارسال: {% if notification.job.status == 'SENT' %}پذیرفته‌شده توسط سرویس پیامک{% else %}{{ notification.job.get_status_display }}{% endif %}

+

وضعیت دریافت: {{ notification.delivery_label }}

+ {% if notification.job.staff_user %}

درخواست‌کننده: {{ notification.job.staff_user.staff_profile.display_name|default:notification.job.staff_user.get_full_name|default:notification.job.staff_user.username }}

{% endif %} + {% if notification.job.sent_at %}

زمان ارسال: {{ notification.job.sent_at|jalali_datetime }}

{% endif %} + {% if notification.job.status == 'RETRYING' %}

تلاش خودکار بعدی: {{ notification.job.next_attempt_at|jalali_datetime }}

{% endif %} + {% if notification.job.last_error %}

علت خطای ارسال: {{ notification.job.last_error }}

{% endif %} + {% if notification.job.delivery_checked_at %}

آخرین استعلام: {{ notification.job.delivery_checked_at|jalali_datetime }} · {{ notification.job.delivery_check_attempts }} نوبت

{% endif %} + {% if notification.job.delivery_next_check_at %}

استعلام بعدی: {{ notification.job.delivery_next_check_at|jalali_datetime }}

{% endif %} + {% if notification.job.delivery_description %}

شرح نتیجه دریافت: {{ notification.job.delivery_description }}

{% endif %} + {% if notification.job.delivery_status_code %}

کد وضعیت دریافت: {{ notification.job.delivery_status_code }}

{% endif %} +
جزئیات پیام و تلاش‌های ارسال ({{ notification.send_attempts|length }}) + {% if notification.rendered_body %}
{{ notification.rendered_body|linebreaksbr }}
{% endif %} + {% for attempt in notification.send_attempts %}
+

· {% if attempt.success == True %}پذیرفته‌شده توسط سرویس{% elif attempt.success == False %}تلاش ناموفق{% else %}نتیجه تلاش هنوز ثبت نشده است{% endif %}

+ {% if attempt.finished_at %}

پایان تلاش: {{ attempt.finished_at|jalali_datetime }}

{% endif %} + {% if attempt.provider_id %}

شناسه رهگیری: {{ attempt.provider_id }}

{% endif %} + {% if attempt.provider_code %}

کد پاسخ سرویس: {{ attempt.provider_code }}

{% endif %} + {% if attempt.error %}

{{ attempt.error }}

{% endif %} + {% empty %}

هنوز تلاشی برای ارسال ثبت نشده است.

{% endfor %} +
+
+ {% empty %}

اطلاع‌رسانی ثبت‌شده‌ای وجود ندارد.

{% endfor %} +
+
diff --git a/templates/payments/_pagination.html b/templates/payments/_pagination.html index c40126f..5a9f337 100644 --- a/templates/payments/_pagination.html +++ b/templates/payments/_pagination.html @@ -1 +1 @@ -{% if page_obj.paginator.num_pages > 1 %}{% endif %} +{% if always or page_obj.paginator.num_pages > 1 %}{% endif %} diff --git a/templates/payments/desk.html b/templates/payments/desk.html index cff1edf..cba6e70 100644 --- a/templates/payments/desk.html +++ b/templates/payments/desk.html @@ -8,13 +8,13 @@ {% if can_create %}{% endif %}
- {% if request.GET.mine %}{% endif %} + {% if mine %}{% endif %}
@@ -30,11 +30,11 @@
- پاک کردن فیلترها + پاک کردن فیلترها
-

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

{{ page_obj.paginator.count }} مورد
+

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

{{ page_obj.paginator.count }} مورد
{% for item in page_obj %}
{% include 'payments/_icon.html' with name='receipt' %} @@ -51,9 +51,9 @@
{% empty %} -
{% include 'payments/_icon.html' with name='search' %}

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

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

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

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

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

{% endfor %}
- {% include 'payments/_pagination.html' %} + {% include 'payments/_pagination.html' with always=True %} {% endblock %} diff --git a/templates/payments/notice_detail.html b/templates/payments/notice_detail.html index cac8ffd..56ac33c 100644 --- a/templates/payments/notice_detail.html +++ b/templates/payments/notice_detail.html @@ -10,7 +10,7 @@ diff --git a/templates/payments/receipt_form.html b/templates/payments/receipt_form.html index 60f9f0d..372f03e 100644 --- a/templates/payments/receipt_form.html +++ b/templates/payments/receipt_form.html @@ -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 }}{% endblock %} +{% block extra_scripts %}{{ block.super }}{% endblock %} diff --git a/tests/e2e/pages/payment_link.py b/tests/e2e/pages/payment_link.py index 579a7b8..edae63d 100644 --- a/tests/e2e/pages/payment_link.py +++ b/tests/e2e/pages/payment_link.py @@ -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() diff --git a/tests/e2e/pages/receipt_upload.py b/tests/e2e/pages/receipt_upload.py new file mode 100644 index 0000000..f45bbde --- /dev/null +++ b/tests/e2e/pages/receipt_upload.py @@ -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 'فیش ثبت شد و در انتظار بررسی است.') diff --git a/tests/e2e/pages/staff_payments.py b/tests/e2e/pages/staff_payments.py new file mode 100644 index 0000000..15fd819 --- /dev/null +++ b/tests/e2e/pages/staff_payments.py @@ -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) diff --git a/tests/e2e/test_payment_links_e2e.py b/tests/e2e/test_payment_links_e2e.py index f769083..54f7365 100644 --- a/tests/e2e/test_payment_links_e2e.py +++ b/tests/e2e/test_payment_links_e2e.py @@ -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) diff --git a/tests/e2e/test_payments_e2e.py b/tests/e2e/test_payments_e2e.py index eea9293..6b5a84e 100644 --- a/tests/e2e/test_payments_e2e.py +++ b/tests/e2e/test_payments_e2e.py @@ -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() diff --git a/tests/e2e/test_receipt_upload_e2e.py b/tests/e2e/test_receipt_upload_e2e.py new file mode 100644 index 0000000..d19d51f --- /dev/null +++ b/tests/e2e/test_receipt_upload_e2e.py @@ -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 diff --git a/tests/e2e/test_staff_payment_sms_e2e.py b/tests/e2e/test_staff_payment_sms_e2e.py new file mode 100644 index 0000000..b2faffe --- /dev/null +++ b/tests/e2e/test_staff_payment_sms_e2e.py @@ -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 diff --git a/tests/test_payment_links.py b/tests/test_payment_links.py index cae990a..5f6713b 100644 --- a/tests/test_payment_links.py +++ b/tests/test_payment_links.py @@ -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 diff --git a/tests/test_payment_notice_concurrency.py b/tests/test_payment_notice_concurrency.py index 237a3b2..3e553fc 100644 --- a/tests/test_payment_notice_concurrency.py +++ b/tests/test_payment_notice_concurrency.py @@ -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 diff --git a/tests/test_payment_receipt_alerts.py b/tests/test_payment_receipt_alerts.py new file mode 100644 index 0000000..8f33004 --- /dev/null +++ b/tests/test_payment_receipt_alerts.py @@ -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='۰۹۱۲۱۱۱۰۰۰۱') diff --git a/tests/test_payments.py b/tests/test_payments.py index 4250b7a..304d87f 100644 --- a/tests/test_payments.py +++ b/tests/test_payments.py @@ -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' diff --git a/tests/test_staff_payment_sms.py b/tests/test_staff_payment_sms.py new file mode 100644 index 0000000..4e10f7f --- /dev/null +++ b/tests/test_staff_payment_sms.py @@ -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