Compare commits
677 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a61fb10784 | |||
| 8d83881cf0 | |||
| d624df5f65 | |||
| a96a7de784 | |||
| fa188e04c5 | |||
| e8c95ece8d | |||
| bc0f4adb7a | |||
| 3eb5a71e8d | |||
| 37c97de5d6 | |||
| 3624832ed8 | |||
| 9ee39d5f71 | |||
| 3a0e558b6f | |||
| ca083c216e | |||
| 8b9c1d1cc6 | |||
| 78830a7884 | |||
| 9fe7d0592f | |||
| 6a3e3c6fc4 | |||
| 54bfff0ed6 | |||
| 6ee305cbba | |||
| 340041ddae | |||
| 8a3628a6c5 | |||
| 8ac3350ac6 | |||
| a507ef9966 | |||
| 113405ed71 | |||
| 1bdf1a0f4c | |||
| ff7883e33c | |||
| 9bd909c1b6 | |||
| 408b4e8a4d | |||
| 0ee9708b4d | |||
| 44fadf6709 | |||
| 7384763466 | |||
| 9b6f3b10c6 | |||
| 0e4007bde6 | |||
| 9fcffc7df1 | |||
| 8e1a88c5cd | |||
| 7bb21c9f77 | |||
| 7333379fcf | |||
| fc040007b4 | |||
| b30a3c7961 | |||
| 62e18156f5 | |||
| 853666af6f | |||
| 3416f7b56c | |||
| d78d82c6ce | |||
| 47bd1be714 | |||
| 1eec919b88 | |||
| 777ad52bad | |||
| 5fcadb5544 | |||
| 0a8af20b58 | |||
| 95163a0e35 | |||
| ac58466c1a | |||
| 5ce14dcd9b | |||
| 21d700e16d | |||
| 5f2d4a5e04 | |||
| 7b90d73007 | |||
| 68c90aec44 | |||
| 9280c9a39a | |||
| 245e31e262 | |||
| c00d7ef4d3 | |||
| 2a652c562b | |||
| 1cc31729eb | |||
| 34e5c20c1a | |||
| c8545fe725 | |||
| a8339d376d | |||
| 47150ccbd0 | |||
| 0a0890d8bb | |||
| 6bcf24e3d8 | |||
| 898008a1bd | |||
| 09af2e1d41 | |||
| 045d44f451 | |||
| 04b2325cb9 | |||
| 504581b0f8 | |||
| 0572f414d4 | |||
| 50736dc005 | |||
| fe4bdb6a04 | |||
| 7d7c5f3972 | |||
| 131a2d18cc | |||
| 47296c5c08 | |||
| c15b7a63fb | |||
| e476afe208 | |||
| b59d422fb0 | |||
| 8b105c2396 | |||
| 9b6e0b26c3 | |||
| 4ca038e186 | |||
| 3e185e7ffe | |||
| 5e27dcdcfb | |||
| 8730a68b40 | |||
| d88f13784a | |||
| 6e471c9094 | |||
| 3201b60ff2 | |||
| d30d1e2a29 | |||
| bd73ad069f | |||
| 2186780c32 | |||
| fbb2e561c5 | |||
| c8aba42d3d | |||
| 05d495e874 | |||
| 427011fe41 | |||
| 2feacccb13 | |||
| dc196fb478 | |||
| 71c219e6ea | |||
| e250812463 | |||
| 477b0b26c4 | |||
| 06323f7168 | |||
| 6b471caa22 | |||
| 6fb28dd363 | |||
| 22dcff47d1 | |||
| 1d9b5ea3c6 | |||
| 08e0b3a657 | |||
| 10b80981b2 | |||
| 348f02f34c | |||
| 9e9e984f36 | |||
| d47ca550ae | |||
| 16f4285948 | |||
| 23cb6dd8d0 | |||
| dfbb6b0058 | |||
| 62f8fdd9bb | |||
| fc114d15a0 | |||
| a9fb8a069d | |||
| 9c6f738382 | |||
| 1a7b2eb908 | |||
| b108cc6b60 | |||
| 92cb1e3971 | |||
| 005fc95433 | |||
| f09771bc1c | |||
| d5b72198d7 | |||
| 991bf1161c | |||
| 1a955d920a | |||
| d575c914a6 | |||
| 90a054e942 | |||
| aac5fabbca | |||
| 3ce5d1650b | |||
| 24879b9cf2 | |||
| 37f27df062 | |||
| b78ed6797a | |||
| f73dcd6f66 | |||
| 0634d01336 | |||
| b43d6a83ee | |||
| 93144eea0e | |||
| 7d078a58e1 | |||
| 224c346bc0 | |||
| 51e937ae0c | |||
| 04073815ca | |||
| ff556ba347 | |||
| 2d751c238c | |||
| 5d27fa2963 | |||
| fe71935d58 | |||
| d0d1b7617a | |||
| 310990ae32 | |||
| b565c32e77 | |||
| f3edb4344b | |||
| ccdcff6a7b | |||
| 1d4090ea7a | |||
| 68c7d35165 | |||
| 221cc05af3 | |||
| 1524970401 | |||
| 32a03c5e4b | |||
| a03036c099 | |||
| 811e8a97b3 | |||
| c7625643c6 | |||
| 5ce87238c8 | |||
| 7d12a327ca | |||
| 8c93637d79 | |||
| 2d0ea02b4b | |||
| 98692ac3bd | |||
| 940b6b93b2 | |||
| 17768c303f | |||
| 441fcd2a36 | |||
| c2b6b4e370 | |||
| 2f8d27b1ec | |||
| dc32f53585 | |||
| 165691412b | |||
| 824d4b3a23 | |||
| 060f0ca199 | |||
| ac43f17a43 | |||
| 54fc1c029c | |||
| d259bcf169 | |||
| da46c18210 | |||
| ace7121a4e | |||
| 61a93f86b3 | |||
| 49bcd18a9a | |||
| 7df64d8edf | |||
| ca63b4d758 | |||
| 3cbb304e9b | |||
| ef5192234a | |||
| f7bb9ac4d9 | |||
| b8bb4f3c3f | |||
| e43ff27639 | |||
| e02123c1ed | |||
| f26df389b4 | |||
| 10d36a829d | |||
| 61e936dd49 | |||
| d0f9a04d5f | |||
| cf6bbffd9b | |||
| 5ad45addfa | |||
| 2af013b75b | |||
| 74bda5c986 | |||
| b290ccfff2 | |||
| 904d2883e8 | |||
| 1a54beed17 | |||
| 701a8d33af | |||
| aa03384012 | |||
| d346c63184 | |||
| b8005aaad7 | |||
| bfa9490425 | |||
| 1f4af21a07 | |||
| f952c0e38d | |||
| d7dab5d30f | |||
| 8407f7cdb1 | |||
| 6c72987201 | |||
| 81b6d3f18f | |||
| 95754ab224 | |||
| 949d964a7f | |||
| 8eab79434a | |||
| 8ab2351995 | |||
| e8802dde55 | |||
| acc5ace234 | |||
| 7b33721305 | |||
| e94f4598b5 | |||
| c66c8b7933 | |||
| 0b934efa09 | |||
| 79e644f6dd | |||
| 5c68a1aca7 | |||
| bde613c4bf | |||
| 184d8c3ba7 | |||
| d48221d57f | |||
| 657fd6d07d | |||
| 1211cd22ad | |||
| 19ed88009d | |||
| 0f30ee87ab | |||
| 4cc6778e45 | |||
| ab00a0d136 | |||
| 3d4f1af9a9 | |||
| 0302176fd2 | |||
| 8910780332 | |||
| 8bea64179a | |||
| 777f90eab1 | |||
| ad0b0f475a | |||
| 499292eab6 | |||
| 21b2f1bd2a | |||
| 3c025b536e | |||
| bded6fe59f | |||
| 344ad8f3c0 | |||
| b43dd3167f | |||
| 79ed9dbab2 | |||
| 38ff59f71f | |||
| 65c85e146d | |||
| 467955e7b3 | |||
| 1a4250a7ed | |||
| e181e4325b | |||
| 1a35237852 | |||
| 90baf3da52 | |||
| 348e4dd4dc | |||
| d2cd96a45c | |||
| 64238e3280 | |||
| fcfac2df1f | |||
| a0734a6eb4 | |||
| 0d7a235715 | |||
| 180788bc7b | |||
| 922681891a | |||
| 257c628cc8 | |||
| dcee842c92 | |||
| 5f02471fd5 | |||
| 82f0f7ce17 | |||
| b805b7eef9 | |||
| ad03fa070a | |||
| 9c354d971f | |||
| 5cc70c4bb8 | |||
| 9db806d387 | |||
| b7f7621d28 | |||
| 822ac2417d | |||
| b3c80a2a82 | |||
| 3e9d606647 | |||
| 0ab89f4956 | |||
| 0090f1c409 | |||
| 1cc37a57c1 | |||
| 7809efa556 | |||
| b4a69e3c85 | |||
| 67c975d9c2 | |||
| c0812a7ee9 | |||
| 8ed755a339 | |||
| 1aae8783ca | |||
| 5997a741fc | |||
| c5d522e6b8 | |||
| fa1d128d76 | |||
| f30516a6c2 | |||
| 1feb0538cf | |||
| 3a791d624f | |||
| fe0f1f7641 | |||
| 212783ae3d | |||
| 7d72fc9555 | |||
| fb4df5cee1 | |||
| 45cd847b44 | |||
| 98fd97dea2 | |||
| e334086ad1 | |||
| 2e792911f1 | |||
| 878b666a0b | |||
| 69c3239469 | |||
| b8d48745c5 | |||
| 6b64e73d6e | |||
| b433a96cd6 | |||
| 0e4c83299d | |||
| c4d1f3eccc | |||
| 22dcf425cb | |||
| edf4132c2d | |||
| a56e14c736 | |||
| 2e3c707745 | |||
| ebd8a10fd9 | |||
| a681a9ff1b | |||
| d11458c4a8 | |||
| 9d6e17b609 | |||
| cb27487021 | |||
| e1e6b40a19 | |||
| 2616292f97 | |||
| 2e1dfa6638 | |||
| 3a730492cb | |||
| c35da4b98d | |||
| 5e98c25020 | |||
| 11a4d5b436 | |||
| 93c733aac0 | |||
| 5b03e7ab8c | |||
| 33912331af | |||
| aacc07835e | |||
| 86df2a1408 | |||
| 018de17c5b | |||
| 66afa2b924 | |||
| 4ee32fe0de | |||
| 5b59ca3c4f | |||
| 0108b0fc22 | |||
| 2e575f82af | |||
| e8af09041d | |||
| 1cf041fb86 | |||
| 63074cb009 | |||
| 89b9a6e04f | |||
| 4efa344e43 | |||
| 2d3d1ba5d3 | |||
| 849d781e92 | |||
| 48a988aa43 | |||
| ec71f733ad | |||
| 051121a366 | |||
| e2c2b9127c | |||
| fdf7263bb0 | |||
| c8dcde0919 | |||
| 9db0cd6328 | |||
| 2375bf51b3 | |||
| 3631b49d6d | |||
| 3938b56802 | |||
| 3c3b793a9f | |||
| 8985a89a9f | |||
| 5750e6a4d2 | |||
| 8d7f73edec | |||
| 4853d5b19b | |||
| 901f5fd90d | |||
| 89e3865019 | |||
| 538b2b52d3 | |||
| f1c9df8471 | |||
| 9e09e97ed8 | |||
| e50355be23 | |||
| bd036fb4a9 | |||
| 0181f8e5fb | |||
| f5eea6798a | |||
| 7c8d0254b2 | |||
| 49c3c1c0c9 | |||
| 38522442d1 | |||
| 0dacd79598 | |||
| 2022654055 | |||
| f3a1de6335 | |||
| 0b5b89ea9a | |||
| 8e21c34c8c | |||
| 400f3b01d6 | |||
| 6a0b2aabaf | |||
| 13a1ddad11 | |||
| ed404a0e54 | |||
| 05acfc0733 | |||
| 969cd6ed90 | |||
| 939c9b9b09 | |||
| 051c0db106 | |||
| e5fbe005f8 | |||
| 63efb31785 | |||
| 32586e177b | |||
| 651dba2648 | |||
| 1bbcf2a95c | |||
| 82af176018 | |||
| c10a4e9f8a | |||
| 9727e07464 | |||
| b780fe2278 | |||
| 947d61f2a8 | |||
| 27725ec1a7 | |||
| 93a29463f4 | |||
| 84cfcf45fa | |||
| 6651e59f63 | |||
| ed46b7c919 | |||
| 88cd9fbc5b | |||
| 46fc5827ca | |||
| 65d386f3b2 | |||
| 9db640931e | |||
| 53e4dec12d | |||
| 174d9f9b84 | |||
| 30ec07f7fe | |||
| dbf1fd8085 | |||
| 32e1f231d3 | |||
| 96b788693d | |||
| a0e00c3735 | |||
| 51e68cf215 | |||
| fa3ed3af81 | |||
| 6e72cbb5b0 | |||
| 5f316f85b3 | |||
| 2abd1fd9df | |||
| fcb5fedf9f | |||
| d35c9ad87e | |||
| 0cc0368b0b | |||
| 00fb154cb9 | |||
| 4441cf79ff | |||
| 7428bd5cf7 | |||
| 76c524e980 | |||
| 5a9fa167c2 | |||
| c61e9c78ba | |||
| e5e3a9e4b5 | |||
| a1f47e13e3 | |||
| 7ff0ed1248 | |||
| 5eef24fb91 | |||
| 05ba26d3ac | |||
| ef68ce19ce | |||
| 2fa84f6dc9 | |||
| 32409d7471 | |||
| 270282a340 | |||
| 87eaa06652 | |||
| 845f03e873 | |||
| 97eabb0604 | |||
| f0d714d787 | |||
| 27a34d6e9f | |||
| a4e97e0317 | |||
| 97dcd0b7cc | |||
| 9b57904cf4 | |||
| 6ebdae0cae | |||
| b9d4a8f8e9 | |||
| 707c3c5daf | |||
| d075abfeea | |||
| d3399c569c | |||
| e36e3f7f8f | |||
| 583e01f5e6 | |||
| 784b5e0af6 | |||
| cc42d33fbe | |||
| 9c3d8fd701 | |||
| d281eeb69d | |||
| b510ffb7d8 | |||
| 6855b7765c | |||
| 9ed1129268 | |||
| 0c4ba3c0d0 | |||
| 70337ac310 | |||
| e981082713 | |||
| 104d98542e | |||
| fe63ef5fc5 | |||
| b1ff964dd6 | |||
| aba1d5dbe5 | |||
| 0af7e4ac68 | |||
| 7d9a572e6e | |||
| de6557d7e7 | |||
| a052c7bb80 | |||
| 8c47f3c49b | |||
| 323ad903e7 | |||
| 8105fe285d | |||
| 82a19025f1 | |||
| e592b3e5c4 | |||
| 2da12354a1 | |||
| fc65b62d65 | |||
| 28603a87e1 | |||
| 8956aafc9a | |||
| 2ee9b263b8 | |||
| 3a5d838be3 | |||
| bf25fcfda6 | |||
| ecdf2fcae4 | |||
| 6fec14de4b | |||
| 7b4cbb27b7 | |||
| d7d7795257 | |||
| 93554c7034 | |||
| fbe7febdde | |||
| f091b3ea5e | |||
| aae469853e | |||
| dac68ccd8c | |||
| fb140e82f6 | |||
| 8cac442de1 | |||
| 8d8a465c43 | |||
| 51f2c660e8 | |||
| 39ecca306d | |||
| 7896a24e13 | |||
| c21325f260 | |||
| 18df7395a2 | |||
| f139251333 | |||
| 369e384b7d | |||
| 008f62e02b | |||
| 6e8981a245 | |||
| bb17cf7361 | |||
| d13c6767e6 | |||
| bf2ee37f33 | |||
| b82929ac3d | |||
| 9b0ea9bf2a | |||
| 5bde7196dc | |||
| 71c3e7fe0d | |||
| 201543c263 | |||
| 5ce7417c80 | |||
| e98564b5ab | |||
| 37dd48cc20 | |||
| 9dff6e8045 | |||
| 572094050e | |||
| 2b41337bbf | |||
| ca94c402bd | |||
| 6fdc46c3c6 | |||
| 34fc568f97 | |||
| 38664ff853 | |||
| e44284d4ba | |||
| d54dc822cc | |||
| dbe9c2d5e9 | |||
| b036d090c6 | |||
| d44b303dd3 | |||
| e69bfdac20 | |||
| 53f6c4e71b | |||
| c67223a332 | |||
| 2c253382d5 | |||
| 02b65cb1c9 | |||
| 49ace78a68 | |||
| 0669086742 | |||
| 2f2d9bc1af | |||
| 9b2b11ee44 | |||
| d318738596 | |||
| 6ae41369e9 | |||
| ce9f2e0d41 | |||
| aa7bd42375 | |||
| a6b19e4b95 | |||
| 7a21518ce2 | |||
| ba92f7da6c | |||
| f62f37581f | |||
| 0d08c2c7f0 | |||
| 078e59f09c | |||
| ab708a78a8 | |||
| 630688ecf5 | |||
| 2b6901e87a | |||
| 0e43daf48b | |||
| e9badff1d8 | |||
| f588191931 | |||
| e17dfbff35 | |||
| b7e9770588 | |||
| 9371524257 | |||
| c1f8fe69cf | |||
| 87e3449444 | |||
| e8ef1bc255 | |||
| fe3a593da0 | |||
| bf96d85edb | |||
| 700d718da6 | |||
| 0c9ad3cec8 | |||
| 73d789d3f7 | |||
| 0a09ae4290 | |||
| 0758988b5f | |||
| 089f668e14 | |||
| 1e5bf143c4 | |||
| d1132c82f8 | |||
| 62545276e4 | |||
| 2878bc3c3d | |||
| eed0fe39e7 | |||
| 6bbee5586f | |||
| 7ac2354197 | |||
| 3463f08162 | |||
| 4b0aaa02c7 | |||
| a946f7b501 | |||
| 7f2e4615ee | |||
| 28ab7719f5 | |||
| 31b5101f64 | |||
| 62777f8995 | |||
| f9cff3e169 | |||
| 04c255d748 | |||
| aae771764e | |||
| d4e1544fc8 | |||
| 6db5cae013 | |||
| 61089a9f30 | |||
| 3ad1f0fc97 | |||
| 67ac3c933f | |||
| d6f4751274 | |||
| 6fdb02af10 | |||
| ce456c4f7a | |||
| 314b17ce79 | |||
| 598d9ad0a1 | |||
| 734711dab7 | |||
| c4c5d6a018 | |||
| 883cd5a952 | |||
| 8cf7445b99 | |||
| 4c11af4644 | |||
| 47036d5616 | |||
| 025330e052 | |||
| 8d18c78fb2 | |||
| d93a2a0682 | |||
| 0c93516056 | |||
| bf126bdadb | |||
| d421d43522 | |||
| 29cda4dcd7 | |||
| dde90f9ca4 | |||
| 8415c68063 | |||
| 9edde19b8a | |||
| 83a473606f | |||
| 849f06ad2b | |||
| e27e1299f7 | |||
| 03a45e39d4 | |||
| 4663464ea4 | |||
| 338b98bae3 | |||
| 7326d64fbe | |||
| bee94bcad0 | |||
| 414b796700 | |||
| f76abce939 | |||
| 99460665f1 | |||
| 730a6ae185 | |||
| ab8a5ca336 | |||
| d1f0cf5854 | |||
| 1f3c85e182 | |||
| bfcfe0de3d | |||
| f55f745080 | |||
| a26524e6ea | |||
| b5f57d4d0e | |||
| 5aa9c1316e | |||
| 524b4f5e64 | |||
| a2bb0b8341 | |||
| 9cabbf72d2 | |||
| 5f368f84ce | |||
| ed01d2bc6f | |||
| 7107070624 | |||
| c361f0c1d4 | |||
| ab95bc2192 | |||
| bf6a68ae27 | |||
| 1083651591 | |||
| b35fb824ec | |||
| ad839fb63d | |||
| 377a731ca2 | |||
| 3395021fcf | |||
| a4ce1a83be | |||
| 8b2f9e1803 | |||
| a40c07a6af | |||
| 2dbc23fa80 | |||
| 233401c057 | |||
| 66f17f90fd | |||
| 9748cc6b3e | |||
| bcab53cfe3 | |||
| 1af387de2b | |||
| 25c54c588b | |||
| 6ba7c6ed5e | |||
| 222c1a488f | |||
| c1a92adf41 | |||
| 972bb18344 | |||
| de11de4cd0 | |||
| f8c3daac23 | |||
| 91a3b14a1f | |||
| d310896168 | |||
| d4df56b173 | |||
| 3eab0b3848 | |||
| 609a557502 | |||
| 6a4ebad89f | |||
| e229b625f6 | |||
| 3ff5dec4d3 | |||
| 6dfd7db115 | |||
| 782dff4f82 | |||
| 1540d2665f | |||
| 567d0c1a8d | |||
| 6b031d4e9c | |||
| 72046d1ccf | |||
| d41af2cd85 | |||
| 174647c963 | |||
| e5e52b839c | |||
| ab19becdc8 | |||
| f5e9a6f2b9 | |||
| b492437b54 | |||
| 07546da0fd | |||
| fc4423d9de | |||
| c9a0f59e28 | |||
| 2d31bc6f23 | |||
| dac2e1c85f | |||
| f4d9b0d219 | |||
| 9b485bf822 | |||
| af3e8eab4e | |||
| 63d7500398 | |||
| 545f52f207 | |||
| 7fbb4cf2da | |||
| 7be8757964 |
+77
-6
@@ -67,6 +67,14 @@ REMNAWAVE_SECRET_KEY=
|
||||
# {telegram_id} — ID Telegram
|
||||
REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}"
|
||||
|
||||
# Шаблон имени пользователя в панели Remnawave
|
||||
# Доступные плейсхолдеры аналогичны описанию выше
|
||||
# {full_name} — Имя, Фамилия из Telegram
|
||||
# {username} — @логин из Telegram (c @)
|
||||
# {username_clean} — логин из Telegram (без @)
|
||||
# {telegram_id} — ID Telegram
|
||||
REMNAWAVE_USER_USERNAME_TEMPLATE="user_{telegram_id}"
|
||||
|
||||
# Режим удаления пользователей из панели RemnaWave
|
||||
# delete - полностью удалить пользователя из панели
|
||||
# disable - только деактивировать пользователя
|
||||
@@ -113,12 +121,24 @@ FIXED_TRAFFIC_LIMIT_GB=100
|
||||
AVAILABLE_SUBSCRIPTION_PERIODS=30,90,180
|
||||
AVAILABLE_RENEWAL_PERIODS=30,90,180
|
||||
|
||||
# ===== НАСТРОЙКИ ПРОСТОЙ ПОКУПКИ =====
|
||||
# Включить упрощённую покупку из меню
|
||||
SIMPLE_SUBSCRIPTION_ENABLED=false
|
||||
# Стандартный период (должен совпадать с одним из AVAILABLE_SUBSCRIPTION_PERIODS)
|
||||
SIMPLE_SUBSCRIPTION_PERIOD_DAYS=30
|
||||
# Сколько устройств выдаётся в рамках простой подписки
|
||||
SIMPLE_SUBSCRIPTION_DEVICE_LIMIT=1
|
||||
# Лимит трафика в ГБ (0 — безлимит)
|
||||
SIMPLE_SUBSCRIPTION_TRAFFIC_GB=0
|
||||
# UUID сквада (оставьте пустым, чтобы использовать сквады по умолчанию)
|
||||
# SIMPLE_SUBSCRIPTION_SQUAD_UUID=
|
||||
|
||||
# ===== ЦЕНЫ (в копейках) =====
|
||||
BASE_SUBSCRIPTION_PRICE=0
|
||||
|
||||
# Цены за периоды
|
||||
PRICE_14_DAYS=7000
|
||||
PRICE_30_DAYS=9900
|
||||
PRICE_30_DAYS=1000
|
||||
PRICE_60_DAYS=25900
|
||||
PRICE_90_DAYS=36900
|
||||
PRICE_180_DAYS=69900
|
||||
@@ -130,10 +150,14 @@ BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED=false
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
|
||||
|
||||
# Выводимые пакеты трафика и их цены в копейках
|
||||
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true"
|
||||
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:0:true"
|
||||
|
||||
# Цена за дополнительное устройство (DEFAULT_DEVICE_LIMIT идет бесплатно!)
|
||||
PRICE_PER_DEVICE=5000
|
||||
PRICE_PER_DEVICE=10000
|
||||
# Включить выбор количества устройств при покупке и продлении
|
||||
DEVICES_SELECTION_ENABLED=true
|
||||
# Единое количество устройств для режима без выбора (0 — не назначать устройства)
|
||||
DEVICES_SELECTION_DISABLED_AMOUNT=0
|
||||
|
||||
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
|
||||
REFERRAL_PROGRAM_ENABLED=true
|
||||
@@ -155,8 +179,8 @@ MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
|
||||
# ===== ПЛАТЕЖНЫЕ СИСТЕМЫ =====
|
||||
|
||||
# Telegram Stars (работает автоматически)
|
||||
TELEGRAM_STARS_ENABLED=true
|
||||
TELEGRAM_STARS_RATE_RUB=1.3
|
||||
TELEGRAM_STARS_ENABLED=false
|
||||
TELEGRAM_STARS_RATE_RUB=1.79
|
||||
|
||||
# Tribute (https://tribute.app)
|
||||
TRIBUTE_ENABLED=false
|
||||
@@ -164,6 +188,8 @@ TRIBUTE_API_KEY=
|
||||
TRIBUTE_DONATE_LINK=
|
||||
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
|
||||
TRIBUTE_WEBHOOK_HOST=0.0.0.0
|
||||
# Примечание: индивидуальные *_WEBHOOK_PORT используются только для старой схемы запуска.
|
||||
# В режиме единого FastAPI сервера оставьте эти значения по умолчанию.
|
||||
TRIBUTE_WEBHOOK_PORT=8081
|
||||
|
||||
# YooKassa (https://yookassa.ru)
|
||||
@@ -228,8 +254,13 @@ YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
|
||||
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
|
||||
DISABLE_TOPUP_BUTTONS=false
|
||||
|
||||
# Автоматическая проверка зависших пополнений и повторные обращения к провайдерам
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED=false
|
||||
# Интервал (в минутах) между автоматическими проверками пополнений
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES=10
|
||||
|
||||
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
# чтобы избежать блокировок платежных систем
|
||||
PAYMENT_SERVICE_NAME=Интернет-сервис
|
||||
PAYMENT_BALANCE_DESCRIPTION=Пополнение баланса
|
||||
@@ -244,11 +275,29 @@ CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here
|
||||
CRYPTOBOT_BASE_URL=https://pay.crypt.bot
|
||||
CRYPTOBOT_TESTNET=false
|
||||
CRYPTOBOT_WEBHOOK_PATH=/cryptobot-webhook
|
||||
# Эти порты используются только при ручном запуске старого aiohttp сервера.
|
||||
# В новой конфигурации все вебхуки обрабатываются через порт 8080.
|
||||
CRYPTOBOT_WEBHOOK_PORT=8081
|
||||
CRYPTOBOT_DEFAULT_ASSET=USDT
|
||||
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
|
||||
CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
|
||||
|
||||
# HELEKET
|
||||
HELEKET_ENABLED=false
|
||||
HELEKET_MERCHANT_ID=
|
||||
HELEKET_API_KEY=
|
||||
HELEKET_BASE_URL=https://api.heleket.com/v1
|
||||
HELEKET_DEFAULT_CURRENCY=USDT
|
||||
HELEKET_DEFAULT_NETWORK=
|
||||
HELEKET_INVOICE_LIFETIME=3600
|
||||
HELEKET_MARKUP_PERCENT=0
|
||||
HELEKET_WEBHOOK_PATH=/heleket-webhook
|
||||
HELEKET_WEBHOOK_HOST=0.0.0.0
|
||||
HELEKET_WEBHOOK_PORT=8086
|
||||
HELEKET_CALLBACK_URL=
|
||||
HELEKET_RETURN_URL=
|
||||
HELEKET_SUCCESS_URL=
|
||||
|
||||
# MULENPAY
|
||||
MULENPAY_ENABLED=false
|
||||
MULENPAY_API_KEY=
|
||||
@@ -304,6 +353,7 @@ CONNECT_BUTTON_MODE=guide
|
||||
|
||||
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
|
||||
MINIAPP_CUSTOM_URL=
|
||||
MINIAPP_STATIC_PATH=miniapp
|
||||
MINIAPP_SERVICE_NAME_EN=Bedolaga VPN
|
||||
MINIAPP_SERVICE_NAME_RU=Bedolaga VPN
|
||||
MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection
|
||||
@@ -355,6 +405,8 @@ SERVER_STATUS_ITEMS_PER_PAGE=10
|
||||
MAINTENANCE_MODE=false
|
||||
MAINTENANCE_CHECK_INTERVAL=30
|
||||
MAINTENANCE_AUTO_ENABLE=true
|
||||
MAINTENANCE_MONITORING_ENABLED=true
|
||||
MAINTENANCE_RETRY_ATTEMPTS=1
|
||||
MAINTENANCE_MESSAGE=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже.
|
||||
|
||||
# ===== ЛОКАЛИЗАЦИЯ =====
|
||||
@@ -363,6 +415,8 @@ DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en
|
||||
# Включить выбор языка при старте и отображение кнопки в меню
|
||||
LANGUAGE_SELECTION_ENABLED=true
|
||||
# Часовой пояс
|
||||
TZ=Europe/Moscow # или UTC, America/New_York и т.д.
|
||||
|
||||
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
|
||||
# Конфигурация приложений для гайда подключения
|
||||
@@ -400,3 +454,20 @@ LOG_FILE=logs/bot.log
|
||||
DEBUG=false
|
||||
WEBHOOK_URL=
|
||||
WEBHOOK_PATH=/webhook
|
||||
WEBHOOK_SECRET_TOKEN=
|
||||
WEBHOOK_DROP_PENDING_UPDATES=true
|
||||
WEBHOOK_MAX_QUEUE_SIZE=1024
|
||||
WEBHOOK_WORKERS=4
|
||||
WEBHOOK_ENQUEUE_TIMEOUT=0.1
|
||||
WEBHOOK_WORKER_SHUTDOWN_TIMEOUT=30.0
|
||||
BOT_RUN_MODE=polling # polling, webhook или both
|
||||
|
||||
# ===== ЕДИНЫЙ ВЕБ-СЕРВЕР =====
|
||||
WEB_API_ENABLED=false
|
||||
WEB_API_HOST=0.0.0.0
|
||||
WEB_API_PORT=8080
|
||||
WEB_API_ALLOWED_ORIGINS=*
|
||||
WEB_API_DOCS_ENABLED=false
|
||||
WEB_API_DEFAULT_TOKEN=
|
||||
WEB_API_DEFAULT_TOKEN_NAME=Bootstrap Token
|
||||
MINIAPP_STATIC_PATH=miniapp
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: Bug Bedolage
|
||||
about: Создать баг-репорт «Бедолаге»
|
||||
title: "[BUG] Заголовок"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Описание / Конфигурация
|
||||
|
||||
Кратко описываем текущую проблему.
|
||||
|
||||
Указываем настройки из `.env` конфига (если таковые имеются):
|
||||
|
||||
```yml
|
||||
|
||||
```
|
||||
|
||||
## Шаги для воспроизведения
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
### Фактический результат
|
||||
|
||||
*
|
||||
*
|
||||
*
|
||||
|
||||
### Скрины поведения
|
||||
|
||||
Копируем изображение и вставляем прям сюда, если есть. Если нет — удаляем секцию.
|
||||
|
||||
## Ожидаемое поведение
|
||||
|
||||
Опишите, как должно работать.
|
||||
|
||||
## Дополнительная информация
|
||||
|
||||
Добавляем сюда дополнительную инфу, если есть. Если нет — удаляем секцию.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: Feat Bedolage
|
||||
about: Создать запрос на фичу «Бедолаге»
|
||||
title: "[FEAT] Заголовок"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Описание / Конфигурация
|
||||
|
||||
Кратко описываем что хотим.
|
||||
|
||||
Указываем настройки из `.env` конфига (если таковые к месту):
|
||||
|
||||
```yml
|
||||
|
||||
```
|
||||
|
||||
## Текущее поведение
|
||||
|
||||
### Скрины поведения
|
||||
|
||||
Копируем изображение и вставляем прям сюда, если есть. Если нет — удаляем секцию.
|
||||
|
||||
## Желаемое поведение
|
||||
|
||||
Опишите, как хотелось бы, чтобы было.
|
||||
|
||||
## Дополнительная информация
|
||||
|
||||
Добавляем сюда дополнительную инфу, если есть. Если нет — удаляем секцию.
|
||||
@@ -36,15 +36,15 @@ jobs:
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.5.2-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.6.0-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.5.2-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.6.0-dev-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.5.2-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.6.0-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.5.2-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.6.0-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.5.2-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.6.0-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.5.2-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.6.0-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
+1
-2
@@ -1,5 +1,6 @@
|
||||
# Игнорируем все файлы и папки по умолчанию
|
||||
*
|
||||
docker-compose.override.yml
|
||||
|
||||
# Исключения: разрешаем только нужные файлы
|
||||
!.dockerignore
|
||||
@@ -15,8 +16,6 @@
|
||||
# Разрешаем папку app/ и все её содержимое рекурсивно
|
||||
!app/
|
||||
!app/**
|
||||
!locales/
|
||||
!locales/**
|
||||
!tests/
|
||||
!tests/**
|
||||
|
||||
|
||||
+3
-3
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.5.2"
|
||||
ARG VERSION="v2.6.0"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
@@ -45,7 +45,7 @@ ENV PYTHONPATH=/app \
|
||||
BUILD_DATE=${BUILD_DATE} \
|
||||
VCS_REF=${VCS_REF}
|
||||
|
||||
EXPOSE 8080 8081 8082
|
||||
EXPOSE 8080
|
||||
|
||||
LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
|
||||
org.opencontainers.image.description="Telegram bot for RemnaWave VPN service" \
|
||||
@@ -57,6 +57,6 @@ LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
|
||||
org.opencontainers.image.vendor="fr1ngg"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8081/health || exit 1
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
.PHONY: up
|
||||
up: ## Поднять контейнеры (detached)
|
||||
@echo "🚀 Поднимаем контейнеры (detached)..."
|
||||
docker compose up -d --build
|
||||
|
||||
.PHONY: up-follow
|
||||
up-follow: ## Поднять контейнеры с логами
|
||||
@echo "📡 Поднимаем контейнеры (в консоли)..."
|
||||
docker compose up --build
|
||||
|
||||
.PHONY: down
|
||||
down: ## Остановить и удалить контейнеры
|
||||
@echo "🛑 Останавливаем и удаляем контейнеры..."
|
||||
docker compose down
|
||||
|
||||
.PHONY: reload
|
||||
reload: ## Перезапустить контейнеры (detached)
|
||||
@$(MAKE) down
|
||||
@$(MAKE) up
|
||||
|
||||
.PHONY: reload-follow
|
||||
reload-follow: ## Перезапустить контейнеры с логами
|
||||
@$(MAKE) down
|
||||
@$(MAKE) up-follow
|
||||
|
||||
.PHONY: test
|
||||
test: ## Запустить тесты
|
||||
@echo "🧪 Запускаем тесты..."
|
||||
pytest -v
|
||||
|
||||
.PHONY: help
|
||||
help: ## Показать список доступных команд
|
||||
@echo ""
|
||||
@echo "📘 Команды Makefile:"
|
||||
@echo ""
|
||||
@grep -E '^[a-zA-Z0-9_-]+:.*?##' $(MAKEFILE_LIST) | \
|
||||
sed -E 's/:.*?## /| /' | \
|
||||
awk -F'|' '{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
|
||||
@echo ""
|
||||
+2
-3
@@ -136,8 +136,7 @@ logger.debug(f"Webhook payload: {webhook_data}")
|
||||
# Открыть только необходимые порты
|
||||
ufw allow 80/tcp # HTTP (redirect to HTTPS)
|
||||
ufw allow 443/tcp # HTTPS
|
||||
ufw deny 8081/tcp # Webhook порты (только через reverse proxy)
|
||||
ufw deny 8082/tcp
|
||||
ufw deny 8080/tcp # Unified FastAPI сервер доступен только из внутренней сети
|
||||
```
|
||||
|
||||
### 📊 Мониторинг безопасности
|
||||
@@ -283,7 +282,7 @@ services:
|
||||
|
||||
# Не пробрасывайте порты наружу без необходимости
|
||||
ports:
|
||||
- "127.0.0.1:8081:8081" # Только localhost
|
||||
- "127.0.0.1:8080:8080" # Только localhost
|
||||
|
||||
# Read-only root filesystem
|
||||
read_only: true
|
||||
|
||||
+28
-7
@@ -16,9 +16,19 @@ from app.services.maintenance_service import maintenance_service
|
||||
from app.utils.cache import cache
|
||||
|
||||
from app.handlers import (
|
||||
start, menu, subscription, balance, promocode,
|
||||
referral, support, server_status, common, tickets
|
||||
start,
|
||||
menu,
|
||||
subscription,
|
||||
balance,
|
||||
promocode,
|
||||
referral,
|
||||
support,
|
||||
server_status,
|
||||
common,
|
||||
tickets,
|
||||
)
|
||||
from app.handlers import polls as user_polls
|
||||
from app.handlers import simple_subscription
|
||||
from app.handlers.admin import (
|
||||
main as admin_main,
|
||||
users as admin_users,
|
||||
@@ -30,6 +40,7 @@ from app.handlers.admin import (
|
||||
rules as admin_rules,
|
||||
remnawave as admin_remnawave,
|
||||
statistics as admin_statistics,
|
||||
polls as admin_polls,
|
||||
servers as admin_servers,
|
||||
maintenance as admin_maintenance,
|
||||
promo_groups as admin_promo_groups,
|
||||
@@ -47,6 +58,7 @@ from app.handlers.admin import (
|
||||
privacy_policy as admin_privacy_policy,
|
||||
public_offer as admin_public_offer,
|
||||
faq as admin_faq,
|
||||
payments as admin_payments,
|
||||
)
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
|
||||
@@ -144,6 +156,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_rules.register_handlers(dp)
|
||||
admin_remnawave.register_handlers(dp)
|
||||
admin_statistics.register_handlers(dp)
|
||||
admin_polls.register_handlers(dp)
|
||||
admin_promo_groups.register_handlers(dp)
|
||||
admin_campaigns.register_handlers(dp)
|
||||
admin_promo_offers.register_handlers(dp)
|
||||
@@ -160,15 +173,23 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_privacy_policy.register_handlers(dp)
|
||||
admin_public_offer.register_handlers(dp)
|
||||
admin_faq.register_handlers(dp)
|
||||
admin_payments.register_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
register_stars_handlers(dp)
|
||||
user_polls.register_handlers(dp)
|
||||
simple_subscription.register_simple_subscription_handlers(dp)
|
||||
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
|
||||
logger.info("⚡ Зарегистрированы обработчики простой покупки")
|
||||
logger.info("⚡ Зарегистрированы обработчики простой подписки")
|
||||
|
||||
try:
|
||||
await maintenance_service.start_monitoring()
|
||||
logger.info("Мониторинг техработ запущен")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запуска мониторинга техработ: {e}")
|
||||
if settings.is_maintenance_monitoring_enabled():
|
||||
try:
|
||||
await maintenance_service.start_monitoring()
|
||||
logger.info("Мониторинг техработ запущен")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запуска мониторинга техработ: {e}")
|
||||
else:
|
||||
logger.info("Мониторинг техработ отключен настройками")
|
||||
|
||||
logger.info("🛡️ GlobalErrorMiddleware активирован - бот защищен от устаревших callback queries")
|
||||
logger.info("Бот успешно настроен")
|
||||
|
||||
+251
-36
@@ -7,6 +7,7 @@ import html
|
||||
from collections import defaultdict
|
||||
from datetime import time
|
||||
from typing import List, Optional, Union, Dict
|
||||
from zoneinfo import ZoneInfo
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import field_validator, Field
|
||||
from pathlib import Path
|
||||
@@ -18,6 +19,9 @@ DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS = [
|
||||
]
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
|
||||
BOT_TOKEN: str
|
||||
@@ -57,6 +61,8 @@ class Settings(BaseSettings):
|
||||
|
||||
SQLITE_PATH: str = "./data/bot.db"
|
||||
LOCALES_PATH: str = "./locales"
|
||||
|
||||
TIMEZONE: str = Field(default_factory=lambda: os.getenv("TZ", "UTC"))
|
||||
|
||||
DATABASE_MODE: str = "auto"
|
||||
|
||||
@@ -70,6 +76,7 @@ class Settings(BaseSettings):
|
||||
REMNAWAVE_PASSWORD: Optional[str] = None
|
||||
REMNAWAVE_AUTH_TYPE: str = "api_key"
|
||||
REMNAWAVE_USER_DESCRIPTION_TEMPLATE: str = "Bot user: {full_name} {username}"
|
||||
REMNAWAVE_USER_USERNAME_TEMPLATE: str = "user_{telegram_id}"
|
||||
REMNAWAVE_USER_DELETE_MODE: str = "delete" # "delete" или "disable"
|
||||
REMNAWAVE_AUTO_SYNC_ENABLED: bool = False
|
||||
REMNAWAVE_AUTO_SYNC_TIMES: str = "03:00"
|
||||
@@ -120,10 +127,12 @@ class Settings(BaseSettings):
|
||||
PRICE_TRAFFIC_500GB: int = 19000
|
||||
PRICE_TRAFFIC_1000GB: int = 19500
|
||||
PRICE_TRAFFIC_UNLIMITED: int = 20000
|
||||
|
||||
|
||||
TRAFFIC_PACKAGES_CONFIG: str = ""
|
||||
|
||||
PRICE_PER_DEVICE: int = 5000
|
||||
DEVICES_SELECTION_ENABLED: bool = True
|
||||
DEVICES_SELECTION_DISABLED_AMOUNT: Optional[int] = None
|
||||
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: bool = False
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS: str = ""
|
||||
@@ -139,7 +148,6 @@ class Settings(BaseSettings):
|
||||
REFERRAL_PROGRAM_ENABLED: bool = True
|
||||
REFERRAL_NOTIFICATIONS_ENABLED: bool = True
|
||||
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS: int = 3
|
||||
REFERRED_USER_REWARD: int = 0
|
||||
|
||||
AUTOPAY_WARNING_DAYS: str = "3,1"
|
||||
|
||||
@@ -151,8 +159,10 @@ class Settings(BaseSettings):
|
||||
INACTIVE_USER_DELETE_MONTHS: int = 3
|
||||
|
||||
MAINTENANCE_MODE: bool = False
|
||||
MAINTENANCE_CHECK_INTERVAL: int = 30
|
||||
MAINTENANCE_AUTO_ENABLE: bool = True
|
||||
MAINTENANCE_CHECK_INTERVAL: int = 30
|
||||
MAINTENANCE_AUTO_ENABLE: bool = True
|
||||
MAINTENANCE_MONITORING_ENABLED: bool = True
|
||||
MAINTENANCE_RETRY_ATTEMPTS: int = 1
|
||||
MAINTENANCE_MESSAGE: str = "🔧 Ведутся технические работы. Сервис временно недоступен. Попробуйте позже."
|
||||
|
||||
TELEGRAM_STARS_ENABLED: bool = True
|
||||
@@ -182,6 +192,17 @@ class Settings(BaseSettings):
|
||||
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
|
||||
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
|
||||
DISABLE_TOPUP_BUTTONS: bool = False
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10
|
||||
|
||||
AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False
|
||||
|
||||
# Настройки простой покупки
|
||||
SIMPLE_SUBSCRIPTION_ENABLED: bool = False
|
||||
SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30
|
||||
SIMPLE_SUBSCRIPTION_DEVICE_LIMIT: int = 1
|
||||
SIMPLE_SUBSCRIPTION_TRAFFIC_GB: int = 0 # 0 означает безлимит
|
||||
SIMPLE_SUBSCRIPTION_SQUAD_UUID: Optional[str] = None
|
||||
PAYMENT_BALANCE_DESCRIPTION: str = "Пополнение баланса"
|
||||
PAYMENT_SUBSCRIPTION_DESCRIPTION: str = "Оплата подписки"
|
||||
PAYMENT_SERVICE_NAME: str = "Интернет-сервис"
|
||||
@@ -199,12 +220,28 @@ class Settings(BaseSettings):
|
||||
CRYPTOBOT_ASSETS: str = "USDT,TON,BTC,ETH"
|
||||
CRYPTOBOT_INVOICE_EXPIRES_HOURS: int = 24
|
||||
|
||||
HELEKET_ENABLED: bool = False
|
||||
HELEKET_MERCHANT_ID: Optional[str] = None
|
||||
HELEKET_API_KEY: Optional[str] = None
|
||||
HELEKET_BASE_URL: str = "https://api.heleket.com/v1"
|
||||
HELEKET_DEFAULT_CURRENCY: str = "USDT"
|
||||
HELEKET_DEFAULT_NETWORK: Optional[str] = None
|
||||
HELEKET_INVOICE_LIFETIME: int = 3600
|
||||
HELEKET_MARKUP_PERCENT: float = 0.0
|
||||
HELEKET_WEBHOOK_PATH: str = "/heleket-webhook"
|
||||
HELEKET_WEBHOOK_HOST: str = "0.0.0.0"
|
||||
HELEKET_WEBHOOK_PORT: int = 8086
|
||||
HELEKET_CALLBACK_URL: Optional[str] = None
|
||||
HELEKET_RETURN_URL: Optional[str] = None
|
||||
HELEKET_SUCCESS_URL: Optional[str] = None
|
||||
|
||||
MULENPAY_ENABLED: bool = False
|
||||
MULENPAY_API_KEY: Optional[str] = None
|
||||
MULENPAY_SECRET_KEY: Optional[str] = None
|
||||
MULENPAY_SHOP_ID: Optional[int] = None
|
||||
MULENPAY_BASE_URL: str = "https://mulenpay.ru/api"
|
||||
MULENPAY_WEBHOOK_PATH: str = "/mulenpay-webhook"
|
||||
MULENPAY_DISPLAY_NAME: str = "Mulen Pay"
|
||||
MULENPAY_DESCRIPTION: str = "Пополнение баланса"
|
||||
MULENPAY_LANGUAGE: str = "ru"
|
||||
MULENPAY_VAT_CODE: int = 0
|
||||
@@ -245,10 +282,16 @@ class Settings(BaseSettings):
|
||||
WATA_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
WATA_MAX_AMOUNT_KOPEKS: int = 100000000
|
||||
WATA_REQUEST_TIMEOUT: int = 30
|
||||
WATA_WEBHOOK_PATH: str = "/wata-webhook"
|
||||
WATA_WEBHOOK_HOST: str = "0.0.0.0"
|
||||
WATA_WEBHOOK_PORT: int = 8085
|
||||
WATA_PUBLIC_KEY_URL: Optional[str] = None
|
||||
WATA_PUBLIC_KEY_CACHE_SECONDS: int = 3600
|
||||
|
||||
MAIN_MENU_MODE: str = "default"
|
||||
CONNECT_BUTTON_MODE: str = "guide"
|
||||
MINIAPP_CUSTOM_URL: str = ""
|
||||
MINIAPP_STATIC_PATH: str = "miniapp"
|
||||
MINIAPP_PURCHASE_URL: str = ""
|
||||
MINIAPP_SERVICE_NAME_EN: str = "Bedolaga VPN"
|
||||
MINIAPP_SERVICE_NAME_RU: str = "Bedolaga VPN"
|
||||
@@ -277,6 +320,13 @@ class Settings(BaseSettings):
|
||||
DEBUG: bool = False
|
||||
WEBHOOK_URL: Optional[str] = None
|
||||
WEBHOOK_PATH: str = "/webhook"
|
||||
WEBHOOK_SECRET_TOKEN: Optional[str] = None
|
||||
WEBHOOK_DROP_PENDING_UPDATES: bool = True
|
||||
WEBHOOK_MAX_QUEUE_SIZE: int = 1024
|
||||
WEBHOOK_WORKERS: int = 4
|
||||
WEBHOOK_ENQUEUE_TIMEOUT: float = 0.1
|
||||
WEBHOOK_WORKER_SHUTDOWN_TIMEOUT: float = 30.0
|
||||
BOT_RUN_MODE: str = "polling"
|
||||
|
||||
WEB_API_ENABLED: bool = False
|
||||
WEB_API_HOST: str = "0.0.0.0"
|
||||
@@ -515,6 +565,34 @@ class Settings(BaseSettings):
|
||||
description = re.sub(r'\s+', ' ', description).strip()
|
||||
return description
|
||||
|
||||
def format_remnawave_username(
|
||||
self,
|
||||
*,
|
||||
full_name: str,
|
||||
username: Optional[str],
|
||||
telegram_id: int
|
||||
) -> str:
|
||||
template = self.REMNAWAVE_USER_USERNAME_TEMPLATE or "user_{telegram_id}"
|
||||
|
||||
username_clean = (username or "").lstrip("@")
|
||||
full_name_value = full_name or ""
|
||||
|
||||
values = defaultdict(str, {
|
||||
"full_name": full_name_value,
|
||||
"username": username_clean,
|
||||
"username_clean": username_clean,
|
||||
"telegram_id": str(telegram_id),
|
||||
})
|
||||
|
||||
raw_username = template.format_map(values).strip()
|
||||
sanitized_username = re.sub(r"[^0-9A-Za-z._-]+", "_", raw_username)
|
||||
sanitized_username = re.sub(r"_+", "_", sanitized_username).strip("._-")
|
||||
|
||||
if not sanitized_username:
|
||||
sanitized_username = f"user_{telegram_id}"
|
||||
|
||||
return sanitized_username[:64]
|
||||
|
||||
@staticmethod
|
||||
def parse_daily_time_list(raw_value: Optional[str]) -> List[time]:
|
||||
if not raw_value:
|
||||
@@ -593,6 +671,15 @@ class Settings(BaseSettings):
|
||||
return normalized in {"1", "true", "yes", "on"}
|
||||
|
||||
return bool(value)
|
||||
|
||||
def is_auto_purchase_after_topup_enabled(self) -> bool:
|
||||
value = getattr(self, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", False)
|
||||
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
return normalized in {"1", "true", "yes", "on"}
|
||||
|
||||
return bool(value)
|
||||
|
||||
def get_available_languages(self) -> List[str]:
|
||||
try:
|
||||
@@ -753,9 +840,35 @@ class Settings(BaseSettings):
|
||||
|
||||
def is_traffic_fixed(self) -> bool:
|
||||
return self.TRAFFIC_SELECTION_MODE.lower() == "fixed"
|
||||
|
||||
|
||||
def get_fixed_traffic_limit(self) -> int:
|
||||
return self.FIXED_TRAFFIC_LIMIT_GB
|
||||
|
||||
def is_devices_selection_enabled(self) -> bool:
|
||||
return self.DEVICES_SELECTION_ENABLED
|
||||
|
||||
def get_devices_selection_disabled_amount(self) -> Optional[int]:
|
||||
raw_value = self.DEVICES_SELECTION_DISABLED_AMOUNT
|
||||
|
||||
if raw_value in (None, ""):
|
||||
return None
|
||||
|
||||
try:
|
||||
value = int(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Некорректное значение DEVICES_SELECTION_DISABLED_AMOUNT: %s",
|
||||
raw_value,
|
||||
)
|
||||
return None
|
||||
|
||||
if value < 0:
|
||||
return 0
|
||||
|
||||
return value
|
||||
|
||||
def get_disabled_mode_device_limit(self) -> Optional[int]:
|
||||
return self.get_devices_selection_disabled_amount()
|
||||
|
||||
def is_yookassa_enabled(self) -> bool:
|
||||
return (self.YOOKASSA_ENABLED and
|
||||
@@ -773,6 +886,13 @@ class Settings(BaseSettings):
|
||||
return (self.CRYPTOBOT_ENABLED and
|
||||
self.CRYPTOBOT_API_TOKEN is not None)
|
||||
|
||||
def is_heleket_enabled(self) -> bool:
|
||||
return (
|
||||
self.HELEKET_ENABLED
|
||||
and self.HELEKET_MERCHANT_ID is not None
|
||||
and self.HELEKET_API_KEY is not None
|
||||
)
|
||||
|
||||
def is_mulenpay_enabled(self) -> bool:
|
||||
return (
|
||||
self.MULENPAY_ENABLED
|
||||
@@ -781,6 +901,15 @@ class Settings(BaseSettings):
|
||||
and self.MULENPAY_SHOP_ID is not None
|
||||
)
|
||||
|
||||
def get_mulenpay_display_name(self) -> str:
|
||||
name = (self.MULENPAY_DISPLAY_NAME or "").strip()
|
||||
if not name:
|
||||
return "Mulen Pay"
|
||||
return name
|
||||
|
||||
def get_mulenpay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_mulenpay_display_name())
|
||||
|
||||
def is_pal24_enabled(self) -> bool:
|
||||
return (
|
||||
self.PAL24_ENABLED
|
||||
@@ -795,6 +924,24 @@ class Settings(BaseSettings):
|
||||
and self.WATA_TERMINAL_PUBLIC_ID is not None
|
||||
)
|
||||
|
||||
def is_payment_verification_auto_check_enabled(self) -> bool:
|
||||
return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED
|
||||
|
||||
def get_payment_verification_auto_check_interval(self) -> int:
|
||||
try:
|
||||
minutes = int(self.PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES)
|
||||
except (TypeError, ValueError): # pragma: no cover - защитная проверка конфигурации
|
||||
minutes = 10
|
||||
|
||||
if minutes <= 0:
|
||||
logger.warning(
|
||||
"Некорректный интервал автопроверки платежей: %s. Используется значение по умолчанию 10 минут.",
|
||||
self.PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES,
|
||||
)
|
||||
return 10
|
||||
|
||||
return minutes
|
||||
|
||||
def get_cryptobot_base_url(self) -> str:
|
||||
if self.CRYPTOBOT_TESTNET:
|
||||
return "https://testnet-pay.crypt.bot"
|
||||
@@ -812,6 +959,26 @@ class Settings(BaseSettings):
|
||||
def get_cryptobot_invoice_expires_seconds(self) -> int:
|
||||
return self.CRYPTOBOT_INVOICE_EXPIRES_HOURS * 3600
|
||||
|
||||
def get_heleket_markup_percent(self) -> float:
|
||||
try:
|
||||
return float(self.HELEKET_MARKUP_PERCENT)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
def get_heleket_lifetime(self) -> int:
|
||||
try:
|
||||
value = int(self.HELEKET_INVOICE_LIFETIME)
|
||||
except (TypeError, ValueError):
|
||||
value = 3600
|
||||
return max(300, min(43200, value))
|
||||
|
||||
def get_heleket_callback_url(self) -> Optional[str]:
|
||||
if self.HELEKET_CALLBACK_URL:
|
||||
return self.HELEKET_CALLBACK_URL
|
||||
if self.WEBHOOK_URL:
|
||||
return f"{self.WEBHOOK_URL}{self.HELEKET_WEBHOOK_PATH}"
|
||||
return None
|
||||
|
||||
def is_happ_cryptolink_mode(self) -> bool:
|
||||
return self.CONNECT_BUTTON_MODE == "happ_cryptolink"
|
||||
|
||||
@@ -856,6 +1023,13 @@ class Settings(BaseSettings):
|
||||
def get_maintenance_check_interval(self) -> int:
|
||||
return self.MAINTENANCE_CHECK_INTERVAL
|
||||
|
||||
def get_maintenance_retry_attempts(self) -> int:
|
||||
try:
|
||||
attempts = int(self.MAINTENANCE_RETRY_ATTEMPTS)
|
||||
except (TypeError, ValueError):
|
||||
attempts = 1
|
||||
return max(1, attempts)
|
||||
|
||||
def is_base_promo_group_period_discount_enabled(self) -> bool:
|
||||
return self.BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED
|
||||
|
||||
@@ -898,6 +1072,9 @@ class Settings(BaseSettings):
|
||||
def is_maintenance_auto_enable(self) -> bool:
|
||||
return self.MAINTENANCE_AUTO_ENABLE
|
||||
|
||||
def is_maintenance_monitoring_enabled(self) -> bool:
|
||||
return self.MAINTENANCE_MONITORING_ENABLED
|
||||
|
||||
def get_available_subscription_periods(self) -> List[int]:
|
||||
try:
|
||||
periods_str = self.AVAILABLE_SUBSCRIPTION_PERIODS
|
||||
@@ -989,34 +1166,7 @@ class Settings(BaseSettings):
|
||||
return (self.BACKUP_SEND_ENABLED and
|
||||
self.get_backup_send_chat_id() is not None)
|
||||
|
||||
def get_referred_user_reward_kopeks(self) -> int:
|
||||
"""Return the referred user reward normalized to kopeks.
|
||||
|
||||
Historically the value was stored in kopeks, however some
|
||||
installations provide it in rubles. To keep backward compatibility we
|
||||
treat any value greater than or equal to one thousand as already being
|
||||
in kopeks (≥ 10 ₽). Smaller positive values are assumed to be provided
|
||||
in rubles and therefore converted to kopeks.
|
||||
"""
|
||||
|
||||
raw_value = getattr(self, "REFERRED_USER_REWARD", 0)
|
||||
|
||||
try:
|
||||
value = int(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
if value <= 0:
|
||||
return 0
|
||||
|
||||
if value >= 1000:
|
||||
return value
|
||||
|
||||
return value * 100
|
||||
|
||||
def get_referral_settings(self) -> Dict:
|
||||
referred_reward_kopeks = self.get_referred_user_reward_kopeks()
|
||||
|
||||
return {
|
||||
"program_enabled": self.is_referral_program_enabled(),
|
||||
"minimum_topup_kopeks": self.REFERRAL_MINIMUM_TOPUP_KOPEKS,
|
||||
@@ -1024,8 +1174,6 @@ class Settings(BaseSettings):
|
||||
"inviter_bonus_kopeks": self.REFERRAL_INVITER_BONUS_KOPEKS,
|
||||
"commission_percent": self.REFERRAL_COMMISSION_PERCENT,
|
||||
"notifications_enabled": self.REFERRAL_NOTIFICATIONS_ENABLED,
|
||||
"referred_user_reward": referred_reward_kopeks,
|
||||
"referred_user_reward_kopeks": referred_reward_kopeks,
|
||||
}
|
||||
|
||||
def is_referral_program_enabled(self) -> bool:
|
||||
@@ -1283,15 +1431,82 @@ class Settings(BaseSettings):
|
||||
|
||||
def is_support_contact_enabled(self) -> bool:
|
||||
return self.get_support_system_mode() in {"contact", "both"}
|
||||
|
||||
|
||||
def get_bot_run_mode(self) -> str:
|
||||
mode = (self.BOT_RUN_MODE or "polling").strip().lower()
|
||||
if mode not in {"polling", "webhook", "both"}:
|
||||
return "polling"
|
||||
return mode
|
||||
|
||||
def get_telegram_webhook_path(self) -> str:
|
||||
raw_path = (self.WEBHOOK_PATH or "/webhook").strip()
|
||||
if not raw_path:
|
||||
raw_path = "/webhook"
|
||||
if not raw_path.startswith("/"):
|
||||
raw_path = "/" + raw_path
|
||||
return raw_path
|
||||
|
||||
def get_webhook_queue_maxsize(self) -> int:
|
||||
try:
|
||||
size = int(self.WEBHOOK_MAX_QUEUE_SIZE)
|
||||
except (TypeError, ValueError):
|
||||
size = 1024
|
||||
return max(1, size)
|
||||
|
||||
def get_webhook_worker_count(self) -> int:
|
||||
try:
|
||||
workers = int(self.WEBHOOK_WORKERS)
|
||||
except (TypeError, ValueError):
|
||||
workers = 1
|
||||
return max(1, workers)
|
||||
|
||||
def get_webhook_enqueue_timeout(self) -> float:
|
||||
try:
|
||||
timeout = float(self.WEBHOOK_ENQUEUE_TIMEOUT)
|
||||
except (TypeError, ValueError):
|
||||
timeout = 0.0
|
||||
return max(0.0, timeout)
|
||||
|
||||
def get_webhook_shutdown_timeout(self) -> float:
|
||||
try:
|
||||
timeout = float(self.WEBHOOK_WORKER_SHUTDOWN_TIMEOUT)
|
||||
except (TypeError, ValueError):
|
||||
timeout = 30.0
|
||||
return max(1.0, timeout)
|
||||
|
||||
def get_telegram_webhook_url(self) -> Optional[str]:
|
||||
base_url = (self.WEBHOOK_URL or "").strip()
|
||||
if not base_url:
|
||||
return None
|
||||
path = self.get_telegram_webhook_path()
|
||||
return f"{base_url.rstrip('/')}{path}"
|
||||
|
||||
def get_miniapp_static_path(self) -> Path:
|
||||
raw_path = (self.MINIAPP_STATIC_PATH or "miniapp").strip()
|
||||
if not raw_path:
|
||||
raw_path = "miniapp"
|
||||
return Path(raw_path)
|
||||
|
||||
model_config = {
|
||||
"env_file": ".env",
|
||||
"env_file_encoding": "utf-8",
|
||||
"extra": "ignore"
|
||||
"extra": "ignore"
|
||||
}
|
||||
|
||||
@field_validator("TIMEZONE")
|
||||
@classmethod
|
||||
def validate_timezone(cls, value: str) -> str:
|
||||
try:
|
||||
ZoneInfo(value)
|
||||
except Exception as exc: # pragma: no cover - defensive validation
|
||||
raise ValueError(
|
||||
f"Некорректный идентификатор часового пояса: {value}"
|
||||
) from exc
|
||||
return value
|
||||
|
||||
|
||||
settings = Settings()
|
||||
ENV_OVERRIDE_KEYS = set(settings.model_fields_set)
|
||||
|
||||
_PERIOD_PRICE_FIELDS: Dict[int, str] = {
|
||||
14: "PRICE_14_DAYS",
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import HeleketPayment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_heleket_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
uuid: str,
|
||||
order_id: str,
|
||||
amount: str,
|
||||
currency: str,
|
||||
status: str,
|
||||
payer_amount: Optional[str] = None,
|
||||
payer_currency: Optional[str] = None,
|
||||
exchange_rate: Optional[float] = None,
|
||||
discount_percent: Optional[int] = None,
|
||||
payment_url: Optional[str] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> HeleketPayment:
|
||||
payment = HeleketPayment(
|
||||
user_id=user_id,
|
||||
uuid=uuid,
|
||||
order_id=order_id,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
status=status,
|
||||
payer_amount=payer_amount,
|
||||
payer_currency=payer_currency,
|
||||
exchange_rate=exchange_rate,
|
||||
discount_percent=discount_percent,
|
||||
payment_url=payment_url,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata or {},
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Создан Heleket платеж: uuid=%s order_id=%s amount=%s %s для пользователя %s",
|
||||
uuid,
|
||||
order_id,
|
||||
amount,
|
||||
currency,
|
||||
user_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def get_heleket_payment_by_uuid(
|
||||
db: AsyncSession,
|
||||
uuid: str,
|
||||
) -> Optional[HeleketPayment]:
|
||||
result = await db.execute(
|
||||
select(HeleketPayment)
|
||||
.options(selectinload(HeleketPayment.user))
|
||||
.where(HeleketPayment.uuid == uuid)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_heleket_payment_by_order_id(
|
||||
db: AsyncSession,
|
||||
order_id: str,
|
||||
) -> Optional[HeleketPayment]:
|
||||
result = await db.execute(
|
||||
select(HeleketPayment)
|
||||
.options(selectinload(HeleketPayment.user))
|
||||
.where(HeleketPayment.order_id == order_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_heleket_payment_by_id(
|
||||
db: AsyncSession,
|
||||
payment_id: int,
|
||||
) -> Optional[HeleketPayment]:
|
||||
result = await db.execute(
|
||||
select(HeleketPayment)
|
||||
.options(selectinload(HeleketPayment.user))
|
||||
.where(HeleketPayment.id == payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_heleket_payment(
|
||||
db: AsyncSession,
|
||||
uuid: str,
|
||||
*,
|
||||
status: Optional[str] = None,
|
||||
payer_amount: Optional[str] = None,
|
||||
payer_currency: Optional[str] = None,
|
||||
exchange_rate: Optional[float] = None,
|
||||
discount_percent: Optional[int] = None,
|
||||
paid_at: Optional[datetime] = None,
|
||||
payment_url: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[HeleketPayment]:
|
||||
payment = await get_heleket_payment_by_uuid(db, uuid)
|
||||
|
||||
if not payment:
|
||||
logger.error("Heleket платеж с uuid=%s не найден", uuid)
|
||||
return None
|
||||
|
||||
if status is not None:
|
||||
payment.status = status
|
||||
if payer_amount is not None:
|
||||
payment.payer_amount = payer_amount
|
||||
if payer_currency is not None:
|
||||
payment.payer_currency = payer_currency
|
||||
if exchange_rate is not None:
|
||||
payment.exchange_rate = exchange_rate
|
||||
if discount_percent is not None:
|
||||
payment.discount_percent = discount_percent
|
||||
if payment_url is not None:
|
||||
payment.payment_url = payment_url
|
||||
if metadata is not None:
|
||||
existing = dict(payment.metadata_json or {})
|
||||
existing.update(metadata)
|
||||
payment.metadata_json = existing
|
||||
if paid_at is not None:
|
||||
payment.paid_at = paid_at
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Обновлен Heleket платеж %s: статус=%s payer_amount=%s %s",
|
||||
uuid,
|
||||
payment.status,
|
||||
payment.payer_amount,
|
||||
payment.payer_currency,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def link_heleket_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
uuid: str,
|
||||
transaction_id: int,
|
||||
) -> Optional[HeleketPayment]:
|
||||
payment = await get_heleket_payment_by_uuid(db, uuid)
|
||||
|
||||
if not payment:
|
||||
logger.error("Не найден Heleket платеж для связи с транзакцией: %s", uuid)
|
||||
return None
|
||||
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Heleket платеж %s связан с транзакцией %s",
|
||||
uuid,
|
||||
transaction_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
@@ -5,6 +5,7 @@ from typing import Optional
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import MulenPayPayment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -40,7 +41,8 @@ async def create_mulenpay_payment(
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Создан MulenPay платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s",
|
||||
"Создан %s платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s",
|
||||
settings.get_mulenpay_display_name(),
|
||||
payment.mulen_payment_id,
|
||||
uuid,
|
||||
amount_kopeks,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy import select, update
|
||||
@@ -87,6 +88,7 @@ async def update_pal24_payment_status(
|
||||
status: str,
|
||||
is_active: Optional[bool] = None,
|
||||
is_paid: Optional[bool] = None,
|
||||
paid_at: Optional[datetime] = None,
|
||||
payment_id: Optional[str] = None,
|
||||
payment_status: Optional[str] = None,
|
||||
payment_method: Optional[str] = None,
|
||||
@@ -103,6 +105,8 @@ async def update_pal24_payment_status(
|
||||
update_values["is_active"] = is_active
|
||||
if is_paid is not None:
|
||||
update_values["is_paid"] = is_paid
|
||||
if paid_at is not None:
|
||||
update_values["paid_at"] = paid_at
|
||||
if payment_id is not None:
|
||||
update_values["payment_id"] = payment_id
|
||||
if payment_status is not None:
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
import logging
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
from sqlalchemy import and_, delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
Poll,
|
||||
PollAnswer,
|
||||
PollOption,
|
||||
PollQuestion,
|
||||
PollResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_poll(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
title: str,
|
||||
description: str | None,
|
||||
reward_enabled: bool,
|
||||
reward_amount_kopeks: int,
|
||||
created_by: int | None,
|
||||
questions: Sequence[dict[str, Iterable[str]]],
|
||||
) -> Poll:
|
||||
poll = Poll(
|
||||
title=title,
|
||||
description=description,
|
||||
reward_enabled=reward_enabled,
|
||||
reward_amount_kopeks=reward_amount_kopeks if reward_enabled else 0,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(poll)
|
||||
await db.flush()
|
||||
|
||||
for order, question_data in enumerate(questions, start=1):
|
||||
question_text = question_data.get("text", "").strip()
|
||||
if not question_text:
|
||||
continue
|
||||
|
||||
question = PollQuestion(
|
||||
poll_id=poll.id,
|
||||
text=question_text,
|
||||
order=order,
|
||||
)
|
||||
db.add(question)
|
||||
await db.flush()
|
||||
|
||||
for option_order, option_text in enumerate(question_data.get("options", []), start=1):
|
||||
option_text = option_text.strip()
|
||||
if not option_text:
|
||||
continue
|
||||
option = PollOption(
|
||||
question_id=question.id,
|
||||
text=option_text,
|
||||
order=option_order,
|
||||
)
|
||||
db.add(option)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(
|
||||
poll,
|
||||
attribute_names=["questions"],
|
||||
)
|
||||
return poll
|
||||
|
||||
|
||||
async def list_polls(db: AsyncSession) -> list[Poll]:
|
||||
result = await db.execute(
|
||||
select(Poll)
|
||||
.options(
|
||||
selectinload(Poll.questions).options(selectinload(PollQuestion.options))
|
||||
)
|
||||
.order_by(Poll.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_poll_by_id(db: AsyncSession, poll_id: int) -> Poll | None:
|
||||
result = await db.execute(
|
||||
select(Poll)
|
||||
.options(
|
||||
selectinload(Poll.questions).options(selectinload(PollQuestion.options)),
|
||||
selectinload(Poll.responses),
|
||||
)
|
||||
.where(Poll.id == poll_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def delete_poll(db: AsyncSession, poll_id: int) -> bool:
|
||||
poll = await db.get(Poll, poll_id)
|
||||
if not poll:
|
||||
return False
|
||||
|
||||
await db.delete(poll)
|
||||
await db.commit()
|
||||
logger.info("🗑️ Удалён опрос %s", poll_id)
|
||||
return True
|
||||
|
||||
|
||||
async def create_poll_response(
|
||||
db: AsyncSession,
|
||||
poll_id: int,
|
||||
user_id: int,
|
||||
) -> PollResponse:
|
||||
result = await db.execute(
|
||||
select(PollResponse)
|
||||
.where(
|
||||
and_(
|
||||
PollResponse.poll_id == poll_id,
|
||||
PollResponse.user_id == user_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
response = result.scalar_one_or_none()
|
||||
if response:
|
||||
return response
|
||||
|
||||
response = PollResponse(
|
||||
poll_id=poll_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
db.add(response)
|
||||
await db.commit()
|
||||
await db.refresh(response)
|
||||
return response
|
||||
|
||||
|
||||
async def get_poll_response_by_id(
|
||||
db: AsyncSession,
|
||||
response_id: int,
|
||||
) -> PollResponse | None:
|
||||
result = await db.execute(
|
||||
select(PollResponse)
|
||||
.options(
|
||||
selectinload(PollResponse.poll)
|
||||
.options(selectinload(Poll.questions).options(selectinload(PollQuestion.options))),
|
||||
selectinload(PollResponse.answers),
|
||||
selectinload(PollResponse.user),
|
||||
)
|
||||
.where(PollResponse.id == response_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def record_poll_answer(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
response_id: int,
|
||||
question_id: int,
|
||||
option_id: int,
|
||||
) -> PollAnswer:
|
||||
result = await db.execute(
|
||||
select(PollAnswer)
|
||||
.where(
|
||||
and_(
|
||||
PollAnswer.response_id == response_id,
|
||||
PollAnswer.question_id == question_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
answer = result.scalar_one_or_none()
|
||||
if answer:
|
||||
answer.option_id = option_id
|
||||
await db.commit()
|
||||
await db.refresh(answer)
|
||||
return answer
|
||||
|
||||
answer = PollAnswer(
|
||||
response_id=response_id,
|
||||
question_id=question_id,
|
||||
option_id=option_id,
|
||||
)
|
||||
db.add(answer)
|
||||
await db.commit()
|
||||
await db.refresh(answer)
|
||||
return answer
|
||||
|
||||
|
||||
async def reset_poll_answers(db: AsyncSession, response_id: int) -> None:
|
||||
await db.execute(
|
||||
delete(PollAnswer).where(PollAnswer.response_id == response_id)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_poll_statistics(db: AsyncSession, poll_id: int) -> dict:
|
||||
totals_result = await db.execute(
|
||||
select(
|
||||
func.count(PollResponse.id),
|
||||
func.count(PollResponse.completed_at),
|
||||
func.coalesce(func.sum(PollResponse.reward_amount_kopeks), 0),
|
||||
).where(PollResponse.poll_id == poll_id)
|
||||
)
|
||||
total_responses, completed_responses, reward_sum = totals_result.one()
|
||||
|
||||
option_counts_result = await db.execute(
|
||||
select(
|
||||
PollQuestion.id,
|
||||
PollQuestion.text,
|
||||
PollQuestion.order,
|
||||
PollOption.id,
|
||||
PollOption.text,
|
||||
PollOption.order,
|
||||
func.count(PollAnswer.id),
|
||||
)
|
||||
.join(PollOption, PollOption.question_id == PollQuestion.id)
|
||||
.outerjoin(
|
||||
PollAnswer,
|
||||
and_(
|
||||
PollAnswer.question_id == PollQuestion.id,
|
||||
PollAnswer.option_id == PollOption.id,
|
||||
),
|
||||
)
|
||||
.where(PollQuestion.poll_id == poll_id)
|
||||
.group_by(
|
||||
PollQuestion.id,
|
||||
PollQuestion.text,
|
||||
PollQuestion.order,
|
||||
PollOption.id,
|
||||
PollOption.text,
|
||||
PollOption.order,
|
||||
)
|
||||
.order_by(PollQuestion.order.asc(), PollOption.order.asc())
|
||||
)
|
||||
|
||||
questions_map: dict[int, dict] = {}
|
||||
for (
|
||||
question_id,
|
||||
question_text,
|
||||
question_order,
|
||||
option_id,
|
||||
option_text,
|
||||
option_order,
|
||||
answer_count,
|
||||
) in option_counts_result:
|
||||
question_entry = questions_map.setdefault(
|
||||
question_id,
|
||||
{
|
||||
"id": question_id,
|
||||
"text": question_text,
|
||||
"order": question_order,
|
||||
"options": [],
|
||||
},
|
||||
)
|
||||
question_entry["options"].append(
|
||||
{
|
||||
"id": option_id,
|
||||
"text": option_text,
|
||||
"count": answer_count,
|
||||
}
|
||||
)
|
||||
|
||||
questions = sorted(questions_map.values(), key=lambda item: item["order"])
|
||||
|
||||
return {
|
||||
"total_responses": total_responses,
|
||||
"completed_responses": completed_responses,
|
||||
"reward_sum_kopeks": reward_sum,
|
||||
"questions": questions,
|
||||
}
|
||||
|
||||
|
||||
async def get_poll_responses_with_answers(
|
||||
db: AsyncSession,
|
||||
poll_id: int,
|
||||
*,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> tuple[list[PollResponse], int]:
|
||||
total_result = await db.execute(
|
||||
select(func.count()).select_from(PollResponse).where(PollResponse.poll_id == poll_id)
|
||||
)
|
||||
total = int(total_result.scalar_one() or 0)
|
||||
|
||||
if total == 0:
|
||||
return [], 0
|
||||
|
||||
result = await db.execute(
|
||||
select(PollResponse)
|
||||
.options(
|
||||
selectinload(PollResponse.user),
|
||||
selectinload(PollResponse.answers).selectinload(PollAnswer.question),
|
||||
selectinload(PollResponse.answers).selectinload(PollAnswer.option),
|
||||
)
|
||||
.where(PollResponse.poll_id == poll_id)
|
||||
.order_by(PollResponse.sent_at.asc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
responses = result.scalars().unique().all()
|
||||
return responses, total
|
||||
@@ -1,11 +1,11 @@
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import PromoGroup, User
|
||||
from app.database.models import PromoGroup, User, UserPromoGroup
|
||||
|
||||
|
||||
def _normalize_period_discounts(period_discounts: Optional[Dict[int, int]]) -> Dict[int, int]:
|
||||
@@ -38,7 +38,7 @@ async def get_promo_groups_with_counts(
|
||||
select(PromoGroup, func.count(User.id))
|
||||
.outerjoin(User, User.promo_group_id == PromoGroup.id)
|
||||
.group_by(PromoGroup.id)
|
||||
.order_by(PromoGroup.is_default.desc(), PromoGroup.name)
|
||||
.order_by(PromoGroup.priority.desc(), PromoGroup.name)
|
||||
)
|
||||
|
||||
if offset:
|
||||
@@ -89,6 +89,7 @@ async def create_promo_group(
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
*,
|
||||
priority: int = 0,
|
||||
server_discount_percent: int,
|
||||
traffic_discount_percent: int,
|
||||
device_discount_percent: int,
|
||||
@@ -110,6 +111,7 @@ async def create_promo_group(
|
||||
|
||||
promo_group = PromoGroup(
|
||||
name=name.strip(),
|
||||
priority=max(0, priority),
|
||||
server_discount_percent=max(0, min(100, server_discount_percent)),
|
||||
traffic_discount_percent=max(0, min(100, traffic_discount_percent)),
|
||||
device_discount_percent=max(0, min(100, device_discount_percent)),
|
||||
@@ -152,6 +154,7 @@ async def update_promo_group(
|
||||
group: PromoGroup,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
priority: Optional[int] = None,
|
||||
server_discount_percent: Optional[int] = None,
|
||||
traffic_discount_percent: Optional[int] = None,
|
||||
device_discount_percent: Optional[int] = None,
|
||||
@@ -162,6 +165,8 @@ async def update_promo_group(
|
||||
) -> PromoGroup:
|
||||
if name is not None:
|
||||
group.name = name.strip()
|
||||
if priority is not None:
|
||||
group.priority = max(0, priority)
|
||||
if server_discount_percent is not None:
|
||||
group.server_discount_percent = max(0, min(100, server_discount_percent))
|
||||
if traffic_discount_percent is not None:
|
||||
@@ -228,11 +233,42 @@ async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# Получаем список пользователей, связанных с удаляемой промогруппой
|
||||
affected_user_ids: Set[int] = set()
|
||||
|
||||
user_ids_result = await db.execute(
|
||||
select(User.id).where(User.promo_group_id == group.id)
|
||||
)
|
||||
affected_user_ids.update(user_ids_result.scalars().all())
|
||||
|
||||
promo_group_links_result = await db.execute(
|
||||
select(UserPromoGroup.user_id).where(UserPromoGroup.promo_group_id == group.id)
|
||||
)
|
||||
affected_user_ids.update(promo_group_links_result.scalars().all())
|
||||
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.promo_group_id == group.id)
|
||||
.values(promo_group_id=default_group.id)
|
||||
)
|
||||
|
||||
if affected_user_ids:
|
||||
existing_defaults_result = await db.execute(
|
||||
select(UserPromoGroup.user_id)
|
||||
.where(UserPromoGroup.promo_group_id == default_group.id)
|
||||
.where(UserPromoGroup.user_id.in_(affected_user_ids))
|
||||
)
|
||||
existing_default_user_ids = set(existing_defaults_result.scalars().all())
|
||||
|
||||
for user_id in affected_user_ids - existing_default_user_ids:
|
||||
db.add(
|
||||
UserPromoGroup(
|
||||
user_id=user_id,
|
||||
promo_group_id=default_group.id,
|
||||
assigned_by="system",
|
||||
)
|
||||
)
|
||||
|
||||
await db.delete(group)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -13,12 +13,50 @@ logger = logging.getLogger(__name__)
|
||||
async def get_promocode_by_code(db: AsyncSession, code: str) -> Optional[PromoCode]:
|
||||
result = await db.execute(
|
||||
select(PromoCode)
|
||||
.options(selectinload(PromoCode.uses))
|
||||
.options(
|
||||
selectinload(PromoCode.uses),
|
||||
selectinload(PromoCode.promo_group)
|
||||
)
|
||||
.where(PromoCode.code == code.upper())
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_promocode_by_id(db: AsyncSession, promo_id: int) -> Optional[PromoCode]:
|
||||
"""
|
||||
Получает промокод по ID с eager loading всех связанных данных.
|
||||
Используется для избежания lazy loading в async контексте.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(PromoCode)
|
||||
.options(
|
||||
selectinload(PromoCode.uses),
|
||||
selectinload(PromoCode.promo_group)
|
||||
)
|
||||
.where(PromoCode.id == promo_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def check_promocode_validity(db: AsyncSession, code: str) -> dict:
|
||||
"""
|
||||
Проверяет существование и валидность промокода без активации.
|
||||
Возвращает словарь с информацией о промокоде.
|
||||
"""
|
||||
promocode = await get_promocode_by_code(db, code)
|
||||
|
||||
if not promocode:
|
||||
return {"valid": False, "error": "not_found", "promocode": None}
|
||||
|
||||
if not promocode.is_valid:
|
||||
if promocode.current_uses >= promocode.max_uses:
|
||||
return {"valid": False, "error": "used", "promocode": None}
|
||||
else:
|
||||
return {"valid": False, "error": "expired", "promocode": None}
|
||||
|
||||
return {"valid": True, "error": None, "promocode": promocode}
|
||||
|
||||
|
||||
async def create_promocode(
|
||||
db: AsyncSession,
|
||||
code: str,
|
||||
@@ -27,7 +65,8 @@ async def create_promocode(
|
||||
subscription_days: int = 0,
|
||||
max_uses: int = 1,
|
||||
valid_until: Optional[datetime] = None,
|
||||
created_by: Optional[int] = None
|
||||
created_by: Optional[int] = None,
|
||||
promo_group_id: Optional[int] = None
|
||||
) -> PromoCode:
|
||||
|
||||
promocode = PromoCode(
|
||||
@@ -37,14 +76,18 @@ async def create_promocode(
|
||||
subscription_days=subscription_days,
|
||||
max_uses=max_uses,
|
||||
valid_until=valid_until,
|
||||
created_by=created_by
|
||||
created_by=created_by,
|
||||
promo_group_id=promo_group_id
|
||||
)
|
||||
|
||||
db.add(promocode)
|
||||
await db.commit()
|
||||
await db.refresh(promocode)
|
||||
|
||||
logger.info(f"✅ Создан промокод: {code}")
|
||||
|
||||
if promo_group_id:
|
||||
logger.info(f"✅ Создан промокод: {code} с промогруппой ID {promo_group_id}")
|
||||
else:
|
||||
logger.info(f"✅ Создан промокод: {code}")
|
||||
return promocode
|
||||
|
||||
|
||||
@@ -53,9 +96,9 @@ async def use_promocode(
|
||||
promocode_id: int,
|
||||
user_id: int
|
||||
) -> bool:
|
||||
|
||||
|
||||
try:
|
||||
promocode = await db.get(PromoCode, promocode_id)
|
||||
promocode = await get_promocode_by_id(db, promocode_id)
|
||||
if not promocode:
|
||||
return False
|
||||
|
||||
@@ -143,14 +186,17 @@ async def get_promocodes_list(
|
||||
limit: int = 50,
|
||||
is_active: Optional[bool] = None
|
||||
) -> List[PromoCode]:
|
||||
|
||||
query = select(PromoCode).options(selectinload(PromoCode.uses))
|
||||
|
||||
|
||||
query = select(PromoCode).options(
|
||||
selectinload(PromoCode.uses),
|
||||
selectinload(PromoCode.promo_group)
|
||||
)
|
||||
|
||||
if is_active is not None:
|
||||
query = query.where(PromoCode.is_active == is_active)
|
||||
|
||||
|
||||
query = query.order_by(PromoCode.created_at.desc()).offset(offset).limit(limit)
|
||||
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ async def create_server_squad(
|
||||
max_users: int = None,
|
||||
is_available: bool = True,
|
||||
is_trial_eligible: bool = False,
|
||||
sort_order: int = 0,
|
||||
promo_group_ids: Optional[Iterable[int]] = None,
|
||||
) -> ServerSquad:
|
||||
|
||||
@@ -80,6 +81,7 @@ async def create_server_squad(
|
||||
max_users=max_users,
|
||||
is_available=is_available,
|
||||
is_trial_eligible=is_trial_eligible,
|
||||
sort_order=sort_order,
|
||||
allowed_promo_groups=promo_groups,
|
||||
)
|
||||
|
||||
@@ -167,6 +169,58 @@ async def get_available_server_squads(
|
||||
return result.scalars().unique().all()
|
||||
|
||||
|
||||
async def get_active_server_squads(db: AsyncSession) -> List[ServerSquad]:
|
||||
"""Возвращает список активных серверов, доступных для подключения."""
|
||||
|
||||
squads = await get_available_server_squads(db)
|
||||
|
||||
if not squads:
|
||||
return []
|
||||
|
||||
eligible: List[ServerSquad] = []
|
||||
|
||||
for squad in squads:
|
||||
max_users = squad.max_users
|
||||
current_users = squad.current_users or 0
|
||||
|
||||
if max_users is not None and current_users >= max_users:
|
||||
continue
|
||||
|
||||
eligible.append(squad)
|
||||
|
||||
if eligible:
|
||||
return eligible
|
||||
|
||||
return squads
|
||||
|
||||
|
||||
async def choose_random_active_server_squad(
|
||||
db: AsyncSession,
|
||||
) -> Optional[ServerSquad]:
|
||||
"""Возвращает случайный активный сервер."""
|
||||
|
||||
squads = await get_active_server_squads(db)
|
||||
|
||||
if not squads:
|
||||
return None
|
||||
|
||||
return random.choice(squads)
|
||||
|
||||
|
||||
async def get_random_active_squad_uuid(
|
||||
db: AsyncSession,
|
||||
fallback_uuid: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Возвращает UUID случайного активного сервера или запасной UUID."""
|
||||
|
||||
squad = await choose_random_active_server_squad(db)
|
||||
|
||||
if squad:
|
||||
return squad.squad_uuid
|
||||
|
||||
return fallback_uuid
|
||||
|
||||
|
||||
async def update_server_squad_promo_groups(
|
||||
db: AsyncSession, server_id: int, promo_group_ids: Iterable[int]
|
||||
) -> Optional[ServerSquad]:
|
||||
@@ -208,8 +262,15 @@ async def update_server_squad(
|
||||
) -> Optional[ServerSquad]:
|
||||
|
||||
valid_fields = {
|
||||
'display_name', 'country_code', 'price_kopeks', 'description',
|
||||
'max_users', 'is_available', 'sort_order', 'is_trial_eligible'
|
||||
"display_name",
|
||||
"original_name",
|
||||
"country_code",
|
||||
"price_kopeks",
|
||||
"description",
|
||||
"max_users",
|
||||
"is_available",
|
||||
"sort_order",
|
||||
"is_trial_eligible",
|
||||
}
|
||||
|
||||
filtered_updates = {k: v for k, v in updates.items() if k in valid_fields}
|
||||
|
||||
@@ -11,10 +11,12 @@ from app.database.models import (
|
||||
User,
|
||||
SubscriptionServer,
|
||||
PromoGroup,
|
||||
UserPromoGroup,
|
||||
)
|
||||
from app.database.crud.notification import clear_notifications
|
||||
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
|
||||
from app.config import settings
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -30,6 +32,7 @@ async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Optiona
|
||||
subscription = result.scalar_one_or_none()
|
||||
|
||||
if subscription:
|
||||
logger.info(f"🔍 Загружена подписка {subscription.id} для пользователя {user_id}, статус: {subscription.status}")
|
||||
subscription = await check_and_update_subscription_status(db, subscription)
|
||||
|
||||
return subscription
|
||||
@@ -40,13 +43,14 @@ async def create_trial_subscription(
|
||||
user_id: int,
|
||||
duration_days: int = None,
|
||||
traffic_limit_gb: int = None,
|
||||
device_limit: int = None,
|
||||
device_limit: Optional[int] = None,
|
||||
squad_uuid: str = None
|
||||
) -> Subscription:
|
||||
|
||||
duration_days = duration_days or settings.TRIAL_DURATION_DAYS
|
||||
traffic_limit_gb = traffic_limit_gb or settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
device_limit = device_limit or settings.TRIAL_DEVICE_LIMIT
|
||||
if device_limit is None:
|
||||
device_limit = settings.TRIAL_DEVICE_LIMIT
|
||||
if not squad_uuid:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
@@ -125,13 +129,16 @@ async def create_paid_subscription(
|
||||
user_id: int,
|
||||
duration_days: int,
|
||||
traffic_limit_gb: int = 0,
|
||||
device_limit: int = 1,
|
||||
device_limit: Optional[int] = None,
|
||||
connected_squads: List[str] = None,
|
||||
update_server_counters: bool = False,
|
||||
) -> Subscription:
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
|
||||
if device_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
@@ -149,7 +156,7 @@ async def create_paid_subscription(
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"💎 Создана платная подписка для пользователя {user_id}")
|
||||
logger.info(f"💎 Создана платная подписка для пользователя {user_id}, ID: {subscription.id}, статус: {subscription.status}")
|
||||
|
||||
squad_uuids = list(connected_squads or [])
|
||||
if update_server_counters and squad_uuids:
|
||||
@@ -189,10 +196,27 @@ async def extend_subscription(
|
||||
days: int
|
||||
) -> Subscription:
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
|
||||
logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней")
|
||||
logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}")
|
||||
|
||||
|
||||
# НОВОЕ: Вычисляем бонусные дни от триала ДО изменения end_date
|
||||
bonus_days = 0
|
||||
if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID:
|
||||
# Вычисляем остаток триала
|
||||
if subscription.end_date and subscription.end_date > current_time:
|
||||
remaining = subscription.end_date - current_time
|
||||
if remaining.total_seconds() > 0:
|
||||
bonus_days = max(0, remaining.days)
|
||||
logger.info(
|
||||
"🎁 Обнаружен остаток триала: %s дней для подписки %s",
|
||||
bonus_days,
|
||||
subscription.id,
|
||||
)
|
||||
|
||||
# Применяем продление с учетом бонусных дней
|
||||
total_days = days + bonus_days
|
||||
|
||||
if days < 0:
|
||||
subscription.end_date = subscription.end_date + timedelta(days=days)
|
||||
logger.info(
|
||||
@@ -201,28 +225,19 @@ async def extend_subscription(
|
||||
subscription.end_date,
|
||||
)
|
||||
elif subscription.end_date > current_time:
|
||||
subscription.end_date = subscription.end_date + timedelta(days=days)
|
||||
logger.info(f"📅 Подписка активна, добавляем {days} дней к текущей дате окончания")
|
||||
subscription.end_date = subscription.end_date + timedelta(days=total_days)
|
||||
logger.info(f"📅 Подписка активна, добавляем {total_days} дней ({days} + {bonus_days} бонус) к текущей дате окончания")
|
||||
else:
|
||||
subscription.end_date = current_time + timedelta(days=days)
|
||||
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания")
|
||||
subscription.end_date = current_time + timedelta(days=total_days)
|
||||
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания на {total_days} дней ({days} + {bonus_days} бонус)")
|
||||
|
||||
if subscription.is_trial:
|
||||
start_date = subscription.start_date or current_time
|
||||
total_duration = subscription.end_date - start_date
|
||||
max_trial_duration = timedelta(days=settings.TRIAL_DURATION_DAYS)
|
||||
|
||||
if total_duration > max_trial_duration:
|
||||
subscription.is_trial = False
|
||||
logger.info(
|
||||
"🎯 Подписка %s автоматически переведена из триальной в платную после продления"
|
||||
", итоговая длительность: %s дней",
|
||||
subscription.id,
|
||||
total_duration.days,
|
||||
)
|
||||
if subscription.user:
|
||||
subscription.user.has_had_paid_subscription = True
|
||||
# УДАЛЕНО: Автоматическая конвертация триала по длительности
|
||||
# Теперь триал конвертируется ТОЛЬКО после успешного коммита продления
|
||||
# и ТОЛЬКО вызывающей функцией (например, _auto_extend_subscription)
|
||||
|
||||
# Логируем статус подписки перед проверкой
|
||||
logger.info(f"🔄 Продление подписки {subscription.id}, текущий статус: {subscription.status}, дни: {days}")
|
||||
|
||||
if days > 0 and subscription.status in (
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
@@ -234,6 +249,12 @@ async def extend_subscription(
|
||||
subscription.id,
|
||||
previous_status,
|
||||
)
|
||||
elif days > 0 and subscription.status == SubscriptionStatus.PENDING.value:
|
||||
logger.warning(
|
||||
"⚠️ Попытка продлить PENDING подписку %s, дни: %s",
|
||||
subscription.id,
|
||||
days
|
||||
)
|
||||
|
||||
if settings.RESET_TRAFFIC_ON_PAYMENT:
|
||||
subscription.traffic_used_gb = 0.0
|
||||
@@ -900,7 +921,7 @@ async def get_subscription_renewal_cost(
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(
|
||||
selectinload(Subscription.user).selectinload(User.promo_group),
|
||||
selectinload(Subscription.user).selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
)
|
||||
.where(Subscription.id == subscription_id)
|
||||
)
|
||||
@@ -1117,6 +1138,14 @@ async def check_and_update_subscription_status(
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
logger.info(
|
||||
"🔍 Проверка статуса подписки %s, текущий статус: %s, дата окончания: %s, текущее время: %s",
|
||||
subscription.id,
|
||||
subscription.status,
|
||||
format_local_datetime(subscription.end_date),
|
||||
format_local_datetime(current_time),
|
||||
)
|
||||
|
||||
if (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date <= current_time):
|
||||
|
||||
@@ -1127,9 +1156,68 @@ async def check_and_update_subscription_status(
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"⏰ Статус подписки пользователя {subscription.user_id} изменен на 'expired'")
|
||||
elif subscription.status == SubscriptionStatus.PENDING.value:
|
||||
logger.info(f"ℹ️ Проверка PENDING подписки {subscription.id}, статус остается без изменений")
|
||||
|
||||
return subscription
|
||||
|
||||
async def create_subscription_no_commit(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
status: str = "trial",
|
||||
is_trial: bool = True,
|
||||
end_date: datetime = None,
|
||||
traffic_limit_gb: int = 10,
|
||||
traffic_used_gb: float = 0.0,
|
||||
device_limit: int = 1,
|
||||
connected_squads: list = None,
|
||||
remnawave_short_uuid: str = None,
|
||||
subscription_url: str = "",
|
||||
subscription_crypto_link: str = "",
|
||||
autopay_enabled: Optional[bool] = None,
|
||||
autopay_days_before: Optional[int] = None,
|
||||
) -> Subscription:
|
||||
"""
|
||||
Создает подписку без немедленного коммита для пакетной обработки
|
||||
"""
|
||||
|
||||
if end_date is None:
|
||||
end_date = datetime.utcnow() + timedelta(days=3)
|
||||
|
||||
if connected_squads is None:
|
||||
connected_squads = []
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=status,
|
||||
is_trial=is_trial,
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
traffic_used_gb=traffic_used_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads,
|
||||
remnawave_short_uuid=remnawave_short_uuid,
|
||||
subscription_url=subscription_url,
|
||||
subscription_crypto_link=subscription_crypto_link,
|
||||
autopay_enabled=(
|
||||
settings.is_autopay_enabled_by_default()
|
||||
if autopay_enabled is None
|
||||
else autopay_enabled
|
||||
),
|
||||
autopay_days_before=(
|
||||
settings.DEFAULT_AUTOPAY_DAYS_BEFORE
|
||||
if autopay_days_before is None
|
||||
else autopay_days_before
|
||||
),
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
# Не коммитим сразу, оставляем для пакетной обработки
|
||||
|
||||
logger.info(f"✅ Подготовлена подписка для пользователя {user_id} (ожидает коммита)")
|
||||
return subscription
|
||||
|
||||
|
||||
async def create_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
@@ -1183,3 +1271,130 @@ async def create_subscription(
|
||||
|
||||
logger.info(f"✅ Создана подписка для пользователя {user_id}")
|
||||
return subscription
|
||||
|
||||
|
||||
async def create_pending_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
duration_days: int,
|
||||
traffic_limit_gb: int = 0,
|
||||
device_limit: int = 1,
|
||||
connected_squads: List[str] = None,
|
||||
payment_method: str = "pending",
|
||||
total_price_kopeks: int = 0
|
||||
) -> Subscription:
|
||||
"""Creates a pending subscription that will be activated after payment."""
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
end_date = current_time + timedelta(days=duration_days)
|
||||
|
||||
existing_subscription = await get_subscription_by_user_id(db, user_id)
|
||||
|
||||
if existing_subscription:
|
||||
if (
|
||||
existing_subscription.status == SubscriptionStatus.ACTIVE.value
|
||||
and existing_subscription.end_date > current_time
|
||||
):
|
||||
logger.warning(
|
||||
"⚠️ Попытка создать pending подписку для активного пользователя %s. Возвращаем существующую запись.",
|
||||
user_id,
|
||||
)
|
||||
return existing_subscription
|
||||
|
||||
existing_subscription.status = SubscriptionStatus.PENDING.value
|
||||
existing_subscription.is_trial = False
|
||||
existing_subscription.start_date = current_time
|
||||
existing_subscription.end_date = end_date
|
||||
existing_subscription.traffic_limit_gb = traffic_limit_gb
|
||||
existing_subscription.device_limit = device_limit
|
||||
existing_subscription.connected_squads = connected_squads or []
|
||||
existing_subscription.traffic_used_gb = 0.0
|
||||
existing_subscription.updated_at = current_time
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_subscription)
|
||||
|
||||
logger.info(
|
||||
"♻️ Обновлена ожидающая подписка пользователя %s, ID: %s, метод оплаты: %s",
|
||||
user_id,
|
||||
existing_subscription.id,
|
||||
payment_method,
|
||||
)
|
||||
return existing_subscription
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.PENDING.value,
|
||||
is_trial=False,
|
||||
start_date=current_time,
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads or [],
|
||||
autopay_enabled=settings.is_autopay_enabled_by_default(),
|
||||
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(
|
||||
"💳 Создана ожидающая подписка для пользователя %s, ID: %s, метод оплаты: %s",
|
||||
user_id,
|
||||
subscription.id,
|
||||
payment_method,
|
||||
)
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
async def activate_pending_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
period_days: int = None
|
||||
) -> Optional[Subscription]:
|
||||
"""Активирует pending подписку пользователя, меняя её статус на ACTIVE."""
|
||||
from sqlalchemy import and_
|
||||
|
||||
logger.info(f"Активация pending подписки: пользователь {user_id}, период {period_days} дней")
|
||||
|
||||
# Находим pending подписку пользователя
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.where(
|
||||
and_(
|
||||
Subscription.user_id == user_id,
|
||||
Subscription.status == SubscriptionStatus.PENDING.value
|
||||
)
|
||||
)
|
||||
)
|
||||
pending_subscription = result.scalar_one_or_none()
|
||||
|
||||
if not pending_subscription:
|
||||
logger.warning(f"Не найдена pending подписка для пользователя {user_id}")
|
||||
return None
|
||||
|
||||
logger.info(f"Найдена pending подписка {pending_subscription.id} для пользователя {user_id}, статус: {pending_subscription.status}")
|
||||
|
||||
# Обновляем статус подписки на ACTIVE
|
||||
current_time = datetime.utcnow()
|
||||
pending_subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
|
||||
# Если указан период, обновляем дату окончания
|
||||
if period_days is not None:
|
||||
effective_start = pending_subscription.start_date or current_time
|
||||
if effective_start < current_time:
|
||||
effective_start = current_time
|
||||
pending_subscription.end_date = effective_start + timedelta(days=period_days)
|
||||
|
||||
# Обновляем дату начала, если она не установлена или в прошлом
|
||||
if not pending_subscription.start_date or pending_subscription.start_date < current_time:
|
||||
pending_subscription.start_date = current_time
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(pending_subscription)
|
||||
|
||||
logger.info(f"Подписка пользователя {user_id} активирована, ID: {pending_subscription.id}")
|
||||
|
||||
return pending_subscription
|
||||
|
||||
@@ -274,6 +274,30 @@ class TicketCRUD:
|
||||
db, ticket_id, TicketStatus.CLOSED.value, datetime.utcnow()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def close_all_open_tickets(
|
||||
db: AsyncSession,
|
||||
) -> List[int]:
|
||||
"""Закрыть все открытые тикеты. Возвращает список идентификаторов закрытых тикетов."""
|
||||
open_statuses = [TicketStatus.OPEN.value, TicketStatus.ANSWERED.value]
|
||||
result = await db.execute(
|
||||
select(Ticket.id).where(Ticket.status.in_(open_statuses))
|
||||
)
|
||||
ticket_ids = result.scalars().all()
|
||||
|
||||
if not ticket_ids:
|
||||
return []
|
||||
|
||||
now = datetime.utcnow()
|
||||
await db.execute(
|
||||
update(Ticket)
|
||||
.where(Ticket.id.in_(ticket_ids))
|
||||
.values(status=TicketStatus.CLOSED.value, closed_at=now, updated_at=now)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return ticket_ids
|
||||
|
||||
@staticmethod
|
||||
async def add_support_audit(
|
||||
db: AsyncSession,
|
||||
@@ -309,18 +333,42 @@ class TicketCRUD:
|
||||
*,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
action: Optional[str] = None,
|
||||
) -> List[SupportAuditLog]:
|
||||
from sqlalchemy import select, desc
|
||||
result = await db.execute(
|
||||
select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at)).offset(offset).limit(limit)
|
||||
)
|
||||
|
||||
query = select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at))
|
||||
|
||||
if action:
|
||||
query = query.where(SupportAuditLog.action == action)
|
||||
|
||||
result = await db.execute(query.offset(offset).limit(limit))
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def count_support_audit(db: AsyncSession) -> int:
|
||||
async def count_support_audit(db: AsyncSession, action: Optional[str] = None) -> int:
|
||||
from sqlalchemy import select, func
|
||||
result = await db.execute(select(func.count()).select_from(SupportAuditLog))
|
||||
|
||||
query = select(func.count()).select_from(SupportAuditLog)
|
||||
|
||||
if action:
|
||||
query = query.where(SupportAuditLog.action == action)
|
||||
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
@staticmethod
|
||||
async def list_support_audit_actions(db: AsyncSession) -> List[str]:
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await db.execute(
|
||||
select(SupportAuditLog.action)
|
||||
.where(SupportAuditLog.action.isnot(None))
|
||||
.distinct()
|
||||
.order_by(SupportAuditLog.action)
|
||||
)
|
||||
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
@staticmethod
|
||||
async def get_open_tickets_count(db: AsyncSession) -> int:
|
||||
|
||||
+179
-22
@@ -5,7 +5,7 @@ from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict
|
||||
from sqlalchemy import select, and_, or_, func, case, nullslast, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.orm import selectinload, joinedload
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.database.models import (
|
||||
@@ -15,6 +15,7 @@ from app.database.models import (
|
||||
SubscriptionStatus,
|
||||
Transaction,
|
||||
PromoGroup,
|
||||
UserPromoGroup,
|
||||
PaymentMethod,
|
||||
TransactionType,
|
||||
)
|
||||
@@ -38,14 +39,16 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(User.id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return user
|
||||
@@ -56,26 +59,64 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(User.telegram_id == telegram_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_user_by_username(db: AsyncSession, username: str) -> Optional[User]:
|
||||
if not username:
|
||||
return None
|
||||
|
||||
normalized = username.lower()
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(func.lower(User.username) == normalized)
|
||||
)
|
||||
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Optional[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.promo_group))
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.where(User.referral_code == referral_code)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def create_unique_referral_code(db: AsyncSession) -> str:
|
||||
@@ -122,6 +163,57 @@ async def _get_or_create_default_promo_group(db: AsyncSession) -> PromoGroup:
|
||||
return default_group
|
||||
|
||||
|
||||
async def create_user_no_commit(
|
||||
db: AsyncSession,
|
||||
telegram_id: int,
|
||||
username: str = None,
|
||||
first_name: str = None,
|
||||
last_name: str = None,
|
||||
language: str = "ru",
|
||||
referred_by_id: int = None,
|
||||
referral_code: str = None
|
||||
) -> User:
|
||||
"""
|
||||
Создает пользователя без немедленного коммита для пакетной обработки
|
||||
"""
|
||||
|
||||
if not referral_code:
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
promo_group_id = default_group.id
|
||||
|
||||
safe_first = sanitize_telegram_name(first_name)
|
||||
safe_last = sanitize_telegram_name(last_name)
|
||||
user = User(
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=safe_first,
|
||||
last_name=safe_last,
|
||||
language=language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
has_had_paid_subscription=False,
|
||||
has_made_first_topup=False,
|
||||
promo_group_id=promo_group_id,
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
|
||||
# Обязательно выполняем flush, чтобы получить присвоенный первичный ключ
|
||||
await db.flush()
|
||||
|
||||
# Сохраняем ссылку на группу, чтобы дальнейшие операции могли её использовать
|
||||
user.promo_group = default_group
|
||||
|
||||
# Не коммитим сразу, оставляем для пакетной обработки
|
||||
logger.info(
|
||||
f"✅ Подготовлен пользователь {telegram_id} с реферальным кодом {referral_code} (ожидает коммита)"
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
async def create_user(
|
||||
db: AsyncSession,
|
||||
telegram_id: int,
|
||||
@@ -219,7 +311,8 @@ async def add_user_balance(
|
||||
amount_kopeks: int,
|
||||
description: str = "Пополнение баланса",
|
||||
create_transaction: bool = True,
|
||||
bot = None
|
||||
transaction_type: TransactionType = TransactionType.DEPOSIT,
|
||||
bot = None
|
||||
) -> bool:
|
||||
try:
|
||||
old_balance = user.balance_kopeks
|
||||
@@ -228,12 +321,11 @@ async def add_user_balance(
|
||||
|
||||
if create_transaction:
|
||||
from app.database.crud.transaction import create_transaction as create_trans
|
||||
from app.database.models import TransactionType
|
||||
|
||||
|
||||
await create_trans(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
type=transaction_type,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description
|
||||
)
|
||||
@@ -253,9 +345,10 @@ async def add_user_balance(
|
||||
|
||||
async def add_user_balance_by_id(
|
||||
db: AsyncSession,
|
||||
telegram_id: int,
|
||||
telegram_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str = "Пополнение баланса"
|
||||
description: str = "Пополнение баланса",
|
||||
transaction_type: TransactionType = TransactionType.DEPOSIT,
|
||||
) -> bool:
|
||||
try:
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
@@ -263,7 +356,13 @@ async def add_user_balance_by_id(
|
||||
logger.error(f"Пользователь с telegram_id {telegram_id} не найден")
|
||||
return False
|
||||
|
||||
return await add_user_balance(db, user, amount_kopeks, description)
|
||||
return await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
amount_kopeks,
|
||||
description,
|
||||
transaction_type=transaction_type,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка пополнения баланса пользователя {telegram_id}: {e}")
|
||||
@@ -493,7 +592,11 @@ async def get_users_list(
|
||||
order_by_purchase_count: bool = False
|
||||
) -> List[User]:
|
||||
|
||||
query = select(User).options(selectinload(User.subscription))
|
||||
query = select(User).options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
|
||||
if status:
|
||||
query = query.where(User.status == status.value)
|
||||
@@ -507,7 +610,14 @@ async def get_users_list(
|
||||
]
|
||||
|
||||
if search.isdigit():
|
||||
conditions.append(User.telegram_id == int(search))
|
||||
try:
|
||||
search_int = int(search)
|
||||
# Добавляем условие поиска по telegram_id, который является BigInteger
|
||||
# и может содержать большие значения, в отличие от User.id (INTEGER)
|
||||
conditions.append(User.telegram_id == search_int)
|
||||
except ValueError:
|
||||
# Если не удалось преобразовать в int, просто ищем по текстовым полям
|
||||
pass
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
@@ -581,7 +691,15 @@ async def get_users_list(
|
||||
query = query.offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
users = result.scalars().all()
|
||||
|
||||
# Загружаем дополнительные зависимости для всех пользователей
|
||||
for user in users:
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return users
|
||||
|
||||
|
||||
async def get_users_count(
|
||||
@@ -604,7 +722,14 @@ async def get_users_count(
|
||||
]
|
||||
|
||||
if search.isdigit():
|
||||
conditions.append(User.telegram_id == int(search))
|
||||
try:
|
||||
search_int = int(search)
|
||||
# Добавляем условие поиска по telegram_id, который является BigInteger
|
||||
# и может содержать большие значения, в отличие от User.id (INTEGER)
|
||||
conditions.append(User.telegram_id == search_int)
|
||||
except ValueError:
|
||||
# Если не удалось преобразовать в int, просто ищем по текстовым полям
|
||||
pass
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
@@ -673,12 +798,22 @@ async def get_referrals(db: AsyncSession, user_id: int) -> List[User]:
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(User.referred_by_id == user_id)
|
||||
.order_by(User.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
users = result.scalars().all()
|
||||
|
||||
# Загружаем дополнительные зависимости для всех пользователей
|
||||
for user in users:
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return users
|
||||
|
||||
|
||||
async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[User]:
|
||||
@@ -686,7 +821,11 @@ async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[Us
|
||||
|
||||
base_query = (
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.where(User.status == UserStatus.ACTIVE.value)
|
||||
)
|
||||
|
||||
@@ -731,7 +870,15 @@ async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[Us
|
||||
return []
|
||||
|
||||
result = await db.execute(query.order_by(User.id))
|
||||
return result.scalars().unique().all()
|
||||
users = result.scalars().unique().all()
|
||||
|
||||
# Загружаем дополнительные зависимости для всех пользователей
|
||||
for user in users:
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return users
|
||||
|
||||
|
||||
async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
|
||||
@@ -741,6 +888,8 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(
|
||||
@@ -750,7 +899,15 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
users = result.scalars().all()
|
||||
|
||||
# Загружаем дополнительные зависимости для всех пользователей
|
||||
for user in users:
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return users
|
||||
|
||||
|
||||
async def delete_user(db: AsyncSession, user: User) -> bool:
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
"""CRUD операции для связи пользователей с промогруппами (Many-to-Many)."""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import select, and_, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import UserPromoGroup, PromoGroup, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _sync_user_primary_promo_group(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> None:
|
||||
"""Синхронизирует колонку users.promo_group_id с primary промогруппой."""
|
||||
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup.promo_group_id)
|
||||
.join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id)
|
||||
.where(UserPromoGroup.user_id == user_id)
|
||||
.order_by(desc(PromoGroup.priority), PromoGroup.id)
|
||||
)
|
||||
|
||||
first = result.first()
|
||||
new_primary_id = first[0] if first else None
|
||||
|
||||
user = await db.get(User, user_id)
|
||||
if not user:
|
||||
return
|
||||
|
||||
if user.promo_group_id != new_primary_id:
|
||||
user.promo_group_id = new_primary_id
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка синхронизации primary промогруппы пользователя %s: %s",
|
||||
user_id,
|
||||
error,
|
||||
)
|
||||
|
||||
|
||||
async def sync_user_primary_promo_group(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> None:
|
||||
"""Публичная обертка для синхронизации primary промогруппы пользователя."""
|
||||
|
||||
await _sync_user_primary_promo_group(db, user_id)
|
||||
|
||||
|
||||
async def add_user_to_promo_group(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promo_group_id: int,
|
||||
assigned_by: str = "admin"
|
||||
) -> Optional[UserPromoGroup]:
|
||||
"""
|
||||
Добавляет пользователю промогруппу.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
promo_group_id: ID промогруппы
|
||||
assigned_by: Кто назначил ('admin', 'system', 'auto', 'promocode')
|
||||
|
||||
Returns:
|
||||
UserPromoGroup или None если уже существует
|
||||
"""
|
||||
try:
|
||||
# Проверяем существование связи
|
||||
existing = await has_user_promo_group(db, user_id, promo_group_id)
|
||||
if existing:
|
||||
logger.info(f"Пользователь {user_id} уже имеет промогруппу {promo_group_id}")
|
||||
return None
|
||||
|
||||
# Создаем новую связь
|
||||
user_promo_group = UserPromoGroup(
|
||||
user_id=user_id,
|
||||
promo_group_id=promo_group_id,
|
||||
assigned_by=assigned_by,
|
||||
)
|
||||
db.add(user_promo_group)
|
||||
await db.flush()
|
||||
|
||||
await _sync_user_primary_promo_group(db, user_id)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user_promo_group)
|
||||
|
||||
logger.info(f"Пользователю {user_id} добавлена промогруппа {promo_group_id} ({assigned_by})")
|
||||
return user_promo_group
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка добавления промогруппы пользователю: {error}")
|
||||
await db.rollback()
|
||||
return None
|
||||
|
||||
|
||||
async def remove_user_from_promo_group(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promo_group_id: int
|
||||
) -> bool:
|
||||
"""
|
||||
Удаляет промогруппу у пользователя.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
promo_group_id: ID промогруппы
|
||||
|
||||
Returns:
|
||||
True если удалено, False если связи не было
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup).where(
|
||||
and_(
|
||||
UserPromoGroup.user_id == user_id,
|
||||
UserPromoGroup.promo_group_id == promo_group_id
|
||||
)
|
||||
)
|
||||
)
|
||||
user_promo_group = result.scalar_one_or_none()
|
||||
|
||||
if not user_promo_group:
|
||||
logger.warning(f"Связь пользователя {user_id} с промогруппой {promo_group_id} не найдена")
|
||||
return False
|
||||
|
||||
await db.delete(user_promo_group)
|
||||
await db.flush()
|
||||
|
||||
await _sync_user_primary_promo_group(db, user_id)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"У пользователя {user_id} удалена промогруппа {promo_group_id}")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка удаления промогруппы у пользователя: {error}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def get_user_promo_groups(
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> List[UserPromoGroup]:
|
||||
"""
|
||||
Получает все промогруппы пользователя, отсортированные по приоритету.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
Список UserPromoGroup с загруженными PromoGroup, отсортированный по приоритету DESC
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup)
|
||||
.options(selectinload(UserPromoGroup.promo_group))
|
||||
.where(UserPromoGroup.user_id == user_id)
|
||||
.join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id)
|
||||
.order_by(desc(PromoGroup.priority), PromoGroup.id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка получения промогрупп пользователя {user_id}: {error}")
|
||||
return []
|
||||
|
||||
|
||||
async def get_primary_user_promo_group(
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> Optional[PromoGroup]:
|
||||
"""
|
||||
Получает промогруппу пользователя с максимальным приоритетом.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
PromoGroup с максимальным приоритетом или None
|
||||
"""
|
||||
try:
|
||||
user_promo_groups = await get_user_promo_groups(db, user_id)
|
||||
|
||||
if not user_promo_groups:
|
||||
return None
|
||||
|
||||
# Первая в списке имеет максимальный приоритет (список уже отсортирован)
|
||||
return user_promo_groups[0].promo_group if user_promo_groups[0].promo_group else None
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка получения primary промогруппы пользователя {user_id}: {error}")
|
||||
return None
|
||||
|
||||
|
||||
async def has_user_promo_group(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promo_group_id: int
|
||||
) -> bool:
|
||||
"""
|
||||
Проверяет наличие промогруппы у пользователя.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
promo_group_id: ID промогруппы
|
||||
|
||||
Returns:
|
||||
True если пользователь уже имеет эту промогруппу
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup).where(
|
||||
and_(
|
||||
UserPromoGroup.user_id == user_id,
|
||||
UserPromoGroup.promo_group_id == promo_group_id
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка проверки промогруппы пользователя: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def count_user_promo_groups(
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> int:
|
||||
"""
|
||||
Подсчитывает количество промогрупп у пользователя.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
Количество промогрупп
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup).where(UserPromoGroup.user_id == user_id)
|
||||
)
|
||||
return len(list(result.scalars().all()))
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка подсчета промогрупп пользователя: {error}")
|
||||
return 0
|
||||
|
||||
|
||||
async def replace_user_promo_groups(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promo_group_ids: List[int],
|
||||
assigned_by: str = "admin"
|
||||
) -> bool:
|
||||
"""
|
||||
Заменяет все промогруппы пользователя на новый список.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
promo_group_ids: Список ID промогрупп
|
||||
assigned_by: Кто назначил
|
||||
|
||||
Returns:
|
||||
True если успешно
|
||||
"""
|
||||
try:
|
||||
# Удаляем все текущие промогруппы
|
||||
await db.execute(
|
||||
select(UserPromoGroup).where(UserPromoGroup.user_id == user_id)
|
||||
)
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup).where(UserPromoGroup.user_id == user_id)
|
||||
)
|
||||
for upg in result.scalars().all():
|
||||
await db.delete(upg)
|
||||
|
||||
# Добавляем новые
|
||||
for promo_group_id in promo_group_ids:
|
||||
user_promo_group = UserPromoGroup(
|
||||
user_id=user_id,
|
||||
promo_group_id=promo_group_id,
|
||||
assigned_by=assigned_by
|
||||
)
|
||||
db.add(user_promo_group)
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"Промогруппы пользователя {user_id} заменены на {promo_group_ids}")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка замены промогрупп пользователя: {error}")
|
||||
await db.rollback()
|
||||
return False
|
||||
+238
-19
@@ -1,5 +1,5 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict
|
||||
from typing import Optional, List, Dict, Any
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import (
|
||||
@@ -54,20 +54,23 @@ class SubscriptionStatus(Enum):
|
||||
ACTIVE = "active"
|
||||
EXPIRED = "expired"
|
||||
DISABLED = "disabled"
|
||||
PENDING = "pending"
|
||||
|
||||
|
||||
class TransactionType(Enum):
|
||||
DEPOSIT = "deposit"
|
||||
WITHDRAWAL = "withdrawal"
|
||||
SUBSCRIPTION_PAYMENT = "subscription_payment"
|
||||
REFUND = "refund"
|
||||
REFERRAL_REWARD = "referral_reward"
|
||||
DEPOSIT = "deposit"
|
||||
WITHDRAWAL = "withdrawal"
|
||||
SUBSCRIPTION_PAYMENT = "subscription_payment"
|
||||
REFUND = "refund"
|
||||
REFERRAL_REWARD = "referral_reward"
|
||||
POLL_REWARD = "poll_reward"
|
||||
|
||||
|
||||
class PromoCodeType(Enum):
|
||||
BALANCE = "balance"
|
||||
SUBSCRIPTION_DAYS = "subscription_days"
|
||||
TRIAL_SUBSCRIPTION = "trial_subscription"
|
||||
BALANCE = "balance"
|
||||
SUBSCRIPTION_DAYS = "subscription_days"
|
||||
TRIAL_SUBSCRIPTION = "trial_subscription"
|
||||
PROMO_GROUP = "promo_group"
|
||||
|
||||
|
||||
class PaymentMethod(Enum):
|
||||
@@ -75,6 +78,7 @@ class PaymentMethod(Enum):
|
||||
TRIBUTE = "tribute"
|
||||
YOOKASSA = "yookassa"
|
||||
CRYPTOBOT = "cryptobot"
|
||||
HELEKET = "heleket"
|
||||
MULENPAY = "mulenpay"
|
||||
PAL24 = "pal24"
|
||||
WATA = "wata"
|
||||
@@ -189,6 +193,72 @@ class CryptoBotPayment(Base):
|
||||
return f"<CryptoBotPayment(id={self.id}, invoice_id={self.invoice_id}, amount={self.amount} {self.asset}, status={self.status})>"
|
||||
|
||||
|
||||
class HeleketPayment(Base):
|
||||
__tablename__ = "heleket_payments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
uuid = Column(String(255), unique=True, nullable=False, index=True)
|
||||
order_id = Column(String(128), unique=True, nullable=False, index=True)
|
||||
|
||||
amount = Column(String(50), nullable=False)
|
||||
currency = Column(String(10), nullable=False)
|
||||
payer_amount = Column(String(50), nullable=True)
|
||||
payer_currency = Column(String(10), nullable=True)
|
||||
exchange_rate = Column(Float, nullable=True)
|
||||
discount_percent = Column(Integer, nullable=True)
|
||||
|
||||
status = Column(String(50), nullable=False)
|
||||
payment_url = Column(Text, nullable=True)
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
|
||||
paid_at = Column(DateTime, nullable=True)
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
user = relationship("User", backref="heleket_payments")
|
||||
transaction = relationship("Transaction", backref="heleket_payment")
|
||||
|
||||
@property
|
||||
def amount_float(self) -> float:
|
||||
try:
|
||||
return float(self.amount)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
@property
|
||||
def amount_kopeks(self) -> int:
|
||||
return int(round(self.amount_float * 100))
|
||||
|
||||
@property
|
||||
def payer_amount_float(self) -> float:
|
||||
try:
|
||||
return float(self.payer_amount) if self.payer_amount is not None else 0.0
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
@property
|
||||
def is_paid(self) -> bool:
|
||||
return self.status in {"paid", "paid_over"}
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
"<HeleketPayment(id={id}, uuid={uuid}, order_id={order_id}, amount={amount}"
|
||||
" {currency}, status={status})>"
|
||||
).format(
|
||||
id=self.id,
|
||||
uuid=self.uuid,
|
||||
order_id=self.order_id,
|
||||
amount=self.amount,
|
||||
currency=self.currency,
|
||||
status=self.status,
|
||||
)
|
||||
|
||||
|
||||
class MulenPayPayment(Base):
|
||||
__tablename__ = "mulenpay_payments"
|
||||
|
||||
@@ -349,6 +419,7 @@ class PromoGroup(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), unique=True, nullable=False)
|
||||
priority = Column(Integer, nullable=False, default=0, index=True)
|
||||
server_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
traffic_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
device_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
@@ -360,6 +431,7 @@ class PromoGroup(Base):
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
users = relationship("User", back_populates="promo_group")
|
||||
user_promo_groups = relationship("UserPromoGroup", back_populates="promo_group", cascade="all, delete-orphan")
|
||||
server_squads = relationship(
|
||||
"ServerSquad",
|
||||
secondary=server_squad_promo_groups,
|
||||
@@ -423,6 +495,22 @@ class PromoGroup(Base):
|
||||
return max(0, min(100, percent))
|
||||
|
||||
|
||||
class UserPromoGroup(Base):
|
||||
"""Таблица связи Many-to-Many между пользователями и промогруппами."""
|
||||
__tablename__ = "user_promo_groups"
|
||||
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
|
||||
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="CASCADE"), primary_key=True)
|
||||
assigned_at = Column(DateTime, default=func.now())
|
||||
assigned_by = Column(String(50), default="system")
|
||||
|
||||
user = relationship("User", back_populates="user_promo_groups")
|
||||
promo_group = relationship("PromoGroup", back_populates="user_promo_groups")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserPromoGroup(user_id={self.user_id}, promo_group_id={self.promo_group_id}, assigned_by='{self.assigned_by}')>"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
@@ -460,22 +548,51 @@ class User(Base):
|
||||
vless_uuid = Column(String(255), nullable=True)
|
||||
ss_password = Column(String(255), nullable=True)
|
||||
has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=True, index=True)
|
||||
promo_group = relationship("PromoGroup", back_populates="users")
|
||||
|
||||
user_promo_groups = relationship("UserPromoGroup", back_populates="user", cascade="all, delete-orphan")
|
||||
poll_responses = relationship("PollResponse", back_populates="user")
|
||||
|
||||
@property
|
||||
def balance_rubles(self) -> float:
|
||||
return self.balance_kopeks / 100
|
||||
|
||||
|
||||
@property
|
||||
def full_name(self) -> str:
|
||||
parts = [self.first_name, self.last_name]
|
||||
return " ".join(filter(None, parts)) or self.username or f"ID{self.telegram_id}"
|
||||
|
||||
def get_primary_promo_group(self):
|
||||
"""Возвращает промогруппу с максимальным приоритетом."""
|
||||
if not self.user_promo_groups:
|
||||
return getattr(self, "promo_group", None)
|
||||
|
||||
try:
|
||||
# Сортируем по приоритету группы (убывание), затем по ID группы
|
||||
# Используем getattr для защиты от ленивой загрузки
|
||||
sorted_groups = sorted(
|
||||
self.user_promo_groups,
|
||||
key=lambda upg: (
|
||||
getattr(upg.promo_group, 'priority', 0) if upg.promo_group else 0,
|
||||
upg.promo_group_id
|
||||
),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
if sorted_groups and sorted_groups[0].promo_group:
|
||||
return sorted_groups[0].promo_group
|
||||
except Exception:
|
||||
# Если возникла ошибка (например, ленивая загрузка), fallback на старую связь
|
||||
pass
|
||||
|
||||
# Fallback на старую связь если новая пустая или возникла ошибка
|
||||
return getattr(self, "promo_group", None)
|
||||
|
||||
def get_promo_discount(self, category: str, period_days: Optional[int] = None) -> int:
|
||||
if not self.promo_group:
|
||||
primary_group = self.get_primary_promo_group()
|
||||
if not primary_group:
|
||||
return 0
|
||||
return self.promo_group.get_discount_percent(category, period_days)
|
||||
return primary_group.get_discount_percent(category, period_days)
|
||||
|
||||
def add_balance(self, kopeks: int) -> None:
|
||||
self.balance_kopeks += kopeks
|
||||
@@ -723,13 +840,15 @@ class PromoCode(Base):
|
||||
valid_until = Column(DateTime, nullable=True)
|
||||
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
uses = relationship("PromoCodeUse", back_populates="promocode")
|
||||
promo_group = relationship("PromoGroup")
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
@@ -993,9 +1112,9 @@ class PromoOfferLog(Base):
|
||||
|
||||
class BroadcastHistory(Base):
|
||||
__tablename__ = "broadcast_history"
|
||||
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
target_type = Column(String(100), nullable=False)
|
||||
target_type = Column(String(100), nullable=False)
|
||||
message_text = Column(Text, nullable=False)
|
||||
has_media = Column(Boolean, default=False)
|
||||
media_type = Column(String(20), nullable=True)
|
||||
@@ -1011,6 +1130,106 @@ class BroadcastHistory(Base):
|
||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
admin = relationship("User", back_populates="broadcasts")
|
||||
|
||||
|
||||
class Poll(Base):
|
||||
__tablename__ = "polls"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
title = Column(String(255), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
reward_enabled = Column(Boolean, nullable=False, default=False)
|
||||
reward_amount_kopeks = Column(Integer, nullable=False, default=0)
|
||||
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
creator = relationship("User", backref="created_polls", foreign_keys=[created_by])
|
||||
questions = relationship(
|
||||
"PollQuestion",
|
||||
back_populates="poll",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="PollQuestion.order",
|
||||
)
|
||||
responses = relationship(
|
||||
"PollResponse",
|
||||
back_populates="poll",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class PollQuestion(Base):
|
||||
__tablename__ = "poll_questions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
text = Column(Text, nullable=False)
|
||||
order = Column(Integer, nullable=False, default=0)
|
||||
|
||||
poll = relationship("Poll", back_populates="questions")
|
||||
options = relationship(
|
||||
"PollOption",
|
||||
back_populates="question",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="PollOption.order",
|
||||
)
|
||||
answers = relationship("PollAnswer", back_populates="question")
|
||||
|
||||
|
||||
class PollOption(Base):
|
||||
__tablename__ = "poll_options"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
text = Column(Text, nullable=False)
|
||||
order = Column(Integer, nullable=False, default=0)
|
||||
|
||||
question = relationship("PollQuestion", back_populates="options")
|
||||
answers = relationship("PollAnswer", back_populates="option")
|
||||
|
||||
|
||||
class PollResponse(Base):
|
||||
__tablename__ = "poll_responses"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
sent_at = Column(DateTime, default=func.now(), nullable=False)
|
||||
started_at = Column(DateTime, nullable=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
reward_given = Column(Boolean, nullable=False, default=False)
|
||||
reward_amount_kopeks = Column(Integer, nullable=False, default=0)
|
||||
|
||||
poll = relationship("Poll", back_populates="responses")
|
||||
user = relationship("User", back_populates="poll_responses")
|
||||
answers = relationship(
|
||||
"PollAnswer",
|
||||
back_populates="response",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("poll_id", "user_id", name="uq_poll_user"),
|
||||
)
|
||||
|
||||
|
||||
class PollAnswer(Base):
|
||||
__tablename__ = "poll_answers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
response_id = Column(Integer, ForeignKey("poll_responses.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
option_id = Column(Integer, ForeignKey("poll_options.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
created_at = Column(DateTime, default=func.now(), nullable=False)
|
||||
|
||||
response = relationship("PollResponse", back_populates="answers")
|
||||
question = relationship("PollQuestion", back_populates="answers")
|
||||
option = relationship("PollOption", back_populates="answers")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("response_id", "question_id", name="uq_poll_answer_unique"),
|
||||
)
|
||||
|
||||
|
||||
class ServerSquad(Base):
|
||||
__tablename__ = "server_squads"
|
||||
|
||||
|
||||
@@ -576,6 +576,120 @@ async def create_cryptobot_payments_table():
|
||||
return False
|
||||
|
||||
|
||||
async def create_heleket_payments_table():
|
||||
table_exists = await check_table_exists('heleket_payments')
|
||||
if table_exists:
|
||||
logger.info("Таблица heleket_payments уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
create_sql = """
|
||||
CREATE TABLE heleket_payments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
uuid VARCHAR(255) UNIQUE NOT NULL,
|
||||
order_id VARCHAR(128) UNIQUE NOT NULL,
|
||||
amount VARCHAR(50) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
payer_amount VARCHAR(50) NULL,
|
||||
payer_currency VARCHAR(10) NULL,
|
||||
exchange_rate DOUBLE PRECISION NULL,
|
||||
discount_percent INTEGER NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
payment_url TEXT NULL,
|
||||
metadata_json JSON NULL,
|
||||
paid_at DATETIME NULL,
|
||||
expires_at DATETIME NULL,
|
||||
transaction_id INTEGER NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_heleket_payments_user_id ON heleket_payments(user_id);
|
||||
CREATE INDEX idx_heleket_payments_uuid ON heleket_payments(uuid);
|
||||
CREATE INDEX idx_heleket_payments_order_id ON heleket_payments(order_id);
|
||||
CREATE INDEX idx_heleket_payments_status ON heleket_payments(status);
|
||||
"""
|
||||
|
||||
elif db_type == 'postgresql':
|
||||
create_sql = """
|
||||
CREATE TABLE heleket_payments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
uuid VARCHAR(255) UNIQUE NOT NULL,
|
||||
order_id VARCHAR(128) UNIQUE NOT NULL,
|
||||
amount VARCHAR(50) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
payer_amount VARCHAR(50) NULL,
|
||||
payer_currency VARCHAR(10) NULL,
|
||||
exchange_rate DOUBLE PRECISION NULL,
|
||||
discount_percent INTEGER NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
payment_url TEXT NULL,
|
||||
metadata_json JSON NULL,
|
||||
paid_at TIMESTAMP NULL,
|
||||
expires_at TIMESTAMP NULL,
|
||||
transaction_id INTEGER NULL REFERENCES transactions(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_heleket_payments_user_id ON heleket_payments(user_id);
|
||||
CREATE INDEX idx_heleket_payments_uuid ON heleket_payments(uuid);
|
||||
CREATE INDEX idx_heleket_payments_order_id ON heleket_payments(order_id);
|
||||
CREATE INDEX idx_heleket_payments_status ON heleket_payments(status);
|
||||
"""
|
||||
|
||||
elif db_type == 'mysql':
|
||||
create_sql = """
|
||||
CREATE TABLE heleket_payments (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
uuid VARCHAR(255) UNIQUE NOT NULL,
|
||||
order_id VARCHAR(128) UNIQUE NOT NULL,
|
||||
amount VARCHAR(50) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
payer_amount VARCHAR(50) NULL,
|
||||
payer_currency VARCHAR(10) NULL,
|
||||
exchange_rate DOUBLE NULL,
|
||||
discount_percent INT NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
payment_url TEXT NULL,
|
||||
metadata_json JSON NULL,
|
||||
paid_at DATETIME NULL,
|
||||
expires_at DATETIME NULL,
|
||||
transaction_id INT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_heleket_payments_user_id ON heleket_payments(user_id);
|
||||
CREATE INDEX idx_heleket_payments_uuid ON heleket_payments(uuid);
|
||||
CREATE INDEX idx_heleket_payments_order_id ON heleket_payments(order_id);
|
||||
CREATE INDEX idx_heleket_payments_status ON heleket_payments(status);
|
||||
"""
|
||||
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для таблицы heleket_payments: {db_type}")
|
||||
return False
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("Таблица heleket_payments успешно создана")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания таблицы heleket_payments: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_mulenpay_payments_table():
|
||||
table_exists = await check_table_exists('mulenpay_payments')
|
||||
if table_exists:
|
||||
@@ -3345,6 +3459,219 @@ async def ensure_default_web_api_token() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def add_promo_group_priority_column() -> bool:
|
||||
"""Добавляет колонку priority в таблицу promo_groups."""
|
||||
column_exists = await check_column_exists('promo_groups', 'priority')
|
||||
if column_exists:
|
||||
logger.info("Колонка priority уже существует в promo_groups")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
column_def = 'INTEGER NOT NULL DEFAULT 0'
|
||||
elif db_type == 'postgresql':
|
||||
column_def = 'INTEGER NOT NULL DEFAULT 0'
|
||||
else:
|
||||
column_def = 'INT NOT NULL DEFAULT 0'
|
||||
|
||||
await conn.execute(
|
||||
text(f"ALTER TABLE promo_groups ADD COLUMN priority {column_def}")
|
||||
)
|
||||
|
||||
# Создаем индекс для оптимизации сортировки
|
||||
if db_type == 'postgresql':
|
||||
await conn.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_promo_groups_priority ON promo_groups(priority DESC)")
|
||||
)
|
||||
elif db_type == 'sqlite':
|
||||
await conn.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_promo_groups_priority ON promo_groups(priority DESC)")
|
||||
)
|
||||
else: # MySQL
|
||||
await conn.execute(
|
||||
text("CREATE INDEX idx_promo_groups_priority ON promo_groups(priority DESC)")
|
||||
)
|
||||
|
||||
logger.info("✅ Добавлена колонка priority в promo_groups с индексом")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка добавления колонки priority: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_user_promo_groups_table() -> bool:
|
||||
"""Создает таблицу user_promo_groups для связи Many-to-Many между users и promo_groups."""
|
||||
table_exists = await check_table_exists("user_promo_groups")
|
||||
if table_exists:
|
||||
logger.info("ℹ️ Таблица user_promo_groups уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
create_sql = """
|
||||
CREATE TABLE user_promo_groups (
|
||||
user_id INTEGER NOT NULL,
|
||||
promo_group_id INTEGER NOT NULL,
|
||||
assigned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
assigned_by VARCHAR(50) DEFAULT 'system',
|
||||
PRIMARY KEY (user_id, promo_group_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
index_sql = "CREATE INDEX idx_user_promo_groups_user_id ON user_promo_groups(user_id);"
|
||||
elif db_type == "postgresql":
|
||||
create_sql = """
|
||||
CREATE TABLE user_promo_groups (
|
||||
user_id INTEGER NOT NULL,
|
||||
promo_group_id INTEGER NOT NULL,
|
||||
assigned_at TIMESTAMP DEFAULT NOW(),
|
||||
assigned_by VARCHAR(50) DEFAULT 'system',
|
||||
PRIMARY KEY (user_id, promo_group_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
index_sql = "CREATE INDEX idx_user_promo_groups_user_id ON user_promo_groups(user_id);"
|
||||
else: # MySQL
|
||||
create_sql = """
|
||||
CREATE TABLE user_promo_groups (
|
||||
user_id INT NOT NULL,
|
||||
promo_group_id INT NOT NULL,
|
||||
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
assigned_by VARCHAR(50) DEFAULT 'system',
|
||||
PRIMARY KEY (user_id, promo_group_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
index_sql = "CREATE INDEX idx_user_promo_groups_user_id ON user_promo_groups(user_id);"
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
await conn.execute(text(index_sql))
|
||||
logger.info("✅ Таблица user_promo_groups создана с индексом")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка создания таблицы user_promo_groups: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def migrate_existing_user_promo_groups_data() -> bool:
|
||||
"""Переносит существующие связи users.promo_group_id в таблицу user_promo_groups."""
|
||||
try:
|
||||
table_exists = await check_table_exists("user_promo_groups")
|
||||
if not table_exists:
|
||||
logger.warning("⚠️ Таблица user_promo_groups не существует, пропускаем миграцию данных")
|
||||
return False
|
||||
|
||||
column_exists = await check_column_exists('users', 'promo_group_id')
|
||||
if not column_exists:
|
||||
logger.warning("⚠️ Колонка users.promo_group_id не существует, пропускаем миграцию данных")
|
||||
return True
|
||||
|
||||
async with engine.begin() as conn:
|
||||
# Проверяем есть ли уже данные в user_promo_groups
|
||||
result = await conn.execute(text("SELECT COUNT(*) FROM user_promo_groups"))
|
||||
count = result.scalar()
|
||||
|
||||
if count > 0:
|
||||
logger.info(f"ℹ️ В таблице user_promo_groups уже есть {count} записей, пропускаем миграцию")
|
||||
return True
|
||||
|
||||
# Переносим данные из users.promo_group_id
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
migrate_sql = """
|
||||
INSERT INTO user_promo_groups (user_id, promo_group_id, assigned_at, assigned_by)
|
||||
SELECT id, promo_group_id, CURRENT_TIMESTAMP, 'system'
|
||||
FROM users
|
||||
WHERE promo_group_id IS NOT NULL
|
||||
"""
|
||||
else: # PostgreSQL and MySQL
|
||||
migrate_sql = """
|
||||
INSERT INTO user_promo_groups (user_id, promo_group_id, assigned_at, assigned_by)
|
||||
SELECT id, promo_group_id, NOW(), 'system'
|
||||
FROM users
|
||||
WHERE promo_group_id IS NOT NULL
|
||||
"""
|
||||
|
||||
result = await conn.execute(text(migrate_sql))
|
||||
migrated_count = result.rowcount if hasattr(result, 'rowcount') else 0
|
||||
|
||||
logger.info(f"✅ Перенесено {migrated_count} связей пользователей с промогруппами")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка миграции данных user_promo_groups: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def add_promocode_promo_group_column() -> bool:
|
||||
"""Добавляет колонку promo_group_id в таблицу promocodes."""
|
||||
column_exists = await check_column_exists('promocodes', 'promo_group_id')
|
||||
if column_exists:
|
||||
logger.info("Колонка promo_group_id уже существует в promocodes")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
# Add column
|
||||
if db_type == 'sqlite':
|
||||
await conn.execute(
|
||||
text("ALTER TABLE promocodes ADD COLUMN promo_group_id INTEGER")
|
||||
)
|
||||
elif db_type == 'postgresql':
|
||||
await conn.execute(
|
||||
text("ALTER TABLE promocodes ADD COLUMN promo_group_id INTEGER")
|
||||
)
|
||||
# Add foreign key
|
||||
await conn.execute(
|
||||
text("""
|
||||
ALTER TABLE promocodes
|
||||
ADD CONSTRAINT fk_promocodes_promo_group
|
||||
FOREIGN KEY (promo_group_id)
|
||||
REFERENCES promo_groups(id)
|
||||
ON DELETE SET NULL
|
||||
""")
|
||||
)
|
||||
# Add index
|
||||
await conn.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_promocodes_promo_group_id ON promocodes(promo_group_id)")
|
||||
)
|
||||
elif db_type == 'mysql':
|
||||
await conn.execute(
|
||||
text("""
|
||||
ALTER TABLE promocodes
|
||||
ADD COLUMN promo_group_id INT,
|
||||
ADD CONSTRAINT fk_promocodes_promo_group
|
||||
FOREIGN KEY (promo_group_id)
|
||||
REFERENCES promo_groups(id)
|
||||
ON DELETE SET NULL
|
||||
""")
|
||||
)
|
||||
await conn.execute(
|
||||
text("CREATE INDEX idx_promocodes_promo_group_id ON promocodes(promo_group_id)")
|
||||
)
|
||||
|
||||
logger.info("✅ Добавлена колонка promo_group_id в promocodes")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка добавления promo_group_id в promocodes: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def run_universal_migration():
|
||||
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
|
||||
|
||||
@@ -3427,18 +3754,26 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей CryptoBot payments")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ MULEN PAY ===")
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ HELEKET ===")
|
||||
heleket_created = await create_heleket_payments_table()
|
||||
if heleket_created:
|
||||
logger.info("✅ Таблица Heleket payments готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей Heleket payments")
|
||||
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ %s ===", mulenpay_name)
|
||||
mulenpay_created = await create_mulenpay_payments_table()
|
||||
if mulenpay_created:
|
||||
logger.info("✅ Таблица Mulen Pay payments готова")
|
||||
logger.info("✅ Таблица %s payments готова", mulenpay_name)
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей Mulen Pay payments")
|
||||
logger.warning("⚠️ Проблемы с таблицей %s payments", mulenpay_name)
|
||||
|
||||
mulenpay_schema_ok = await ensure_mulenpay_payment_schema()
|
||||
if mulenpay_schema_ok:
|
||||
logger.info("✅ Схема Mulen Pay payments актуальна")
|
||||
logger.info("✅ Схема %s payments актуальна", mulenpay_name)
|
||||
else:
|
||||
logger.warning("⚠️ Не удалось обновить схему Mulen Pay payments")
|
||||
logger.warning("⚠️ Не удалось обновить схему %s payments", mulenpay_name)
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PAL24 ===")
|
||||
pal24_created = await create_pal24_payments_table()
|
||||
@@ -3498,6 +3833,34 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей promo_offer_templates")
|
||||
|
||||
logger.info("=== ДОБАВЛЕНИЕ ПРИОРИТЕТА В ПРОМОГРУППЫ ===")
|
||||
priority_column_ready = await add_promo_group_priority_column()
|
||||
if priority_column_ready:
|
||||
logger.info("✅ Колонка priority в promo_groups готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с добавлением priority в promo_groups")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_PROMO_GROUPS ===")
|
||||
user_promo_groups_ready = await create_user_promo_groups_table()
|
||||
if user_promo_groups_ready:
|
||||
logger.info("✅ Таблица user_promo_groups готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей user_promo_groups")
|
||||
|
||||
logger.info("=== МИГРАЦИЯ ДАННЫХ В USER_PROMO_GROUPS ===")
|
||||
data_migrated = await migrate_existing_user_promo_groups_data()
|
||||
if data_migrated:
|
||||
logger.info("✅ Данные перенесены в user_promo_groups")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с миграцией данных в user_promo_groups")
|
||||
|
||||
logger.info("=== ДОБАВЛЕНИЕ PROMO_GROUP_ID В PROMOCODES ===")
|
||||
promocode_column_ready = await add_promocode_promo_group_column()
|
||||
if promocode_column_ready:
|
||||
logger.info("✅ Колонка promo_group_id в promocodes готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с добавлением promo_group_id в promocodes")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ MAIN_MENU_BUTTONS ===")
|
||||
main_menu_buttons_created = await create_main_menu_buttons_table()
|
||||
if main_menu_buttons_created:
|
||||
@@ -3694,6 +4057,7 @@ async def run_universal_migration():
|
||||
logger.info("=== МИГРАЦИЯ ЗАВЕРШЕНА УСПЕШНО ===")
|
||||
logger.info("✅ Реферальная система обновлена")
|
||||
logger.info("✅ CryptoBot таблица готова")
|
||||
logger.info("✅ Heleket таблица готова")
|
||||
logger.info("✅ Таблица конверсий подписок создана")
|
||||
logger.info("✅ Таблица welcome_texts с полем is_enabled готова")
|
||||
logger.info("✅ Медиа поля в broadcast_history добавлены")
|
||||
@@ -3711,6 +4075,7 @@ async def check_migration_status():
|
||||
status = {
|
||||
"has_made_first_topup_column": False,
|
||||
"cryptobot_table": False,
|
||||
"heleket_table": False,
|
||||
"user_messages_table": False,
|
||||
"welcome_texts_table": False,
|
||||
"welcome_texts_is_enabled_column": False,
|
||||
@@ -3744,6 +4109,7 @@ async def check_migration_status():
|
||||
status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup')
|
||||
|
||||
status["cryptobot_table"] = await check_table_exists('cryptobot_payments')
|
||||
status["heleket_table"] = await check_table_exists('heleket_payments')
|
||||
status["user_messages_table"] = await check_table_exists('user_messages')
|
||||
status["welcome_texts_table"] = await check_table_exists('welcome_texts')
|
||||
status["privacy_policies_table"] = await check_table_exists('privacy_policies')
|
||||
@@ -3796,6 +4162,7 @@ async def check_migration_status():
|
||||
check_names = {
|
||||
"has_made_first_topup_column": "Колонка реферальной системы",
|
||||
"cryptobot_table": "Таблица CryptoBot payments",
|
||||
"heleket_table": "Таблица Heleket payments",
|
||||
"user_messages_table": "Таблица пользовательских сообщений",
|
||||
"welcome_texts_table": "Таблица приветственных текстов",
|
||||
"privacy_policies_table": "Таблица политик конфиденциальности",
|
||||
|
||||
Vendored
+34
-14
@@ -19,10 +19,10 @@ class CryptoBotService:
|
||||
self.webhook_secret = settings.CRYPTOBOT_WEBHOOK_SECRET
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
data: Optional[Dict] = None
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
data: Optional[Dict] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if not self.api_token:
|
||||
@@ -37,11 +37,18 @@ class CryptoBotService:
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
request_kwargs: Dict[str, Any] = {"headers": headers}
|
||||
|
||||
if method.upper() == "GET":
|
||||
if data:
|
||||
request_kwargs["params"] = data
|
||||
elif data:
|
||||
request_kwargs["json"] = data
|
||||
|
||||
async with session.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
json=data if data else None
|
||||
method,
|
||||
url,
|
||||
**request_kwargs,
|
||||
) as response:
|
||||
|
||||
response_data = await response.json()
|
||||
@@ -95,21 +102,34 @@ class CryptoBotService:
|
||||
asset: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
offset: int = 0,
|
||||
count: int = 100
|
||||
count: int = 100,
|
||||
invoice_ids: Optional[list] = None,
|
||||
) -> Optional[list]:
|
||||
|
||||
|
||||
data = {
|
||||
'offset': offset,
|
||||
'count': count
|
||||
}
|
||||
|
||||
|
||||
if asset:
|
||||
data['asset'] = asset
|
||||
|
||||
|
||||
if status:
|
||||
data['status'] = status
|
||||
|
||||
return await self._make_request('GET', 'getInvoices', data)
|
||||
|
||||
if invoice_ids:
|
||||
data['invoice_ids'] = invoice_ids
|
||||
|
||||
result = await self._make_request('GET', 'getInvoices', data)
|
||||
|
||||
if isinstance(result, dict):
|
||||
items = result.get('items')
|
||||
return items if isinstance(items, list) else []
|
||||
|
||||
if isinstance(result, list):
|
||||
return result
|
||||
|
||||
return []
|
||||
|
||||
async def get_balance(self) -> Optional[list]:
|
||||
return await self._make_request('GET', 'getBalance')
|
||||
|
||||
Vendored
+156
@@ -0,0 +1,156 @@
|
||||
"""HTTP client for Heleket payment API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HeleketService:
|
||||
"""Minimal wrapper around Heleket API endpoints."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.base_url = settings.HELEKET_BASE_URL.rstrip("/")
|
||||
self.merchant_id = settings.HELEKET_MERCHANT_ID
|
||||
self.api_key = settings.HELEKET_API_KEY
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self.merchant_id and self.api_key)
|
||||
|
||||
def _prepare_body(self, payload: Dict[str, Any]) -> str:
|
||||
cleaned = {key: value for key, value in payload.items() if value is not None}
|
||||
serialized = json.dumps(cleaned, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
||||
if "/" in serialized:
|
||||
serialized = serialized.replace("/", "\\/")
|
||||
return serialized
|
||||
|
||||
def _generate_signature(self, body: str) -> str:
|
||||
api_key = self.api_key or ""
|
||||
encoded = base64.b64encode(body.encode("utf-8")).decode("utf-8")
|
||||
raw = f"{encoded}{api_key}"
|
||||
return hashlib.md5(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
endpoint: str,
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not self.is_configured:
|
||||
logger.error("Heleket сервис не настроен: merchant или api_key отсутствуют")
|
||||
return None
|
||||
|
||||
body = self._prepare_body(payload)
|
||||
signature = self._generate_signature(body)
|
||||
|
||||
url = f"{self.base_url}/{endpoint.lstrip('/')}"
|
||||
headers = {
|
||||
"merchant": self.merchant_id or "",
|
||||
"sign": signature,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=30)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.post(
|
||||
url,
|
||||
data=body.encode("utf-8"),
|
||||
headers=headers,
|
||||
params=params,
|
||||
) as response:
|
||||
text = await response.text()
|
||||
if response.content_type != "application/json":
|
||||
logger.error("Ответ Heleket не JSON (%s): %s", response.content_type, text)
|
||||
return None
|
||||
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Ошибка парсинга Heleket JSON: %s", text)
|
||||
return None
|
||||
|
||||
if response.status >= 400:
|
||||
logger.error("Heleket API %s вернул статус %s: %s", endpoint, response.status, data)
|
||||
return None
|
||||
|
||||
if isinstance(data, dict) and data.get("state") == 0:
|
||||
return data
|
||||
|
||||
logger.error("Heleket API вернул ошибку: %s", data)
|
||||
return None
|
||||
except Exception as error: # pragma: no cover - defensive
|
||||
logger.error("Ошибка запроса к Heleket API: %s", error)
|
||||
return None
|
||||
|
||||
async def create_payment(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
return await self._request("payment", payload)
|
||||
|
||||
async def get_payment_info(
|
||||
self,
|
||||
*,
|
||||
uuid: Optional[str] = None,
|
||||
order_id: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not uuid and not order_id:
|
||||
raise ValueError("Нужно указать uuid или order_id для Heleket payment/info")
|
||||
|
||||
payload: Dict[str, Any] = {}
|
||||
if uuid:
|
||||
payload["uuid"] = uuid
|
||||
if order_id:
|
||||
payload["order_id"] = order_id
|
||||
|
||||
return await self._request("payment/info", payload)
|
||||
|
||||
async def list_payments(
|
||||
self,
|
||||
*,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
cursor: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
payload: Dict[str, Any] = {}
|
||||
if date_from:
|
||||
payload["date_from"] = date_from
|
||||
if date_to:
|
||||
payload["date_to"] = date_to
|
||||
|
||||
params = {"cursor": cursor} if cursor else None
|
||||
return await self._request("payment/list", payload, params=params)
|
||||
|
||||
def verify_webhook_signature(self, payload: Dict[str, Any]) -> bool:
|
||||
if not self.is_configured:
|
||||
logger.warning("Heleket сервис не настроен, подпись пропускается")
|
||||
return True
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
logger.error("Heleket webhook payload не dict: %s", payload)
|
||||
return False
|
||||
|
||||
signature = payload.get("sign")
|
||||
if not signature:
|
||||
logger.error("Heleket webhook без подписи")
|
||||
return False
|
||||
|
||||
data = {key: value for key, value in payload.items() if key != "sign"}
|
||||
body = self._prepare_body(data)
|
||||
expected = self._generate_signature(body)
|
||||
|
||||
is_valid = hmac.compare_digest(expected, str(signature))
|
||||
|
||||
if not is_valid:
|
||||
logger.error("Неверная подпись Heleket webhook: ожидается %s, получено %s", expected, signature)
|
||||
return is_valid
|
||||
Vendored
+111
@@ -0,0 +1,111 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.external.heleket import HeleketService
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HeleketWebhookHandler:
|
||||
def __init__(self, payment_service: PaymentService) -> None:
|
||||
self.payment_service = payment_service
|
||||
self.service = HeleketService()
|
||||
|
||||
async def handle(self, request: web.Request) -> web.Response:
|
||||
if not settings.is_heleket_enabled():
|
||||
logger.warning("Получен Heleket webhook, но сервис отключен")
|
||||
return web.json_response({"status": "error", "reason": "disabled"}, status=503)
|
||||
|
||||
try:
|
||||
payload: Dict[str, Any] = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Некорректный JSON Heleket webhook")
|
||||
return web.json_response({"status": "error", "reason": "invalid_json"}, status=400)
|
||||
|
||||
if not self.service.verify_webhook_signature(payload):
|
||||
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
|
||||
|
||||
processed: Optional[bool] = None
|
||||
async for db in get_db():
|
||||
processed = await self.payment_service.process_heleket_webhook(db, payload)
|
||||
|
||||
if processed:
|
||||
return web.json_response({"status": "ok"}, status=200)
|
||||
|
||||
return web.json_response({"status": "error", "reason": "not_processed"}, status=400)
|
||||
|
||||
async def health_check(self, _: web.Request) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "heleket_webhook",
|
||||
"enabled": settings.is_heleket_enabled(),
|
||||
"path": settings.HELEKET_WEBHOOK_PATH,
|
||||
}
|
||||
)
|
||||
|
||||
async def options_handler(self, _: web.Request) -> web.Response:
|
||||
return web.Response(
|
||||
status=200,
|
||||
headers={
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def create_heleket_app(payment_service: PaymentService) -> web.Application:
|
||||
handler = HeleketWebhookHandler(payment_service)
|
||||
app = web.Application()
|
||||
app.router.add_post(settings.HELEKET_WEBHOOK_PATH, handler.handle)
|
||||
app.router.add_get("/heleket/health", handler.health_check)
|
||||
app.router.add_get("/health", handler.health_check)
|
||||
app.router.add_options(settings.HELEKET_WEBHOOK_PATH, handler.options_handler)
|
||||
return app
|
||||
|
||||
|
||||
async def start_heleket_webhook_server(payment_service: PaymentService) -> None:
|
||||
if not settings.is_heleket_enabled():
|
||||
logger.info("Heleket отключен, webhook сервер не запускается")
|
||||
return
|
||||
|
||||
app = create_heleket_app(payment_service)
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
|
||||
site = web.TCPSite(
|
||||
runner,
|
||||
host=settings.HELEKET_WEBHOOK_HOST,
|
||||
port=settings.HELEKET_WEBHOOK_PORT,
|
||||
)
|
||||
|
||||
try:
|
||||
await site.start()
|
||||
logger.info(
|
||||
"Heleket webhook сервер запущен на %s:%s",
|
||||
settings.HELEKET_WEBHOOK_HOST,
|
||||
settings.HELEKET_WEBHOOK_PORT,
|
||||
)
|
||||
logger.info(
|
||||
"Heleket webhook URL: http://%s:%s%s",
|
||||
settings.HELEKET_WEBHOOK_HOST,
|
||||
settings.HELEKET_WEBHOOK_PORT,
|
||||
settings.HELEKET_WEBHOOK_PATH,
|
||||
)
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Heleket webhook сервер остановлен по запросу")
|
||||
finally:
|
||||
await site.stop()
|
||||
await runner.cleanup()
|
||||
logger.info("Heleket webhook сервер корректно остановлен")
|
||||
Vendored
+12
-5
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from concurrent.futures import TimeoutError as FuturesTimeoutError
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
@@ -13,7 +14,7 @@ from flask import Flask, jsonify, request
|
||||
from werkzeug.serving import make_server
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.services.pal24_service import Pal24Service, Pal24APIError
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
@@ -56,6 +57,8 @@ def create_pal24_flask_app(
|
||||
logger.error("Pal24 webhook получен, но сервис не настроен")
|
||||
return jsonify({"status": "error", "reason": "service_not_configured"}), 503
|
||||
|
||||
logger.debug("Получен Pal24 webhook: headers=%s", dict(request.headers))
|
||||
|
||||
payload = _normalize_payload()
|
||||
if not payload:
|
||||
logger.warning("Пустой Pal24 webhook")
|
||||
@@ -68,15 +71,19 @@ def create_pal24_flask_app(
|
||||
return jsonify({"status": "error", "reason": str(error)}), 400
|
||||
|
||||
async def process() -> bool:
|
||||
async for db in get_db():
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
return await payment_service.process_pal24_postback(db, parsed_payload)
|
||||
finally:
|
||||
await db.close()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(process(), loop)
|
||||
processed = future.result()
|
||||
processed = future.result(timeout=settings.PAL24_REQUEST_TIMEOUT)
|
||||
except FuturesTimeoutError:
|
||||
logger.error("Обработка Pal24 webhook превысила таймаут %sс", settings.PAL24_REQUEST_TIMEOUT)
|
||||
return jsonify({"status": "error", "reason": "timeout"}), 504
|
||||
except Exception as error: # pragma: no cover - defensive
|
||||
logger.exception("Критическая ошибка обработки Pal24 webhook: %s", error)
|
||||
return jsonify({"status": "error", "reason": "internal_error"}), 500
|
||||
|
||||
Vendored
+1
-1
@@ -163,7 +163,7 @@ class RemnaWaveAPI:
|
||||
async def __aenter__(self):
|
||||
conn_type = self._detect_connection_type()
|
||||
|
||||
logger.info(f"Подключение к Remnawave: {self.base_url} (тип: {conn_type})")
|
||||
logger.debug(f"Подключение к Remnawave: {self.base_url} (тип: {conn_type})")
|
||||
|
||||
headers = self._prepare_auth_headers()
|
||||
|
||||
|
||||
Vendored
+262
@@ -0,0 +1,262 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WataPublicKeyProvider:
|
||||
"""Loads and caches the WATA public key used for webhook signature validation."""
|
||||
|
||||
def __init__(self, *, cache_seconds: Optional[int] = None) -> None:
|
||||
self._cache_seconds = cache_seconds or int(settings.WATA_PUBLIC_KEY_CACHE_SECONDS)
|
||||
self._cached_key: Optional[str] = None
|
||||
self._expires_at: Optional[datetime] = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def get_public_key(self) -> Optional[str]:
|
||||
"""Returns a cached public key or fetches a new one from WATA."""
|
||||
|
||||
now = datetime.utcnow()
|
||||
if self._cached_key and self._expires_at and now < self._expires_at:
|
||||
return self._cached_key
|
||||
|
||||
async with self._lock:
|
||||
now = datetime.utcnow()
|
||||
if self._cached_key and self._expires_at and now < self._expires_at:
|
||||
return self._cached_key
|
||||
|
||||
key = await self._fetch_public_key()
|
||||
if key:
|
||||
self._cached_key = key
|
||||
if self._cache_seconds > 0:
|
||||
self._expires_at = datetime.utcnow() + timedelta(seconds=self._cache_seconds)
|
||||
else:
|
||||
self._expires_at = None
|
||||
logger.debug("Получен и закеширован публичный ключ WATA")
|
||||
return self._cached_key
|
||||
|
||||
if self._cached_key:
|
||||
logger.warning("Используем ранее закешированный публичный ключ WATA")
|
||||
return self._cached_key
|
||||
|
||||
logger.error("Публичный ключ WATA недоступен")
|
||||
return None
|
||||
|
||||
async def _fetch_public_key(self) -> Optional[str]:
|
||||
url = settings.WATA_PUBLIC_KEY_URL or f"{settings.WATA_BASE_URL.rstrip('/')}/public-key"
|
||||
timeout = aiohttp.ClientTimeout(total=settings.WATA_REQUEST_TIMEOUT)
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url) as response:
|
||||
text = await response.text()
|
||||
if response.status >= 400:
|
||||
logger.error(
|
||||
"Ошибка получения публичного ключа WATA %s: %s",
|
||||
response.status,
|
||||
text,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = await response.json()
|
||||
except aiohttp.ContentTypeError:
|
||||
logger.error("Ответ WATA public-key не является JSON: %s", text)
|
||||
return None
|
||||
|
||||
if isinstance(payload, dict):
|
||||
value = payload.get("value")
|
||||
if value:
|
||||
return value
|
||||
logger.error("Ответ WATA public-key не содержит ключ: %s", payload)
|
||||
else:
|
||||
logger.error("Неожиданный формат ответа WATA public-key: %s", payload)
|
||||
except Exception as error:
|
||||
logger.error("Ошибка запроса публичного ключа WATA: %s", error)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class WataWebhookHandler:
|
||||
"""Processes webhook callbacks coming from WATA."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
payment_service: PaymentService,
|
||||
*,
|
||||
public_key_provider: Optional[WataPublicKeyProvider] = None,
|
||||
) -> None:
|
||||
self.payment_service = payment_service
|
||||
self.public_key_provider = public_key_provider or WataPublicKeyProvider()
|
||||
|
||||
async def _verify_signature(self, raw_body: str, signature: str) -> bool:
|
||||
signature = (signature or "").strip()
|
||||
if not signature:
|
||||
logger.error("WATA webhook без подписи")
|
||||
return False
|
||||
|
||||
public_key_pem = await self.public_key_provider.get_public_key()
|
||||
if not public_key_pem:
|
||||
logger.error("Публичный ключ WATA отсутствует, проверка подписи невозможна")
|
||||
return False
|
||||
|
||||
try:
|
||||
signature_bytes = base64.b64decode(signature)
|
||||
except (ValueError, TypeError):
|
||||
logger.error("Некорректная подпись WATA (не Base64)")
|
||||
return False
|
||||
|
||||
try:
|
||||
public_key = serialization.load_pem_public_key(public_key_pem.encode("utf-8"))
|
||||
except ValueError as error:
|
||||
logger.error("Ошибка загрузки публичного ключа WATA: %s", error)
|
||||
return False
|
||||
|
||||
try:
|
||||
public_key.verify(
|
||||
signature_bytes,
|
||||
raw_body.encode("utf-8"),
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA512(),
|
||||
)
|
||||
return True
|
||||
except InvalidSignature:
|
||||
logger.warning("Подпись WATA webhook не прошла проверку")
|
||||
return False
|
||||
except Exception as error:
|
||||
logger.error("Ошибка проверки подписи WATA: %s", error)
|
||||
return False
|
||||
|
||||
async def handle_webhook(self, request: web.Request) -> web.Response:
|
||||
if not settings.is_wata_enabled():
|
||||
logger.warning("Получен WATA webhook, но сервис отключен")
|
||||
return web.json_response({"status": "error", "reason": "wata_disabled"}, status=503)
|
||||
|
||||
raw_body = await request.text()
|
||||
if not raw_body:
|
||||
logger.warning("Получен пустой WATA webhook")
|
||||
return web.json_response({"status": "error", "reason": "empty_body"}, status=400)
|
||||
|
||||
signature = request.headers.get("X-Signature")
|
||||
if not await self._verify_signature(raw_body, signature or ""):
|
||||
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
|
||||
|
||||
try:
|
||||
payload: Dict[str, Any] = json.loads(raw_body)
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Некорректный JSON WATA webhook")
|
||||
return web.json_response({"status": "error", "reason": "invalid_json"}, status=400)
|
||||
|
||||
logger.info(
|
||||
"Получен WATA webhook: order_id=%s, status=%s",
|
||||
payload.get("orderId"),
|
||||
payload.get("transactionStatus"),
|
||||
)
|
||||
|
||||
processed: Optional[bool] = None
|
||||
async for db in get_db():
|
||||
processed = await self.payment_service.process_wata_webhook(db, payload)
|
||||
# Allow the generator to finish naturally so it can commit/rollback.
|
||||
# get_db() yields only once, so exiting the loop body without breaking
|
||||
# triggers the generator cleanup logic on the next iteration attempt.
|
||||
|
||||
if processed is None:
|
||||
logger.error("Не удалось обработать WATA webhook: нет сессии БД")
|
||||
return web.json_response({"status": "error", "reason": "db_session_unavailable"}, status=500)
|
||||
|
||||
if processed:
|
||||
return web.json_response({"status": "ok"}, status=200)
|
||||
|
||||
return web.json_response({"status": "error", "reason": "not_processed"}, status=400)
|
||||
|
||||
async def health_check(self, request: web.Request) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "wata_webhook",
|
||||
"enabled": settings.is_wata_enabled(),
|
||||
"path": settings.WATA_WEBHOOK_PATH,
|
||||
}
|
||||
)
|
||||
|
||||
async def options_handler(self, _: web.Request) -> web.Response:
|
||||
return web.Response(
|
||||
status=200,
|
||||
headers={
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, X-Signature",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def create_wata_webhook_app(payment_service: PaymentService) -> web.Application:
|
||||
app = web.Application()
|
||||
handler = WataWebhookHandler(payment_service)
|
||||
|
||||
app.router.add_post(settings.WATA_WEBHOOK_PATH, handler.handle_webhook)
|
||||
app.router.add_get(settings.WATA_WEBHOOK_PATH, handler.health_check)
|
||||
app.router.add_options(settings.WATA_WEBHOOK_PATH, handler.options_handler)
|
||||
app.router.add_get("/health", handler.health_check)
|
||||
|
||||
logger.info(
|
||||
"Настроен WATA webhook endpoint на %s",
|
||||
settings.WATA_WEBHOOK_PATH,
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def start_wata_webhook_server(payment_service: PaymentService) -> None:
|
||||
if not settings.is_wata_enabled():
|
||||
logger.info("WATA отключен, webhook сервер не запускается")
|
||||
return
|
||||
|
||||
app = create_wata_webhook_app(payment_service)
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
|
||||
site = web.TCPSite(
|
||||
runner,
|
||||
host=settings.WATA_WEBHOOK_HOST,
|
||||
port=settings.WATA_WEBHOOK_PORT,
|
||||
)
|
||||
|
||||
try:
|
||||
await site.start()
|
||||
logger.info(
|
||||
"WATA webhook сервер запущен на %s:%s",
|
||||
settings.WATA_WEBHOOK_HOST,
|
||||
settings.WATA_WEBHOOK_PORT,
|
||||
)
|
||||
logger.info(
|
||||
"WATA webhook URL: http://%s:%s%s",
|
||||
settings.WATA_WEBHOOK_HOST,
|
||||
settings.WATA_WEBHOOK_PORT,
|
||||
settings.WATA_WEBHOOK_PATH,
|
||||
)
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("WATA webhook сервер остановлен по запросу")
|
||||
finally:
|
||||
await site.stop()
|
||||
await runner.cleanup()
|
||||
logger.info("WATA webhook сервер корректно остановлен")
|
||||
Vendored
+44
-15
@@ -48,7 +48,12 @@ class WebhookServer:
|
||||
logger.info(f"Webhook сервер настроен:")
|
||||
logger.info(f" - Tribute webhook: POST {settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
if settings.is_mulenpay_enabled():
|
||||
logger.info(f" - Mulen Pay webhook: POST {settings.MULENPAY_WEBHOOK_PATH}")
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
logger.info(
|
||||
" - %s webhook: POST %s",
|
||||
mulenpay_name,
|
||||
settings.MULENPAY_WEBHOOK_PATH,
|
||||
)
|
||||
if settings.is_cryptobot_enabled():
|
||||
logger.info(f" - CryptoBot webhook: POST {settings.CRYPTOBOT_WEBHOOK_PATH}")
|
||||
logger.info(f" - Health check: GET /health")
|
||||
@@ -84,8 +89,10 @@ class WebhookServer:
|
||||
settings.TRIBUTE_WEBHOOK_PATH,
|
||||
)
|
||||
if settings.is_mulenpay_enabled():
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
logger.info(
|
||||
"Mulen Pay webhook URL: http://%s:%s%s",
|
||||
"%s webhook URL: http://%s:%s%s",
|
||||
mulenpay_name,
|
||||
settings.TRIBUTE_WEBHOOK_HOST,
|
||||
settings.TRIBUTE_WEBHOOK_PORT,
|
||||
settings.MULENPAY_WEBHOOK_PATH,
|
||||
@@ -128,24 +135,37 @@ class WebhookServer:
|
||||
|
||||
async def _mulenpay_webhook_handler(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
logger.info(f"Получен Mulen Pay webhook: {request.method} {request.path}")
|
||||
logger.info(f"MulenPay webhook headers: {dict(request.headers)}")
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
logger.info(
|
||||
"%s webhook: %s %s",
|
||||
mulenpay_name,
|
||||
request.method,
|
||||
request.path,
|
||||
)
|
||||
logger.info(
|
||||
"%s webhook headers: %s",
|
||||
mulenpay_name,
|
||||
dict(request.headers),
|
||||
)
|
||||
raw_body = await request.read()
|
||||
|
||||
if not raw_body:
|
||||
logger.warning("Пустой Mulen Pay webhook")
|
||||
logger.warning("Пустой %s webhook", mulenpay_name)
|
||||
return web.json_response({"status": "error", "reason": "empty_body"}, status=400)
|
||||
|
||||
# Временно отключаем проверку подписи для отладки
|
||||
# TODO: Включить обратно после настройки MulenPay
|
||||
if not self._verify_mulenpay_signature(request, raw_body):
|
||||
logger.warning("MulenPay webhook signature verification failed, but processing anyway for debugging")
|
||||
logger.warning(
|
||||
"%s webhook signature verification failed, but processing anyway for debugging",
|
||||
mulenpay_name,
|
||||
)
|
||||
# return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_body.decode('utf-8'))
|
||||
except json.JSONDecodeError as error:
|
||||
logger.error(f"Ошибка парсинга Mulen Pay webhook: {error}")
|
||||
logger.error(f"Ошибка парсинга {mulenpay_name} webhook: {error}")
|
||||
return web.json_response({"status": "error", "reason": "invalid_json"}, status=400)
|
||||
|
||||
payment_service = PaymentService(self.bot)
|
||||
@@ -160,7 +180,10 @@ class WebhookServer:
|
||||
return web.json_response({"status": "ok"}, status=200)
|
||||
return web.json_response({"status": "error", "reason": "processing_failed"}, status=400)
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка обработки Mulen Pay webhook: {error}", exc_info=True)
|
||||
logger.error(
|
||||
f"Ошибка обработки {mulenpay_name} webhook: {error}",
|
||||
exc_info=True,
|
||||
)
|
||||
return web.json_response({"status": "error", "reason": "internal_error"}, status=500)
|
||||
finally:
|
||||
try:
|
||||
@@ -169,7 +192,11 @@ class WebhookServer:
|
||||
pass
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Критическая ошибка Mulen Pay webhook: {error}", exc_info=True)
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
logger.error(
|
||||
f"Критическая ошибка {mulenpay_name} webhook: {error}",
|
||||
exc_info=True,
|
||||
)
|
||||
return web.json_response({"status": "error", "reason": "internal_error", "message": str(error)}, status=500)
|
||||
|
||||
@staticmethod
|
||||
@@ -183,12 +210,13 @@ class WebhookServer:
|
||||
@staticmethod
|
||||
def _verify_mulenpay_signature(request: web.Request, raw_body: bytes) -> bool:
|
||||
secret_key = settings.MULENPAY_SECRET_KEY
|
||||
display_name = settings.get_mulenpay_display_name()
|
||||
if not secret_key:
|
||||
logger.error("Mulen Pay secret key is not configured")
|
||||
logger.error("%s secret key is not configured", display_name)
|
||||
return False
|
||||
|
||||
# Логируем все заголовки для отладки
|
||||
logger.info("MulenPay webhook headers for signature verification:")
|
||||
logger.info("%s webhook headers for signature verification:", display_name)
|
||||
for header_name, header_value in request.headers.items():
|
||||
if any(keyword in header_name.lower() for keyword in ['signature', 'sign', 'token', 'auth']):
|
||||
logger.info(f" {header_name}: {header_value}")
|
||||
@@ -239,7 +267,7 @@ class WebhookServer:
|
||||
if hmac.compare_digest(normalized_signature_no_padding, expected_urlsafe_base64_signature.rstrip('=')):
|
||||
return True
|
||||
|
||||
logger.error("Неверная подпись Mulen Pay webhook")
|
||||
logger.error("Неверная подпись %s webhook", display_name)
|
||||
return False
|
||||
|
||||
authorization_header = request.headers.get('Authorization')
|
||||
@@ -252,7 +280,7 @@ class WebhookServer:
|
||||
if hmac.compare_digest(token, secret_key):
|
||||
return True
|
||||
|
||||
logger.error("Неверный %s токен Mulen Pay webhook", scheme)
|
||||
logger.error("Неверный %s токен %s webhook", scheme, display_name)
|
||||
return False
|
||||
|
||||
if not value and hmac.compare_digest(token, secret_key):
|
||||
@@ -270,11 +298,12 @@ class WebhookServer:
|
||||
return True
|
||||
|
||||
logger.info(
|
||||
"Mulen Pay webhook headers received: %s",
|
||||
"%s webhook headers received: %s",
|
||||
display_name,
|
||||
{key: value for key, value in request.headers.items() if 'authorization' not in key.lower()}
|
||||
)
|
||||
|
||||
logger.error("Отсутствует подпись Mulen Pay webhook")
|
||||
logger.error("Отсутствует подпись %s webhook", display_name)
|
||||
return False
|
||||
|
||||
async def _tribute_webhook_handler(self, request: web.Request) -> web.Response:
|
||||
|
||||
@@ -13,6 +13,11 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import SystemSetting, User
|
||||
from app.database.crud.server_squad import (
|
||||
get_all_server_squads,
|
||||
get_server_squad_by_id,
|
||||
get_server_squad_by_uuid,
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.config import settings
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
@@ -30,13 +35,23 @@ from app.external.telegram_stars import TelegramStarsService
|
||||
|
||||
CATEGORY_PAGE_SIZE = 10
|
||||
SETTINGS_PAGE_SIZE = 8
|
||||
SIMPLE_SUBSCRIPTION_SQUADS_PAGE_SIZE = 6
|
||||
|
||||
CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
"core": {
|
||||
"title": "🤖 Основные",
|
||||
"description": "Базовые настройки бота, обязательные каналы и ключевые сервисы.",
|
||||
"icon": "🤖",
|
||||
"categories": ("CORE", "CHANNEL"),
|
||||
"categories": (
|
||||
"CORE",
|
||||
"CHANNEL",
|
||||
"TIMEZONE",
|
||||
"DATABASE",
|
||||
"POSTGRES",
|
||||
"SQLITE",
|
||||
"REDIS",
|
||||
"REMNAWAVE",
|
||||
),
|
||||
},
|
||||
"support": {
|
||||
"title": "💬 Поддержка",
|
||||
@@ -46,12 +61,14 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
},
|
||||
"payments": {
|
||||
"title": "💳 Платежные системы",
|
||||
"description": "YooKassa, CryptoBot, MulenPay, PAL24, Wata, Tribute и Telegram Stars.",
|
||||
"description": "YooKassa, CryptoBot, Heleket, MulenPay, PAL24, Wata, Tribute и Telegram Stars.",
|
||||
"icon": "💳",
|
||||
"categories": (
|
||||
"PAYMENT",
|
||||
"PAYMENT_VERIFICATION",
|
||||
"YOOKASSA",
|
||||
"CRYPTOBOT",
|
||||
"HELEKET",
|
||||
"MULENPAY",
|
||||
"PAL24",
|
||||
"WATA",
|
||||
@@ -61,9 +78,17 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
},
|
||||
"subscriptions": {
|
||||
"title": "📅 Подписки и цены",
|
||||
"description": "Тарифы, периоды, лимиты трафика и автопродление.",
|
||||
"description": "Тарифы, простая покупка, периоды, лимиты трафика и автопродление.",
|
||||
"icon": "📅",
|
||||
"categories": ("SUBSCRIPTIONS_CORE", "PERIODS", "SUBSCRIPTION_PRICES", "TRAFFIC", "TRAFFIC_PACKAGES", "AUTOPAY"),
|
||||
"categories": (
|
||||
"SUBSCRIPTIONS_CORE",
|
||||
"SIMPLE_SUBSCRIPTION",
|
||||
"PERIODS",
|
||||
"SUBSCRIPTION_PRICES",
|
||||
"TRAFFIC",
|
||||
"TRAFFIC_PACKAGES",
|
||||
"AUTOPAY",
|
||||
),
|
||||
},
|
||||
"trial": {
|
||||
"title": "🎁 Пробный период",
|
||||
@@ -99,18 +124,6 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
"ADDITIONAL",
|
||||
),
|
||||
},
|
||||
"database": {
|
||||
"title": "💾 База данных",
|
||||
"description": "Режим базы, параметры PostgreSQL, SQLite и Redis.",
|
||||
"icon": "💾",
|
||||
"categories": ("DATABASE", "POSTGRES", "SQLITE", "REDIS"),
|
||||
},
|
||||
"remnawave": {
|
||||
"title": "🌐 RemnaWave API",
|
||||
"description": "Интеграция с RemnaWave: URL, ключи и способы авторизации.",
|
||||
"icon": "🌐",
|
||||
"categories": ("REMNAWAVE",),
|
||||
},
|
||||
"server": {
|
||||
"title": "📊 Статус серверов",
|
||||
"description": "Мониторинг серверов, SLA и внешние метрики.",
|
||||
@@ -127,13 +140,14 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
"title": "⚡ Расширенные",
|
||||
"description": "Web API, webhook, логирование, модерация и режим отладки.",
|
||||
"icon": "⚡",
|
||||
"categories": ("WEB_API", "WEBHOOK", "LOG", "MODERATION", "DEBUG"),
|
||||
},
|
||||
"external_admin": {
|
||||
"title": "🛡️ Внешняя админка",
|
||||
"description": "Токен, по которому внешняя админка проверяет запросы.",
|
||||
"icon": "🛡️",
|
||||
"categories": ("EXTERNAL_ADMIN",),
|
||||
"categories": (
|
||||
"WEB_API",
|
||||
"WEBHOOK",
|
||||
"LOG",
|
||||
"MODERATION",
|
||||
"DEBUG",
|
||||
"EXTERNAL_ADMIN",
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -146,12 +160,9 @@ CATEGORY_GROUP_ORDER: Tuple[str, ...] = (
|
||||
"referral",
|
||||
"notifications",
|
||||
"interface",
|
||||
"database",
|
||||
"remnawave",
|
||||
"server",
|
||||
"maintenance",
|
||||
"advanced",
|
||||
"external_admin",
|
||||
)
|
||||
|
||||
CATEGORY_GROUP_DEFINITIONS: Tuple[Tuple[str, str, Tuple[str, ...]], ...] = tuple(
|
||||
@@ -298,7 +309,6 @@ def _get_group_status(group_key: str) -> Tuple[str, str]:
|
||||
settings.REFERRAL_COMMISSION_PERCENT
|
||||
or settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS
|
||||
or settings.REFERRAL_INVITER_BONUS_KOPEKS
|
||||
or settings.get_referred_user_reward_kopeks()
|
||||
)
|
||||
return ("🟢", "Программа активна") if active else ("⚪", "Бонусы не заданы")
|
||||
|
||||
@@ -1303,7 +1313,10 @@ def _build_settings_keyboard(
|
||||
label = texts.t("PAYMENT_CARD_TRIBUTE", "💳 Банковская карта (Tribute)")
|
||||
test_payment_buttons.append([_test_button(f"{label} · тест", "tribute")])
|
||||
elif category_key == "MULENPAY":
|
||||
label = texts.t("PAYMENT_CARD_MULENPAY", "💳 Банковская карта (Mulen Pay)")
|
||||
label = texts.t(
|
||||
"PAYMENT_CARD_MULENPAY",
|
||||
"💳 Банковская карта ({mulenpay_name})",
|
||||
).format(mulenpay_name=settings.get_mulenpay_display_name())
|
||||
test_payment_buttons.append([_test_button(f"{label} · тест", "mulenpay")])
|
||||
elif category_key == "WATA":
|
||||
label = texts.t("PAYMENT_CARD_WATA", "💳 Банковская карта (WATA)")
|
||||
@@ -1414,6 +1427,16 @@ def _build_setting_keyboard(
|
||||
for chunk in _chunk(choice_buttons, 2):
|
||||
rows.append(list(chunk))
|
||||
|
||||
if key == "SIMPLE_SUBSCRIPTION_SQUAD_UUID" and not is_read_only:
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="🌍 Выбрать сквад",
|
||||
callback_data=(
|
||||
f"botcfg_simple_squad:{group_key}:{category_page}:{settings_page}:{callback_token}:1"
|
||||
),
|
||||
)
|
||||
])
|
||||
|
||||
if definition.python_type is bool and not is_read_only:
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
@@ -1642,6 +1665,217 @@ async def show_bot_config_category(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_simple_subscription_squad_selector(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
parts = callback.data.split(":", 5)
|
||||
group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY
|
||||
try:
|
||||
category_page = max(1, int(parts[2])) if len(parts) > 2 else 1
|
||||
except ValueError:
|
||||
category_page = 1
|
||||
try:
|
||||
settings_page = max(1, int(parts[3])) if len(parts) > 3 else 1
|
||||
except ValueError:
|
||||
settings_page = 1
|
||||
token = parts[4] if len(parts) > 4 else ""
|
||||
|
||||
try:
|
||||
key = bot_configuration_service.resolve_callback_token(token)
|
||||
except KeyError:
|
||||
await callback.answer("Эта настройка больше недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
if key != "SIMPLE_SUBSCRIPTION_SQUAD_UUID":
|
||||
await callback.answer("Эта настройка больше недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
page = max(1, int(parts[5])) if len(parts) > 5 else 1
|
||||
except ValueError:
|
||||
page = 1
|
||||
|
||||
limit = SIMPLE_SUBSCRIPTION_SQUADS_PAGE_SIZE
|
||||
squads, total_count = await get_all_server_squads(
|
||||
db,
|
||||
available_only=False,
|
||||
page=page,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
total_count = total_count or 0
|
||||
total_pages = max(1, math.ceil(total_count / limit)) if total_count else 1
|
||||
if total_count and page > total_pages:
|
||||
page = total_pages
|
||||
squads, total_count = await get_all_server_squads(
|
||||
db,
|
||||
available_only=False,
|
||||
page=page,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
current_uuid = bot_configuration_service.get_current_value(key) or ""
|
||||
current_display = "Любой доступный"
|
||||
|
||||
if current_uuid:
|
||||
selected_server = next((srv for srv in squads if srv.squad_uuid == current_uuid), None)
|
||||
if not selected_server:
|
||||
selected_server = await get_server_squad_by_uuid(db, current_uuid)
|
||||
if selected_server:
|
||||
current_display = selected_server.display_name
|
||||
else:
|
||||
current_display = current_uuid
|
||||
|
||||
lines = [
|
||||
"🌍 <b>Выберите сквад для простой покупки</b>",
|
||||
"",
|
||||
f"Текущий выбор: {html.escape(current_display)}" if current_display else "Текущий выбор: —",
|
||||
"",
|
||||
]
|
||||
|
||||
if total_count == 0:
|
||||
lines.append("❌ Доступные сервера не найдены.")
|
||||
else:
|
||||
lines.append("Выберите сервер из списка ниже.")
|
||||
if total_pages > 1:
|
||||
lines.append(f"Страница {page}/{total_pages}")
|
||||
|
||||
text = "\n".join(lines)
|
||||
|
||||
keyboard_rows: List[List[types.InlineKeyboardButton]] = []
|
||||
|
||||
for server in squads:
|
||||
status_icon = (
|
||||
"✅" if server.squad_uuid == current_uuid else ("🟢" if server.is_available else "🔒")
|
||||
)
|
||||
label_parts = [status_icon, server.display_name]
|
||||
if server.country_code:
|
||||
label_parts.append(f"({server.country_code.upper()})")
|
||||
if isinstance(server.price_kopeks, int) and server.price_kopeks > 0:
|
||||
try:
|
||||
label_parts.append(f"— {settings.format_price(server.price_kopeks)}")
|
||||
except Exception:
|
||||
pass
|
||||
label = " ".join(label_parts)
|
||||
|
||||
keyboard_rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=label,
|
||||
callback_data=(
|
||||
f"botcfg_simple_squad_select:{group_key}:{category_page}:{settings_page}:{token}:{server.id}:{page}"
|
||||
),
|
||||
)
|
||||
])
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row: List[types.InlineKeyboardButton] = []
|
||||
if page > 1:
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=(
|
||||
f"botcfg_simple_squad:{group_key}:{category_page}:{settings_page}:{token}:{page - 1}"
|
||||
),
|
||||
)
|
||||
)
|
||||
if page < total_pages:
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=(
|
||||
f"botcfg_simple_squad:{group_key}:{category_page}:{settings_page}:{token}:{page + 1}"
|
||||
),
|
||||
)
|
||||
)
|
||||
if nav_row:
|
||||
keyboard_rows.append(nav_row)
|
||||
|
||||
keyboard_rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data=(
|
||||
f"botcfg_setting:{group_key}:{category_page}:{settings_page}:{token}"
|
||||
),
|
||||
)
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def select_simple_subscription_squad(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
parts = callback.data.split(":", 6)
|
||||
group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY
|
||||
try:
|
||||
category_page = max(1, int(parts[2])) if len(parts) > 2 else 1
|
||||
except ValueError:
|
||||
category_page = 1
|
||||
try:
|
||||
settings_page = max(1, int(parts[3])) if len(parts) > 3 else 1
|
||||
except ValueError:
|
||||
settings_page = 1
|
||||
token = parts[4] if len(parts) > 4 else ""
|
||||
try:
|
||||
server_id = int(parts[5]) if len(parts) > 5 else None
|
||||
except ValueError:
|
||||
server_id = None
|
||||
|
||||
if server_id is None:
|
||||
await callback.answer("Не удалось определить сервер", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
key = bot_configuration_service.resolve_callback_token(token)
|
||||
except KeyError:
|
||||
await callback.answer("Эта настройка больше недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
if bot_configuration_service.is_read_only(key):
|
||||
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
|
||||
return
|
||||
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if not server:
|
||||
await callback.answer("Сервер не найден", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
await bot_configuration_service.set_value(db, key, server.squad_uuid)
|
||||
except ReadOnlySettingError:
|
||||
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
|
||||
return
|
||||
|
||||
await db.commit()
|
||||
|
||||
text = _render_setting_text(key)
|
||||
keyboard = _build_setting_keyboard(key, group_key, category_page, settings_page)
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await _store_setting_context(
|
||||
state,
|
||||
key=key,
|
||||
group_key=group_key,
|
||||
category_page=category_page,
|
||||
settings_page=settings_page,
|
||||
)
|
||||
await callback.answer("Сквад выбран")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def test_remnawave_connection(
|
||||
@@ -1834,8 +2068,13 @@ async def test_payment_provider(
|
||||
return
|
||||
|
||||
if method == "mulenpay":
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
mulenpay_name_html = settings.get_mulenpay_display_name_html()
|
||||
if not settings.is_mulenpay_enabled():
|
||||
await callback.answer("❌ MulenPay отключен", show_alert=True)
|
||||
await callback.answer(
|
||||
f"❌ {mulenpay_name} отключен",
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
amount_kopeks = 1 * 100
|
||||
@@ -1843,18 +2082,21 @@ async def test_payment_provider(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description="Тестовый платеж MulenPay (админ)",
|
||||
description=f"Тестовый платеж {mulenpay_name} (админ)",
|
||||
language=language,
|
||||
)
|
||||
|
||||
if not payment_result or not payment_result.get("payment_url"):
|
||||
await callback.answer("❌ Не удалось создать платеж MulenPay", show_alert=True)
|
||||
await callback.answer(
|
||||
f"❌ Не удалось создать платеж {mulenpay_name}",
|
||||
show_alert=True,
|
||||
)
|
||||
await _refresh_markup()
|
||||
return
|
||||
|
||||
payment_url = payment_result["payment_url"]
|
||||
message_text = (
|
||||
"🧪 <b>Тестовый платеж MulenPay</b>\n\n"
|
||||
f"🧪 <b>Тестовый платеж {mulenpay_name_html}</b>\n\n"
|
||||
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
|
||||
f"🆔 ID: {payment_result['mulen_payment_id']}"
|
||||
)
|
||||
@@ -1875,7 +2117,10 @@ async def test_payment_provider(
|
||||
]
|
||||
)
|
||||
await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode="HTML")
|
||||
await callback.answer("✅ Ссылка на платеж MulenPay отправлена", show_alert=True)
|
||||
await callback.answer(
|
||||
f"✅ Ссылка на платеж {mulenpay_name} отправлена",
|
||||
show_alert=True,
|
||||
)
|
||||
await _refresh_markup()
|
||||
return
|
||||
|
||||
@@ -2505,6 +2750,14 @@ def register_handlers(dp: Dispatcher) -> None:
|
||||
test_payment_provider,
|
||||
F.data.startswith("botcfg_test_payment:"),
|
||||
)
|
||||
dp.callback_query.register(
|
||||
select_simple_subscription_squad,
|
||||
F.data.startswith("botcfg_simple_squad_select:"),
|
||||
)
|
||||
dp.callback_query.register(
|
||||
show_simple_subscription_squad_selector,
|
||||
F.data.startswith("botcfg_simple_squad:"),
|
||||
)
|
||||
dp.callback_query.register(
|
||||
show_bot_config_setting,
|
||||
F.data.startswith("botcfg_setting:"),
|
||||
@@ -2543,4 +2796,3 @@ def register_handlers(dp: Dispatcher) -> None:
|
||||
handle_import_message,
|
||||
BotConfigStates.waiting_for_import_file,
|
||||
)
|
||||
|
||||
|
||||
@@ -46,6 +46,9 @@ def _format_campaign_summary(campaign, texts) -> str:
|
||||
bonus_info = f"💰 Бонус на баланс: <b>{bonus_text}</b>"
|
||||
else:
|
||||
traffic_text = texts.format_traffic(campaign.subscription_traffic_gb or 0)
|
||||
device_limit = campaign.subscription_device_limit
|
||||
if device_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
bonus_info = (
|
||||
"📱 Подписка: <b>{days} д.</b>\n"
|
||||
"🌐 Трафик: <b>{traffic}</b>\n"
|
||||
@@ -53,7 +56,7 @@ def _format_campaign_summary(campaign, texts) -> str:
|
||||
).format(
|
||||
days=campaign.subscription_duration_days or 0,
|
||||
traffic=traffic_text,
|
||||
devices=campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT,
|
||||
devices=device_limit,
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -935,7 +938,9 @@ async def start_edit_campaign_subscription_devices(
|
||||
campaign_edit_message_is_caption=is_caption,
|
||||
)
|
||||
|
||||
current_devices = campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
current_devices = campaign.subscription_device_limit
|
||||
if current_devices is None:
|
||||
current_devices = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
await callback.message.edit_text(
|
||||
(
|
||||
|
||||
@@ -207,6 +207,7 @@ async def show_support_audit(
|
||||
'close_ticket': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET", "Закрытие тикета"),
|
||||
'block_user_timed': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED", "Блокировка (время)"),
|
||||
'block_user_perm': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM", "Блокировка (навсегда)"),
|
||||
'close_all_tickets': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_ALL_TICKETS", "Массовое закрытие тикетов"),
|
||||
'unblock_user': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK", "Снятие блока"),
|
||||
}
|
||||
action_text = action_map.get(log.action, log.action)
|
||||
@@ -215,6 +216,8 @@ async def show_support_audit(
|
||||
extra = ""
|
||||
if log.action == 'block_user_timed' and 'minutes' in details:
|
||||
extra = f" ({details['minutes']} мин)"
|
||||
elif log.action == 'close_all_tickets' and 'count' in details:
|
||||
extra = f" ({details['count']})"
|
||||
lines.append(f"{ts} • {role} <code>{log.actor_telegram_id}</code> — {action_text}{ticket_part}{extra}")
|
||||
|
||||
# keyboard with pagination
|
||||
@@ -446,4 +449,4 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.message.register(
|
||||
admin_commands_help,
|
||||
Command("admin_help")
|
||||
)
|
||||
)
|
||||
|
||||
@@ -74,6 +74,7 @@ async def show_maintenance_panel(
|
||||
{status_emoji} <b>Режим техработ:</b> {status_text}
|
||||
{api_emoji} <b>API Remnawave:</b> {api_text}
|
||||
{monitoring_emoji} <b>Мониторинг:</b> {monitoring_text}
|
||||
🛠️ <b>Автозапуск мониторинга:</b> {'Включен' if status_info['monitoring_configured'] else 'Отключен'}
|
||||
⏱️ <b>Интервал проверки:</b> {status_info['check_interval']}с
|
||||
🤖 <b>Автовключение:</b> {'Включено' if status_info['auto_enable_configured'] else 'Отключено'}
|
||||
{panel_info}
|
||||
@@ -233,7 +234,11 @@ async def check_panel_status(
|
||||
f"👥 Пользователей онлайн: {status_data.get('users_online', 0)}",
|
||||
f"🖥️ Нод онлайн: {status_data.get('nodes_online', 0)}/{status_data.get('total_nodes', 0)}"
|
||||
]
|
||||
|
||||
|
||||
attempts_used = status_data.get("attempts_used")
|
||||
if attempts_used:
|
||||
message_parts.append(f"🔁 Попыток проверки: {attempts_used}")
|
||||
|
||||
if status_data.get("api_error"):
|
||||
message_parts.append(f"❌ Ошибка: {status_data['api_error'][:100]}")
|
||||
|
||||
|
||||
@@ -764,48 +764,69 @@ async def confirm_broadcast(
|
||||
|
||||
broadcast_keyboard = create_broadcast_keyboard(selected_buttons, db_user.language)
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
if has_media and media_file_id:
|
||||
if media_type == "photo":
|
||||
await callback.bot.send_photo(
|
||||
# Ограничение на количество одновременных отправок
|
||||
semaphore = asyncio.Semaphore(20)
|
||||
|
||||
async def send_single_broadcast(user):
|
||||
"""Отправляет одно сообщение рассылки с семафором ограничения"""
|
||||
async with semaphore:
|
||||
try:
|
||||
if has_media and media_file_id:
|
||||
if media_type == "photo":
|
||||
await callback.bot.send_photo(
|
||||
chat_id=user.telegram_id,
|
||||
photo=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
elif media_type == "video":
|
||||
await callback.bot.send_video(
|
||||
chat_id=user.telegram_id,
|
||||
video=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
elif media_type == "document":
|
||||
await callback.bot.send_document(
|
||||
chat_id=user.telegram_id,
|
||||
document=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
else:
|
||||
await callback.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
photo=media_file_id,
|
||||
caption=message_text,
|
||||
text=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
elif media_type == "video":
|
||||
await callback.bot.send_video(
|
||||
chat_id=user.telegram_id,
|
||||
video=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
elif media_type == "document":
|
||||
await callback.bot.send_document(
|
||||
chat_id=user.telegram_id,
|
||||
document=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
else:
|
||||
await callback.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
sent_count += 1
|
||||
|
||||
if sent_count % 20 == 0:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
logger.error(f"Ошибка отправки рассылки пользователю {user.telegram_id}: {e}")
|
||||
return True, user.telegram_id
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки рассылки пользователю {user.telegram_id}: {e}")
|
||||
return False, user.telegram_id
|
||||
|
||||
# Отправляем сообщения пакетами для эффективности
|
||||
batch_size = 100
|
||||
for i in range(0, len(users), batch_size):
|
||||
batch = users[i:i + batch_size]
|
||||
tasks = [send_single_broadcast(user) for user in batch]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for result in results:
|
||||
if isinstance(result, tuple): # (success, telegram_id)
|
||||
success, _ = result
|
||||
if success:
|
||||
sent_count += 1
|
||||
else:
|
||||
failed_count += 1
|
||||
elif isinstance(result, Exception):
|
||||
failed_count += 1
|
||||
|
||||
# Небольшая задержка между пакетами для снижения нагрузки на API
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
broadcast_history.sent_count = sent_count
|
||||
broadcast_history.failed_count = failed_count
|
||||
|
||||
@@ -1027,6 +1027,20 @@ async def process_notification_value_input(message: Message, state: FSMContext):
|
||||
language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
# Добавляем дополнительные проверки диапазона значений
|
||||
if (key == "expired_second_wave" and field == "percent") or (key == "expired_third_wave" and field == "percent"):
|
||||
if value < 0 or value > 100:
|
||||
await message.answer("❌ Процент скидки должен быть от 0 до 100.")
|
||||
return
|
||||
elif (key == "expired_second_wave" and field == "hours") or (key == "expired_third_wave" and field == "hours"):
|
||||
if value < 1 or value > 168: # Максимум 168 часов (7 дней)
|
||||
await message.answer("❌ Количество часов должно быть от 1 до 168.")
|
||||
return
|
||||
elif key == "expired_third_wave" and field == "trigger":
|
||||
if value < 2: # Минимум 2 дня
|
||||
await message.answer("❌ Количество дней должно быть не менее 2.")
|
||||
return
|
||||
|
||||
success = False
|
||||
if key == "expired_second_wave" and field == "percent":
|
||||
success = NotificationSettingsService.set_second_wave_discount_percent(value)
|
||||
@@ -1075,4 +1089,4 @@ async def process_notification_value_input(message: Message, state: FSMContext):
|
||||
|
||||
|
||||
def register_handlers(dp):
|
||||
dp.include_router(router)
|
||||
dp.include_router(router)
|
||||
|
||||
@@ -0,0 +1,596 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, User
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.payment_verification_service import (
|
||||
PendingPayment,
|
||||
SUPPORTED_MANUAL_CHECK_METHODS,
|
||||
get_payment_record,
|
||||
list_recent_pending_payments,
|
||||
run_manual_check,
|
||||
)
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_datetime, format_time_ago, format_username
|
||||
|
||||
|
||||
PAGE_SIZE = 6
|
||||
|
||||
|
||||
def _method_display(method: PaymentMethod) -> str:
|
||||
if method == PaymentMethod.MULENPAY:
|
||||
return settings.get_mulenpay_display_name()
|
||||
if method == PaymentMethod.PAL24:
|
||||
return "PayPalych"
|
||||
if method == PaymentMethod.WATA:
|
||||
return "WATA"
|
||||
if method == PaymentMethod.HELEKET:
|
||||
return "Heleket"
|
||||
if method == PaymentMethod.YOOKASSA:
|
||||
return "YooKassa"
|
||||
if method == PaymentMethod.CRYPTOBOT:
|
||||
return "CryptoBot"
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
return "Telegram Stars"
|
||||
return method.value
|
||||
|
||||
|
||||
def _status_info(
|
||||
record: PendingPayment,
|
||||
*,
|
||||
texts,
|
||||
) -> tuple[str, str]:
|
||||
status = (record.status or "").lower()
|
||||
|
||||
if record.is_paid:
|
||||
return "✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
|
||||
|
||||
if record.method == PaymentMethod.PAL24:
|
||||
mapping = {
|
||||
"new": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"process": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
|
||||
"success": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"fail": ("❌", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
|
||||
"canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
"cancel": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.MULENPAY:
|
||||
mapping = {
|
||||
"created": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"processing": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
|
||||
"hold": ("🔒", texts.t("ADMIN_PAYMENT_STATUS_ON_HOLD", "🔒 Hold")),
|
||||
"success": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
"cancel": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
"error": ("⚠️", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.WATA:
|
||||
mapping = {
|
||||
"opened": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"pending": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"processing": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
|
||||
"paid": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"closed": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"declined": ("❌", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
|
||||
"canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
"expired": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")),
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.HELEKET:
|
||||
if status in {"pending", "created", "waiting", "check", "processing"}:
|
||||
return "⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")
|
||||
if status in {"paid", "paid_over"}:
|
||||
return "✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
|
||||
if status in {"cancel", "canceled", "fail", "failed", "expired"}:
|
||||
return "❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")
|
||||
return "❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")
|
||||
|
||||
if record.method == PaymentMethod.YOOKASSA:
|
||||
mapping = {
|
||||
"pending": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"waiting_for_capture": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
|
||||
"succeeded": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
mapping = {
|
||||
"active": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"paid": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"expired": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")),
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.TELEGRAM_STARS:
|
||||
if record.is_paid:
|
||||
return "✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
|
||||
return "⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")
|
||||
|
||||
return "❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")
|
||||
|
||||
|
||||
def _is_checkable(record: PendingPayment) -> bool:
|
||||
if record.method not in SUPPORTED_MANUAL_CHECK_METHODS:
|
||||
return False
|
||||
if not record.is_recent():
|
||||
return False
|
||||
status = (record.status or "").lower()
|
||||
if record.method == PaymentMethod.PAL24:
|
||||
return status in {"new", "process"}
|
||||
if record.method == PaymentMethod.MULENPAY:
|
||||
return status in {"created", "processing", "hold"}
|
||||
if record.method == PaymentMethod.WATA:
|
||||
return status in {"opened", "pending", "processing", "inprogress", "in_progress"}
|
||||
if record.method == PaymentMethod.HELEKET:
|
||||
return status not in {"paid", "paid_over", "cancel", "canceled", "fail", "failed", "expired"}
|
||||
if record.method == PaymentMethod.YOOKASSA:
|
||||
return status in {"pending", "waiting_for_capture"}
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
return status in {"active"}
|
||||
return False
|
||||
|
||||
|
||||
def _record_display_number(record: PendingPayment) -> str:
|
||||
if record.identifier:
|
||||
return str(record.identifier)
|
||||
return str(record.local_id)
|
||||
|
||||
|
||||
def _build_list_keyboard(
|
||||
records: list[PendingPayment],
|
||||
*,
|
||||
page: int,
|
||||
total_pages: int,
|
||||
language: str,
|
||||
) -> InlineKeyboardMarkup:
|
||||
buttons: list[list[InlineKeyboardButton]] = []
|
||||
texts = get_texts(language)
|
||||
|
||||
for record in records:
|
||||
number = _record_display_number(record)
|
||||
details_template = texts.t("ADMIN_PAYMENTS_ITEM_DETAILS", "📄 #{number}")
|
||||
try:
|
||||
button_text = details_template.format(number=number)
|
||||
except Exception: # pragma: no cover - fallback for broken localization
|
||||
button_text = f"📄 {number}"
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"admin_payment_{record.method.value}_{record.local_id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if total_pages > 1:
|
||||
navigation_row: list[InlineKeyboardButton] = []
|
||||
if page > 1:
|
||||
navigation_row.append(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=f"admin_payments_page_{page - 1}",
|
||||
)
|
||||
)
|
||||
|
||||
navigation_row.append(
|
||||
InlineKeyboardButton(
|
||||
text=f"{page}/{total_pages}",
|
||||
callback_data="admin_payments_page_current",
|
||||
)
|
||||
)
|
||||
|
||||
if page < total_pages:
|
||||
navigation_row.append(
|
||||
InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=f"admin_payments_page_{page + 1}",
|
||||
)
|
||||
)
|
||||
|
||||
buttons.append(navigation_row)
|
||||
|
||||
buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def _build_detail_keyboard(
|
||||
record: PendingPayment,
|
||||
*,
|
||||
language: str,
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
rows: list[list[InlineKeyboardButton]] = []
|
||||
|
||||
payment = record.payment
|
||||
payment_url = getattr(payment, "payment_url", None)
|
||||
if record.method == PaymentMethod.PAL24:
|
||||
payment_url = payment.link_url or payment.link_page_url or payment_url
|
||||
elif record.method == PaymentMethod.WATA:
|
||||
payment_url = payment.url or payment_url
|
||||
elif record.method == PaymentMethod.YOOKASSA:
|
||||
payment_url = getattr(payment, "confirmation_url", None) or payment_url
|
||||
elif record.method == PaymentMethod.CRYPTOBOT:
|
||||
payment_url = (
|
||||
payment.bot_invoice_url
|
||||
or payment.mini_app_invoice_url
|
||||
or payment.web_app_invoice_url
|
||||
or payment_url
|
||||
)
|
||||
|
||||
if payment_url:
|
||||
rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_PAYMENT_OPEN_LINK", "🔗 Open link"),
|
||||
url=payment_url,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if _is_checkable(record):
|
||||
rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_PAYMENT_CHECK_BUTTON", "🔁 Check status"),
|
||||
callback_data=f"admin_payment_check_{record.method.value}_{record.local_id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
rows.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_payments")])
|
||||
return InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
||||
|
||||
def _format_user_line(user: User) -> str:
|
||||
username = format_username(user.username, user.telegram_id, user.full_name)
|
||||
return f"👤 {html.escape(username)} (<code>{user.telegram_id}</code>)"
|
||||
|
||||
|
||||
def _build_record_lines(
|
||||
record: PendingPayment,
|
||||
*,
|
||||
index: int,
|
||||
texts,
|
||||
language: str,
|
||||
) -> list[str]:
|
||||
amount = settings.format_price(record.amount_kopeks)
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
crypto_amount = getattr(record.payment, "amount", None)
|
||||
crypto_asset = getattr(record.payment, "asset", None)
|
||||
if crypto_amount and crypto_asset:
|
||||
amount = f"{crypto_amount} {crypto_asset}"
|
||||
method_name = _method_display(record.method)
|
||||
emoji, status_text = _status_info(record, texts=texts)
|
||||
created = format_datetime(record.created_at)
|
||||
age = format_time_ago(record.created_at, language)
|
||||
identifier = (
|
||||
html.escape(str(record.identifier)) if record.identifier else ""
|
||||
)
|
||||
display_number = html.escape(_record_display_number(record))
|
||||
|
||||
lines = [
|
||||
f"{index}. <b>{html.escape(method_name)}</b> — {amount}",
|
||||
f" {emoji} {status_text}",
|
||||
f" 🕒 {created} ({age})",
|
||||
_format_user_line(record.user),
|
||||
]
|
||||
|
||||
if identifier:
|
||||
lines.append(f" 🆔 ID: <code>{identifier}</code>")
|
||||
else:
|
||||
lines.append(f" 🆔 ID: <code>{display_number}</code>")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def _build_payment_details_text(record: PendingPayment, *, texts, language: str) -> str:
|
||||
method_name = _method_display(record.method)
|
||||
emoji, status_text = _status_info(record, texts=texts)
|
||||
amount = settings.format_price(record.amount_kopeks)
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
crypto_amount = getattr(record.payment, "amount", None)
|
||||
crypto_asset = getattr(record.payment, "asset", None)
|
||||
if crypto_amount and crypto_asset:
|
||||
amount = f"{crypto_amount} {crypto_asset}"
|
||||
created = format_datetime(record.created_at)
|
||||
age = format_time_ago(record.created_at, language)
|
||||
raw_identifier = record.identifier if record.identifier else record.local_id
|
||||
identifier = html.escape(str(raw_identifier)) if raw_identifier is not None else "—"
|
||||
lines = [
|
||||
texts.t("ADMIN_PAYMENT_DETAILS_TITLE", "💳 <b>Payment details</b>"),
|
||||
"",
|
||||
f"<b>{html.escape(method_name)}</b>",
|
||||
f"{emoji} {status_text}",
|
||||
"",
|
||||
f"💰 {texts.t('ADMIN_PAYMENT_AMOUNT', 'Amount')}: {amount}",
|
||||
f"🕒 {texts.t('ADMIN_PAYMENT_CREATED', 'Created')}: {created} ({age})",
|
||||
f"🆔 ID: <code>{identifier}</code>",
|
||||
_format_user_line(record.user),
|
||||
]
|
||||
|
||||
if record.expires_at:
|
||||
expires_at = format_datetime(record.expires_at)
|
||||
lines.append(f"⏳ {texts.t('ADMIN_PAYMENT_EXPIRES', 'Expires')}: {expires_at}")
|
||||
|
||||
payment = record.payment
|
||||
|
||||
if record.method == PaymentMethod.PAL24:
|
||||
if getattr(payment, "payment_status", None):
|
||||
lines.append(
|
||||
f"💳 {texts.t('ADMIN_PAYMENT_GATEWAY_STATUS', 'Gateway status')}: "
|
||||
f"{html.escape(str(payment.payment_status))}"
|
||||
)
|
||||
if getattr(payment, "payment_method", None):
|
||||
lines.append(
|
||||
f"🏦 {texts.t('ADMIN_PAYMENT_GATEWAY_METHOD', 'Method')}: "
|
||||
f"{html.escape(str(payment.payment_method))}"
|
||||
)
|
||||
if getattr(payment, "balance_amount", None):
|
||||
lines.append(
|
||||
f"💱 {texts.t('ADMIN_PAYMENT_GATEWAY_AMOUNT', 'Gateway amount')}: "
|
||||
f"{html.escape(str(payment.balance_amount))}"
|
||||
)
|
||||
if getattr(payment, "payer_account", None):
|
||||
lines.append(
|
||||
f"👛 {texts.t('ADMIN_PAYMENT_GATEWAY_ACCOUNT', 'Payer account')}: "
|
||||
f"{html.escape(str(payment.payer_account))}"
|
||||
)
|
||||
|
||||
if record.method == PaymentMethod.MULENPAY:
|
||||
if getattr(payment, "mulen_payment_id", None):
|
||||
lines.append(
|
||||
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
|
||||
f"{html.escape(str(payment.mulen_payment_id))}"
|
||||
)
|
||||
|
||||
if record.method == PaymentMethod.WATA:
|
||||
if getattr(payment, "order_id", None):
|
||||
lines.append(
|
||||
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
|
||||
f"{html.escape(str(payment.order_id))}"
|
||||
)
|
||||
if getattr(payment, "terminal_public_id", None):
|
||||
lines.append(
|
||||
f"🏦 Terminal: {html.escape(str(payment.terminal_public_id))}"
|
||||
)
|
||||
|
||||
if record.method == PaymentMethod.HELEKET:
|
||||
if getattr(payment, "order_id", None):
|
||||
lines.append(
|
||||
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
|
||||
f"{html.escape(str(payment.order_id))}"
|
||||
)
|
||||
if getattr(payment, "payer_amount", None) and getattr(payment, "payer_currency", None):
|
||||
lines.append(
|
||||
f"🪙 {texts.t('ADMIN_PAYMENT_PAYER_AMOUNT', 'Paid amount')}: "
|
||||
f"{html.escape(str(payment.payer_amount))} {html.escape(str(payment.payer_currency))}"
|
||||
)
|
||||
|
||||
if record.method == PaymentMethod.YOOKASSA:
|
||||
if getattr(payment, "payment_method_type", None):
|
||||
lines.append(
|
||||
f"💳 {texts.t('ADMIN_PAYMENT_GATEWAY_METHOD', 'Method')}: "
|
||||
f"{html.escape(str(payment.payment_method_type))}"
|
||||
)
|
||||
if getattr(payment, "confirmation_url", None):
|
||||
lines.append(texts.t("ADMIN_PAYMENT_HAS_LINK", "🔗 Payment link is available above."))
|
||||
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
if getattr(payment, "amount", None) and getattr(payment, "asset", None):
|
||||
lines.append(
|
||||
f"🪙 {texts.t('ADMIN_PAYMENT_CRYPTO_AMOUNT', 'Crypto amount')}: "
|
||||
f"{html.escape(str(payment.amount))} {html.escape(str(payment.asset))}"
|
||||
)
|
||||
if getattr(payment, "bot_invoice_url", None) or getattr(payment, "mini_app_invoice_url", None):
|
||||
lines.append(
|
||||
texts.t("ADMIN_PAYMENT_HAS_LINK", "🔗 Payment link is available above.")
|
||||
)
|
||||
if getattr(payment, "status", None):
|
||||
lines.append(
|
||||
f"📊 {texts.t('ADMIN_PAYMENT_GATEWAY_STATUS', 'Gateway status')}: "
|
||||
f"{html.escape(str(payment.status))}"
|
||||
)
|
||||
|
||||
if record.method == PaymentMethod.TELEGRAM_STARS:
|
||||
description = getattr(payment, "description", "") or ""
|
||||
if description:
|
||||
lines.append(f"📝 {html.escape(description)}")
|
||||
if getattr(payment, "external_id", None):
|
||||
lines.append(
|
||||
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
|
||||
f"{html.escape(str(payment.external_id))}"
|
||||
)
|
||||
|
||||
if _is_checkable(record):
|
||||
lines.append("")
|
||||
lines.append(texts.t("ADMIN_PAYMENT_CHECK_HINT", "ℹ️ You can trigger a manual status check."))
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _parse_method_and_id(payload: str, *, prefix: str) -> Optional[tuple[PaymentMethod, int]]:
|
||||
suffix = payload[len(prefix) :]
|
||||
try:
|
||||
method_str, identifier = suffix.rsplit("_", 1)
|
||||
method = PaymentMethod(method_str)
|
||||
payment_id = int(identifier)
|
||||
return method, payment_id
|
||||
except (ValueError, KeyError):
|
||||
return None
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_payments_overview(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
page = 1
|
||||
if callback.data.startswith("admin_payments_page_"):
|
||||
try:
|
||||
page = int(callback.data.split("_")[-1])
|
||||
except ValueError:
|
||||
page = 1
|
||||
|
||||
records = await list_recent_pending_payments(db)
|
||||
total = len(records)
|
||||
total_pages = max(1, math.ceil(total / PAGE_SIZE))
|
||||
if page < 1:
|
||||
page = 1
|
||||
if page > total_pages:
|
||||
page = total_pages
|
||||
|
||||
start_index = (page - 1) * PAGE_SIZE
|
||||
page_records = records[start_index : start_index + PAGE_SIZE]
|
||||
|
||||
header = texts.t("ADMIN_PAYMENTS_TITLE", "💳 <b>Top-up verification</b>")
|
||||
description = texts.t(
|
||||
"ADMIN_PAYMENTS_DESCRIPTION",
|
||||
"Pending invoices created during the last 24 hours.",
|
||||
)
|
||||
notice = texts.t(
|
||||
"ADMIN_PAYMENTS_NOTICE",
|
||||
"Only invoices younger than 24 hours and waiting for payment can be checked.",
|
||||
)
|
||||
|
||||
lines = [header, "", description]
|
||||
|
||||
if page_records:
|
||||
for idx, record in enumerate(page_records, start=start_index + 1):
|
||||
lines.extend(_build_record_lines(record, index=idx, texts=texts, language=db_user.language))
|
||||
lines.append("")
|
||||
lines.append(notice)
|
||||
else:
|
||||
empty_text = texts.t("ADMIN_PAYMENTS_EMPTY", "No pending top-ups in the last 24 hours.")
|
||||
lines.append("")
|
||||
lines.append(empty_text)
|
||||
|
||||
keyboard = _build_list_keyboard(
|
||||
page_records,
|
||||
page=page,
|
||||
total_pages=total_pages,
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(line for line in lines if line is not None),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_payment_details(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
record: PendingPayment,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
text = _build_payment_details_text(record, texts=texts, language=db_user.language)
|
||||
keyboard = _build_detail_keyboard(record, language=db_user.language)
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_payment_details(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
parsed = _parse_method_and_id(callback.data, prefix="admin_payment_")
|
||||
if not parsed:
|
||||
await callback.answer("❌ Invalid payment reference", show_alert=True)
|
||||
return
|
||||
|
||||
method, payment_id = parsed
|
||||
record = await get_payment_record(db, method, payment_id)
|
||||
if not record:
|
||||
await callback.answer("❌ Платеж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
await _render_payment_details(callback, db_user, record)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def manual_check_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
parsed = _parse_method_and_id(callback.data, prefix="admin_payment_check_")
|
||||
if not parsed:
|
||||
await callback.answer("❌ Invalid payment reference", show_alert=True)
|
||||
return
|
||||
|
||||
method, payment_id = parsed
|
||||
record = await get_payment_record(db, method, payment_id)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not record:
|
||||
await callback.answer(texts.t("ADMIN_PAYMENT_NOT_FOUND", "Payment not found."), show_alert=True)
|
||||
return
|
||||
|
||||
if not _is_checkable(record):
|
||||
await callback.answer(
|
||||
texts.t("ADMIN_PAYMENT_CHECK_NOT_AVAILABLE", "Manual check is not available for this invoice."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(callback.bot)
|
||||
updated = await run_manual_check(db, method, payment_id, payment_service)
|
||||
|
||||
if not updated:
|
||||
await callback.answer(
|
||||
texts.t("ADMIN_PAYMENT_CHECK_FAILED", "Failed to refresh the payment status."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await _render_payment_details(callback, db_user, updated)
|
||||
|
||||
if updated.status != record.status or updated.is_paid != record.is_paid:
|
||||
emoji, status_text = _status_info(updated, texts=texts)
|
||||
message = texts.t(
|
||||
"ADMIN_PAYMENT_CHECK_SUCCESS",
|
||||
"Status updated: {status}",
|
||||
).format(status=f"{emoji} {status_text}")
|
||||
else:
|
||||
message = texts.t(
|
||||
"ADMIN_PAYMENT_CHECK_NO_CHANGES",
|
||||
"Status is unchanged after the check.",
|
||||
)
|
||||
|
||||
await callback.answer(message, show_alert=True)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher) -> None:
|
||||
dp.callback_query.register(manual_check_payment, F.data.startswith("admin_payment_check_"))
|
||||
dp.callback_query.register(
|
||||
show_payment_details,
|
||||
F.data.startswith("admin_payment_") & ~F.data.startswith("admin_payment_check_"),
|
||||
)
|
||||
dp.callback_query.register(show_payments_overview, F.data.startswith("admin_payments_page_"))
|
||||
dp.callback_query.register(show_payments_overview, F.data == "admin_payments")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -211,6 +211,14 @@ def _format_rubles(amount_kopeks: int) -> str:
|
||||
return formatted.replace(",", " ")
|
||||
|
||||
|
||||
def _format_priority_line(texts, group: PromoGroup) -> str:
|
||||
priority = getattr(group, "priority", 0)
|
||||
return texts.t(
|
||||
"ADMIN_PROMO_GROUP_PRIORITY_LINE",
|
||||
"🎯 Приоритет: {priority}",
|
||||
).format(priority=priority)
|
||||
|
||||
|
||||
def _format_auto_assign_line(texts, group: PromoGroup) -> str:
|
||||
threshold = getattr(group, "auto_assign_total_spent_kopeks", 0) or 0
|
||||
|
||||
@@ -294,6 +302,7 @@ def _build_edit_menu_content(
|
||||
lines = [header]
|
||||
lines.extend(_format_discount_lines(texts, group))
|
||||
lines.append(_format_addon_discounts_line(texts, group))
|
||||
lines.append(_format_priority_line(texts, group))
|
||||
lines.append(_format_auto_assign_line(texts, group))
|
||||
|
||||
period_lines = _format_period_discounts_lines(texts, group, language)
|
||||
@@ -318,6 +327,15 @@ def _build_edit_menu_content(
|
||||
callback_data=f"promo_group_edit_field_{group.id}_name",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_PRIORITY",
|
||||
"🎯 Приоритет",
|
||||
),
|
||||
callback_data=f"promo_group_edit_field_{group.id}_priority",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
@@ -640,6 +658,32 @@ async def process_create_group_name(message: types.Message, state: FSMContext):
|
||||
return
|
||||
|
||||
await state.update_data(new_group_name=name)
|
||||
await state.set_state(AdminStates.creating_promo_group_priority)
|
||||
texts = get_texts((await state.get_data()).get("language", "ru"))
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_CREATE_PRIORITY_PROMPT",
|
||||
"Введите приоритет группы (0 = базовая, чем больше - тем выше приоритет):",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def process_create_group_priority(message: types.Message, state: FSMContext):
|
||||
texts = get_texts((await state.get_data()).get("language", "ru"))
|
||||
try:
|
||||
priority = int(message.text)
|
||||
if priority < 0:
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_INVALID_PRIORITY",
|
||||
"❌ Приоритет должен быть неотрицательным целым числом",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(new_group_priority=priority)
|
||||
await state.set_state(AdminStates.creating_promo_group_traffic_discount)
|
||||
await _prompt_for_discount(
|
||||
message,
|
||||
@@ -772,6 +816,7 @@ async def process_create_group_auto_assign(
|
||||
group = await create_promo_group(
|
||||
db,
|
||||
data["new_group_name"],
|
||||
priority=data.get("new_group_priority", 0),
|
||||
traffic_discount_percent=data["new_group_traffic"],
|
||||
server_discount_percent=data["new_group_servers"],
|
||||
device_discount_percent=data["new_group_devices"],
|
||||
@@ -862,6 +907,12 @@ async def prompt_edit_promo_group_field(
|
||||
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT",
|
||||
"Введите новое название промогруппы (текущее: {name}):",
|
||||
).format(name=group.name)
|
||||
elif field == "priority":
|
||||
await state.set_state(AdminStates.editing_promo_group_priority)
|
||||
prompt = texts.t(
|
||||
"ADMIN_PROMO_GROUP_EDIT_PRIORITY_PROMPT",
|
||||
"Введите новый приоритет (текущий: {current}):",
|
||||
).format(current=getattr(group, "priority", 0))
|
||||
elif field == "traffic":
|
||||
await state.set_state(AdminStates.editing_promo_group_traffic_discount)
|
||||
prompt = texts.t(
|
||||
@@ -935,6 +986,48 @@ async def process_edit_group_name(
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_edit_group_priority(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
data = await state.get_data()
|
||||
texts = get_texts(data.get("language", db_user.language))
|
||||
|
||||
try:
|
||||
priority = int(message.text)
|
||||
if priority < 0:
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_INVALID_PRIORITY",
|
||||
"❌ Приоритет должен быть неотрицательным целым числом",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
group = await get_promo_group_by_id(db, data.get("edit_group_id"))
|
||||
if not group:
|
||||
await message.answer("❌ Промогруппа не найдена")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
group = await update_promo_group(db, group, priority=priority)
|
||||
await state.set_state(AdminStates.editing_promo_group_menu)
|
||||
|
||||
await _send_edit_menu_after_update(
|
||||
message,
|
||||
texts,
|
||||
group,
|
||||
data.get("language", db_user.language),
|
||||
texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name),
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_edit_group_traffic(
|
||||
@@ -1158,8 +1251,9 @@ async def show_promo_group_members(
|
||||
lines = []
|
||||
for index, user in enumerate(members, start=offset + 1):
|
||||
username = f"@{user.username}" if user.username else "—"
|
||||
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
|
||||
lines.append(
|
||||
f"{index}. {user.full_name} (ID {user.id}, {username}, TG {user.telegram_id})"
|
||||
f"{index}. {user_link} (ID {user.id}, {username}, TG {user.telegram_id})"
|
||||
)
|
||||
body = "\n".join(lines)
|
||||
|
||||
@@ -1181,6 +1275,7 @@ async def show_promo_group_members(
|
||||
await callback.message.edit_text(
|
||||
f"{title}\n\n{body}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -1323,6 +1418,10 @@ def register_handlers(dp: Dispatcher):
|
||||
)
|
||||
|
||||
dp.message.register(process_create_group_name, AdminStates.creating_promo_group_name)
|
||||
dp.message.register(
|
||||
process_create_group_priority,
|
||||
AdminStates.creating_promo_group_priority,
|
||||
)
|
||||
dp.message.register(
|
||||
process_create_group_traffic,
|
||||
AdminStates.creating_promo_group_traffic_discount,
|
||||
@@ -1345,6 +1444,10 @@ def register_handlers(dp: Dispatcher):
|
||||
)
|
||||
|
||||
dp.message.register(process_edit_group_name, AdminStates.editing_promo_group_name)
|
||||
dp.message.register(
|
||||
process_edit_group_priority,
|
||||
AdminStates.editing_promo_group_priority,
|
||||
)
|
||||
dp.message.register(
|
||||
process_edit_group_traffic,
|
||||
AdminStates.editing_promo_group_traffic_discount,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
@@ -1896,66 +1897,94 @@ async def _send_offer_to_users(
|
||||
squad_name: Optional[str],
|
||||
effect_type: str,
|
||||
) -> Tuple[int, int]:
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
sent = 0
|
||||
failed = 0
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
offer_record = await upsert_discount_offer(
|
||||
db,
|
||||
user_id=user.id,
|
||||
subscription_id=user.subscription.id if user.subscription else None,
|
||||
notification_type=f"promo_template_{template.id}",
|
||||
discount_percent=template.discount_percent,
|
||||
bonus_amount_kopeks=0,
|
||||
valid_hours=template.valid_hours,
|
||||
effect_type=effect_type,
|
||||
extra_data={
|
||||
"template_id": template.id,
|
||||
"offer_type": template.offer_type,
|
||||
"test_duration_hours": template.test_duration_hours,
|
||||
"test_squad_uuids": template.test_squad_uuids,
|
||||
"active_discount_hours": template.active_discount_hours,
|
||||
},
|
||||
)
|
||||
# Ограничение на количество одновременных отправок
|
||||
semaphore = asyncio.Semaphore(20)
|
||||
|
||||
user_texts = get_texts(user.language or db_user.language)
|
||||
keyboard_rows: List[List[InlineKeyboardButton]] = [
|
||||
[
|
||||
build_miniapp_or_callback_button(
|
||||
text=template.button_text,
|
||||
callback_data=f"claim_discount_{offer_record.id}",
|
||||
async def send_single_offer(user):
|
||||
"""Отправляет одно предложение с семафором ограничения"""
|
||||
async with semaphore:
|
||||
try:
|
||||
# Используем отдельную сессию для изоляции транзакции
|
||||
async with AsyncSessionLocal() as new_db:
|
||||
offer_record = await upsert_discount_offer(
|
||||
new_db,
|
||||
user_id=user.id,
|
||||
subscription_id=getattr(user, "subscription", None).id if getattr(user, "subscription", None) else None,
|
||||
notification_type=f"promo_template_{template.id}",
|
||||
discount_percent=template.discount_percent,
|
||||
bonus_amount_kopeks=0,
|
||||
valid_hours=template.valid_hours,
|
||||
effect_type=effect_type,
|
||||
extra_data={
|
||||
"template_id": template.id,
|
||||
"offer_type": template.offer_type,
|
||||
"test_duration_hours": template.test_duration_hours,
|
||||
"test_squad_uuids": template.test_squad_uuids,
|
||||
"active_discount_hours": template.active_discount_hours,
|
||||
},
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
keyboard_rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=user_texts.t("PROMO_OFFER_CLOSE", "❌ Закрыть"),
|
||||
callback_data="promo_offer_close",
|
||||
)
|
||||
])
|
||||
user_texts = get_texts(user.language or db_user.language)
|
||||
keyboard_rows: List[List[InlineKeyboardButton]] = [
|
||||
[
|
||||
build_miniapp_or_callback_button(
|
||||
text=template.button_text,
|
||||
callback_data=f"claim_discount_{offer_record.id}",
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
keyboard_rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=user_texts.t("PROMO_OFFER_CLOSE", "❌ Закрыть"),
|
||||
callback_data="promo_offer_close",
|
||||
)
|
||||
])
|
||||
|
||||
message_text = _render_template_text(
|
||||
template,
|
||||
user.language or db_user.language,
|
||||
server_name=squad_name,
|
||||
)
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
sent += 1
|
||||
except (TelegramForbiddenError, TelegramBadRequest) as exc:
|
||||
logger.warning("Не удалось отправить предложение пользователю %s: %s", user.telegram_id, exc)
|
||||
failed += 1
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Ошибка рассылки промо предложения пользователю %s: %s", user.telegram_id, exc)
|
||||
failed += 1
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
message_text = _render_template_text(
|
||||
template,
|
||||
user.language or db_user.language,
|
||||
server_name=squad_name,
|
||||
)
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return True
|
||||
except (TelegramForbiddenError, TelegramBadRequest) as exc:
|
||||
logger.warning("Не удалось отправить предложение пользователю %s: %s", user.telegram_id, exc)
|
||||
return False
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Ошибка рассылки промо предложения пользователю %s: %s", user.telegram_id, exc)
|
||||
return False
|
||||
|
||||
# Отправляем предложения пакетами для эффективности
|
||||
batch_size = 100
|
||||
for i in range(0, len(users), batch_size):
|
||||
batch = users[i:i + batch_size]
|
||||
tasks = [send_single_offer(user) for user in batch]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for result in results:
|
||||
if isinstance(result, bool): # Успешно или неуспешно
|
||||
if result:
|
||||
sent += 1
|
||||
else:
|
||||
failed += 1
|
||||
elif isinstance(result, Exception): # Ошибка выполнения задачи
|
||||
failed += 1
|
||||
|
||||
# Небольшая задержка между пакетами для снижения нагрузки на API
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
return sent, failed
|
||||
|
||||
|
||||
@@ -15,8 +15,9 @@ from app.localization.texts import get_texts
|
||||
from app.database.crud.promocode import (
|
||||
get_promocodes_list, get_promocodes_count, create_promocode,
|
||||
get_promocode_statistics, get_promocode_by_code, update_promocode,
|
||||
delete_promocode
|
||||
delete_promocode, get_promocode_by_id
|
||||
)
|
||||
from app.database.crud.promo_group import get_promo_group_by_id, get_promo_groups_with_counts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_datetime
|
||||
|
||||
@@ -81,16 +82,24 @@ async def show_promocodes_list(
|
||||
|
||||
for promo in promocodes:
|
||||
status_emoji = "✅" if promo.is_active else "❌"
|
||||
type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫")
|
||||
|
||||
type_emoji = {
|
||||
"balance": "💰",
|
||||
"subscription_days": "📅",
|
||||
"trial_subscription": "🎁",
|
||||
"promo_group": "🏷️"
|
||||
}.get(promo.type, "🎫")
|
||||
|
||||
text += f"{status_emoji} {type_emoji} <code>{promo.code}</code>\n"
|
||||
text += f"📊 Использований: {promo.current_uses}/{promo.max_uses}\n"
|
||||
|
||||
|
||||
if promo.type == PromoCodeType.BALANCE.value:
|
||||
text += f"💰 Бонус: {settings.format_price(promo.balance_bonus_kopeks)}\n"
|
||||
elif promo.type == PromoCodeType.SUBSCRIPTION_DAYS.value:
|
||||
text += f"📅 Дней: {promo.subscription_days}\n"
|
||||
|
||||
elif promo.type == PromoCodeType.PROMO_GROUP.value:
|
||||
if promo.promo_group:
|
||||
text += f"🏷️ Промогруппа: {promo.promo_group.name}\n"
|
||||
|
||||
if promo.valid_until:
|
||||
text += f"⏰ До: {format_datetime(promo.valid_until)}\n"
|
||||
|
||||
@@ -129,15 +138,20 @@ async def show_promocode_management(
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
|
||||
status_emoji = "✅" if promo.is_active else "❌"
|
||||
type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫")
|
||||
|
||||
type_emoji = {
|
||||
"balance": "💰",
|
||||
"subscription_days": "📅",
|
||||
"trial_subscription": "🎁",
|
||||
"promo_group": "🏷️"
|
||||
}.get(promo.type, "🎫")
|
||||
|
||||
text = f"""
|
||||
🎫 <b>Управление промокодом</b>
|
||||
|
||||
@@ -145,12 +159,17 @@ async def show_promocode_management(
|
||||
{status_emoji} <b>Статус:</b> {'Активен' if promo.is_active else 'Неактивен'}
|
||||
📊 <b>Использований:</b> {promo.current_uses}/{promo.max_uses}
|
||||
"""
|
||||
|
||||
|
||||
if promo.type == PromoCodeType.BALANCE.value:
|
||||
text += f"💰 <b>Бонус:</b> {settings.format_price(promo.balance_bonus_kopeks)}\n"
|
||||
elif promo.type == PromoCodeType.SUBSCRIPTION_DAYS.value:
|
||||
text += f"📅 <b>Дней:</b> {promo.subscription_days}\n"
|
||||
|
||||
elif promo.type == PromoCodeType.PROMO_GROUP.value:
|
||||
if promo.promo_group:
|
||||
text += f"🏷️ <b>Промогруппа:</b> {promo.promo_group.name} (приоритет: {promo.promo_group.priority})\n"
|
||||
elif promo.promo_group_id:
|
||||
text += f"🏷️ <b>Промогруппа ID:</b> {promo.promo_group_id} (не найдена)\n"
|
||||
|
||||
if promo.valid_until:
|
||||
text += f"⏰ <b>Действует до:</b> {format_datetime(promo.valid_until)}\n"
|
||||
|
||||
@@ -200,8 +219,8 @@ async def show_promocode_edit_menu(
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
|
||||
return
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
@@ -445,13 +464,14 @@ async def select_promocode_type(
|
||||
state: FSMContext
|
||||
):
|
||||
promo_type = callback.data.split('_')[-1]
|
||||
|
||||
|
||||
type_names = {
|
||||
"balance": "💰 Пополнение баланса",
|
||||
"days": "📅 Дни подписки",
|
||||
"trial": "🎁 Тестовая подписка"
|
||||
"days": "📅 Дни подписки",
|
||||
"trial": "🎁 Тестовая подписка",
|
||||
"group": "🏷️ Промогруппа"
|
||||
}
|
||||
|
||||
|
||||
await state.update_data(promocode_type=promo_type)
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -509,6 +529,77 @@ async def process_promocode_code(
|
||||
f"Введите количество дней тестовой подписки:"
|
||||
)
|
||||
await state.set_state(AdminStates.setting_promocode_value)
|
||||
elif promo_type == "group":
|
||||
# Show promo group selection
|
||||
groups_with_counts = await get_promo_groups_with_counts(db, limit=50)
|
||||
|
||||
if not groups_with_counts:
|
||||
await message.answer(
|
||||
"❌ Промогруппы не найдены. Создайте хотя бы одну промогруппу.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")]
|
||||
])
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
keyboard = []
|
||||
text = f"🏷️ <b>Промокод:</b> <code>{code}</code>\n\nВыберите промогруппу для назначения:\n\n"
|
||||
|
||||
for promo_group, user_count in groups_with_counts:
|
||||
text += f"• {promo_group.name} (приоритет: {promo_group.priority}, пользователей: {user_count})\n"
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{promo_group.name} (↑{promo_group.priority})",
|
||||
callback_data=f"promo_select_group_{promo_group.id}"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_promocodes")
|
||||
])
|
||||
|
||||
await message.answer(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await state.set_state(AdminStates.selecting_promo_group)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_promo_group_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Handle promo group selection for promocode"""
|
||||
try:
|
||||
promo_group_id = int(callback.data.split('_')[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промогруппы", show_alert=True)
|
||||
return
|
||||
|
||||
promo_group = await get_promo_group_by_id(db, promo_group_id)
|
||||
if not promo_group:
|
||||
await callback.answer("❌ Промогруппа не найдена", show_alert=True)
|
||||
return
|
||||
|
||||
await state.update_data(
|
||||
promo_group_id=promo_group_id,
|
||||
promo_group_name=promo_group.name
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🏷️ <b>Промокод для промогруппы</b>\n\n"
|
||||
f"Промогруппа: {promo_group.name}\n"
|
||||
f"Приоритет: {promo_group.priority}\n\n"
|
||||
f"📊 Введите количество использований промокода (или 0 для безлимита):"
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.setting_promocode_uses)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -557,8 +648,8 @@ async def handle_edit_value(
|
||||
data = await state.get_data()
|
||||
promo_id = data.get('editing_promo_id')
|
||||
edit_action = data.get('edit_action')
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await message.answer("❌ Промокод не найден")
|
||||
await state.clear()
|
||||
@@ -643,8 +734,8 @@ async def handle_edit_uses(
|
||||
):
|
||||
data = await state.get_data()
|
||||
promo_id = data.get('editing_promo_id')
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await message.answer("❌ Промокод не найден")
|
||||
await state.clear()
|
||||
@@ -708,17 +799,20 @@ async def process_promocode_expiry(
|
||||
promo_type = data.get('promocode_type')
|
||||
value = data.get('promocode_value', 0)
|
||||
max_uses = data.get('promocode_max_uses', 1)
|
||||
|
||||
promo_group_id = data.get('promo_group_id')
|
||||
promo_group_name = data.get('promo_group_name')
|
||||
|
||||
valid_until = None
|
||||
if expiry_days > 0:
|
||||
valid_until = datetime.utcnow() + timedelta(days=expiry_days)
|
||||
|
||||
|
||||
type_map = {
|
||||
"balance": PromoCodeType.BALANCE,
|
||||
"days": PromoCodeType.SUBSCRIPTION_DAYS,
|
||||
"trial": PromoCodeType.TRIAL_SUBSCRIPTION
|
||||
"trial": PromoCodeType.TRIAL_SUBSCRIPTION,
|
||||
"group": PromoCodeType.PROMO_GROUP
|
||||
}
|
||||
|
||||
|
||||
promocode = await create_promocode(
|
||||
db=db,
|
||||
code=code,
|
||||
@@ -727,27 +821,31 @@ async def process_promocode_expiry(
|
||||
subscription_days=value if promo_type in ["days", "trial"] else 0,
|
||||
max_uses=max_uses,
|
||||
valid_until=valid_until,
|
||||
created_by=db_user.id
|
||||
created_by=db_user.id,
|
||||
promo_group_id=promo_group_id if promo_type == "group" else None
|
||||
)
|
||||
|
||||
type_names = {
|
||||
"balance": "Пополнение баланса",
|
||||
"days": "Дни подписки",
|
||||
"trial": "Тестовая подписка"
|
||||
"balance": "Пополнение баланса",
|
||||
"days": "Дни подписки",
|
||||
"trial": "Тестовая подписка",
|
||||
"group": "Промогруппа"
|
||||
}
|
||||
|
||||
|
||||
summary_text = f"""
|
||||
✅ <b>Промокод создан!</b>
|
||||
|
||||
🎫 <b>Код:</b> <code>{promocode.code}</code>
|
||||
📝 <b>Тип:</b> {type_names.get(promo_type)}
|
||||
"""
|
||||
|
||||
|
||||
if promo_type == "balance":
|
||||
summary_text += f"💰 <b>Сумма:</b> {settings.format_price(promocode.balance_bonus_kopeks)}\n"
|
||||
elif promo_type in ["days", "trial"]:
|
||||
summary_text += f"📅 <b>Дней:</b> {promocode.subscription_days}\n"
|
||||
|
||||
elif promo_type == "group" and promo_group_name:
|
||||
summary_text += f"🏷️ <b>Промогруппа:</b> {promo_group_name}\n"
|
||||
|
||||
summary_text += f"📊 <b>Использований:</b> {promocode.max_uses}\n"
|
||||
|
||||
if promocode.valid_until:
|
||||
@@ -775,8 +873,8 @@ async def handle_edit_expiry(
|
||||
):
|
||||
data = await state.get_data()
|
||||
promo_id = data.get('editing_promo_id')
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await message.answer("❌ Промокод не найден")
|
||||
await state.clear()
|
||||
@@ -822,8 +920,8 @@ async def toggle_promocode_status(
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
@@ -849,8 +947,8 @@ async def confirm_delete_promocode(
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
|
||||
return
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
@@ -897,8 +995,8 @@ async def delete_promocode_confirmed(
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
|
||||
return
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
@@ -921,8 +1019,8 @@ async def show_promocode_stats(
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
@@ -1007,6 +1105,7 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_promocodes_list, F.data == "admin_promo_list")
|
||||
dp.callback_query.register(start_promocode_creation, F.data == "admin_promo_create")
|
||||
dp.callback_query.register(select_promocode_type, F.data.startswith("promo_type_"))
|
||||
dp.callback_query.register(process_promo_group_selection, F.data.startswith("promo_select_group_"))
|
||||
|
||||
dp.callback_query.register(show_promocode_management, F.data.startswith("promo_manage_"))
|
||||
dp.callback_query.register(toggle_promocode_status, F.data.startswith("promo_toggle_"))
|
||||
|
||||
@@ -68,7 +68,6 @@ async def show_referral_statistics(
|
||||
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
|
||||
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
|
||||
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
|
||||
- Бонус новому пользователю: {settings.format_price(settings.get_referred_user_reward_kopeks())}
|
||||
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
- Уведомления: {'✅ Включены' if settings.REFERRAL_NOTIFICATIONS_ENABLED else '❌ Отключены'}
|
||||
|
||||
@@ -105,7 +104,6 @@ async def show_referral_statistics(
|
||||
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
|
||||
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
|
||||
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
|
||||
- Бонус новому пользователю: {settings.format_price(settings.get_referred_user_reward_kopeks())}
|
||||
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
|
||||
<i>🕐 Время: {current_time}</i>
|
||||
@@ -190,7 +188,6 @@ async def show_referral_settings(
|
||||
• Минимальная сумма пополнения для участия: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
|
||||
• Бонус за первое пополнение реферала: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
|
||||
• Бонус пригласившему за первое пополнение: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
|
||||
• Бонус новому пользователю при регистрации: {settings.format_price(settings.get_referred_user_reward_kopeks())}
|
||||
|
||||
<b>Комиссионные:</b>
|
||||
• Процент с каждой покупки реферала: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
|
||||
@@ -421,7 +421,8 @@ async def show_server_users(
|
||||
lines = []
|
||||
for index, user in enumerate(page_users, start=start_index + 1):
|
||||
safe_user_name = html.escape(user.full_name)
|
||||
lines.append(f"{index}. {safe_user_name}")
|
||||
user_link = f'<a href="tg://user?id={user.telegram_id}">{safe_user_name}</a>'
|
||||
lines.append(f"{index}. {user_link}")
|
||||
|
||||
text += "\n" + "\n".join(lines)
|
||||
else:
|
||||
|
||||
+113
-16
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc, and_
|
||||
from datetime import datetime, timedelta
|
||||
import time
|
||||
import html
|
||||
|
||||
from app.database.models import User, Ticket, TicketStatus
|
||||
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
|
||||
@@ -174,12 +175,24 @@ async def view_admin_ticket(
|
||||
|
||||
user_name = ticket.user.full_name if ticket.user else "Unknown"
|
||||
telegram_id_display = ticket.user.telegram_id if ticket.user else "—"
|
||||
username_display = (ticket.user.username or "отсутствует") if ticket.user else "отсутствует"
|
||||
username_value = ticket.user.username if ticket.user else None
|
||||
|
||||
ticket_text = f"🎫 Тикет #{ticket.id}\n\n"
|
||||
ticket_text += f"👤 Пользователь: {user_name}\n"
|
||||
ticket_text += f"🆔 Telegram ID: <code>{telegram_id_display}</code>\n"
|
||||
ticket_text += f"📱 Username: @{username_display}\n"
|
||||
if username_value:
|
||||
safe_username = html.escape(username_value)
|
||||
ticket_text += f"📱 Username: @{safe_username}\n"
|
||||
ticket_text += (
|
||||
f"🔗 ЛС: <a href=\"tg://resolve?domain={safe_username}\">"
|
||||
f"tg://resolve?domain={safe_username}</a>\n"
|
||||
)
|
||||
else:
|
||||
ticket_text += "📱 Username: отсутствует\n"
|
||||
if ticket.user and ticket.user.telegram_id:
|
||||
chat_link = f"tg://user?id={int(ticket.user.telegram_id)}"
|
||||
ticket_text += f"🔗 Чат по ID: <a href=\"{chat_link}\">{chat_link}</a>\n"
|
||||
ticket_text += "\n"
|
||||
ticket_text += f"📝 Заголовок: {ticket.title}\n"
|
||||
ticket_text += f"📊 Статус: {ticket.status_emoji} {status_text}\n"
|
||||
ticket_text += f"📅 Создан: {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
@@ -221,15 +234,11 @@ async def view_admin_ticket(
|
||||
pass
|
||||
# Кнопки ЛС и профиль
|
||||
try:
|
||||
if ticket.user and ticket.user.telegram_id:
|
||||
if ticket.user and ticket.user.telegram_id and ticket.user.username:
|
||||
safe_username = html.escape(ticket.user.username)
|
||||
buttons_row = []
|
||||
# DM: при наличии username используем tg://resolve, иначе fallback по ID
|
||||
if ticket.user.username:
|
||||
pm_url = f"tg://resolve?domain={ticket.user.username}"
|
||||
else:
|
||||
pm_url = f"tg://user?id={ticket.user.telegram_id}"
|
||||
pm_url = f"tg://resolve?domain={safe_username}"
|
||||
buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=pm_url))
|
||||
# Профиль: по ID
|
||||
profile_url = f"tg://user?id={ticket.user.telegram_id}"
|
||||
buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url))
|
||||
if buttons_row:
|
||||
@@ -461,6 +470,81 @@ async def mark_ticket_as_answered(
|
||||
)
|
||||
|
||||
|
||||
async def close_all_open_admin_tickets(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Закрыть все открытые тикеты."""
|
||||
if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
try:
|
||||
closed_ticket_ids = await TicketCRUD.close_all_open_tickets(db)
|
||||
except Exception as error:
|
||||
logger.error("Error closing all open tickets: %s", error)
|
||||
await callback.answer(
|
||||
texts.t("TICKET_UPDATE_ERROR", "❌ Ошибка при обновлении тикета."),
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
closed_count = len(closed_ticket_ids)
|
||||
|
||||
if closed_count == 0:
|
||||
await callback.answer(
|
||||
texts.t("ADMIN_CLOSE_ALL_OPEN_TICKETS_EMPTY", "ℹ️ Нет открытых тикетов для закрытия."),
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
is_moderator = (
|
||||
not settings.is_admin(callback.from_user.id)
|
||||
and SupportSettingsService.is_moderator(callback.from_user.id)
|
||||
)
|
||||
await TicketCRUD.add_support_audit(
|
||||
db,
|
||||
actor_user_id=db_user.id if db_user else None,
|
||||
actor_telegram_id=callback.from_user.id,
|
||||
is_moderator=is_moderator,
|
||||
action="close_all_tickets",
|
||||
ticket_id=None,
|
||||
target_user_id=None,
|
||||
details={
|
||||
"count": closed_count,
|
||||
"ticket_ids": closed_ticket_ids,
|
||||
}
|
||||
)
|
||||
except Exception as audit_error:
|
||||
logger.warning("Failed to add support audit for bulk close: %s", audit_error)
|
||||
|
||||
# Обновляем список тикетов
|
||||
await show_admin_tickets(callback, db_user, db)
|
||||
|
||||
success_text = texts.t(
|
||||
"ADMIN_CLOSE_ALL_OPEN_TICKETS_SUCCESS",
|
||||
"✅ Закрыто открытых тикетов: {count}"
|
||||
).format(count=closed_count)
|
||||
|
||||
notification_keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.answer(success_text, reply_markup=notification_keyboard)
|
||||
except Exception:
|
||||
# Если не удалось отправить отдельное сообщение, пробуем ответить алертом
|
||||
try:
|
||||
await callback.answer(success_text, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def close_admin_ticket(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
@@ -675,7 +759,21 @@ async def handle_admin_block_duration_input(
|
||||
ticket_text += f"📝 Заголовок: {updated.title}\n"
|
||||
ticket_text += f"📊 Статус: {updated.status_emoji} {status_text}\n"
|
||||
ticket_text += f"📅 Создан: {updated.created_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
ticket_text += f"🔄 Обновлен: {updated.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
ticket_text += f"🔄 Обновлен: {updated.updated_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
if updated.user and updated.user.telegram_id:
|
||||
ticket_text += f"🆔 Telegram ID: <code>{updated.user.telegram_id}</code>\n"
|
||||
if updated.user.username:
|
||||
safe_username = html.escape(updated.user.username)
|
||||
ticket_text += f"📱 Username: @{safe_username}\n"
|
||||
ticket_text += (
|
||||
f"🔗 ЛС: <a href=\"tg://resolve?domain={safe_username}\">"
|
||||
f"tg://resolve?domain={safe_username}</a>\n"
|
||||
)
|
||||
else:
|
||||
ticket_text += "📱 Username: отсутствует\n"
|
||||
chat_link = f"tg://user?id={int(updated.user.telegram_id)}"
|
||||
ticket_text += f"🔗 Чат по ID: <a href=\"{chat_link}\">{chat_link}</a>\n"
|
||||
ticket_text += "\n"
|
||||
if updated.is_user_reply_blocked:
|
||||
if updated.user_reply_block_permanent:
|
||||
ticket_text += "🚫 Пользователь заблокирован навсегда для ответов в этом тикете\n"
|
||||
@@ -703,12 +801,10 @@ async def handle_admin_block_duration_input(
|
||||
pass
|
||||
# Кнопки ЛС и профиль при обновлении карточки
|
||||
try:
|
||||
if updated.user and updated.user.telegram_id:
|
||||
if updated.user and updated.user.telegram_id and updated.user.username:
|
||||
safe_username = html.escape(updated.user.username)
|
||||
buttons_row = []
|
||||
if updated.user.username:
|
||||
pm_url = f"tg://resolve?domain={updated.user.username}"
|
||||
else:
|
||||
pm_url = f"tg://user?id={updated.user.telegram_id}"
|
||||
pm_url = f"tg://resolve?domain={safe_username}"
|
||||
buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=pm_url))
|
||||
profile_url = f"tg://user?id={updated.user.telegram_id}"
|
||||
buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url))
|
||||
@@ -935,7 +1031,8 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets")
|
||||
dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets_scope_open")
|
||||
dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets_scope_closed")
|
||||
|
||||
dp.callback_query.register(close_all_open_admin_tickets, F.data == "admin_tickets_close_all_open")
|
||||
|
||||
dp.callback_query.register(view_admin_ticket, F.data.startswith("admin_view_ticket_"))
|
||||
|
||||
# Ответы на тикеты
|
||||
|
||||
+708
-82
@@ -1,7 +1,9 @@
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from typing import Optional, List, Tuple
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -9,7 +11,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User, UserStatus, Subscription, SubscriptionStatus, TransactionType
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.crud.user import (
|
||||
get_user_by_id,
|
||||
get_user_by_telegram_id,
|
||||
get_user_by_username,
|
||||
get_referrals,
|
||||
)
|
||||
from app.database.crud.campaign import (
|
||||
get_campaign_registration_by_user,
|
||||
get_campaign_statistics,
|
||||
@@ -34,6 +41,9 @@ from app.database.crud.server_squad import (
|
||||
get_server_ids_by_uuids,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.utils.subscription_utils import (
|
||||
resolve_hwid_device_limit_for_payload,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -998,7 +1008,8 @@ async def _render_user_subscription_overview(
|
||||
subscription = profile["subscription"]
|
||||
|
||||
text = "📱 <b>Подписка и настройки пользователя</b>\n\n"
|
||||
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\n\n"
|
||||
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
|
||||
text += f"👤 {user_link} (ID: <code>{user.telegram_id}</code>)\n\n"
|
||||
|
||||
keyboard = []
|
||||
|
||||
@@ -1158,7 +1169,8 @@ async def show_user_transactions(
|
||||
transactions = await get_user_transactions(db, user_id, limit=10)
|
||||
|
||||
text = f"💳 <b>Транзакции пользователя</b>\n\n"
|
||||
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\n"
|
||||
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
|
||||
text += f"👤 {user_link} (ID: <code>{user.telegram_id}</code>)\n"
|
||||
text += f"💰 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n"
|
||||
|
||||
if transactions:
|
||||
@@ -1435,16 +1447,41 @@ async def show_user_management(
|
||||
else:
|
||||
sections.append(texts.ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE)
|
||||
|
||||
if user.promo_group:
|
||||
promo_group = user.promo_group
|
||||
# Display promo groups
|
||||
primary_group = user.get_primary_promo_group()
|
||||
if primary_group:
|
||||
sections.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_PROMO_GROUPS_PRIMARY",
|
||||
"⭐ Основная: {name} (Priority: {priority})",
|
||||
).format(name=primary_group.name, priority=getattr(primary_group, "priority", 0))
|
||||
)
|
||||
sections.append(
|
||||
texts.ADMIN_USER_MANAGEMENT_PROMO_GROUP.format(
|
||||
name=promo_group.name,
|
||||
server_discount=promo_group.server_discount_percent,
|
||||
traffic_discount=promo_group.traffic_discount_percent,
|
||||
device_discount=promo_group.device_discount_percent,
|
||||
name=primary_group.name,
|
||||
server_discount=primary_group.server_discount_percent,
|
||||
traffic_discount=primary_group.traffic_discount_percent,
|
||||
device_discount=primary_group.device_discount_percent,
|
||||
)
|
||||
)
|
||||
|
||||
# Show additional groups if any
|
||||
if user.user_promo_groups and len(user.user_promo_groups) > 1:
|
||||
additional_groups = [
|
||||
upg.promo_group for upg in user.user_promo_groups
|
||||
if upg.promo_group and upg.promo_group.id != primary_group.id
|
||||
]
|
||||
if additional_groups:
|
||||
sections.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_PROMO_GROUPS_ADDITIONAL",
|
||||
"Дополнительные группы:",
|
||||
)
|
||||
)
|
||||
for group in additional_groups:
|
||||
sections.append(
|
||||
f" • {group.name} (Priority: {getattr(group, 'priority', 0)})"
|
||||
)
|
||||
else:
|
||||
sections.append(texts.ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE)
|
||||
|
||||
@@ -1485,6 +1522,396 @@ async def show_user_management(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _build_user_referrals_view(
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
user_id: int,
|
||||
limit: int = 30,
|
||||
) -> Optional[Tuple[str, InlineKeyboardMarkup]]:
|
||||
texts = get_texts(language)
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
referrals = await get_referrals(db, user_id)
|
||||
|
||||
header = texts.t(
|
||||
"ADMIN_USER_REFERRALS_TITLE",
|
||||
"🤝 <b>Рефералы пользователя</b>",
|
||||
)
|
||||
summary = texts.t(
|
||||
"ADMIN_USER_REFERRALS_SUMMARY",
|
||||
"👤 {name} (ID: <code>{telegram_id}</code>)\n👥 Всего рефералов: {count}",
|
||||
).format(
|
||||
name=user.full_name,
|
||||
telegram_id=user.telegram_id,
|
||||
count=len(referrals),
|
||||
)
|
||||
|
||||
lines: List[str] = [header, summary]
|
||||
|
||||
if referrals:
|
||||
lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_LIST_HEADER",
|
||||
"<b>Список рефералов:</b>",
|
||||
)
|
||||
)
|
||||
items = []
|
||||
for referral in referrals[:limit]:
|
||||
username_part = (
|
||||
f", @{referral.username}"
|
||||
if referral.username
|
||||
else ""
|
||||
)
|
||||
referral_link = f'<a href="tg://user?id={referral.telegram_id}">{referral.full_name}</a>'
|
||||
items.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_LIST_ITEM",
|
||||
"• {name} (ID: <code>{telegram_id}</code>{username_part})",
|
||||
).format(
|
||||
name=referral_link,
|
||||
telegram_id=referral.telegram_id,
|
||||
username_part=username_part,
|
||||
)
|
||||
)
|
||||
|
||||
lines.append("\n".join(items))
|
||||
|
||||
if len(referrals) > limit:
|
||||
remaining = len(referrals) - limit
|
||||
lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_LIST_TRUNCATED",
|
||||
"• … и ещё {count} рефералов",
|
||||
).format(count=remaining)
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_EMPTY",
|
||||
"Рефералов пока нет.",
|
||||
)
|
||||
)
|
||||
|
||||
lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_EDIT_HINT",
|
||||
"✏️ Чтобы изменить список, нажмите «✏️ Редактировать» ниже.",
|
||||
)
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_USER_REFERRALS_EDIT_BUTTON",
|
||||
"✏️ Редактировать",
|
||||
),
|
||||
callback_data=f"admin_user_referrals_edit_{user_id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data=f"admin_user_manage_{user_id}",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
return "\n\n".join(lines), keyboard
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_user_referrals(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
current_state = await state.get_state()
|
||||
if current_state == AdminStates.editing_user_referrals:
|
||||
data = await state.get_data()
|
||||
preserved_data = {
|
||||
key: value
|
||||
for key, value in data.items()
|
||||
if key not in {"editing_referrals_user_id", "referrals_message_id"}
|
||||
}
|
||||
await state.clear()
|
||||
if preserved_data:
|
||||
await state.update_data(**preserved_data)
|
||||
|
||||
view = await _build_user_referrals_view(db, db_user.language, user_id)
|
||||
if not view:
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
text, keyboard = view
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_user_referrals(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
prompt = texts.t(
|
||||
"ADMIN_USER_REFERRALS_EDIT_PROMPT",
|
||||
(
|
||||
"✏️ <b>Редактирование рефералов</b>\n\n"
|
||||
"Отправьте список рефералов для пользователя <b>{name}</b> (ID: <code>{telegram_id}</code>):\n"
|
||||
"• Используйте TG ID или @username\n"
|
||||
"• Значения можно указывать через запятую, пробел или с новой строки\n"
|
||||
"• Чтобы очистить список, отправьте 0 или слово 'нет'\n\n"
|
||||
"Или нажмите кнопку ниже, чтобы отменить."
|
||||
),
|
||||
).format(
|
||||
name=user.full_name,
|
||||
telegram_id=user.telegram_id,
|
||||
)
|
||||
|
||||
await state.update_data(
|
||||
editing_referrals_user_id=user_id,
|
||||
referrals_message_id=callback.message.message_id,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
prompt,
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data=f"admin_user_referrals_{user_id}",
|
||||
)
|
||||
]
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.editing_user_referrals)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_edit_user_referrals(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
data = await state.get_data()
|
||||
|
||||
user_id = data.get("editing_referrals_user_id")
|
||||
if not user_id:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_STATE_LOST",
|
||||
"❌ Не удалось определить пользователя. Попробуйте начать сначала.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
raw_text = message.text.strip()
|
||||
lower_text = raw_text.lower()
|
||||
clear_keywords = {"0", "нет", "none", "пусто", "clear"}
|
||||
clear_requested = lower_text in clear_keywords
|
||||
|
||||
tokens: List[str] = []
|
||||
if not clear_requested:
|
||||
parts = re.split(r"[,\n]+", raw_text)
|
||||
for part in parts:
|
||||
for token in part.split():
|
||||
cleaned = token.strip()
|
||||
if cleaned and cleaned not in tokens:
|
||||
tokens.append(cleaned)
|
||||
|
||||
found_users: List[User] = []
|
||||
not_found: List[str] = []
|
||||
skipped_self: List[str] = []
|
||||
duplicate_tokens: List[str] = []
|
||||
|
||||
seen_ids = set()
|
||||
|
||||
for token in tokens:
|
||||
normalized = token.strip()
|
||||
if not normalized:
|
||||
continue
|
||||
|
||||
if normalized.startswith("@"):
|
||||
normalized = normalized[1:]
|
||||
|
||||
user = None
|
||||
if normalized.isdigit():
|
||||
try:
|
||||
user = await get_user_by_telegram_id(db, int(normalized))
|
||||
except ValueError:
|
||||
user = None
|
||||
else:
|
||||
user = await get_user_by_username(db, normalized)
|
||||
|
||||
if not user:
|
||||
not_found.append(token)
|
||||
continue
|
||||
|
||||
if user.id == user_id:
|
||||
skipped_self.append(token)
|
||||
continue
|
||||
|
||||
if user.id in seen_ids:
|
||||
duplicate_tokens.append(token)
|
||||
continue
|
||||
|
||||
seen_ids.add(user.id)
|
||||
found_users.append(user)
|
||||
|
||||
if not found_users and not clear_requested:
|
||||
error_lines = [
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_NO_VALID",
|
||||
"❌ Не удалось найти ни одного пользователя по введённым данным.",
|
||||
)
|
||||
]
|
||||
if not_found:
|
||||
error_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_INVALID_ENTRIES",
|
||||
"Не найдены: {values}",
|
||||
).format(values=", ".join(not_found))
|
||||
)
|
||||
if skipped_self:
|
||||
error_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_SELF_SKIPPED",
|
||||
"Пропущены значения пользователя: {values}",
|
||||
).format(values=", ".join(skipped_self))
|
||||
)
|
||||
await message.answer("\n".join(error_lines))
|
||||
return
|
||||
|
||||
user_service = UserService()
|
||||
|
||||
new_referral_ids = [user.id for user in found_users] if not clear_requested else []
|
||||
|
||||
success, details = await user_service.update_user_referrals(
|
||||
db,
|
||||
user_id,
|
||||
new_referral_ids,
|
||||
db_user.id,
|
||||
)
|
||||
|
||||
if not success:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_UPDATE_ERROR",
|
||||
"❌ Не удалось обновить рефералов. Попробуйте позже.",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
response_lines = [
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_UPDATED",
|
||||
"✅ Список рефералов обновлён.",
|
||||
)
|
||||
]
|
||||
|
||||
total_referrals = details.get("total", len(new_referral_ids))
|
||||
added = details.get("added", 0)
|
||||
removed = details.get("removed", 0)
|
||||
|
||||
response_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_UPDATED_TOTAL",
|
||||
"• Текущий список: {total}",
|
||||
).format(total=total_referrals)
|
||||
)
|
||||
|
||||
if added > 0:
|
||||
response_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_UPDATED_ADDED",
|
||||
"• Добавлено: {count}",
|
||||
).format(count=added)
|
||||
)
|
||||
|
||||
if removed > 0:
|
||||
response_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_UPDATED_REMOVED",
|
||||
"• Удалено: {count}",
|
||||
).format(count=removed)
|
||||
)
|
||||
|
||||
if not_found:
|
||||
response_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_INVALID_ENTRIES",
|
||||
"Не найдены: {values}",
|
||||
).format(values=", ".join(not_found))
|
||||
)
|
||||
|
||||
if skipped_self:
|
||||
response_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_SELF_SKIPPED",
|
||||
"Пропущены значения пользователя: {values}",
|
||||
).format(values=", ".join(skipped_self))
|
||||
)
|
||||
|
||||
if duplicate_tokens:
|
||||
response_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_DUPLICATES",
|
||||
"Игнорированы дубли: {values}",
|
||||
).format(values=", ".join(duplicate_tokens))
|
||||
)
|
||||
|
||||
view = await _build_user_referrals_view(db, db_user.language, user_id)
|
||||
message_id = data.get("referrals_message_id")
|
||||
|
||||
if view and message_id:
|
||||
try:
|
||||
await message.bot.edit_message_text(
|
||||
view[0],
|
||||
chat_id=message.chat.id,
|
||||
message_id=message_id,
|
||||
reply_markup=view[1],
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
await message.answer(view[0], reply_markup=view[1])
|
||||
elif view:
|
||||
await message.answer(view[0], reply_markup=view[1])
|
||||
|
||||
await message.answer("\n".join(response_lines))
|
||||
await state.clear()
|
||||
|
||||
async def _render_user_promo_group(
|
||||
message: types.Message,
|
||||
language: str,
|
||||
@@ -1493,20 +1920,43 @@ async def _render_user_promo_group(
|
||||
) -> None:
|
||||
texts = get_texts(language)
|
||||
|
||||
current_group = user.promo_group
|
||||
# Get primary and all user groups
|
||||
primary_group = user.get_primary_promo_group()
|
||||
user_group_ids = [upg.promo_group_id for upg in user.user_promo_groups] if user.user_promo_groups else []
|
||||
|
||||
# Build current groups section
|
||||
if primary_group:
|
||||
current_line = texts.t(
|
||||
"ADMIN_USER_PROMO_GROUPS_PRIMARY",
|
||||
"⭐ Основная: {name} (Priority: {priority})",
|
||||
).format(name=primary_group.name, priority=getattr(primary_group, "priority", 0))
|
||||
|
||||
if current_group:
|
||||
current_line = texts.ADMIN_USER_PROMO_GROUP_CURRENT.format(name=current_group.name)
|
||||
discount_line = texts.ADMIN_USER_PROMO_GROUP_DISCOUNTS.format(
|
||||
servers=current_group.server_discount_percent,
|
||||
traffic=current_group.traffic_discount_percent,
|
||||
devices=current_group.device_discount_percent,
|
||||
servers=primary_group.server_discount_percent,
|
||||
traffic=primary_group.traffic_discount_percent,
|
||||
devices=primary_group.device_discount_percent,
|
||||
)
|
||||
current_group_id = current_group.id
|
||||
|
||||
# Show additional groups if any
|
||||
if len(user_group_ids) > 1:
|
||||
additional_groups = [
|
||||
upg.promo_group for upg in user.user_promo_groups
|
||||
if upg.promo_group and upg.promo_group.id != primary_group.id
|
||||
]
|
||||
if additional_groups:
|
||||
additional_line = "\n" + texts.t(
|
||||
"ADMIN_USER_PROMO_GROUPS_ADDITIONAL",
|
||||
"Дополнительные группы:",
|
||||
) + "\n"
|
||||
for group in additional_groups:
|
||||
additional_line += f" • {group.name} (Priority: {getattr(group, 'priority', 0)})\n"
|
||||
discount_line += additional_line
|
||||
else:
|
||||
current_line = texts.ADMIN_USER_PROMO_GROUP_CURRENT_NONE
|
||||
discount_line = texts.ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE
|
||||
current_group_id = None
|
||||
current_line = texts.t(
|
||||
"ADMIN_USER_PROMO_GROUPS_NONE",
|
||||
"У пользователя нет промогрупп",
|
||||
)
|
||||
discount_line = ""
|
||||
|
||||
text = (
|
||||
f"{texts.ADMIN_USER_PROMO_GROUP_TITLE}\n\n"
|
||||
@@ -1520,7 +1970,7 @@ async def _render_user_promo_group(
|
||||
reply_markup=get_user_promo_group_keyboard(
|
||||
promo_groups,
|
||||
user.id,
|
||||
current_group_id,
|
||||
user_group_ids, # Pass list of all group IDs
|
||||
language
|
||||
)
|
||||
)
|
||||
@@ -1558,6 +2008,13 @@ async def set_user_promo_group(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.database.crud.user_promo_group import (
|
||||
has_user_promo_group,
|
||||
add_user_to_promo_group,
|
||||
remove_user_from_promo_group,
|
||||
count_user_promo_groups
|
||||
)
|
||||
from app.database.crud.promo_group import get_promo_group_by_id
|
||||
|
||||
parts = callback.data.split('_')
|
||||
user_id = int(parts[-2])
|
||||
@@ -1570,49 +2027,52 @@ async def set_user_promo_group(
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
if user.promo_group_id == group_id:
|
||||
await callback.answer(texts.ADMIN_USER_PROMO_GROUP_ALREADY, show_alert=True)
|
||||
return
|
||||
# Check if user already has this group
|
||||
has_group = await has_user_promo_group(db, user_id, group_id)
|
||||
|
||||
user_service = UserService()
|
||||
success, updated_user, new_group, old_group = await user_service.update_user_promo_group(
|
||||
db,
|
||||
user_id,
|
||||
group_id
|
||||
)
|
||||
if has_group:
|
||||
# Remove group
|
||||
# Check if it's the last group
|
||||
groups_count = await count_user_promo_groups(db, user_id)
|
||||
if groups_count <= 1:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_USER_PROMO_GROUP_CANNOT_REMOVE_LAST",
|
||||
"❌ Нельзя удалить последнюю промогруппу",
|
||||
),
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
if not success or not updated_user or not new_group:
|
||||
await callback.answer(texts.ADMIN_USER_PROMO_GROUP_ERROR, show_alert=True)
|
||||
return
|
||||
group = await get_promo_group_by_id(db, group_id)
|
||||
await remove_user_from_promo_group(db, user_id, group_id)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_USER_PROMO_GROUP_REMOVED",
|
||||
"🗑 Группа «{name}» удалена",
|
||||
).format(name=group.name if group else ""),
|
||||
show_alert=True
|
||||
)
|
||||
else:
|
||||
# Add group
|
||||
group = await get_promo_group_by_id(db, group_id)
|
||||
if not group:
|
||||
await callback.answer(texts.ADMIN_USER_PROMO_GROUP_ERROR, show_alert=True)
|
||||
return
|
||||
|
||||
await add_user_to_promo_group(db, user_id, group_id, assigned_by="admin")
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_USER_PROMO_GROUP_ADDED",
|
||||
"✅ Группа «{name}» добавлена",
|
||||
).format(name=group.name),
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
# Refresh user data and show updated list
|
||||
user = await get_user_by_id(db, user_id)
|
||||
promo_groups = await get_promo_groups_with_counts(db)
|
||||
|
||||
await _render_user_promo_group(callback.message, db_user.language, updated_user, promo_groups)
|
||||
await callback.answer(
|
||||
texts.ADMIN_USER_PROMO_GROUP_UPDATED.format(name=new_group.name),
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
try:
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
reason = (
|
||||
f"Назначено администратором {db_user.full_name} (ID: {db_user.telegram_id})"
|
||||
)
|
||||
await notification_service.send_user_promo_group_change_notification(
|
||||
db,
|
||||
updated_user,
|
||||
old_group,
|
||||
new_group,
|
||||
reason=reason,
|
||||
initiator=db_user,
|
||||
automatic=False,
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о смене промогруппы пользователя %s: %s",
|
||||
updated_user.telegram_id,
|
||||
notify_error,
|
||||
)
|
||||
await _render_user_promo_group(callback.message, db_user.language, user, promo_groups)
|
||||
|
||||
|
||||
|
||||
@@ -1644,6 +2104,107 @@ async def start_balance_edit(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_send_user_message(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
target_user = await get_user_by_id(db, user_id)
|
||||
if not target_user:
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
await state.update_data(direct_message_user_id=user_id)
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
prompt = (
|
||||
texts.t("ADMIN_USER_SEND_MESSAGE_PROMPT",
|
||||
"✉️ <b>Отправка сообщения пользователю</b>\n\n"
|
||||
"Введите текст, который бот отправит пользователю."
|
||||
"\n\nВы можете отменить действие командой /cancel или кнопкой ниже." )
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
prompt,
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_manage_{user_id}")]
|
||||
]
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.sending_user_message)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_send_user_message(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
data = await state.get_data()
|
||||
user_id = data.get("direct_message_user_id")
|
||||
|
||||
if not user_id:
|
||||
await message.answer(texts.t("ADMIN_USER_SEND_MESSAGE_ERROR_NOT_FOUND", "❌ Пользователь для отправки сообщения не найден"))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
target_user = await get_user_by_id(db, int(user_id))
|
||||
if not target_user:
|
||||
await message.answer(texts.t("ADMIN_USER_SEND_MESSAGE_ERROR_NOT_FOUND", "❌ Пользователь не найден или был удалён"))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
text = (message.text or "").strip()
|
||||
if not text:
|
||||
await message.answer(texts.t("ADMIN_USER_SEND_MESSAGE_EMPTY", "❌ Пожалуйста, введите непустое сообщение"))
|
||||
return
|
||||
|
||||
confirmation_keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="👤 К пользователю", callback_data=f"admin_user_manage_{user_id}")]]
|
||||
)
|
||||
|
||||
try:
|
||||
await message.bot.send_message(target_user.telegram_id, text)
|
||||
await message.answer(
|
||||
texts.t("ADMIN_USER_SEND_MESSAGE_SUCCESS", "✅ Сообщение отправлено пользователю"),
|
||||
reply_markup=confirmation_keyboard,
|
||||
)
|
||||
except TelegramForbiddenError:
|
||||
await message.answer(
|
||||
texts.t("ADMIN_USER_SEND_MESSAGE_FORBIDDEN", "⚠️ Пользователь заблокировал бота или не может получить сообщения."),
|
||||
reply_markup=confirmation_keyboard,
|
||||
)
|
||||
except TelegramBadRequest as err:
|
||||
logger.error("Ошибка отправки сообщения пользователю %s: %s", target_user.telegram_id, err)
|
||||
await message.answer(
|
||||
texts.t("ADMIN_USER_SEND_MESSAGE_BAD_REQUEST", "❌ Telegram отклонил сообщение. Проверьте текст и попробуйте ещё раз."),
|
||||
reply_markup=confirmation_keyboard,
|
||||
)
|
||||
return
|
||||
except Exception as err:
|
||||
logger.error("Неожиданная ошибка отправки сообщения пользователю %s: %s", target_user.telegram_id, err)
|
||||
await message.answer(
|
||||
texts.t("ADMIN_USER_SEND_MESSAGE_ERROR", "❌ Не удалось отправить сообщение. Попробуйте позже."),
|
||||
reply_markup=confirmation_keyboard,
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_balance_edit(
|
||||
@@ -1780,9 +2341,10 @@ async def show_inactive_users(
|
||||
|
||||
text = f"🗑️ <b>Неактивные пользователи</b>\n"
|
||||
text += f"Без активности более {settings.INACTIVE_USER_DELETE_MONTHS} месяцев: {len(inactive_users)}\n\n"
|
||||
|
||||
for user in inactive_users[:10]:
|
||||
text += f"👤 {user.full_name}\n"
|
||||
|
||||
for user in inactive_users[:10]:
|
||||
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
|
||||
text += f"👤 {user_link}\n"
|
||||
text += f"🆔 <code>{user.telegram_id}</code>\n"
|
||||
last_activity_display = (
|
||||
format_time_ago(user.last_activity, db_user.language)
|
||||
@@ -1884,7 +2446,8 @@ async def show_user_statistics(
|
||||
campaign_stats = await get_campaign_statistics(db, campaign_registration.campaign_id)
|
||||
|
||||
text = f"📊 <b>Статистика пользователя</b>\n\n"
|
||||
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\n\n"
|
||||
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
|
||||
text += f"👤 {user_link} (ID: <code>{user.telegram_id}</code>)\n\n"
|
||||
|
||||
text += f"<b>Основная информация:</b>\n"
|
||||
text += f"• Дней с регистрации: {profile['registration_days']}\n"
|
||||
@@ -3236,7 +3799,15 @@ async def _grant_trial_subscription(db: AsyncSession, user_id: int, admin_id: in
|
||||
logger.error(f"У пользователя {user_id} уже есть подписка")
|
||||
return False
|
||||
|
||||
subscription = await create_trial_subscription(db, user_id)
|
||||
forced_devices = None
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_devices = settings.get_disabled_mode_device_limit()
|
||||
|
||||
subscription = await create_trial_subscription(
|
||||
db,
|
||||
user_id,
|
||||
device_limit=forced_devices,
|
||||
)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.create_remnawave_user(db, subscription)
|
||||
@@ -3280,12 +3851,20 @@ async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, ad
|
||||
if getattr(settings, "TRIAL_SQUAD_UUID", None):
|
||||
trial_squads = [settings.TRIAL_SQUAD_UUID]
|
||||
|
||||
forced_devices = None
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_devices = settings.get_disabled_mode_device_limit()
|
||||
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
if forced_devices is not None:
|
||||
device_limit = forced_devices
|
||||
|
||||
subscription = await create_paid_subscription(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
duration_days=days,
|
||||
traffic_limit_gb=settings.DEFAULT_TRAFFIC_LIMIT_GB,
|
||||
device_limit=settings.DEFAULT_DEVICE_LIMIT,
|
||||
device_limit=device_limit,
|
||||
connected_squads=trial_squads,
|
||||
update_server_counters=True,
|
||||
)
|
||||
@@ -3489,10 +4068,13 @@ async def admin_buy_subscription(
|
||||
])
|
||||
|
||||
text = f"💳 <b>Покупка подписки для пользователя</b>\n\n"
|
||||
text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n"
|
||||
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
|
||||
text += f"👤 {target_user_link} (ID: {target_user.telegram_id})\n"
|
||||
text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n"
|
||||
traffic_text = "Безлимит" if (subscription.traffic_limit_gb or 0) <= 0 else f"{subscription.traffic_limit_gb} ГБ"
|
||||
devices_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
devices_limit = subscription.device_limit
|
||||
if devices_limit is None:
|
||||
devices_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
servers_count = len(subscription.connected_squads or [])
|
||||
text += f"📶 Трафик: {traffic_text}\n"
|
||||
text += f"📱 Устройства: {devices_limit}\n"
|
||||
@@ -3578,12 +4160,15 @@ async def admin_buy_subscription_confirm(
|
||||
return
|
||||
|
||||
text = f"💳 <b>Подтверждение покупки подписки</b>\n\n"
|
||||
text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n"
|
||||
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
|
||||
text += f"👤 {target_user_link} (ID: {target_user.telegram_id})\n"
|
||||
text += f"📅 Период подписки: {period_days} дней\n"
|
||||
text += f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
|
||||
text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n"
|
||||
traffic_text = "Безлимит" if (subscription.traffic_limit_gb or 0) <= 0 else f"{subscription.traffic_limit_gb} ГБ"
|
||||
devices_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
devices_limit = subscription.device_limit
|
||||
if devices_limit is None:
|
||||
devices_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
servers_count = len(subscription.connected_squads or [])
|
||||
text += f"📶 Трафик: {traffic_text}\n"
|
||||
text += f"📱 Устройства: {devices_limit}\n"
|
||||
@@ -3730,40 +4315,54 @@ async def admin_buy_subscription_execute(
|
||||
from app.external.remnawave_api import UserStatus, TrafficLimitStrategy
|
||||
remnawave_service = RemnaWaveService()
|
||||
|
||||
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
|
||||
|
||||
if target_user.remnawave_uuid:
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
remnawave_user = await api.update_user(
|
||||
update_kwargs = dict(
|
||||
uuid=target_user.remnawave_uuid,
|
||||
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=target_user.full_name,
|
||||
username=target_user.username,
|
||||
telegram_id=target_user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
)
|
||||
|
||||
if hwid_limit is not None:
|
||||
update_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
remnawave_user = await api.update_user(**update_kwargs)
|
||||
else:
|
||||
username = f"user_{target_user.telegram_id}"
|
||||
username = settings.format_remnawave_username(
|
||||
full_name=target_user.full_name,
|
||||
username=target_user.username,
|
||||
telegram_id=target_user.telegram_id,
|
||||
)
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
remnawave_user = await api.create_user(
|
||||
create_kwargs = dict(
|
||||
username=username,
|
||||
expire_at=subscription.end_date,
|
||||
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
|
||||
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
telegram_id=target_user.telegram_id,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=target_user.full_name,
|
||||
username=target_user.username,
|
||||
telegram_id=target_user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
)
|
||||
|
||||
if hwid_limit is not None:
|
||||
create_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
remnawave_user = await api.create_user(**create_kwargs)
|
||||
|
||||
if remnawave_user and hasattr(remnawave_user, 'uuid'):
|
||||
target_user.remnawave_uuid = remnawave_user.uuid
|
||||
@@ -3780,9 +4379,10 @@ async def admin_buy_subscription_execute(
|
||||
else:
|
||||
message = "❌ Ошибка: у пользователя нет существующей подписки"
|
||||
|
||||
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
|
||||
await callback.message.edit_text(
|
||||
f"{message}\n\n"
|
||||
f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n"
|
||||
f"👤 {target_user_link} (ID: {target_user.telegram_id})\n"
|
||||
f"💰 Списано: {settings.format_price(price_kopeks)}\n"
|
||||
f"📅 Подписка действительна до: {format_datetime(subscription.end_date)}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
@@ -3790,7 +4390,8 @@ async def admin_buy_subscription_execute(
|
||||
text="⬅️ Назад к подписке",
|
||||
callback_data=f"admin_user_subscription_{user_id}"
|
||||
)]
|
||||
])
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -4002,23 +4603,48 @@ def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_user_promo_group,
|
||||
F.data.startswith("admin_user_promo_group_") & ~F.data.contains("_set_")
|
||||
F.data.startswith("admin_user_promo_group_") & ~F.data.contains("_set_") & ~F.data.contains("_toggle_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
set_user_promo_group,
|
||||
F.data.startswith("admin_user_promo_group_set_")
|
||||
F.data.startswith("admin_user_promo_group_toggle_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_balance_edit,
|
||||
F.data.startswith("admin_user_balance_")
|
||||
)
|
||||
|
||||
|
||||
dp.message.register(
|
||||
process_balance_edit,
|
||||
AdminStates.editing_user_balance
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_user_referrals,
|
||||
F.data.startswith("admin_user_referrals_") & ~F.data.contains("_edit")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_edit_user_referrals,
|
||||
F.data.startswith("admin_user_referrals_edit_")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_edit_user_referrals,
|
||||
AdminStates.editing_user_referrals
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_send_user_message,
|
||||
F.data.startswith("admin_user_send_message_")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_send_user_message,
|
||||
AdminStates.sending_user_message
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_inactive_users,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import re
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -18,6 +19,92 @@ from app.database.crud.welcome_text import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def validate_html_tags(text: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Проверяет HTML-теги в тексте на соответствие требованиям Telegram API.
|
||||
|
||||
Args:
|
||||
text: Текст для проверки
|
||||
|
||||
Returns:
|
||||
Кортеж из (валидно ли, сообщение об ошибке или None)
|
||||
"""
|
||||
# Поддерживаемые теги в parse_mode="HTML" для Telegram API
|
||||
allowed_tags = {
|
||||
'b', 'strong', # жирный
|
||||
'i', 'em', # курсив
|
||||
'u', 'ins', # подчеркнуто
|
||||
's', 'strike', 'del', # зачеркнуто
|
||||
'code', # моноширинный для коротких фрагментов
|
||||
'pre', # моноширинный блок кода
|
||||
'a' # ссылки
|
||||
}
|
||||
|
||||
# Убираем плейсхолдеры из строки перед проверкой тегов
|
||||
# Плейсхолдеры имеют формат {ключ}, и не являются тегами
|
||||
placeholder_pattern = r'\{[^{}]+\}'
|
||||
clean_text = re.sub(placeholder_pattern, '', text)
|
||||
|
||||
# Находим все открывающие и закрывающие теги
|
||||
tag_pattern = r'<(/?)([a-zA-Z]+)(\s[^>]*)?>'
|
||||
tags_with_pos = [(m.group(1), m.group(2), m.group(3), m.start(), m.end()) for m in re.finditer(tag_pattern, clean_text)]
|
||||
|
||||
for closing, tag, attrs, start_pos, end_pos in tags_with_pos:
|
||||
tag_lower = tag.lower()
|
||||
|
||||
# Проверяем, является ли тег поддерживаемым
|
||||
if tag_lower not in allowed_tags:
|
||||
return False, f"Неподдерживаемый HTML-тег: <{tag}>. Используйте только теги: {', '.join(sorted(allowed_tags))}"
|
||||
|
||||
# Проверяем атрибуты для тега <a>
|
||||
if tag_lower == 'a':
|
||||
if closing:
|
||||
continue # Для закрывающего тега не нужно проверять атрибуты
|
||||
if not attrs:
|
||||
return False, "Тег <a> должен содержать атрибут href, например: <a href='URL'>ссылка</a>"
|
||||
|
||||
# Проверяем, что есть атрибут href
|
||||
if 'href=' not in attrs.lower():
|
||||
return False, "Тег <a> должен содержать атрибут href, например: <a href='URL'>ссылка</a>"
|
||||
|
||||
# Проверяем формат URL
|
||||
href_match = re.search(r'href\s*=\s*[\'"]([^\'"]+)[\'"]', attrs, re.IGNORECASE)
|
||||
if href_match:
|
||||
url = href_match.group(1)
|
||||
# Проверяем, что URL начинается с поддерживаемой схемы
|
||||
if not re.match(r'^https?://|^tg://', url, re.IGNORECASE):
|
||||
return False, f"URL в теге <a> должен начинаться с http://, https:// или tg://. Найдено: {url}"
|
||||
else:
|
||||
return False, "Не удалось извлечь URL из атрибута href тега <a>"
|
||||
|
||||
# Проверяем парность тегов с использованием стека
|
||||
stack = []
|
||||
for closing, tag, attrs, start_pos, end_pos in tags_with_pos:
|
||||
tag_lower = tag.lower()
|
||||
|
||||
if tag_lower not in allowed_tags:
|
||||
continue
|
||||
|
||||
if closing:
|
||||
# Это закрывающий тег
|
||||
if not stack:
|
||||
return False, f"Лишний закрывающий тег: </{tag}>"
|
||||
|
||||
last_opening_tag = stack.pop()
|
||||
if last_opening_tag.lower() != tag_lower:
|
||||
return False, f"Тег </{tag}> не соответствует открывающему тегу <{last_opening_tag}>"
|
||||
else:
|
||||
# Это открывающий тег
|
||||
stack.append(tag)
|
||||
|
||||
# Если остались незакрытые теги
|
||||
if stack:
|
||||
unclosed_tags = ", ".join([f"<{tag}>" for tag in stack])
|
||||
return False, f"Незакрытые теги: {unclosed_tags}"
|
||||
|
||||
return True, None
|
||||
|
||||
def get_telegram_formatting_info() -> str:
|
||||
return """
|
||||
📝 <b>Поддерживаемые теги форматирования:</b>
|
||||
@@ -202,6 +289,12 @@ async def process_welcome_text_edit(
|
||||
await message.answer("❌ Текст слишком длинный! Максимум 4000 символов.")
|
||||
return
|
||||
|
||||
# Проверяем HTML-теги на валидность
|
||||
is_valid, error_msg = validate_html_tags(new_text)
|
||||
if not is_valid:
|
||||
await message.answer(f"❌ Ошибка в HTML-разметке:\n\n{error_msg}")
|
||||
return
|
||||
|
||||
success = await set_welcome_text(db, new_text, db_user.id)
|
||||
|
||||
if success:
|
||||
|
||||
@@ -67,7 +67,7 @@ async def start_cryptobot_payment(
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_heleket_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_heleket_enabled():
|
||||
await callback.answer("❌ Оплата через Heleket недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
markup = settings.get_heleket_markup_percent()
|
||||
markup_text: Optional[str]
|
||||
if markup > 0:
|
||||
label = texts.t("PAYMENT_HELEKET_MARKUP_LABEL", "Наценка провайдера")
|
||||
markup_text = f"{label}: {markup:.0f}%"
|
||||
elif markup < 0:
|
||||
label = texts.t("PAYMENT_HELEKET_DISCOUNT_LABEL", "Скидка провайдера")
|
||||
markup_text = f"{label}: {abs(markup):.0f}%"
|
||||
else:
|
||||
markup_text = None
|
||||
|
||||
message_lines = [
|
||||
"🪙 <b>Пополнение через Heleket</b>",
|
||||
"\n",
|
||||
"Введите сумму пополнения от 100 до 100,000 ₽:",
|
||||
"",
|
||||
"⚡ Мгновенное зачисление", "🔒 Безопасная оплата",
|
||||
]
|
||||
|
||||
if markup_text:
|
||||
message_lines.extend(["", markup_text])
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_buttons:
|
||||
keyboard.inline_keyboard = quick_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(filter(None, message_lines)),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="heleket")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_heleket_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_heleket_enabled():
|
||||
await message.answer("❌ Оплата через Heleket недоступна")
|
||||
return
|
||||
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
if amount_rubles < 100:
|
||||
await message.answer("Минимальная сумма пополнения: 100 ₽")
|
||||
return
|
||||
|
||||
if amount_rubles > 100000:
|
||||
await message.answer("Максимальная сумма пополнения: 100,000 ₽")
|
||||
return
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
|
||||
result = await payment_service.create_heleket_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f"Пополнение баланса на {amount_rubles:.0f} ₽",
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not result:
|
||||
await message.answer(
|
||||
"❌ Не удалось создать счёт в Heleket. Попробуйте позже или обратитесь в поддержку."
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
payment_url = result.get("payment_url")
|
||||
if not payment_url:
|
||||
await message.answer("❌ Не удалось получить ссылку для оплаты Heleket")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
payer_amount = result.get("payer_amount")
|
||||
payer_currency = result.get("payer_currency")
|
||||
exchange_rate = result.get("exchange_rate")
|
||||
discount_percent = result.get("discount_percent")
|
||||
|
||||
details = [
|
||||
"🪙 <b>Оплата через Heleket</b>",
|
||||
"",
|
||||
f"💰 Сумма к зачислению: {amount_rubles:.0f} ₽",
|
||||
]
|
||||
|
||||
if payer_amount and payer_currency:
|
||||
details.append(f"🪙 К оплате: {payer_amount} {payer_currency}")
|
||||
|
||||
markup_percent: Optional[float] = None
|
||||
if discount_percent is not None:
|
||||
try:
|
||||
discount_int = int(discount_percent)
|
||||
markup_percent = -discount_int
|
||||
except (TypeError, ValueError):
|
||||
markup_percent = None
|
||||
|
||||
if markup_percent:
|
||||
label_markup = texts.t("PAYMENT_HELEKET_MARKUP_LABEL", "Наценка провайдера")
|
||||
label_discount = texts.t("PAYMENT_HELEKET_DISCOUNT_LABEL", "Скидка провайдера")
|
||||
absolute = abs(markup_percent)
|
||||
if markup_percent > 0:
|
||||
details.append(f"📈 {label_markup}: +{absolute}%")
|
||||
else:
|
||||
details.append(f"📉 {label_discount}: {absolute}%")
|
||||
|
||||
if payer_amount and payer_currency:
|
||||
try:
|
||||
payer_amount_float = float(payer_amount)
|
||||
if payer_amount_float > 0:
|
||||
rub_per_currency = amount_rubles / payer_amount_float
|
||||
details.append(
|
||||
f"💱 Курс: 1 {payer_currency} ≈ {rub_per_currency:.2f} ₽"
|
||||
)
|
||||
except (TypeError, ValueError, ZeroDivisionError):
|
||||
pass
|
||||
|
||||
details.extend(
|
||||
[
|
||||
"",
|
||||
"📱 Инструкция:",
|
||||
"1. Нажмите кнопку 'Оплатить'",
|
||||
"2. Перейдите на страницу Heleket",
|
||||
"3. Оплатите указанную сумму",
|
||||
"4. Баланс пополнится автоматически",
|
||||
]
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.t("PAY_WITH_COINS_BUTTON", "🪙 Оплатить"), url=payment_url)],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_heleket_{result['local_payment_id']}"
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
])
|
||||
|
||||
await message.answer("\n".join(details), parse_mode="HTML", reply_markup=keyboard)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_heleket_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
try:
|
||||
local_payment_id = int(callback.data.split("_")[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("Некорректный идентификатор платежа", show_alert=True)
|
||||
return
|
||||
|
||||
from app.database.crud.heleket import get_heleket_payment_by_id
|
||||
|
||||
payment = await get_heleket_payment_by_id(db, local_payment_id)
|
||||
if not payment:
|
||||
await callback.answer("Платёж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
language = getattr(payment.user, "language", None) or settings.DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
if payment.is_paid:
|
||||
message = texts.t("HELEKET_PAYMENT_ALREADY_PAID", "✅ Платёж уже зачислен")
|
||||
await callback.answer(message, show_alert=True)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(callback.bot)
|
||||
updated_payment = await payment_service.sync_heleket_payment_status(
|
||||
db,
|
||||
local_payment_id=local_payment_id,
|
||||
)
|
||||
|
||||
if updated_payment:
|
||||
payment = updated_payment
|
||||
|
||||
if payment.is_paid:
|
||||
message = texts.t("HELEKET_PAYMENT_SUCCESS", "✅ Платёж зачислен на баланс")
|
||||
await callback.answer(message, show_alert=True)
|
||||
return
|
||||
|
||||
status_normalized = (payment.status or "").lower()
|
||||
status_messages = {
|
||||
"check": texts.t("HELEKET_STATUS_CHECK", "⏳ Ожидание оплаты"),
|
||||
"process": texts.t("HELEKET_STATUS_PROCESS", "⚙️ Платёж обрабатывается"),
|
||||
"confirm_check": texts.t("HELEKET_STATUS_CONFIRM_CHECK", "⛓ Ожидание подтверждений сети"),
|
||||
"wrong_amount": texts.t("HELEKET_STATUS_WRONG_AMOUNT", "❗️ Оплачена неверная сумма"),
|
||||
"wrong_amount_waiting": texts.t(
|
||||
"HELEKET_STATUS_WRONG_AMOUNT_WAITING",
|
||||
"❗️ Недостаточная сумма, ожидаем доплату",
|
||||
),
|
||||
"paid_over": texts.t("HELEKET_STATUS_PAID_OVER", "✅ Платёж зачислен (с переплатой)"),
|
||||
"paid": texts.t("HELEKET_STATUS_PAID", "✅ Платёж зачислен"),
|
||||
"cancel": texts.t("HELEKET_STATUS_CANCEL", "🚫 Платёж отменён"),
|
||||
"fail": texts.t("HELEKET_STATUS_FAIL", "❌ Ошибка при оплате"),
|
||||
"system_fail": texts.t("HELEKET_STATUS_SYSTEM_FAIL", "❌ Системная ошибка Heleket"),
|
||||
"refund_process": texts.t("HELEKET_STATUS_REFUND_PROCESS", "↩️ Возврат обрабатывается"),
|
||||
"refund_fail": texts.t("HELEKET_STATUS_REFUND_FAIL", "⚠️ Ошибка возврата"),
|
||||
"refund_paid": texts.t("HELEKET_STATUS_REFUND_PAID", "✅ Возврат выполнен"),
|
||||
"locked": texts.t("HELEKET_STATUS_LOCKED", "🔒 Средства заблокированы"),
|
||||
}
|
||||
|
||||
message = status_messages.get(status_normalized)
|
||||
if message is None:
|
||||
template = texts.t("HELEKET_STATUS_UNKNOWN", "ℹ️ Статус платежа: {status}")
|
||||
status_value = payment.status or status_normalized or "—"
|
||||
try:
|
||||
message = template.format(status=status_value)
|
||||
except Exception: # pragma: no cover - defensive formatting
|
||||
message = f"ℹ️ Статус платежа: {status_value}"
|
||||
|
||||
await callback.answer(message, show_alert=True)
|
||||
+200
-22
@@ -1,12 +1,15 @@
|
||||
import html
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.states import BalanceStates
|
||||
from app.database.crud.user import add_user_balance
|
||||
from app.utils.price_display import calculate_user_price, format_price_button
|
||||
from app.utils.pricing_utils import format_period_description
|
||||
from app.database.crud.transaction import (
|
||||
get_user_transactions, get_user_transactions_count,
|
||||
create_transaction
|
||||
@@ -26,34 +29,61 @@ logger = logging.getLogger(__name__)
|
||||
TRANSACTIONS_PER_PAGE = 10
|
||||
|
||||
|
||||
def get_quick_amount_buttons(language: str) -> list:
|
||||
def get_quick_amount_buttons(language: str, user: User) -> list:
|
||||
"""
|
||||
Generate quick amount buttons with user-specific pricing and discounts.
|
||||
|
||||
Args:
|
||||
language: User's language for formatting
|
||||
user: User object to calculate personalized discounts
|
||||
|
||||
Returns:
|
||||
List of button rows for inline keyboard
|
||||
"""
|
||||
if not settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED or settings.DISABLE_TOPUP_BUTTONS:
|
||||
return []
|
||||
|
||||
|
||||
from app.localization.texts import get_texts
|
||||
texts = get_texts(language)
|
||||
|
||||
buttons = []
|
||||
periods = settings.get_available_subscription_periods()
|
||||
|
||||
periods = periods[:6]
|
||||
|
||||
periods = periods[:6] # Limit to 6 periods
|
||||
|
||||
for period in periods:
|
||||
price_attr = f"PRICE_{period}_DAYS"
|
||||
if hasattr(settings, price_attr):
|
||||
price_kopeks = getattr(settings, price_attr)
|
||||
price_rubles = price_kopeks // 100
|
||||
|
||||
callback_data = f"quick_amount_{price_kopeks}"
|
||||
|
||||
base_price_kopeks = getattr(settings, price_attr)
|
||||
|
||||
# Calculate price with user's promo group discount using unified system
|
||||
price_info = calculate_user_price(user, base_price_kopeks, period, "period")
|
||||
|
||||
callback_data = f"quick_amount_{price_info.final_price}"
|
||||
|
||||
# Format button text with discount display
|
||||
period_label = f"{period} дней"
|
||||
|
||||
# For balance buttons, use simpler format without emoji and period label prefix
|
||||
if price_info.has_discount:
|
||||
button_text = (
|
||||
f"{texts.format_price(price_info.base_price)} ➜ "
|
||||
f"{texts.format_price(price_info.final_price)} "
|
||||
f"(-{price_info.discount_percent}%) • {period_label}"
|
||||
)
|
||||
else:
|
||||
button_text = f"{texts.format_price(price_info.final_price)} • {period_label}"
|
||||
|
||||
buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{price_rubles} ₽ ({period} дней)",
|
||||
text=button_text,
|
||||
callback_data=callback_data
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
keyboard_rows = []
|
||||
for i in range(0, len(buttons), 2):
|
||||
keyboard_rows.append(buttons[i:i + 2])
|
||||
|
||||
|
||||
return keyboard_rows
|
||||
|
||||
|
||||
@@ -179,18 +209,117 @@ async def handle_balance_history_pagination(
|
||||
async def show_payment_methods(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
from app.utils.payment_utils import get_payment_methods_text
|
||||
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.utils.pricing_utils import calculate_months_from_days, apply_percentage_discount
|
||||
from app.config import settings
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
payment_text = get_payment_methods_text(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
payment_text,
|
||||
reply_markup=get_payment_methods_keyboard(0, db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
# Добавляем информацию о текущем тарифе пользователя
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
tariff_info = ""
|
||||
if subscription and not subscription.is_trial:
|
||||
# Рассчитываем приблизительную стоимость продления на 30 дней
|
||||
duration_days = 30 # Берем для примера 30 дней
|
||||
current_traffic = subscription.traffic_limit_gb
|
||||
current_connected_squads = subscription.connected_squads or []
|
||||
current_device_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
try:
|
||||
# Получаем цены для текущих параметров
|
||||
from app.config import PERIOD_PRICES
|
||||
base_price_original = PERIOD_PRICES.get(duration_days, 0)
|
||||
period_discount_percent = db_user.get_promo_discount("period", duration_days)
|
||||
base_price, base_discount_total = apply_percentage_discount(
|
||||
base_price_original,
|
||||
period_discount_percent,
|
||||
)
|
||||
|
||||
# Рассчитываем стоимость серверов
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
subscription_service = SubscriptionService()
|
||||
servers_price_per_month, per_server_monthly_prices = await subscription_service.get_countries_price_by_uuids(
|
||||
current_connected_squads,
|
||||
db,
|
||||
promo_group_id=db_user.promo_group_id,
|
||||
)
|
||||
servers_discount_percent = db_user.get_promo_discount("servers", duration_days)
|
||||
total_servers_price = 0
|
||||
for server_price in per_server_monthly_prices:
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
server_price,
|
||||
servers_discount_percent,
|
||||
)
|
||||
total_servers_price += discounted_per_month
|
||||
|
||||
# Рассчитываем стоимость трафика
|
||||
traffic_price_per_month = settings.get_traffic_price(current_traffic)
|
||||
traffic_discount_percent = db_user.get_promo_discount("traffic", duration_days)
|
||||
traffic_discounted_per_month, traffic_discount_per_month = apply_percentage_discount(
|
||||
traffic_price_per_month,
|
||||
traffic_discount_percent,
|
||||
)
|
||||
|
||||
# Рассчитываем стоимость устройств
|
||||
additional_devices = max(0, (current_device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = db_user.get_promo_discount("devices", duration_days)
|
||||
devices_discounted_per_month, devices_discount_per_month = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
)
|
||||
|
||||
# Общая стоимость
|
||||
months_in_period = calculate_months_from_days(duration_days)
|
||||
total_price = (
|
||||
base_price +
|
||||
total_servers_price * months_in_period +
|
||||
traffic_discounted_per_month * months_in_period +
|
||||
devices_discounted_per_month * months_in_period
|
||||
)
|
||||
|
||||
current_tariff_desc = f"📱 Подписка: {len(current_connected_squads)} серверов, {current_traffic} ГБ, {current_device_limit} устр."
|
||||
estimated_price_info = f"💰 Стоимость продления (примерно): {texts.format_price(total_price)} за {duration_days} дней"
|
||||
|
||||
tariff_info = f"\n\n📋 <b>Ваш текущий тариф:</b>\n{current_tariff_desc}\n{estimated_price_info}"
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось рассчитать стоимость текущей подписки для пользователя {db_user.id}: {e}")
|
||||
tariff_info = ""
|
||||
|
||||
full_text = payment_text + tariff_info
|
||||
|
||||
keyboard = get_payment_methods_keyboard(0, db_user.language)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
full_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
try:
|
||||
await callback.message.edit_caption(
|
||||
full_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
try:
|
||||
await callback.message.delete()
|
||||
except TelegramBadRequest:
|
||||
pass
|
||||
await callback.message.answer(
|
||||
full_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -406,6 +535,11 @@ async def process_topup_amount(
|
||||
from .cryptobot import process_cryptobot_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_cryptobot_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "heleket":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .heleket import process_heleket_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_heleket_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
else:
|
||||
await message.answer("Неизвестный способ оплаты")
|
||||
|
||||
@@ -511,10 +645,32 @@ async def handle_quick_amount_selection(
|
||||
await process_pal24_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "cryptobot":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .cryptobot import process_cryptobot_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_cryptobot_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "heleket":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .heleket import process_heleket_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_heleket_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "stars":
|
||||
from .stars import process_stars_payment_amount
|
||||
|
||||
await process_stars_payment_amount(
|
||||
callback.message, db_user, amount_kopeks, state
|
||||
)
|
||||
else:
|
||||
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
|
||||
return
|
||||
|
||||
|
||||
except ValueError:
|
||||
await callback.answer("❌ Ошибка обработки суммы", show_alert=True)
|
||||
except Exception as e:
|
||||
@@ -575,6 +731,13 @@ async def handle_topup_amount_callback(
|
||||
await process_cryptobot_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "heleket":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .heleket import process_heleket_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_heleket_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "stars":
|
||||
from .stars import process_stars_payment_amount
|
||||
await process_stars_payment_amount(
|
||||
@@ -652,6 +815,11 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
start_pal24_payment,
|
||||
F.data == "topup_pal24"
|
||||
)
|
||||
from .pal24 import handle_pal24_method_selection
|
||||
dp.callback_query.register(
|
||||
handle_pal24_method_selection,
|
||||
F.data.startswith("pal24_method_"),
|
||||
)
|
||||
|
||||
from .yookassa import check_yookassa_payment_status
|
||||
dp.callback_query.register(
|
||||
@@ -693,6 +861,16 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
F.data.startswith("check_cryptobot_")
|
||||
)
|
||||
|
||||
from .heleket import start_heleket_payment, check_heleket_payment_status
|
||||
dp.callback_query.register(
|
||||
start_heleket_payment,
|
||||
F.data == "topup_heleket"
|
||||
)
|
||||
dp.callback_query.register(
|
||||
check_heleket_payment_status,
|
||||
F.data.startswith("check_heleket_")
|
||||
)
|
||||
|
||||
from .mulenpay import check_mulenpay_payment_status
|
||||
dp.callback_query.register(
|
||||
check_mulenpay_payment_status,
|
||||
@@ -725,4 +903,4 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(
|
||||
handle_topup_amount_callback,
|
||||
F.data.startswith("topup_amount|")
|
||||
)
|
||||
)
|
||||
|
||||
@@ -21,25 +21,34 @@ async def start_mulenpay_payment(
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
mulenpay_name_html = settings.get_mulenpay_display_name_html()
|
||||
|
||||
if not settings.is_mulenpay_enabled():
|
||||
await callback.answer("❌ Оплата через Mulen Pay временно недоступна", show_alert=True)
|
||||
await callback.answer(
|
||||
f"❌ Оплата через {mulenpay_name} временно недоступна",
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
message_text = texts.t(
|
||||
message_template = texts.t(
|
||||
"MULENPAY_TOPUP_PROMPT",
|
||||
(
|
||||
"💳 <b>Оплата через Mulen Pay</b>\n\n"
|
||||
"💳 <b>Оплата через {mulenpay_name_html}</b>\n\n"
|
||||
"Введите сумму для пополнения от 100 до 100 000 ₽.\n"
|
||||
"Оплата происходит через защищенную платформу Mulen Pay."
|
||||
"Оплата происходит через защищенную платформу {mulenpay_name}."
|
||||
),
|
||||
)
|
||||
message_text = message_template.format(
|
||||
mulenpay_name=mulenpay_name,
|
||||
mulenpay_name_html=mulenpay_name_html,
|
||||
)
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
@@ -63,9 +72,11 @@ async def process_mulenpay_payment_amount(
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
mulenpay_name_html = settings.get_mulenpay_display_name_html()
|
||||
|
||||
if not settings.is_mulenpay_enabled():
|
||||
await message.answer("❌ Оплата через Mulen Pay временно недоступна")
|
||||
await message.answer(f"❌ Оплата через {mulenpay_name} временно недоступна")
|
||||
return
|
||||
|
||||
if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS:
|
||||
@@ -96,8 +107,8 @@ async def process_mulenpay_payment_amount(
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"MULENPAY_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
"❌ Ошибка создания платежа {mulenpay_name}. Попробуйте позже или обратитесь в поддержку.",
|
||||
).format(mulenpay_name=mulenpay_name)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
@@ -112,8 +123,8 @@ async def process_mulenpay_payment_amount(
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"MULENPAY_PAY_BUTTON",
|
||||
"💳 Оплатить через Mulen Pay",
|
||||
),
|
||||
"💳 Оплатить через {mulenpay_name}",
|
||||
).format(mulenpay_name=mulenpay_name),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
@@ -132,11 +143,11 @@ async def process_mulenpay_payment_amount(
|
||||
message_template = texts.t(
|
||||
"MULENPAY_PAYMENT_INSTRUCTIONS",
|
||||
(
|
||||
"💳 <b>Оплата через Mulen Pay</b>\n\n"
|
||||
"💳 <b>Оплата через {mulenpay_name_html}</b>\n\n"
|
||||
"💰 Сумма: {amount}\n"
|
||||
"🆔 ID платежа: {payment_id}\n\n"
|
||||
"📱 <b>Инструкция:</b>\n"
|
||||
"1. Нажмите кнопку 'Оплатить через Mulen Pay'\n"
|
||||
"1. Нажмите кнопку 'Оплатить через {mulenpay_name}'\n"
|
||||
"2. Следуйте подсказкам платежной системы\n"
|
||||
"3. Подтвердите перевод\n"
|
||||
"4. Средства зачислятся автоматически\n\n"
|
||||
@@ -148,6 +159,8 @@ async def process_mulenpay_payment_amount(
|
||||
amount=settings.format_price(amount_kopeks),
|
||||
payment_id=payment_id_display,
|
||||
support=settings.get_support_contact_display_html(),
|
||||
mulenpay_name=mulenpay_name,
|
||||
mulenpay_name_html=mulenpay_name_html,
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
@@ -159,19 +172,20 @@ async def process_mulenpay_payment_amount(
|
||||
await state.clear()
|
||||
|
||||
logger.info(
|
||||
"Создан MulenPay платеж для пользователя %s: %s₽, ID: %s",
|
||||
"Создан %s платеж для пользователя %s: %s₽, ID: %s",
|
||||
mulenpay_name,
|
||||
db_user.telegram_id,
|
||||
amount_rubles,
|
||||
payment_id_display,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания MulenPay платежа: {e}")
|
||||
logger.error(f"Ошибка создания {mulenpay_name} платежа: {e}")
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"MULENPAY_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
"❌ Ошибка создания платежа {mulenpay_name}. Попробуйте позже или обратитесь в поддержку.",
|
||||
).format(mulenpay_name=mulenpay_name)
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
@@ -204,8 +218,9 @@ async def check_mulenpay_payment_status(
|
||||
|
||||
emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно"))
|
||||
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
message_lines = [
|
||||
"💳 Статус платежа Mulen Pay:\n\n",
|
||||
f"💳 Статус платежа {mulenpay_name}:\n\n",
|
||||
f"🆔 ID: {payment.mulen_payment_id or payment.id}\n",
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n",
|
||||
f"📊 Статус: {emoji} {status_text}\n",
|
||||
@@ -234,5 +249,7 @@ async def check_mulenpay_payment_status(
|
||||
await callback.answer(message_text, show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки статуса MulenPay: {e}")
|
||||
logger.error(
|
||||
f"Ошибка проверки статуса {settings.get_mulenpay_display_name()}: {e}"
|
||||
)
|
||||
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
|
||||
+192
-74
@@ -1,10 +1,12 @@
|
||||
import html
|
||||
import logging
|
||||
from aiogram import types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
@@ -15,81 +17,27 @@ from app.states import BalanceStates
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_pal24_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_pal24_enabled():
|
||||
await callback.answer("❌ Оплата через PayPalych временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
# Формируем текст сообщения в зависимости от доступных способов оплаты
|
||||
if settings.is_pal24_sbp_button_visible() and settings.is_pal24_card_button_visible():
|
||||
payment_methods_text = "СБП и банковской картой"
|
||||
elif settings.is_pal24_sbp_button_visible():
|
||||
payment_methods_text = "СБП"
|
||||
elif settings.is_pal24_card_button_visible():
|
||||
payment_methods_text = "банковской картой"
|
||||
else:
|
||||
# Если обе кнопки отключены, используем общий текст
|
||||
payment_methods_text = "доступными способами"
|
||||
|
||||
message_text = texts.t(
|
||||
"PAL24_TOPUP_PROMPT",
|
||||
(
|
||||
f"🏦 <b>Оплата через PayPalych ({payment_methods_text})</b>\n\n"
|
||||
"Введите сумму для пополнения от 100 до 1 000 000 ₽.\n"
|
||||
f"Оплата проходит через PayPalych ({payment_methods_text})."
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="pal24")
|
||||
await callback.answer()
|
||||
def _get_available_pal24_methods() -> list[str]:
|
||||
methods: list[str] = []
|
||||
if settings.is_pal24_sbp_button_visible():
|
||||
methods.append("sbp")
|
||||
if settings.is_pal24_card_button_visible():
|
||||
methods.append("card")
|
||||
if not methods:
|
||||
methods.append("sbp")
|
||||
return methods
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_pal24_payment_amount(
|
||||
async def _send_pal24_payment_message(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
payment_method: str,
|
||||
state: FSMContext,
|
||||
):
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_pal24_enabled():
|
||||
await message.answer("❌ Оплата через PayPalych временно недоступна")
|
||||
return
|
||||
|
||||
if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS:
|
||||
min_rubles = settings.PAL24_MIN_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f"❌ Минимальная сумма для оплаты через PayPalych: {min_rubles:.0f} ₽")
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS:
|
||||
max_rubles = settings.PAL24_MAX_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f"❌ Максимальная сумма для оплаты через PayPalych: {max_rubles:,.0f} ₽".replace(',', ' '))
|
||||
return
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(message.bot)
|
||||
payment_result = await payment_service.create_pal24_payment(
|
||||
@@ -98,6 +46,7 @@ async def process_pal24_payment_amount(
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
language=db_user.language,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
|
||||
if not payment_result:
|
||||
@@ -262,14 +211,15 @@ async def process_pal24_payment_amount(
|
||||
await state.clear()
|
||||
|
||||
logger.info(
|
||||
"Создан PayPalych счет для пользователя %s: %s₽, ID: %s",
|
||||
"Создан PayPalych счет для пользователя %s: %s₽, ID: %s, метод: %s",
|
||||
db_user.telegram_id,
|
||||
amount_kopeks / 100,
|
||||
bill_id,
|
||||
payment_method,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания PayPalych платежа: {e}")
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка создания PayPalych платежа: {error}")
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAL24_PAYMENT_ERROR",
|
||||
@@ -278,6 +228,168 @@ async def process_pal24_payment_amount(
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
@error_handler
|
||||
async def start_pal24_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_pal24_enabled():
|
||||
await callback.answer("❌ Оплата через PayPalych временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
# Формируем текст сообщения в зависимости от доступных способов оплаты
|
||||
if settings.is_pal24_sbp_button_visible() and settings.is_pal24_card_button_visible():
|
||||
payment_methods_text = "СБП и банковской картой"
|
||||
elif settings.is_pal24_sbp_button_visible():
|
||||
payment_methods_text = "СБП"
|
||||
elif settings.is_pal24_card_button_visible():
|
||||
payment_methods_text = "банковской картой"
|
||||
else:
|
||||
# Если обе кнопки отключены, используем общий текст
|
||||
payment_methods_text = "доступными способами"
|
||||
|
||||
message_text = texts.t(
|
||||
"PAL24_TOPUP_PROMPT",
|
||||
(
|
||||
f"🏦 <b>Оплата через PayPalych ({payment_methods_text})</b>\n\n"
|
||||
"Введите сумму для пополнения от 100 до 1 000 000 ₽.\n"
|
||||
f"Оплата проходит через PayPalych ({payment_methods_text})."
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="pal24")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_pal24_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_pal24_enabled():
|
||||
await message.answer("❌ Оплата через PayPalych временно недоступна")
|
||||
return
|
||||
|
||||
if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS:
|
||||
min_rubles = settings.PAL24_MIN_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f"❌ Минимальная сумма для оплаты через PayPalych: {min_rubles:.0f} ₽")
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS:
|
||||
max_rubles = settings.PAL24_MAX_AMOUNT_KOPEKS / 100
|
||||
await message.answer(
|
||||
f"❌ Максимальная сумма для оплаты через PayPalych: {max_rubles:,.0f} ₽".replace(',', ' ')
|
||||
)
|
||||
return
|
||||
|
||||
available_methods = _get_available_pal24_methods()
|
||||
|
||||
if len(available_methods) == 1:
|
||||
await _send_pal24_payment_message(
|
||||
message,
|
||||
db_user,
|
||||
db,
|
||||
amount_kopeks,
|
||||
available_methods[0],
|
||||
state,
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(pal24_amount_kopeks=amount_kopeks)
|
||||
await state.set_state(BalanceStates.waiting_for_pal24_method)
|
||||
|
||||
method_buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
if "sbp" in available_methods:
|
||||
method_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=settings.get_pal24_sbp_button_text(
|
||||
texts.t("PAL24_SBP_PAY_BUTTON", "🏦 Оплатить через PayPalych (СБП)")
|
||||
),
|
||||
callback_data="pal24_method_sbp",
|
||||
)
|
||||
]
|
||||
)
|
||||
if "card" in available_methods:
|
||||
method_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=settings.get_pal24_card_button_text(
|
||||
texts.t("PAL24_CARD_PAY_BUTTON", "💳 Оплатить банковской картой (PayPalych)")
|
||||
),
|
||||
callback_data="pal24_method_card",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
method_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")])
|
||||
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAL24_SELECT_PAYMENT_METHOD",
|
||||
"Выберите способ оплаты PayPalych:",
|
||||
),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=method_buttons),
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_pal24_method_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
data = await state.get_data()
|
||||
amount_kopeks = data.get("pal24_amount_kopeks")
|
||||
if not amount_kopeks:
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PAL24_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
method = "sbp" if callback.data.endswith("_sbp") else "card"
|
||||
|
||||
await callback.answer()
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await _send_pal24_payment_message(
|
||||
callback.message,
|
||||
db_user,
|
||||
db,
|
||||
int(amount_kopeks),
|
||||
method,
|
||||
state,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_pal24_payment_status(
|
||||
@@ -353,11 +465,17 @@ async def check_pal24_payment_status(
|
||||
])
|
||||
|
||||
await callback.answer()
|
||||
await callback.message.edit_text(
|
||||
"\n".join(message_lines),
|
||||
reply_markup=keyboard,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
"\n".join(message_lines),
|
||||
reply_markup=keyboard,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except TelegramBadRequest as error:
|
||||
if "message is not modified" in str(error).lower():
|
||||
await callback.answer(texts.t("CHECK_STATUS_NO_CHANGES", "Статус не изменился"))
|
||||
else:
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки статуса PayPalych: {e}")
|
||||
|
||||
@@ -42,7 +42,7 @@ async def start_stars_payment(
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
@@ -45,7 +45,7 @@ async def start_wata_payment(
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ async def start_yookassa_payment(
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
@@ -98,7 +98,7 @@ async def start_yookassa_sbp_payment(
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
@@ -246,34 +246,125 @@ async def process_yookassa_sbp_payment_amount(
|
||||
return
|
||||
|
||||
confirmation_url = payment_result.get("confirmation_url")
|
||||
if not confirmation_url:
|
||||
await message.answer("❌ Ошибка получения ссылки для оплаты через СБП. Обратитесь в поддержку.")
|
||||
qr_confirmation_data = payment_result.get("qr_confirmation_data")
|
||||
|
||||
if not confirmation_url and not qr_confirmation_data:
|
||||
await message.answer("❌ Ошибка получения данных для оплаты через СБП. Обратитесь в поддержку.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🏦 Оплатить через СБП", url=confirmation_url)],
|
||||
[types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
# Подготовим QR-код для вставки в основное сообщение
|
||||
qr_photo = None
|
||||
if qr_confirmation_data:
|
||||
try:
|
||||
# Импортируем необходимые модули для генерации QR-кода
|
||||
import base64
|
||||
from io import BytesIO
|
||||
import qrcode
|
||||
from aiogram.types import BufferedInputFile
|
||||
|
||||
# Создаем QR-код из полученных данных
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=5)
|
||||
qr.add_data(qr_confirmation_data)
|
||||
qr.make(fit=True)
|
||||
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
# Сохраняем изображение в байты
|
||||
img_bytes = BytesIO()
|
||||
img.save(img_bytes, format='PNG')
|
||||
img_bytes.seek(0)
|
||||
|
||||
qr_photo = BufferedInputFile(img_bytes.getvalue(), filename="qrcode.png")
|
||||
except ImportError:
|
||||
logger.warning("qrcode библиотека не установлена, QR-код не будет сгенерирован")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка генерации QR-кода: {e}")
|
||||
|
||||
await message.answer(
|
||||
f"🏦 <b>Оплата через СБП</b>\n\n"
|
||||
# Если нет QR-данных из YooKassa, но есть URL, генерируем QR-код из URL
|
||||
if not qr_photo and confirmation_url:
|
||||
try:
|
||||
# Импортируем необходимые модули для генерации QR-кода
|
||||
import base64
|
||||
from io import BytesIO
|
||||
import qrcode
|
||||
from aiogram.types import BufferedInputFile
|
||||
|
||||
# Создаем QR-код из URL
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=5)
|
||||
qr.add_data(confirmation_url)
|
||||
qr.make(fit=True)
|
||||
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
# Сохраняем изображение в байты
|
||||
img_bytes = BytesIO()
|
||||
img.save(img_bytes, format='PNG')
|
||||
img_bytes.seek(0)
|
||||
|
||||
qr_photo = BufferedInputFile(img_bytes.getvalue(), filename="qrcode.png")
|
||||
except ImportError:
|
||||
logger.warning("qrcode библиотека не установлена, QR-код не будет сгенерирован")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка генерации QR-кода из URL: {e}")
|
||||
|
||||
# Создаем клавиатуру с кнопками для оплаты по ссылке и проверки статуса
|
||||
keyboard_buttons = []
|
||||
|
||||
# Добавляем кнопку оплаты, если доступна ссылка
|
||||
if confirmation_url:
|
||||
keyboard_buttons.append([types.InlineKeyboardButton(text="🔗 Перейти к оплате", url=confirmation_url)])
|
||||
else:
|
||||
# Если ссылка недоступна, предлагаем оплатить через ID платежа в приложении банка
|
||||
keyboard_buttons.append([types.InlineKeyboardButton(text="📱 Оплатить в приложении банка", callback_data="temp_disabled")])
|
||||
|
||||
# Добавляем общие кнопки
|
||||
keyboard_buttons.append([types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")])
|
||||
keyboard_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")])
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
|
||||
|
||||
# Подготавливаем текст сообщения
|
||||
message_text = (
|
||||
f"🔗 <b>Оплата через СБП</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
|
||||
f"📱 <b>Инструкция:</b>\n"
|
||||
f"1. Нажмите кнопку 'Оплатить через СБП'\n"
|
||||
f"2. Вас перенаправит в приложение вашего банка\n"
|
||||
f"3. Подтвердите платеж через СБП\n"
|
||||
f"4. Деньги поступят на баланс автоматически\n\n"
|
||||
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
|
||||
f"✅ Принимаем СБП от всех банков-участников\n\n"
|
||||
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
# Добавляем инструкции в зависимости от доступных способов оплаты
|
||||
if not confirmation_url:
|
||||
message_text += (
|
||||
f"📱 <b>Инструкция по оплате:</b>\n"
|
||||
f"1. Откройте приложение вашего банка\n"
|
||||
f"2. Найдите функцию оплаты по реквизитам или перевод по СБП\n"
|
||||
f"3. Введите ID платежа: <code>{payment_result['yookassa_payment_id']}</code>\n"
|
||||
f"4. Подтвердите платеж в приложении банка\n"
|
||||
f"5. Деньги поступят на баланс автоматически\n\n"
|
||||
)
|
||||
|
||||
message_text += (
|
||||
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
|
||||
f"✅ Принимаем СБП от всех банков-участников\n\n"
|
||||
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}"
|
||||
)
|
||||
|
||||
# Отправляем сообщение с инструкциями и клавиатурой
|
||||
# Если есть QR-код, отправляем его как медиа-сообщение
|
||||
if qr_photo:
|
||||
# Используем метод отправки медиа-группы или фото с описанием
|
||||
await message.answer_photo(
|
||||
photo=qr_photo,
|
||||
caption=message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
# Если QR-код недоступен, отправляем обычное текстовое сообщение
|
||||
await message.answer(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(f"Создан платеж YooKassa СБП для пользователя {db_user.telegram_id}: "
|
||||
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
|
||||
@@ -284,6 +375,9 @@ async def process_yookassa_sbp_payment_amount(
|
||||
await state.clear()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_yookassa_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
|
||||
+42
-30
@@ -38,6 +38,7 @@ from app.utils.promo_offer import (
|
||||
from app.services.privacy_policy_service import PrivacyPolicyService
|
||||
from app.services.public_offer_service import PublicOfferService
|
||||
from app.services.faq_service import FaqService
|
||||
from app.utils.timezone import format_local_datetime
|
||||
from app.utils.pricing_utils import format_period_description
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -153,7 +154,7 @@ async def show_main_menu(
|
||||
db_user.last_activity = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
has_active_subscription = bool(db_user.subscription)
|
||||
has_active_subscription = bool(db_user.subscription and db_user.subscription.is_active)
|
||||
subscription_is_active = False
|
||||
|
||||
if db_user.subscription:
|
||||
@@ -892,7 +893,7 @@ async def handle_back_to_menu(
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
has_active_subscription = db_user.subscription is not None
|
||||
has_active_subscription = bool(db_user.subscription and db_user.subscription.is_active)
|
||||
subscription_is_active = False
|
||||
|
||||
if db_user.subscription:
|
||||
@@ -946,64 +947,75 @@ async def handle_back_to_menu(
|
||||
await callback.answer()
|
||||
|
||||
def _get_subscription_status(user: User, texts) -> str:
|
||||
if not user.subscription:
|
||||
subscription = getattr(user, "subscription", None)
|
||||
if not subscription:
|
||||
return texts.t("SUB_STATUS_NONE", "❌ Отсутствует")
|
||||
|
||||
subscription = user.subscription
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
if subscription.end_date <= current_time:
|
||||
actual_status = (subscription.actual_status or "").lower()
|
||||
end_date = getattr(subscription, "end_date", None)
|
||||
end_date_text = format_local_datetime(end_date, "%d.%m.%Y") if end_date else None
|
||||
days_left = 0
|
||||
|
||||
if subscription.end_date > current_time:
|
||||
days_left = (subscription.end_date - current_time).days
|
||||
|
||||
if actual_status == "pending":
|
||||
return texts.t("SUBSCRIPTION_NONE", "❌ Нет активной подписки")
|
||||
|
||||
if actual_status == "disabled":
|
||||
return texts.t("SUB_STATUS_DISABLED", "⚫ Отключена")
|
||||
|
||||
if actual_status == "expired":
|
||||
return texts.t(
|
||||
"SUB_STATUS_EXPIRED",
|
||||
"🔴 Истекла\n📅 {end_date}",
|
||||
).format(end_date=subscription.end_date.strftime('%d.%m.%Y'))
|
||||
|
||||
days_left = (subscription.end_date - current_time).days
|
||||
|
||||
if subscription.is_trial:
|
||||
if days_left > 1:
|
||||
).format(end_date=end_date_text or "—")
|
||||
|
||||
if actual_status == "trial":
|
||||
if days_left > 1 and end_date_text:
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_ACTIVE",
|
||||
"🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
|
||||
).format(
|
||||
end_date=subscription.end_date.strftime('%d.%m.%Y'),
|
||||
end_date=end_date_text,
|
||||
days=days_left,
|
||||
)
|
||||
elif days_left == 1:
|
||||
if days_left == 1:
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_TOMORROW",
|
||||
"🎁 Тестовая подписка\n⚠️ истекает завтра!",
|
||||
)
|
||||
else:
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_TODAY",
|
||||
"🎁 Тестовая подписка\n⚠️ истекает сегодня!",
|
||||
)
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_TODAY",
|
||||
"🎁 Тестовая подписка\n⚠️ истекает сегодня!",
|
||||
)
|
||||
|
||||
else:
|
||||
if days_left > 7:
|
||||
if actual_status == "active":
|
||||
if days_left > 7 and end_date_text:
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_LONG",
|
||||
"💎 Активна\n📅 до {end_date} ({days} дн.)",
|
||||
).format(
|
||||
end_date=subscription.end_date.strftime('%d.%m.%Y'),
|
||||
end_date=end_date_text,
|
||||
days=days_left,
|
||||
)
|
||||
elif days_left > 1:
|
||||
if days_left > 1:
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_FEW_DAYS",
|
||||
"💎 Активна\n⚠️ истекает через {days} дн.",
|
||||
).format(days=days_left)
|
||||
elif days_left == 1:
|
||||
if days_left == 1:
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_TOMORROW",
|
||||
"💎 Активна\n⚠️ истекает завтра!",
|
||||
)
|
||||
else:
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_TODAY",
|
||||
"💎 Активна\n⚠️ истекает сегодня!",
|
||||
)
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_TODAY",
|
||||
"💎 Активна\n⚠️ истекает сегодня!",
|
||||
)
|
||||
|
||||
return texts.t("SUB_STATUS_UNKNOWN", "❓ Неизвестно")
|
||||
|
||||
|
||||
def _insert_random_message(base_text: str, random_message: str, action_prompt: str) -> str:
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.poll import (
|
||||
get_poll_response_by_id,
|
||||
record_poll_answer,
|
||||
)
|
||||
from app.database.models import PollQuestion, User
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.poll_service import get_next_question, get_question_option, reward_user_for_poll
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _delete_message_later(bot, chat_id: int, message_id: int, delay: int = 10) -> None:
|
||||
try:
|
||||
await asyncio.sleep(delay)
|
||||
await bot.delete_message(chat_id, message_id)
|
||||
except Exception as error: # pragma: no cover - cleanup best effort
|
||||
logger.debug("Не удалось удалить сообщение опроса %s: %s", message_id, error)
|
||||
|
||||
|
||||
async def _render_question_text(
|
||||
poll_title: str,
|
||||
question: PollQuestion,
|
||||
current_index: int,
|
||||
total: int,
|
||||
language: str,
|
||||
) -> str:
|
||||
texts = get_texts(language)
|
||||
header = texts.t("POLL_QUESTION_HEADER", "<b>Вопрос {current}/{total}</b>").format(
|
||||
current=current_index,
|
||||
total=total,
|
||||
)
|
||||
lines = [f"🗳️ <b>{poll_title}</b>", "", header, "", question.text]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def _update_poll_message(
|
||||
message: types.Message,
|
||||
text: str,
|
||||
*,
|
||||
reply_markup: types.InlineKeyboardMarkup | None = None,
|
||||
parse_mode: str | None = "HTML",
|
||||
) -> bool:
|
||||
try:
|
||||
await message.edit_text(
|
||||
text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
return True
|
||||
except TelegramBadRequest as error:
|
||||
error_text = str(error).lower()
|
||||
if "message is not modified" in error_text:
|
||||
logger.debug(
|
||||
"Опросное сообщение уже актуально, пропускаем обновление: %s",
|
||||
error,
|
||||
)
|
||||
return True
|
||||
|
||||
logger.warning(
|
||||
"Не удалось обновить сообщение опроса %s: %s",
|
||||
message.message_id,
|
||||
error,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.exception(
|
||||
"Непредвиденная ошибка при обновлении сообщения опроса %s: %s",
|
||||
message.message_id,
|
||||
error,
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _build_options_keyboard(response_id: int, question: PollQuestion) -> types.InlineKeyboardMarkup:
|
||||
buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
for option in sorted(question.options, key=lambda o: o.order):
|
||||
buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=option.text,
|
||||
callback_data=f"poll_answer:{response_id}:{question.id}:{option.id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
async def handle_poll_start(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
response_id = int(callback.data.split(":")[1])
|
||||
except (IndexError, ValueError):
|
||||
await callback.answer("❌ Опрос не найден", show_alert=True)
|
||||
return
|
||||
|
||||
response = await get_poll_response_by_id(db, response_id)
|
||||
if not response or response.user_id != db_user.id:
|
||||
await callback.answer("❌ Опрос не найден", show_alert=True)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if response.completed_at:
|
||||
await callback.answer(texts.t("POLL_ALREADY_COMPLETED", "Вы уже прошли этот опрос."), show_alert=True)
|
||||
return
|
||||
|
||||
if not response.poll or not response.poll.questions:
|
||||
await callback.answer(texts.t("POLL_EMPTY", "Опрос пока недоступен."), show_alert=True)
|
||||
return
|
||||
|
||||
if not response.started_at:
|
||||
response.started_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
index, question = await get_next_question(response)
|
||||
if not question:
|
||||
await callback.answer(texts.t("POLL_ERROR", "Не удалось загрузить вопросы."), show_alert=True)
|
||||
return
|
||||
|
||||
question_text = await _render_question_text(
|
||||
response.poll.title,
|
||||
question,
|
||||
index,
|
||||
len(response.poll.questions),
|
||||
db_user.language,
|
||||
)
|
||||
|
||||
if not await _update_poll_message(
|
||||
callback.message,
|
||||
question_text,
|
||||
reply_markup=_build_options_keyboard(response.id, question),
|
||||
):
|
||||
await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True)
|
||||
return
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def handle_poll_answer(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
_, response_id, question_id, option_id = callback.data.split(":", 3)
|
||||
response_id = int(response_id)
|
||||
question_id = int(question_id)
|
||||
option_id = int(option_id)
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
response = await get_poll_response_by_id(db, response_id)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not response or response.user_id != db_user.id:
|
||||
await callback.answer("❌ Опрос не найден", show_alert=True)
|
||||
return
|
||||
|
||||
if not response.poll:
|
||||
await callback.answer(texts.t("POLL_ERROR", "Опрос недоступен."), show_alert=True)
|
||||
return
|
||||
|
||||
if response.completed_at:
|
||||
await callback.answer(texts.t("POLL_ALREADY_COMPLETED", "Вы уже прошли этот опрос."), show_alert=True)
|
||||
return
|
||||
|
||||
question = next((q for q in response.poll.questions if q.id == question_id), None)
|
||||
if not question:
|
||||
await callback.answer(texts.t("POLL_ERROR", "Вопрос не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
option = await get_question_option(question, option_id)
|
||||
if not option:
|
||||
await callback.answer(texts.t("POLL_ERROR", "Вариант ответа не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
await record_poll_answer(
|
||||
db,
|
||||
response_id=response.id,
|
||||
question_id=question.id,
|
||||
option_id=option.id,
|
||||
)
|
||||
|
||||
try:
|
||||
await db.refresh(response, attribute_names=["answers"])
|
||||
except Exception as error: # pragma: no cover - defensive cache busting
|
||||
logger.debug(
|
||||
"Не удалось обновить локальные ответы опроса %s: %s",
|
||||
response.id,
|
||||
error,
|
||||
)
|
||||
response = await get_poll_response_by_id(db, response.id)
|
||||
if not response:
|
||||
await callback.answer(texts.t("POLL_ERROR", "Опрос недоступен."), show_alert=True)
|
||||
return
|
||||
index, next_question = await get_next_question(response)
|
||||
|
||||
if next_question:
|
||||
question_text = await _render_question_text(
|
||||
response.poll.title,
|
||||
next_question,
|
||||
index,
|
||||
len(response.poll.questions),
|
||||
db_user.language,
|
||||
)
|
||||
if not await _update_poll_message(
|
||||
callback.message,
|
||||
question_text,
|
||||
reply_markup=_build_options_keyboard(response.id, next_question),
|
||||
):
|
||||
await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True)
|
||||
return
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
response.completed_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
reward_amount = await reward_user_for_poll(db, response)
|
||||
|
||||
thanks_lines = [texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!")]
|
||||
if reward_amount:
|
||||
thanks_lines.append(
|
||||
texts.t(
|
||||
"POLL_REWARD_GRANTED",
|
||||
"Награда {amount} зачислена на ваш баланс.",
|
||||
).format(amount=settings.format_price(reward_amount))
|
||||
)
|
||||
|
||||
if not await _update_poll_message(
|
||||
callback.message,
|
||||
"\n\n".join(thanks_lines),
|
||||
):
|
||||
await callback.answer(texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!"))
|
||||
return
|
||||
asyncio.create_task(
|
||||
_delete_message_later(callback.bot, callback.message.chat.id, callback.message.message_id)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(handle_poll_start, F.data.startswith("poll_start:"))
|
||||
dp.callback_query.register(handle_poll_answer, F.data.startswith("poll_answer:"))
|
||||
+47
-26
@@ -1,9 +1,8 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram import Dispatcher, types, F, Bot
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.states import PromoCodeStates
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
@@ -32,6 +31,45 @@ async def show_promocode_menu(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def activate_promocode_for_registration(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
code: str,
|
||||
bot: Bot = None
|
||||
) -> dict:
|
||||
"""
|
||||
Активирует промокод для пользователя во время регистрации.
|
||||
Возвращает результат активации без отправки сообщений.
|
||||
"""
|
||||
promocode_service = PromoCodeService()
|
||||
result = await promocode_service.activate_promocode(db, user_id, code)
|
||||
|
||||
if result["success"]:
|
||||
logger.info(f"✅ Пользователь {user_id} активировал промокод {code} при регистрации")
|
||||
|
||||
# Отправляем уведомление админу, если бот доступен
|
||||
if bot:
|
||||
try:
|
||||
from app.database.crud.user import get_user_by_id
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if user:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_promocode_activation_notification(
|
||||
db,
|
||||
user,
|
||||
result.get("promocode", {"code": code}),
|
||||
result["description"],
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.error(
|
||||
"Ошибка отправки админ уведомления об активации промокода %s: %s",
|
||||
code,
|
||||
notify_error,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_promocode(
|
||||
message: types.Message,
|
||||
@@ -40,9 +78,9 @@ async def process_promocode(
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
code = message.text.strip()
|
||||
|
||||
|
||||
if not code:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
@@ -52,31 +90,14 @@ async def process_promocode(
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
promocode_service = PromoCodeService()
|
||||
result = await promocode_service.activate_promocode(db, db_user.id, code)
|
||||
|
||||
|
||||
result = await activate_promocode_for_registration(db, db_user.id, code, message.bot)
|
||||
|
||||
if result["success"]:
|
||||
await message.answer(
|
||||
texts.PROMOCODE_SUCCESS.format(description=result["description"]),
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
logger.info(f"✅ Пользователь {db_user.telegram_id} активировал промокод {code}")
|
||||
|
||||
try:
|
||||
notification_service = AdminNotificationService(message.bot)
|
||||
await notification_service.send_promocode_activation_notification(
|
||||
db,
|
||||
db_user,
|
||||
result.get("promocode", {"code": code}),
|
||||
result["description"],
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.error(
|
||||
"Ошибка отправки админ уведомления об активации промокода %s: %s",
|
||||
code,
|
||||
notify_error,
|
||||
)
|
||||
else:
|
||||
error_messages = {
|
||||
"not_found": texts.PROMOCODE_INVALID,
|
||||
@@ -85,13 +106,13 @@ async def process_promocode(
|
||||
"already_used_by_user": texts.PROMOCODE_USED,
|
||||
"server_error": texts.ERROR
|
||||
}
|
||||
|
||||
|
||||
error_text = error_messages.get(result["error"], texts.PROMOCODE_INVALID)
|
||||
await message.answer(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
try:
|
||||
logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}")
|
||||
|
||||
allowed_prefixes = ("balance_", "admin_stars_test_")
|
||||
allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_")
|
||||
|
||||
if not query.invoice_payload or not query.invoice_payload.startswith(allowed_prefixes):
|
||||
logger.warning(f"Невалидный payload: {query.invoice_payload}")
|
||||
|
||||
+186
-81
@@ -37,6 +37,7 @@ from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
build_test_access_hint,
|
||||
)
|
||||
from app.utils.timezone import format_local_datetime
|
||||
from app.database.crud.user_message import get_random_active_message
|
||||
from app.database.crud.subscription import decrement_subscription_server_counts
|
||||
|
||||
@@ -44,6 +45,17 @@ from app.database.crud.subscription import decrement_subscription_server_counts
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _calculate_subscription_flags(subscription):
|
||||
if not subscription:
|
||||
return False, False
|
||||
|
||||
actual_status = getattr(subscription, "actual_status", None)
|
||||
has_active_subscription = actual_status in {"active", "trial"}
|
||||
subscription_is_active = bool(getattr(subscription, "is_active", False))
|
||||
|
||||
return has_active_subscription, subscription_is_active
|
||||
|
||||
|
||||
async def _apply_campaign_bonus_if_needed(
|
||||
db: AsyncSession,
|
||||
user,
|
||||
@@ -88,15 +100,15 @@ async def handle_potential_referral_code(
|
||||
db: AsyncSession
|
||||
):
|
||||
current_state = await state.get_state()
|
||||
logger.info(f"🔍 REFERRAL CHECK: Проверка сообщения '{message.text}' в состоянии {current_state}")
|
||||
|
||||
logger.info(f"🔍 REFERRAL/PROMO CHECK: Проверка сообщения '{message.text}' в состоянии {current_state}")
|
||||
|
||||
if current_state not in [
|
||||
RegistrationStates.waiting_for_rules_accept.state,
|
||||
RegistrationStates.waiting_for_referral_code.state,
|
||||
None
|
||||
None
|
||||
]:
|
||||
return False
|
||||
|
||||
|
||||
user = await get_user_by_telegram_id(db, message.from_user.id)
|
||||
if user and user.status == UserStatus.ACTIVE.value:
|
||||
return False
|
||||
@@ -113,37 +125,73 @@ async def handle_potential_referral_code(
|
||||
if len(potential_code) < 4 or len(potential_code) > 20:
|
||||
return False
|
||||
|
||||
# Сначала проверяем реферальный код
|
||||
referrer = await get_user_by_referral_code(db, potential_code)
|
||||
if not referrer:
|
||||
await message.answer(texts.t(
|
||||
"REFERRAL_CODE_INVALID_HELP",
|
||||
"❌ Неверный реферальный код.\n\n"
|
||||
"💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n"
|
||||
"⏭️ Для продолжения регистрации без реферального кода используйте команду /start",
|
||||
))
|
||||
if referrer:
|
||||
data['referral_code'] = potential_code
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
|
||||
await message.answer(texts.t("REFERRAL_CODE_ACCEPTED", "✅ Реферальный код принят!"))
|
||||
logger.info(f"✅ Реферальный код {potential_code} применен для пользователя {message.from_user.id}")
|
||||
|
||||
if current_state != RegistrationStates.waiting_for_referral_code.state:
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
|
||||
rules_text = await get_rules(language)
|
||||
await message.answer(
|
||||
rules_text,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
logger.info("📋 Правила отправлены после ввода реферального кода")
|
||||
else:
|
||||
await complete_registration(message, state, db)
|
||||
|
||||
return True
|
||||
|
||||
data['referral_code'] = potential_code
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
# Если реферальный код не найден, проверяем промокод
|
||||
from app.database.crud.promocode import check_promocode_validity
|
||||
|
||||
promocode_check = await check_promocode_validity(db, potential_code)
|
||||
|
||||
if promocode_check["valid"]:
|
||||
# Промокод валиден - сохраняем его в state для активации после создания пользователя
|
||||
data['promocode'] = potential_code
|
||||
await state.set_data(data)
|
||||
|
||||
await message.answer(texts.t("REFERRAL_CODE_ACCEPTED", "✅ Реферальный код принят!"))
|
||||
logger.info(f"✅ Реферальный код {potential_code} применен для пользователя {message.from_user.id}")
|
||||
|
||||
if current_state != RegistrationStates.waiting_for_referral_code.state:
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
|
||||
rules_text = await get_rules(language)
|
||||
await message.answer(
|
||||
rules_text,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
texts.t(
|
||||
"PROMOCODE_ACCEPTED_WILL_ACTIVATE",
|
||||
"✅ Промокод принят! Он будет активирован после завершения регистрации."
|
||||
)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
logger.info("📋 Правила отправлены после ввода реферального кода")
|
||||
else:
|
||||
await complete_registration(message, state, db)
|
||||
|
||||
logger.info(f"✅ Промокод {potential_code} сохранен для активации для пользователя {message.from_user.id}")
|
||||
|
||||
if current_state != RegistrationStates.waiting_for_referral_code.state:
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
|
||||
rules_text = await get_rules(language)
|
||||
await message.answer(
|
||||
rules_text,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
logger.info("📋 Правила отправлены после принятия промокода")
|
||||
else:
|
||||
await complete_registration(message, state, db)
|
||||
|
||||
return True
|
||||
|
||||
# Ни реферальный код, ни промокод не найдены
|
||||
await message.answer(texts.t(
|
||||
"REFERRAL_OR_PROMO_CODE_INVALID_HELP",
|
||||
"❌ Неверный реферальный код или промокод.\n\n"
|
||||
"💡 Если у вас есть реферальный код или промокод, убедитесь что он введен правильно.\n"
|
||||
"⏭️ Для продолжения регистрации без кода используйте команду /start",
|
||||
))
|
||||
return True
|
||||
|
||||
|
||||
@@ -224,7 +272,11 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
logger.info(f"🚀 START: Обработка /start от {message.from_user.id}")
|
||||
|
||||
data = await state.get_data() or {}
|
||||
had_pending_payload = "pending_start_payload" in data
|
||||
pending_start_payload = data.pop("pending_start_payload", None)
|
||||
had_campaign_notification_flag = "campaign_notification_sent" in data
|
||||
campaign_notification_sent = data.pop("campaign_notification_sent", False)
|
||||
state_needs_update = had_pending_payload or had_campaign_notification_flag
|
||||
|
||||
referral_code = None
|
||||
campaign = None
|
||||
@@ -240,7 +292,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
pending_start_payload,
|
||||
)
|
||||
|
||||
if pending_start_payload is not None:
|
||||
if state_needs_update:
|
||||
await state.set_data(data)
|
||||
|
||||
if start_parameter:
|
||||
@@ -266,7 +318,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
|
||||
user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id)
|
||||
|
||||
if campaign:
|
||||
if campaign and not campaign_notification_sent:
|
||||
try:
|
||||
notification_service = AdminNotificationService(message.bot)
|
||||
await notification_service.send_campaign_link_visit_notification(
|
||||
@@ -338,11 +390,9 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
f"Ошибка отправки уведомления о рекламной кампании: {e}"
|
||||
)
|
||||
|
||||
has_active_subscription = user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
if user.subscription:
|
||||
subscription_is_active = user.subscription.is_active
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
user.subscription
|
||||
)
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
@@ -678,31 +728,57 @@ async def process_rules_accept(
|
||||
|
||||
|
||||
async def process_referral_code_input(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
logger.info(f"🎫 REFERRAL: Обработка реферального кода: {message.text}")
|
||||
|
||||
|
||||
logger.info(f"🎫 REFERRAL/PROMO: Обработка кода: {message.text}")
|
||||
|
||||
data = await state.get_data() or {}
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
|
||||
referral_code = message.text.strip()
|
||||
code = message.text.strip()
|
||||
|
||||
referrer = await get_user_by_referral_code(db, referral_code)
|
||||
# Сначала проверяем, является ли это реферальным кодом
|
||||
referrer = await get_user_by_referral_code(db, code)
|
||||
if referrer:
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
await message.answer(texts.t("REFERRAL_CODE_ACCEPTED", "✅ Реферальный код принят!"))
|
||||
logger.info(f"✅ Реферальный код применен")
|
||||
else:
|
||||
await message.answer(texts.t("REFERRAL_CODE_INVALID", "❌ Неверный реферальный код"))
|
||||
logger.info(f"❌ Неверный реферальный код")
|
||||
logger.info(f"✅ Реферальный код применен: {code}")
|
||||
await complete_registration(message, state, db)
|
||||
return
|
||||
|
||||
await complete_registration(message, state, db)
|
||||
|
||||
# Если реферальный код не найден, проверяем промокод
|
||||
from app.database.crud.promocode import check_promocode_validity
|
||||
|
||||
promocode_check = await check_promocode_validity(db, code)
|
||||
|
||||
if promocode_check["valid"]:
|
||||
# Промокод валиден - сохраняем его в state для активации после создания пользователя
|
||||
data['promocode'] = code
|
||||
await state.set_data(data)
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PROMOCODE_ACCEPTED_WILL_ACTIVATE",
|
||||
"✅ Промокод принят! Он будет активирован после завершения регистрации."
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Промокод сохранен для активации: {code}")
|
||||
await complete_registration(message, state, db)
|
||||
return
|
||||
|
||||
# Ни реферальный код, ни промокод не найдены
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"REFERRAL_OR_PROMO_CODE_INVALID",
|
||||
"❌ Неверный реферальный код или промокод"
|
||||
)
|
||||
)
|
||||
logger.info(f"❌ Неверный код (ни реферальный, ни промокод): {code}")
|
||||
return
|
||||
|
||||
|
||||
async def process_referral_code_skip(
|
||||
@@ -761,11 +837,9 @@ async def complete_registration_from_callback(
|
||||
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
|
||||
has_active_subscription = existing_user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
if existing_user.subscription:
|
||||
subscription_is_active = existing_user.subscription.is_active
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
existing_user.subscription
|
||||
)
|
||||
|
||||
menu_text = await get_main_menu_text(existing_user, texts, db)
|
||||
|
||||
@@ -943,11 +1017,9 @@ async def complete_registration_from_callback(
|
||||
else:
|
||||
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
|
||||
|
||||
has_active_subscription = bool(getattr(user, "subscription", None))
|
||||
subscription_is_active = False
|
||||
|
||||
if getattr(user, "subscription", None):
|
||||
subscription_is_active = user.subscription.is_active
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
getattr(user, "subscription", None)
|
||||
)
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
@@ -1019,11 +1091,9 @@ async def complete_registration(
|
||||
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
|
||||
has_active_subscription = existing_user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
if existing_user.subscription:
|
||||
subscription_is_active = existing_user.subscription.is_active
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
existing_user.subscription
|
||||
)
|
||||
|
||||
menu_text = await get_main_menu_text(existing_user, texts, db)
|
||||
|
||||
@@ -1149,6 +1219,29 @@ async def complete_registration(
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
|
||||
|
||||
# Активируем промокод если был сохранен в state
|
||||
promocode_to_activate = data.get('promocode')
|
||||
if promocode_to_activate:
|
||||
try:
|
||||
from app.handlers.promocode import activate_promocode_for_registration
|
||||
|
||||
promocode_result = await activate_promocode_for_registration(
|
||||
db, user.id, promocode_to_activate, message.bot
|
||||
)
|
||||
|
||||
if promocode_result["success"]:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PROMOCODE_ACTIVATED_AT_REGISTRATION",
|
||||
"✅ Промокод активирован!\n\n{description}"
|
||||
).format(description=promocode_result["description"])
|
||||
)
|
||||
logger.info(f"✅ Промокод {promocode_to_activate} активирован для пользователя {user.id}")
|
||||
else:
|
||||
logger.warning(f"⚠️ Не удалось активировать промокод {promocode_to_activate}: {promocode_result.get('error')}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при активации промокода {promocode_to_activate}: {e}")
|
||||
|
||||
campaign_message = await _apply_campaign_bonus_if_needed(db, user, data, texts)
|
||||
|
||||
try:
|
||||
@@ -1201,11 +1294,9 @@ async def complete_registration(
|
||||
else:
|
||||
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
|
||||
|
||||
has_active_subscription = bool(getattr(user, "subscription", None))
|
||||
subscription_is_active = False
|
||||
|
||||
if getattr(user, "subscription", None):
|
||||
subscription_is_active = user.subscription.is_active
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
getattr(user, "subscription", None)
|
||||
)
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
@@ -1258,30 +1349,43 @@ def _get_subscription_status(user, texts):
|
||||
return texts.t("SUBSCRIPTION_NONE", "Нет активной подписки")
|
||||
|
||||
subscription = user.subscription
|
||||
actual_status = getattr(subscription, "actual_status", None)
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
end_date = getattr(subscription, "end_date", None)
|
||||
end_date_display = format_local_datetime(end_date, "%d.%m.%Y") if end_date else None
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
if end_date and end_date <= current_time:
|
||||
return texts.t(
|
||||
"SUB_STATUS_EXPIRED",
|
||||
"🔴 Истекла\n📅 {end_date}",
|
||||
).format(end_date=end_date.strftime('%d.%m.%Y'))
|
||||
if actual_status == "disabled":
|
||||
return texts.t("SUB_STATUS_DISABLED", "⚫ Отключена")
|
||||
|
||||
if actual_status == "pending":
|
||||
return texts.t("SUB_STATUS_PENDING", "⏳ Ожидает активации")
|
||||
|
||||
if actual_status == "expired" or (end_date and end_date <= current_time):
|
||||
if end_date_display:
|
||||
return texts.t(
|
||||
"SUB_STATUS_EXPIRED",
|
||||
"🔴 Истекла\n📅 {end_date}",
|
||||
).format(end_date=end_date_display)
|
||||
return texts.t("SUBSCRIPTION_STATUS_EXPIRED", "🔴 Истекла")
|
||||
|
||||
if not end_date:
|
||||
return texts.t("SUBSCRIPTION_ACTIVE", "✅ Активна")
|
||||
|
||||
days_left = (end_date - current_time).days
|
||||
is_trial = getattr(subscription, "is_trial", False)
|
||||
is_trial = actual_status == "trial" or getattr(subscription, "is_trial", False)
|
||||
|
||||
if actual_status not in {"active", "trial", None} and not is_trial:
|
||||
return texts.t("SUBSCRIPTION_STATUS_UNKNOWN", "❓ Статус неизвестен")
|
||||
|
||||
if is_trial:
|
||||
if days_left > 1:
|
||||
if days_left > 1 and end_date_display:
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_ACTIVE",
|
||||
"🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
|
||||
).format(end_date=end_date.strftime('%d.%m.%Y'), days=days_left)
|
||||
).format(end_date=end_date_display, days=days_left)
|
||||
if days_left == 1:
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_TOMORROW",
|
||||
@@ -1292,11 +1396,11 @@ def _get_subscription_status(user, texts):
|
||||
"🎁 Тестовая подписка\n⚠️ истекает сегодня!",
|
||||
)
|
||||
|
||||
if days_left > 7:
|
||||
if days_left > 7 and end_date_display:
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_LONG",
|
||||
"💎 Активна\n📅 до {end_date} ({days} дн.)",
|
||||
).format(end_date=end_date.strftime('%d.%m.%Y'), days=days_left)
|
||||
).format(end_date=end_date_display, days=days_left)
|
||||
if days_left > 1:
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_FEW_DAYS",
|
||||
@@ -1521,8 +1625,9 @@ async def required_sub_channel_check(
|
||||
logger.warning(f"Не удалось удалить сообщение: {e}")
|
||||
|
||||
if user and user.status != UserStatus.DELETED.value:
|
||||
has_active_subscription = bool(user.subscription)
|
||||
subscription_is_active = bool(user.subscription and user.subscription.is_active)
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
user.subscription
|
||||
)
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ from app.services.subscription_checkout_service import (
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
from app.services.promo_offer_service import promo_offer_service
|
||||
from app.states import SubscriptionStates
|
||||
@@ -186,7 +187,7 @@ async def handle_subscription_config_back(
|
||||
if current_state == SubscriptionStates.selecting_traffic.state:
|
||||
await callback.message.edit_text(
|
||||
await _build_subscription_period_prompt(db_user, texts, db),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language, db_user),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_period)
|
||||
@@ -201,35 +202,26 @@ async def handle_subscription_config_back(
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
await _build_subscription_period_prompt(db_user, texts, db),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language, db_user),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_period)
|
||||
|
||||
elif current_state == SubscriptionStates.selecting_devices.state:
|
||||
if await _should_show_countries_management(db_user):
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
await _show_previous_configuration_step(callback, state, db_user, texts, db)
|
||||
|
||||
elif current_state == SubscriptionStates.confirming_purchase.state:
|
||||
if settings.is_devices_selection_enabled():
|
||||
data = await state.get_data()
|
||||
selected_countries = data.get('countries', [])
|
||||
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_COUNTRIES,
|
||||
reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language)
|
||||
texts.SELECT_DEVICES,
|
||||
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_countries)
|
||||
elif settings.is_traffic_selectable():
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_TRAFFIC,
|
||||
reply_markup=get_traffic_packages_keyboard(db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_traffic)
|
||||
await state.set_state(SubscriptionStates.selecting_devices)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
await _build_subscription_period_prompt(db_user, texts, db),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_period)
|
||||
await _show_previous_configuration_step(callback, state, db_user, texts, db)
|
||||
|
||||
else:
|
||||
from app.handlers.menu import show_main_menu
|
||||
@@ -249,7 +241,44 @@ async def handle_subscription_cancel(
|
||||
await state.clear()
|
||||
await clear_subscription_checkout_draft(db_user.id)
|
||||
|
||||
# Удаляем сохраненную корзину, чтобы не показывать кнопку возврата
|
||||
await user_cart_service.delete_user_cart(db_user.id)
|
||||
|
||||
from app.handlers.menu import show_main_menu
|
||||
await show_main_menu(callback, db_user, db)
|
||||
|
||||
await callback.answer("❌ Покупка отменена")
|
||||
async def _show_previous_configuration_step(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
texts,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if await _should_show_countries_management(db_user):
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
data = await state.get_data()
|
||||
selected_countries = data.get('countries', [])
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_COUNTRIES,
|
||||
reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_countries)
|
||||
return
|
||||
|
||||
if settings.is_traffic_selectable():
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_TRAFFIC,
|
||||
reply_markup=get_traffic_packages_keyboard(db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_traffic)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
await _build_subscription_period_prompt(db_user, texts, db),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language, db_user),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_period)
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ def _get_addon_discount_percent_for_user(
|
||||
if user is None:
|
||||
return 0
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
promo_group = user.get_primary_promo_group()
|
||||
if promo_group is None:
|
||||
return 0
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ from app.utils.promo_offer import (
|
||||
)
|
||||
|
||||
from .common import _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, logger
|
||||
from .summary import present_subscription_summary
|
||||
|
||||
async def handle_add_countries(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -588,6 +589,11 @@ async def countries_continue(
|
||||
await callback.answer("⚠️ Выберите хотя бы одну страну!", show_alert=True)
|
||||
return
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
if await present_subscription_summary(callback, state, db_user, texts):
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
|
||||
await callback.message.edit_text(
|
||||
|
||||
@@ -183,6 +183,13 @@ async def handle_change_devices(
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if not subscription or subscription.is_trial:
|
||||
await callback.answer(
|
||||
texts.t("PAID_FEATURE_ONLY", "⚠️ Эта функция доступна только для платных подписок"),
|
||||
@@ -233,6 +240,13 @@ async def confirm_change_devices(
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
current_devices = subscription.device_limit
|
||||
|
||||
if new_devices_count == current_devices:
|
||||
@@ -379,6 +393,13 @@ async def execute_change_devices(
|
||||
subscription = db_user.subscription
|
||||
current_devices = subscription.device_limit
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
if price > 0:
|
||||
success = await subtract_user_balance(
|
||||
@@ -863,6 +884,13 @@ async def confirm_add_devices(
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
resume_callback = None
|
||||
|
||||
new_total_devices = subscription.device_limit + devices_count
|
||||
|
||||
@@ -77,6 +77,7 @@ from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
get_user_active_promo_discount_percent,
|
||||
)
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
from .common import _apply_discount_to_monthly_component, _apply_promo_offer_discount, logger
|
||||
from .countries import _get_available_countries, _get_countries_info, get_countries_price_by_uuids_fallback
|
||||
@@ -158,7 +159,18 @@ async def _prepare_subscription_summary(
|
||||
total_servers_discount += total_discount_for_server
|
||||
selected_server_prices.append(total_price_for_server)
|
||||
|
||||
devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_selection_enabled = settings.is_devices_selection_enabled()
|
||||
forced_disabled_limit: Optional[int] = None
|
||||
if devices_selection_enabled:
|
||||
devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
else:
|
||||
forced_disabled_limit = settings.get_disabled_mode_device_limit()
|
||||
if forced_disabled_limit is None:
|
||||
devices_selected = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
devices_selected = forced_disabled_limit
|
||||
|
||||
summary_data['devices'] = devices_selected
|
||||
additional_devices = max(0, devices_selected - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = db_user.get_promo_discount(
|
||||
@@ -241,17 +253,20 @@ async def _prepare_subscription_summary(
|
||||
else:
|
||||
traffic_display = f"{summary_data.get('traffic_gb', 0)} ГБ"
|
||||
|
||||
if base_discount_total > 0:
|
||||
details_lines = []
|
||||
|
||||
# Добавляем строку базового периода только если цена не равна 0
|
||||
if base_discount_total > 0 and base_price > 0:
|
||||
base_line = (
|
||||
f"- Базовый период: <s>{texts.format_price(base_price_original)}</s> "
|
||||
f"{texts.format_price(base_price)}"
|
||||
f" (скидка {period_discount_percent}%:"
|
||||
f" -{texts.format_price(base_discount_total)})"
|
||||
)
|
||||
else:
|
||||
details_lines.append(base_line)
|
||||
elif base_price_original > 0:
|
||||
base_line = f"- Базовый период: {texts.format_price(base_price_original)}"
|
||||
|
||||
details_lines = [base_line]
|
||||
details_lines.append(base_line)
|
||||
|
||||
if total_traffic_price > 0:
|
||||
traffic_line = (
|
||||
@@ -275,7 +290,7 @@ async def _prepare_subscription_summary(
|
||||
f" -{texts.format_price(total_servers_discount)})"
|
||||
)
|
||||
details_lines.append(servers_line)
|
||||
if total_devices_price > 0:
|
||||
if devices_selection_enabled and total_devices_price > 0:
|
||||
devices_line = (
|
||||
f"- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period}"
|
||||
f" = {texts.format_price(total_devices_price)}"
|
||||
@@ -300,17 +315,28 @@ async def _prepare_subscription_summary(
|
||||
|
||||
details_text = "\n".join(details_lines)
|
||||
|
||||
summary_text = (
|
||||
"📋 <b>Сводка заказа</b>\n\n"
|
||||
f"📅 <b>Период:</b> {period_display}\n"
|
||||
f"📊 <b>Трафик:</b> {traffic_display}\n"
|
||||
f"🌍 <b>Страны:</b> {', '.join(selected_countries_names)}\n"
|
||||
f"📱 <b>Устройства:</b> {devices_selected}\n\n"
|
||||
"💰 <b>Детализация стоимости:</b>\n"
|
||||
f"{details_text}\n\n"
|
||||
f"💎 <b>Общая стоимость:</b> {texts.format_price(total_price)}\n\n"
|
||||
"Подтверждаете покупку?"
|
||||
)
|
||||
summary_lines = [
|
||||
"📋 <b>Сводка заказа</b>",
|
||||
"",
|
||||
f"📅 <b>Период:</b> {period_display}",
|
||||
f"📊 <b>Трафик:</b> {traffic_display}",
|
||||
f"🌍 <b>Страны:</b> {', '.join(selected_countries_names)}",
|
||||
]
|
||||
|
||||
if devices_selection_enabled:
|
||||
summary_lines.append(f"📱 <b>Устройства:</b> {devices_selected}")
|
||||
|
||||
summary_lines.extend([
|
||||
"",
|
||||
"💰 <b>Детализация стоимости:</b>",
|
||||
details_text,
|
||||
"",
|
||||
f"💎 <b>Общая стоимость:</b> {texts.format_price(total_price)}",
|
||||
"",
|
||||
"Подтверждаете покупку?",
|
||||
])
|
||||
|
||||
summary_text = "\n".join(summary_lines)
|
||||
|
||||
return summary_text, summary_data
|
||||
|
||||
@@ -327,7 +353,7 @@ async def _build_subscription_period_prompt(
|
||||
if promo_offer_hint:
|
||||
lines.extend(["", promo_offer_hint])
|
||||
|
||||
promo_text = _build_promo_group_discount_text(
|
||||
promo_text = await _build_promo_group_discount_text(
|
||||
db_user,
|
||||
settings.get_available_subscription_periods(),
|
||||
texts=texts,
|
||||
@@ -382,7 +408,18 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
|
||||
)
|
||||
|
||||
traffic_cost = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
devices_cost = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
device_limit = subscription.device_limit
|
||||
if device_limit is None:
|
||||
if settings.is_devices_selection_enabled():
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
forced_limit = settings.get_disabled_mode_device_limit()
|
||||
if forced_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
device_limit = forced_limit
|
||||
|
||||
devices_cost = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
|
||||
total_cost = base_cost + servers_cost + traffic_cost + devices_cost
|
||||
|
||||
@@ -410,7 +447,12 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
|
||||
return 0
|
||||
|
||||
async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSession):
|
||||
devices_used = await get_current_devices_count(db_user)
|
||||
devices_selection_enabled = settings.is_devices_selection_enabled()
|
||||
|
||||
if devices_selection_enabled:
|
||||
devices_used = await get_current_devices_count(db_user)
|
||||
else:
|
||||
devices_used = 0
|
||||
countries_info = await _get_countries_info(subscription.connected_squads)
|
||||
countries_text = ", ".join([c['name'] for c in countries_info]) if countries_info else "Нет"
|
||||
|
||||
@@ -439,10 +481,21 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
|
||||
|
||||
subscription_cost = await get_subscription_cost(subscription, db)
|
||||
|
||||
info_text = texts.SUBSCRIPTION_INFO.format(
|
||||
info_template = texts.SUBSCRIPTION_INFO
|
||||
|
||||
if not devices_selection_enabled:
|
||||
info_template = info_template.replace(
|
||||
"\n📱 <b>Устройства:</b> {devices_used} / {devices_limit}",
|
||||
"",
|
||||
).replace(
|
||||
"\n📱 <b>Devices:</b> {devices_used} / {devices_limit}",
|
||||
"",
|
||||
)
|
||||
|
||||
info_text = info_template.format(
|
||||
status=status_text,
|
||||
type=type_text,
|
||||
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
|
||||
end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"),
|
||||
days_left=max(0, subscription.days_left),
|
||||
traffic_used=texts.format_traffic(subscription.traffic_used_gb),
|
||||
traffic_limit=traffic_text,
|
||||
|
||||
@@ -88,12 +88,12 @@ async def _get_promo_offer_hint(
|
||||
) -> Optional[str]:
|
||||
return await build_promo_offer_hint(db, db_user, texts, percent)
|
||||
|
||||
def _build_promo_group_discount_text(
|
||||
async def _build_promo_group_discount_text(
|
||||
db_user: User,
|
||||
periods: Optional[List[int]] = None,
|
||||
texts=None,
|
||||
) -> str:
|
||||
promo_group = getattr(db_user, "promo_group", None)
|
||||
promo_group = db_user.get_primary_promo_group()
|
||||
|
||||
if not promo_group:
|
||||
return ""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
import logging
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.subscription_checkout_service import save_subscription_checkout_draft
|
||||
from app.states import SubscriptionStates
|
||||
from app.keyboards.inline import get_subscription_confirm_keyboard
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - only for type checking
|
||||
from .pricing import _prepare_subscription_summary
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def present_subscription_summary(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user,
|
||||
texts: Optional = None,
|
||||
) -> bool:
|
||||
"""Render the subscription purchase summary and switch to the confirmation state.
|
||||
|
||||
Returns ``True`` when the summary is shown successfully and ``False`` if
|
||||
calculation failed (an error is shown to the user in this case).
|
||||
"""
|
||||
|
||||
if texts is None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
data = await state.get_data()
|
||||
|
||||
from .pricing import _prepare_subscription_summary
|
||||
|
||||
try:
|
||||
summary_text, prepared_data = await _prepare_subscription_summary(db_user, data, texts)
|
||||
except ValueError as exc:
|
||||
logger.error(
|
||||
"Ошибка в расчете цены подписки для пользователя %s: %s",
|
||||
db_user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True)
|
||||
return False
|
||||
|
||||
await state.set_data(prepared_data)
|
||||
await save_subscription_checkout_draft(db_user.id, prepared_data)
|
||||
|
||||
await callback.message.edit_text(
|
||||
summary_text,
|
||||
reply_markup=get_subscription_confirm_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(SubscriptionStates.confirming_purchase)
|
||||
return True
|
||||
@@ -80,6 +80,7 @@ from app.utils.promo_offer import (
|
||||
|
||||
from .common import _apply_addon_discount, _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, get_confirm_switch_traffic_keyboard, get_traffic_switch_keyboard, logger
|
||||
from .countries import _get_available_countries, _should_show_countries_management
|
||||
from .summary import present_subscription_summary
|
||||
|
||||
async def handle_add_traffic(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -352,12 +353,15 @@ async def select_traffic(
|
||||
reply_markup=get_countries_keyboard(countries, [], db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_countries)
|
||||
else:
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
available_countries = [c for c in countries if c.get('is_available', True)]
|
||||
data['countries'] = [available_countries[0]['uuid']] if available_countries else []
|
||||
await state.set_data(data)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
available_countries = [c for c in countries if c.get('is_available', True)]
|
||||
data['countries'] = [available_countries[0]['uuid']] if available_countries else []
|
||||
await state.set_data(data)
|
||||
|
||||
if settings.is_devices_selection_enabled():
|
||||
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -365,8 +369,11 @@ async def select_traffic(
|
||||
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_devices)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
if await present_subscription_summary(callback, state, db_user, texts):
|
||||
await callback.answer()
|
||||
|
||||
async def add_traffic(
|
||||
callback: types.CallbackQuery,
|
||||
|
||||
+40
-3
@@ -53,6 +53,12 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
callback_data="admin_submenu_system",
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_MAIN_PAYMENTS", "💳 Пополнения"),
|
||||
callback_data="admin_payments",
|
||||
)
|
||||
],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
|
||||
])
|
||||
|
||||
@@ -101,6 +107,12 @@ def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKey
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_COMMUNICATIONS_POLLS", "🗳️ Опросы"),
|
||||
callback_data="admin_polls",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_COMMUNICATIONS_PROMO_OFFERS", "🎯 Промо-предложения"),
|
||||
@@ -748,6 +760,12 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
|
||||
callback_data=f"admin_user_promo_group_{user_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_USER_REFERRALS_BUTTON", "🤝 Рефералы"),
|
||||
callback_data=f"admin_user_referrals_{user_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_USER_STATISTICS", "📊 Статистика"),
|
||||
@@ -762,6 +780,13 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
|
||||
]
|
||||
]
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_USER_SEND_MESSAGE", "✉️ Отправить сообщение"),
|
||||
callback_data=f"admin_user_send_message_{user_id}"
|
||||
)
|
||||
])
|
||||
|
||||
if user_status == "active":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
@@ -802,20 +827,28 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
|
||||
def get_user_promo_group_keyboard(
|
||||
promo_groups: List[Tuple[Any, int]],
|
||||
user_id: int,
|
||||
current_group_id: Optional[int],
|
||||
current_group_ids, # Can be Optional[int] or List[int]
|
||||
language: str = "ru"
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
# Ensure current_group_ids is a list
|
||||
if current_group_ids is None:
|
||||
current_group_ids = []
|
||||
elif isinstance(current_group_ids, int):
|
||||
current_group_ids = [current_group_ids]
|
||||
|
||||
keyboard: List[List[InlineKeyboardButton]] = []
|
||||
|
||||
for group, members_count in promo_groups:
|
||||
prefix = "✅" if current_group_id is not None and group.id == current_group_id else "👥"
|
||||
# Check if user has this group
|
||||
has_group = group.id in current_group_ids
|
||||
prefix = "✅" if has_group else "👥"
|
||||
count_text = f" ({members_count})" if members_count else ""
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{prefix} {group.name}{count_text}",
|
||||
callback_data=f"admin_user_promo_group_set_{user_id}_{group.id}"
|
||||
callback_data=f"admin_user_promo_group_toggle_{user_id}_{group.id}"
|
||||
)
|
||||
])
|
||||
|
||||
@@ -862,6 +895,10 @@ def get_promocode_type_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_PROMOCODE_TYPE_TRIAL", "🎁 Триал"),
|
||||
callback_data="promo_type_trial"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_PROMOCODE_TYPE_PROMO_GROUP", "🏷️ Промогруппа"),
|
||||
callback_data="promo_type_group"
|
||||
)
|
||||
],
|
||||
[
|
||||
|
||||
+174
-103
@@ -9,7 +9,11 @@ from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES
|
||||
from app.localization.loader import DEFAULT_LANGUAGE
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
from app.utils.pricing_utils import format_period_description, apply_percentage_discount
|
||||
from app.utils.pricing_utils import (
|
||||
format_period_description,
|
||||
apply_percentage_discount,
|
||||
)
|
||||
from app.utils.price_display import PriceInfo, format_price_button
|
||||
from app.utils.subscription_utils import (
|
||||
get_display_subscription_link,
|
||||
get_happ_cryptolink_redirect_link,
|
||||
@@ -257,7 +261,8 @@ def get_main_menu_keyboard(
|
||||
"💰 Баланс: {balance}",
|
||||
).format(balance=texts.format_price(balance_kopeks))
|
||||
|
||||
keyboard = []
|
||||
keyboard: list[list[InlineKeyboardButton]] = []
|
||||
paired_buttons: list[InlineKeyboardButton] = []
|
||||
|
||||
if has_active_subscription and subscription_is_active:
|
||||
connect_mode = settings.CONNECT_BUTTON_MODE
|
||||
@@ -312,22 +317,30 @@ def get_main_menu_keyboard(
|
||||
happ_row = get_happ_download_button_row(texts)
|
||||
if happ_row:
|
||||
keyboard.append(happ_row)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance"),
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription")
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance")
|
||||
])
|
||||
)
|
||||
|
||||
keyboard.append([InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance")])
|
||||
|
||||
show_trial = not has_had_paid_subscription and not has_active_subscription
|
||||
|
||||
|
||||
show_buy = not has_active_subscription or not subscription_is_active
|
||||
|
||||
subscription_buttons = []
|
||||
|
||||
current_subscription = subscription
|
||||
has_active_paid_subscription = bool(
|
||||
current_subscription
|
||||
and not getattr(current_subscription, "is_trial", False)
|
||||
and getattr(current_subscription, "is_active", False)
|
||||
)
|
||||
simple_purchase_button = None
|
||||
if settings.SIMPLE_SUBSCRIPTION_ENABLED:
|
||||
simple_purchase_button = InlineKeyboardButton(
|
||||
text=texts.MENU_SIMPLE_SUBSCRIPTION,
|
||||
callback_data="simple_subscription_purchase",
|
||||
)
|
||||
|
||||
subscription_buttons: list[InlineKeyboardButton] = []
|
||||
|
||||
if show_trial:
|
||||
subscription_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_TRIAL, callback_data="menu_trial")
|
||||
@@ -339,36 +352,36 @@ def get_main_menu_keyboard(
|
||||
)
|
||||
|
||||
if subscription_buttons:
|
||||
if len(subscription_buttons) == 2:
|
||||
keyboard.append(subscription_buttons)
|
||||
else:
|
||||
keyboard.append([subscription_buttons[0]])
|
||||
paired_buttons.extend(subscription_buttons)
|
||||
if simple_purchase_button:
|
||||
paired_buttons.append(simple_purchase_button)
|
||||
|
||||
if show_resume_checkout or has_saved_cart:
|
||||
keyboard.append([
|
||||
resume_callback = (
|
||||
"return_to_saved_cart" if has_saved_cart else "subscription_resume_checkout"
|
||||
)
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout",
|
||||
callback_data=resume_callback,
|
||||
)
|
||||
])
|
||||
)
|
||||
|
||||
if custom_buttons:
|
||||
for button in custom_buttons:
|
||||
if isinstance(button, InlineKeyboardButton):
|
||||
keyboard.append([button])
|
||||
paired_buttons.append(button)
|
||||
|
||||
# Добавляем кнопки промокода и рефералов, учитывая настройки
|
||||
additional_buttons = [
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode")
|
||||
]
|
||||
)
|
||||
|
||||
# Добавляем кнопку рефералов только если программа включена
|
||||
if settings.is_referral_program_enabled():
|
||||
additional_buttons.append(
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_REFERRALS, callback_data="menu_referrals")
|
||||
)
|
||||
|
||||
keyboard.append(additional_buttons)
|
||||
|
||||
# Support button is configurable (runtime via service)
|
||||
try:
|
||||
@@ -377,21 +390,26 @@ def get_main_menu_keyboard(
|
||||
except Exception:
|
||||
support_enabled = settings.SUPPORT_MENU_ENABLED
|
||||
if support_enabled:
|
||||
keyboard.append([
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support")
|
||||
])
|
||||
)
|
||||
|
||||
keyboard.append([
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MENU_INFO", "ℹ️ Инфо"),
|
||||
callback_data="menu_info",
|
||||
)
|
||||
])
|
||||
)
|
||||
|
||||
if settings.is_language_selection_enabled():
|
||||
keyboard.append([
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data="menu_language")
|
||||
])
|
||||
)
|
||||
|
||||
for i in range(0, len(paired_buttons), 2):
|
||||
row = paired_buttons[i : i + 2]
|
||||
keyboard.append(row)
|
||||
|
||||
if settings.DEBUG:
|
||||
print(f"DEBUG KEYBOARD: is_admin={is_admin}, добавляем админ кнопку: {is_admin}")
|
||||
|
||||
@@ -657,7 +675,7 @@ def get_insufficient_balance_keyboard(
|
||||
return_row = [
|
||||
InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout",
|
||||
callback_data="return_to_saved_cart",
|
||||
)
|
||||
]
|
||||
insert_index = back_row_index if back_row_index is not None else len(keyboard.inline_keyboard)
|
||||
@@ -789,7 +807,7 @@ def get_payment_methods_keyboard_with_cart(
|
||||
keyboard.inline_keyboard.insert(-1, [ # Вставляем перед кнопкой "назад"
|
||||
InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout"
|
||||
callback_data="return_to_saved_cart"
|
||||
)
|
||||
])
|
||||
|
||||
@@ -846,34 +864,59 @@ def get_trial_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
])
|
||||
|
||||
|
||||
def get_subscription_period_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||
def get_subscription_period_keyboard(
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
user: Optional[User] = None
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Generate subscription period selection keyboard with personalized pricing.
|
||||
|
||||
Args:
|
||||
language: User's language code
|
||||
user: User object for personalized discounts (None = default discounts)
|
||||
|
||||
Returns:
|
||||
InlineKeyboardMarkup with period buttons showing personalized prices
|
||||
"""
|
||||
from app.utils.price_display import calculate_user_price
|
||||
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
|
||||
available_periods = settings.get_available_subscription_periods()
|
||||
|
||||
period_texts = {
|
||||
14: texts.PERIOD_14_DAYS,
|
||||
30: texts.PERIOD_30_DAYS,
|
||||
60: texts.PERIOD_60_DAYS,
|
||||
90: texts.PERIOD_90_DAYS,
|
||||
180: texts.PERIOD_180_DAYS,
|
||||
360: texts.PERIOD_360_DAYS
|
||||
}
|
||||
|
||||
|
||||
for days in available_periods:
|
||||
if days in period_texts:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=period_texts[days],
|
||||
callback_data=f"period_{days}"
|
||||
)
|
||||
])
|
||||
|
||||
# Get base price for this period
|
||||
base_price = PERIOD_PRICES.get(days, 0)
|
||||
|
||||
# Calculate personalized price with user's discounts
|
||||
price_info = calculate_user_price(user, base_price, days, "period")
|
||||
|
||||
# Format period description
|
||||
period_display = format_period_description(days, language)
|
||||
|
||||
# Format button text with discount display
|
||||
button_text = format_price_button(
|
||||
period_label=period_display,
|
||||
price_info=price_info,
|
||||
format_price_func=texts.format_price,
|
||||
emphasize=False,
|
||||
add_exclamation=False
|
||||
)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"period_{days}"
|
||||
)
|
||||
])
|
||||
|
||||
# Кнопка "Простая покупка" была убрана из выбора периода подписки
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
])
|
||||
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
@@ -1089,9 +1132,13 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
])
|
||||
|
||||
if settings.is_mulenpay_enabled():
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_CARD_MULENPAY", "💳 Банковская карта (Mulen Pay)"),
|
||||
text=texts.t(
|
||||
"PAYMENT_CARD_MULENPAY",
|
||||
"💳 Банковская карта ({mulenpay_name})",
|
||||
).format(mulenpay_name=mulenpay_name),
|
||||
callback_data=_build_callback("mulenpay")
|
||||
)
|
||||
])
|
||||
@@ -1120,6 +1167,14 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
)
|
||||
])
|
||||
|
||||
if settings.is_heleket_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_HELEKET", "🪙 Криптовалюта (Heleket)"),
|
||||
callback_data=_build_callback("heleket")
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_VIA_SUPPORT", "🛠️ Через поддержку"),
|
||||
@@ -1356,7 +1411,7 @@ def get_autopay_days_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboar
|
||||
for days in [1, 3, 7, 14]:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{days} дн{_get_days_suffix(days)}",
|
||||
text=f"{days} {_get_days_word(days)}",
|
||||
callback_data=f"autopay_days_{days}"
|
||||
)
|
||||
])
|
||||
@@ -1368,37 +1423,17 @@ def get_autopay_days_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboar
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def _get_days_suffix(days: int) -> str:
|
||||
if days == 1:
|
||||
return "ь"
|
||||
elif 2 <= days <= 4:
|
||||
return "я"
|
||||
else:
|
||||
return "ей"
|
||||
def _get_days_word(days: int) -> str:
|
||||
if days % 10 == 1 and days % 100 != 11:
|
||||
return "день"
|
||||
if 2 <= days % 10 <= 4 and not (12 <= days % 100 <= 14):
|
||||
return "дня"
|
||||
return "дней"
|
||||
|
||||
|
||||
|
||||
def get_extend_subscription_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
periods = [
|
||||
(14, f"📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}"),
|
||||
(30, f"📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}"),
|
||||
(60, f"📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}"),
|
||||
(90, f"📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}")
|
||||
]
|
||||
|
||||
for days, text in periods:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=text, callback_data=f"extend_period_{days}")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
# Deprecated: get_extend_subscription_keyboard() was removed.
|
||||
# Use get_extend_subscription_keyboard_with_prices() instead for personalized pricing.
|
||||
|
||||
|
||||
def get_add_traffic_keyboard(
|
||||
@@ -1935,15 +1970,39 @@ def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) ->
|
||||
|
||||
if isinstance(price_info, dict):
|
||||
final_price = price_info.get("final")
|
||||
original_price = price_info.get("original", 0)
|
||||
if final_price is None:
|
||||
final_price = price_info.get("original", 0)
|
||||
else:
|
||||
final_price = price_info
|
||||
original_price = price_info
|
||||
|
||||
period_display = format_period_description(days, language)
|
||||
|
||||
# Create PriceInfo from already calculated prices
|
||||
# Note: original_price and final_price are calculated in the handler
|
||||
discount_percent = 0
|
||||
if original_price > final_price and original_price > 0:
|
||||
discount_percent = ((original_price - final_price) * 100) // original_price
|
||||
|
||||
price_info_obj = PriceInfo(
|
||||
base_price=original_price,
|
||||
final_price=final_price,
|
||||
discount_percent=discount_percent
|
||||
)
|
||||
|
||||
# Format button using unified system
|
||||
button_text = format_price_button(
|
||||
period_label=period_display,
|
||||
price_info=price_info_obj,
|
||||
format_price_func=texts.format_price,
|
||||
emphasize=False,
|
||||
add_exclamation=False
|
||||
)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"📅 {period_display} - {texts.format_price(final_price)}",
|
||||
text=button_text,
|
||||
callback_data=f"extend_period_{days}"
|
||||
)
|
||||
])
|
||||
@@ -2058,33 +2117,39 @@ def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE,
|
||||
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
|
||||
if show_countries_management:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.t("ADD_COUNTRIES_BUTTON", "🌐 Добавить страны"), callback_data="subscription_add_countries")
|
||||
])
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"), callback_data="subscription_change_devices")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.t("MANAGE_DEVICES_BUTTON", "🔧 Управление устройствами"), callback_data="subscription_manage_devices")
|
||||
]
|
||||
])
|
||||
|
||||
if settings.is_traffic_selectable():
|
||||
keyboard.insert(-2, [
|
||||
InlineKeyboardButton(text=texts.t("SWITCH_TRAFFIC_BUTTON", "🔄 Переключить трафик"), callback_data="subscription_switch_traffic")
|
||||
])
|
||||
keyboard.insert(-2, [
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.t("RESET_TRAFFIC_BUTTON", "🔄 Сбросить трафик"), callback_data="subscription_reset_traffic")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.t("SWITCH_TRAFFIC_BUTTON", "🔄 Переключить трафик"), callback_data="subscription_switch_traffic")
|
||||
])
|
||||
|
||||
if settings.is_devices_selection_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"),
|
||||
callback_data="subscription_change_devices"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MANAGE_DEVICES_BUTTON", "🔧 Управление устройствами"),
|
||||
callback_data="subscription_manage_devices"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||||
])
|
||||
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
@@ -2300,6 +2365,12 @@ def get_admin_tickets_keyboard(
|
||||
keyboard.append(switch_row)
|
||||
|
||||
if open_rows and scope in ("all", "open"):
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_CLOSE_ALL_OPEN_TICKETS", "🔒 Закрыть все открытые"),
|
||||
callback_data="admin_tickets_close_all_open"
|
||||
)
|
||||
])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.t("OPEN_TICKETS_HEADER", "Открытые тикеты"), callback_data="noop")])
|
||||
keyboard.extend(open_rows)
|
||||
if closed_rows and scope in ("all", "closed"):
|
||||
|
||||
+68
-10
@@ -1,8 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
import shutil
|
||||
import tempfile
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
@@ -161,6 +163,41 @@ def _normalize_locale_dict(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return normalized
|
||||
|
||||
|
||||
def _directory_is_writable(directory: Path) -> bool:
|
||||
try:
|
||||
current_user = f"{os.geteuid()}:{os.getegid()}"
|
||||
user_hint = f" (running as UID:GID {current_user})"
|
||||
except Exception: # pragma: no cover - best effort only
|
||||
user_hint = ""
|
||||
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(dir=directory, prefix=".locale_write_test_", delete=True):
|
||||
pass
|
||||
return True
|
||||
except PermissionError as error:
|
||||
_logger.warning(
|
||||
"Locale directory %s is not writable%s. Ensure the mounted directory allows writes for the container user or configure LOCALES_PATH to a writable path. (%s)",
|
||||
directory,
|
||||
user_hint,
|
||||
error,
|
||||
)
|
||||
except OSError as error:
|
||||
_logger.warning(
|
||||
"Unable to prepare locale directory %s for writing%s: %s. Configure LOCALES_PATH to a writable path.",
|
||||
directory,
|
||||
user_hint,
|
||||
error,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
_logger.warning(
|
||||
"Unexpected error while checking locale directory %s%s: %s",
|
||||
directory,
|
||||
user_hint,
|
||||
error,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def ensure_locale_templates() -> None:
|
||||
destination = _resolve_user_locales_dir()
|
||||
try:
|
||||
@@ -169,27 +206,48 @@ def ensure_locale_templates() -> None:
|
||||
_logger.warning("Unable to create locales directory %s: %s", destination, error)
|
||||
return
|
||||
|
||||
if any(destination.glob("*")):
|
||||
return
|
||||
|
||||
if not _DEFAULT_LOCALES_DIR.exists():
|
||||
_logger.debug("Default locales directory %s is missing", _DEFAULT_LOCALES_DIR)
|
||||
return
|
||||
|
||||
for template in _DEFAULT_LOCALES_DIR.iterdir():
|
||||
if not template.is_file():
|
||||
continue
|
||||
target_path = destination / template.name
|
||||
if not _directory_is_writable(destination):
|
||||
return
|
||||
|
||||
destination_has_files = any(destination.glob("*"))
|
||||
|
||||
def _copy_locale(source: Path, target: Path) -> None:
|
||||
try:
|
||||
shutil.copyfile(template, target_path)
|
||||
shutil.copyfile(source, target)
|
||||
except Exception as error:
|
||||
_logger.warning(
|
||||
"Failed to copy default locale %s to %s: %s",
|
||||
template,
|
||||
target_path,
|
||||
source,
|
||||
target,
|
||||
error,
|
||||
)
|
||||
|
||||
if not destination_has_files:
|
||||
for template in _DEFAULT_LOCALES_DIR.iterdir():
|
||||
if not template.is_file():
|
||||
continue
|
||||
_copy_locale(template, destination / template.name)
|
||||
return
|
||||
|
||||
for locale_code in ("ru", "en"):
|
||||
source_path = _DEFAULT_LOCALES_DIR / f"{locale_code}.json"
|
||||
target_path = destination / f"{locale_code}.json"
|
||||
|
||||
if target_path.exists():
|
||||
continue
|
||||
|
||||
if not source_path.exists():
|
||||
_logger.debug(
|
||||
"Default locale template %s is missing at %s", locale_code, source_path
|
||||
)
|
||||
continue
|
||||
|
||||
_copy_locale(source_path, target_path)
|
||||
|
||||
|
||||
def _load_default_locale(language: str) -> Dict[str, Any]:
|
||||
default_path = _DEFAULT_LOCALES_DIR / f"{language}.json"
|
||||
|
||||
+1349
-666
File diff suppressed because it is too large
Load Diff
+1254
-585
File diff suppressed because it is too large
Load Diff
@@ -30,12 +30,6 @@ def _build_dynamic_values(language: str) -> Dict[str, Any]:
|
||||
|
||||
if language_code == "ru":
|
||||
return {
|
||||
"PERIOD_14_DAYS": f"📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}",
|
||||
"PERIOD_30_DAYS": f"📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}",
|
||||
"PERIOD_60_DAYS": f"📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}",
|
||||
"PERIOD_90_DAYS": f"📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}",
|
||||
"PERIOD_180_DAYS": f"📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}",
|
||||
"PERIOD_360_DAYS": f"📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}",
|
||||
"TRAFFIC_5GB": f"📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
||||
"TRAFFIC_10GB": f"📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
|
||||
"TRAFFIC_25GB": f"📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
|
||||
@@ -55,12 +49,6 @@ def _build_dynamic_values(language: str) -> Dict[str, Any]:
|
||||
|
||||
if language_code == "en":
|
||||
return {
|
||||
"PERIOD_14_DAYS": f"📅 14 days - {settings.format_price(settings.PRICE_14_DAYS)}",
|
||||
"PERIOD_30_DAYS": f"📅 30 days - {settings.format_price(settings.PRICE_30_DAYS)}",
|
||||
"PERIOD_60_DAYS": f"📅 60 days - {settings.format_price(settings.PRICE_60_DAYS)}",
|
||||
"PERIOD_90_DAYS": f"📅 90 days - {settings.format_price(settings.PRICE_90_DAYS)}",
|
||||
"PERIOD_180_DAYS": f"📅 180 days - {settings.format_price(settings.PRICE_180_DAYS)}",
|
||||
"PERIOD_360_DAYS": f"📅 360 days - {settings.format_price(settings.PRICE_360_DAYS)}",
|
||||
"TRAFFIC_5GB": f"📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
||||
"TRAFFIC_10GB": f"📊 10 GB - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
|
||||
"TRAFFIC_25GB": f"📊 25 GB - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from typing import Callable, Dict, Any, Awaitable, Optional
|
||||
from aiogram import BaseMiddleware, Bot
|
||||
from aiogram import BaseMiddleware, Bot, types
|
||||
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import TelegramObject, Update, Message, CallbackQuery
|
||||
@@ -8,6 +8,7 @@ from aiogram.enums import ChatMemberStatus
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.database.crud.campaign import get_campaign_by_start_parameter
|
||||
from app.database.crud.subscription import deactivate_subscription
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.database.models import SubscriptionStatus
|
||||
@@ -16,6 +17,7 @@ from app.localization.loader import DEFAULT_LANGUAGE
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.check_reg_process import is_registration_process
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -104,7 +106,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
if telegram_id:
|
||||
await self._deactivate_trial_subscription(telegram_id)
|
||||
|
||||
await self._capture_start_payload(state, event)
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
|
||||
if isinstance(event, CallbackQuery) and event.data == "sub_channel_check":
|
||||
await event.answer("❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.", show_alert=True)
|
||||
@@ -113,12 +115,12 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
return await self._deny_message(event, bot, channel_link)
|
||||
else:
|
||||
logger.warning(f"⚠️ Неожиданный статус пользователя {telegram_id}: {member.status}")
|
||||
await self._capture_start_payload(state, event)
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
return await self._deny_message(event, bot, channel_link)
|
||||
|
||||
except TelegramForbiddenError as e:
|
||||
logger.error(f"❌ Бот заблокирован в канале {channel_id}: {e}")
|
||||
await self._capture_start_payload(state, event)
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
return await self._deny_message(event, bot, channel_link)
|
||||
except TelegramBadRequest as e:
|
||||
if "chat not found" in str(e).lower():
|
||||
@@ -127,13 +129,18 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
logger.error(f"❌ Пользователь {telegram_id} не найден: {e}")
|
||||
else:
|
||||
logger.error(f"❌ Ошибка запроса к каналу {channel_id}: {e}")
|
||||
await self._capture_start_payload(state, event)
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
return await self._deny_message(event, bot, channel_link)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Неожиданная ошибка при проверке подписки: {e}")
|
||||
return await handler(event, data)
|
||||
|
||||
async def _capture_start_payload(self, state: Optional[FSMContext], event: TelegramObject) -> None:
|
||||
async def _capture_start_payload(
|
||||
self,
|
||||
state: Optional[FSMContext],
|
||||
event: TelegramObject,
|
||||
bot: Optional[Bot] = None,
|
||||
) -> None:
|
||||
if not state:
|
||||
return
|
||||
|
||||
@@ -159,12 +166,69 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
payload = parts[1]
|
||||
|
||||
data = await state.get_data() or {}
|
||||
if data.get("pending_start_payload") == payload:
|
||||
if data.get("pending_start_payload") != payload:
|
||||
data["pending_start_payload"] = payload
|
||||
await state.set_data(data)
|
||||
logger.debug("💾 Сохранен start payload %s для последующей обработки", payload)
|
||||
|
||||
if bot and message.from_user:
|
||||
await self._try_send_campaign_visit_notification(
|
||||
bot,
|
||||
message.from_user,
|
||||
state,
|
||||
payload,
|
||||
)
|
||||
|
||||
async def _try_send_campaign_visit_notification(
|
||||
self,
|
||||
bot: Bot,
|
||||
telegram_user: types.User,
|
||||
state: FSMContext,
|
||||
payload: str,
|
||||
) -> None:
|
||||
try:
|
||||
data = await state.get_data() or {}
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"❌ Не удалось получить данные состояния для уведомления по кампании %s: %s",
|
||||
payload,
|
||||
error,
|
||||
)
|
||||
return
|
||||
|
||||
data["pending_start_payload"] = payload
|
||||
await state.set_data(data)
|
||||
logger.debug("💾 Сохранен start payload %s для последующей обработки", payload)
|
||||
if data.get("campaign_notification_sent"):
|
||||
return
|
||||
|
||||
async for db in get_db():
|
||||
try:
|
||||
campaign = await get_campaign_by_start_parameter(
|
||||
db,
|
||||
payload,
|
||||
only_active=True,
|
||||
)
|
||||
if not campaign:
|
||||
break
|
||||
|
||||
user = await get_user_by_telegram_id(db, telegram_user.id)
|
||||
|
||||
notification_service = AdminNotificationService(bot)
|
||||
sent = await notification_service.send_campaign_link_visit_notification(
|
||||
db,
|
||||
telegram_user,
|
||||
campaign,
|
||||
user,
|
||||
)
|
||||
if sent:
|
||||
await state.update_data(campaign_notification_sent=True)
|
||||
break
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"❌ Ошибка отправки уведомления о переходе по кампании %s: %s",
|
||||
payload,
|
||||
error,
|
||||
)
|
||||
finally:
|
||||
break
|
||||
|
||||
async def _deactivate_trial_subscription(self, telegram_id: int) -> None:
|
||||
async for db in get_db():
|
||||
|
||||
@@ -19,6 +19,7 @@ from app.database.models import (
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -77,6 +78,20 @@ class AdminNotificationService:
|
||||
)
|
||||
return None
|
||||
|
||||
def _get_user_display(self, user: User) -> str:
|
||||
first_name = getattr(user, "first_name", "") or ""
|
||||
if first_name:
|
||||
return first_name
|
||||
|
||||
username = getattr(user, "username", "") or ""
|
||||
if username:
|
||||
return username
|
||||
|
||||
telegram_id = getattr(user, "telegram_id", None)
|
||||
if telegram_id is None:
|
||||
return "IDUnknown"
|
||||
return f"ID{telegram_id}"
|
||||
|
||||
def _format_promo_group_discounts(self, promo_group: PromoGroup) -> List[str]:
|
||||
discount_lines: List[str] = []
|
||||
|
||||
@@ -185,26 +200,35 @@ class AdminNotificationService:
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
trial_device_limit = subscription.device_limit
|
||||
if trial_device_limit is None:
|
||||
fallback_forced_limit = settings.get_disabled_mode_device_limit()
|
||||
if fallback_forced_limit is not None:
|
||||
trial_device_limit = fallback_forced_limit
|
||||
else:
|
||||
trial_device_limit = settings.TRIAL_DEVICE_LIMIT
|
||||
|
||||
message = f"""🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
|
||||
👥 <b>Статус:</b> {user_status}
|
||||
|
||||
{promo_block}
|
||||
|
||||
⏰ <b>Параметры триала:</b>
|
||||
📅 Период: {settings.TRIAL_DURATION_DAYS} дней
|
||||
📊 Трафик: {settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ
|
||||
📱 Устройства: {settings.TRIAL_DEVICE_LIMIT}
|
||||
📊 Трафик: {self._format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB)}
|
||||
📱 Устройства: {trial_device_limit}
|
||||
🌐 Сервер: {subscription.connected_squads[0] if subscription.connected_squads else 'По умолчанию'}
|
||||
|
||||
📆 <b>Действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
📆 <b>Действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}
|
||||
🔗 <b>Реферер:</b> {referrer_info}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
@@ -217,42 +241,47 @@ class AdminNotificationService:
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription: Subscription,
|
||||
transaction: Transaction,
|
||||
transaction: Optional[Transaction],
|
||||
period_days: int,
|
||||
was_trial_conversion: bool = False
|
||||
was_trial_conversion: bool = False,
|
||||
amount_kopeks: Optional[int] = None,
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
event_type = "🔄 КОНВЕРСИЯ ИЗ ТРИАЛА" if was_trial_conversion else "💎 ПОКУПКА ПОДПИСКИ"
|
||||
|
||||
|
||||
if was_trial_conversion:
|
||||
user_status = "🎯 Конверсия из триала"
|
||||
elif user.has_had_paid_subscription:
|
||||
user_status = "🔄 Продление/Обновление"
|
||||
else:
|
||||
user_status = "🆕 Первая покупка"
|
||||
|
||||
|
||||
servers_info = await self._get_servers_info(subscription.connected_squads)
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method) if transaction else "Баланс"
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
total_amount = amount_kopeks if amount_kopeks is not None else (transaction.amount_kopeks if transaction else 0)
|
||||
transaction_id = transaction.id if transaction else "—"
|
||||
|
||||
message = f"""💎 <b>{event_type}</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
|
||||
👥 <b>Статус:</b> {user_status}
|
||||
|
||||
{promo_block}
|
||||
|
||||
💰 <b>Платеж:</b>
|
||||
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
|
||||
💵 Сумма: {settings.format_price(total_amount)}
|
||||
💳 Способ: {payment_method}
|
||||
🆔 ID транзакции: {transaction.id}
|
||||
🆔 ID транзакции: {transaction_id}
|
||||
|
||||
📱 <b>Параметры подписки:</b>
|
||||
📅 Период: {period_days} дней
|
||||
@@ -260,11 +289,11 @@ class AdminNotificationService:
|
||||
📱 Устройства: {subscription.device_limit}
|
||||
🌐 Серверы: {servers_info}
|
||||
|
||||
📆 <b>Действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
📆 <b>Действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}
|
||||
💰 <b>Баланс после покупки:</b> {settings.format_price(user.balance_kopeks)}
|
||||
🔗 <b>Реферер:</b> {referrer_info}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
@@ -311,7 +340,7 @@ class AdminNotificationService:
|
||||
|
||||
ℹ️ Для обновления перезапустите контейнер с новым тегом или обновите код из репозитория.
|
||||
|
||||
⚙️ <i>Автоматическая проверка обновлений • {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
⚙️ <i>Автоматическая проверка обновлений • {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
@@ -336,7 +365,7 @@ class AdminNotificationService:
|
||||
🔄 Следующая попытка через час.
|
||||
⚙️ Проверьте доступность GitHub API и настройки сети.
|
||||
|
||||
⚙️ <i>Система автоматических обновлений • {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
⚙️ <i>Система автоматических обновлений • {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
@@ -359,13 +388,14 @@ class AdminNotificationService:
|
||||
balance_change = user.balance_kopeks - old_balance
|
||||
subscription_status = self._get_subscription_status(subscription)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
timestamp = datetime.now().strftime('%d.%m.%Y %H:%M:%S')
|
||||
timestamp = format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
return f"""💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
|
||||
💳 <b>Статус:</b> {topup_status}
|
||||
|
||||
{promo_block}
|
||||
@@ -536,15 +566,16 @@ class AdminNotificationService:
|
||||
servers_info = await self._get_servers_info(subscription.connected_squads)
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
current_end_date = new_end_date or subscription.end_date
|
||||
current_balance = balance_after if balance_after is not None else user.balance_kopeks
|
||||
|
||||
message = f"""⏰ <b>ПРОДЛЕНИЕ ПОДПИСКИ</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
|
||||
|
||||
{promo_block}
|
||||
|
||||
@@ -555,8 +586,8 @@ class AdminNotificationService:
|
||||
|
||||
📅 <b>Продление:</b>
|
||||
➕ Добавлено дней: {extended_days}
|
||||
📆 Было до: {old_end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
📆 Стало до: {current_end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
📆 Было до: {format_local_datetime(old_end_date, '%d.%m.%Y %H:%M')}
|
||||
📆 Стало до: {format_local_datetime(current_end_date, '%d.%m.%Y %H:%M')}
|
||||
|
||||
📱 <b>Текущие параметры:</b>
|
||||
📊 Трафик: {self._format_traffic(subscription.traffic_limit_gb)}
|
||||
@@ -565,7 +596,7 @@ class AdminNotificationService:
|
||||
|
||||
💰 <b>Баланс после операции:</b> {settings.format_price(current_balance)}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
@@ -588,13 +619,14 @@ class AdminNotificationService:
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
type_display = self._get_promocode_type_display(promocode_data.get("type"))
|
||||
usage_info = f"{promocode_data.get('current_uses', 0)}/{promocode_data.get('max_uses', 0)}"
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
message_lines = [
|
||||
"🎫 <b>АКТИВАЦИЯ ПРОМОКОДА</b>",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {user.full_name}",
|
||||
f"👤 <b>Пользователь:</b> {user_display}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
|
||||
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
|
||||
f"📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}",
|
||||
"",
|
||||
promo_block,
|
||||
"",
|
||||
@@ -617,7 +649,7 @@ class AdminNotificationService:
|
||||
valid_until = promocode_data.get("valid_until")
|
||||
if valid_until:
|
||||
message_lines.append(
|
||||
f"⏳ Действует до: {valid_until.strftime('%d.%m.%Y %H:%M')}"
|
||||
f"⏳ Действует до: {format_local_datetime(valid_until, '%d.%m.%Y %H:%M')}"
|
||||
if isinstance(valid_until, datetime)
|
||||
else f"⏳ Действует до: {valid_until}"
|
||||
)
|
||||
@@ -628,7 +660,7 @@ class AdminNotificationService:
|
||||
"📝 <b>Эффект:</b>",
|
||||
effect_description.strip() or "✅ Промокод активирован",
|
||||
"",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -682,7 +714,7 @@ class AdminNotificationService:
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -715,13 +747,14 @@ class AdminNotificationService:
|
||||
)
|
||||
elif automatic:
|
||||
initiator_line = "🤖 Автоматическое назначение"
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
message_lines = [
|
||||
f"{title}",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {user.full_name}",
|
||||
f"👤 <b>Пользователь:</b> {user_display}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
|
||||
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
|
||||
f"📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}",
|
||||
"",
|
||||
self._format_promo_group_block(new_group, title="Новая промогруппа", icon="🏆"),
|
||||
]
|
||||
@@ -746,7 +779,7 @@ class AdminNotificationService:
|
||||
[
|
||||
"",
|
||||
f"💰 Баланс пользователя: {settings.format_price(user.balance_kopeks)}",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -798,11 +831,12 @@ class AdminNotificationService:
|
||||
return self.enabled and bool(self.chat_id)
|
||||
|
||||
def _get_payment_method_display(self, payment_method: Optional[str]) -> str:
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
method_names = {
|
||||
'telegram_stars': '⭐ Telegram Stars',
|
||||
'yookassa': '💳 YooKassa (карта)',
|
||||
'tribute': '💎 Tribute (карта)',
|
||||
'mulenpay': '💳 Mulen Pay (карта)',
|
||||
'mulenpay': f'💳 {mulenpay_name} (карта)',
|
||||
'pal24': '🏦 PayPalych (СБП)',
|
||||
'manual': '🛠️ Вручную (админ)',
|
||||
'balance': '💰 С баланса'
|
||||
@@ -823,9 +857,9 @@ class AdminNotificationService:
|
||||
return "❌ Нет подписки"
|
||||
|
||||
if subscription.is_trial:
|
||||
return f"🎯 Триал (до {subscription.end_date.strftime('%d.%m')})"
|
||||
return f"🎯 Триал (до {format_local_datetime(subscription.end_date, '%d.%m')})"
|
||||
elif subscription.is_active:
|
||||
return f"✅ Активна (до {subscription.end_date.strftime('%d.%m')})"
|
||||
return f"✅ Активна (до {format_local_datetime(subscription.end_date, '%d.%m')})"
|
||||
else:
|
||||
return "❌ Неактивна"
|
||||
|
||||
@@ -896,7 +930,9 @@ class AdminNotificationService:
|
||||
if isinstance(enabled_at, str):
|
||||
from datetime import datetime
|
||||
enabled_at = datetime.fromisoformat(enabled_at)
|
||||
message_parts.append(f"🕐 <b>Время включения:</b> {enabled_at.strftime('%d.%m.%Y %H:%M:%S')}")
|
||||
message_parts.append(
|
||||
f"🕐 <b>Время включения:</b> {format_local_datetime(enabled_at, '%d.%m.%Y %H:%M:%S')}"
|
||||
)
|
||||
|
||||
message_parts.append(f"🤖 <b>Автоматически:</b> {'Да' if details.get('auto_enabled', False) else 'Нет'}")
|
||||
message_parts.append("")
|
||||
@@ -908,7 +944,9 @@ class AdminNotificationService:
|
||||
if isinstance(disabled_at, str):
|
||||
from datetime import datetime
|
||||
disabled_at = datetime.fromisoformat(disabled_at)
|
||||
message_parts.append(f"🕐 <b>Время отключения:</b> {disabled_at.strftime('%d.%m.%Y %H:%M:%S')}")
|
||||
message_parts.append(
|
||||
f"🕐 <b>Время отключения:</b> {format_local_datetime(disabled_at, '%d.%m.%Y %H:%M:%S')}"
|
||||
)
|
||||
|
||||
if details.get("duration"):
|
||||
duration = details["duration"]
|
||||
@@ -967,9 +1005,10 @@ class AdminNotificationService:
|
||||
else:
|
||||
message_parts.append("Автоматический мониторинг API остановлен.")
|
||||
|
||||
from datetime import datetime
|
||||
message_parts.append("")
|
||||
message_parts.append(f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>")
|
||||
message_parts.append(
|
||||
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"
|
||||
)
|
||||
|
||||
message = "\n".join(message_parts)
|
||||
|
||||
@@ -1015,7 +1054,9 @@ class AdminNotificationService:
|
||||
if isinstance(last_check, str):
|
||||
from datetime import datetime
|
||||
last_check = datetime.fromisoformat(last_check)
|
||||
message_parts.append(f"🕐 <b>Последняя проверка:</b> {last_check.strftime('%H:%M:%S')}")
|
||||
message_parts.append(
|
||||
f"🕐 <b>Последняя проверка:</b> {format_local_datetime(last_check, '%H:%M:%S')}"
|
||||
)
|
||||
|
||||
if status == "online":
|
||||
if details.get("uptime"):
|
||||
@@ -1061,9 +1102,10 @@ class AdminNotificationService:
|
||||
message_parts.append("")
|
||||
message_parts.append("Панель временно недоступна для обслуживания.")
|
||||
|
||||
from datetime import datetime
|
||||
message_parts.append("")
|
||||
message_parts.append(f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>")
|
||||
message_parts.append(
|
||||
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"
|
||||
)
|
||||
|
||||
message = "\n".join(message_parts)
|
||||
|
||||
@@ -1090,6 +1132,7 @@ class AdminNotificationService:
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
update_types = {
|
||||
"traffic": ("📊 ИЗМЕНЕНИЕ ТРАФИКА", "трафик"),
|
||||
@@ -1102,9 +1145,9 @@ class AdminNotificationService:
|
||||
message_lines = [
|
||||
f"{title}",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {user.full_name}",
|
||||
f"👤 <b>Пользователь:</b> {user_display}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
|
||||
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
|
||||
f"📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}",
|
||||
"",
|
||||
promo_block,
|
||||
"",
|
||||
@@ -1137,11 +1180,11 @@ class AdminNotificationService:
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
f"📅 <b>Подписка действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}",
|
||||
f"📅 <b>Подписка действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}",
|
||||
f"💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}",
|
||||
f"🔗 <b>Рефер:</b> {referrer_info}",
|
||||
"",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -137,30 +137,50 @@ class BroadcastService:
|
||||
|
||||
keyboard = self._build_keyboard(config.selected_buttons)
|
||||
|
||||
for index, user in enumerate(recipients, start=1):
|
||||
# Ограничение на количество одновременных отправок
|
||||
semaphore = asyncio.Semaphore(20)
|
||||
|
||||
async def send_single_message(user):
|
||||
"""Отправляет одно сообщение с семафором ограничения"""
|
||||
async with semaphore:
|
||||
if cancel_event.is_set():
|
||||
return False
|
||||
|
||||
telegram_id = getattr(user, "telegram_id", None)
|
||||
if telegram_id is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
await self._deliver_message(telegram_id, config, keyboard)
|
||||
return True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error(
|
||||
"Ошибка отправки рассылки %s пользователю %s: %s",
|
||||
broadcast_id,
|
||||
telegram_id,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
# Отправляем сообщения пакетами для эффективности
|
||||
batch_size = 100
|
||||
for i in range(0, len(recipients), batch_size):
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return
|
||||
|
||||
telegram_id = getattr(user, "telegram_id", None)
|
||||
if telegram_id is None:
|
||||
failed_count += 1
|
||||
continue
|
||||
batch = recipients[i:i + batch_size]
|
||||
tasks = [send_single_message(user) for user in batch]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
try:
|
||||
await self._deliver_message(telegram_id, config, keyboard)
|
||||
sent_count += 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failed_count += 1
|
||||
logger.error(
|
||||
"Ошибка отправки рассылки %s пользователю %s: %s",
|
||||
broadcast_id,
|
||||
telegram_id,
|
||||
exc,
|
||||
)
|
||||
for result in results:
|
||||
if result is True:
|
||||
sent_count += 1
|
||||
else:
|
||||
failed_count += 1
|
||||
|
||||
if index % 20 == 0:
|
||||
await asyncio.sleep(1)
|
||||
# Небольшая задержка между пакетами для снижения нагрузки на API
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
|
||||
|
||||
|
||||
@@ -120,9 +120,9 @@ class AdvertisingCampaignService:
|
||||
return CampaignBonusResult(success=False)
|
||||
|
||||
traffic_limit = campaign.subscription_traffic_gb
|
||||
device_limit = (
|
||||
campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
)
|
||||
device_limit = campaign.subscription_device_limit
|
||||
if device_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
squads = list(campaign.subscription_squads or [])
|
||||
|
||||
if not squads:
|
||||
|
||||
@@ -7,6 +7,7 @@ from dataclasses import dataclass
|
||||
from app.config import settings
|
||||
from app.external.remnawave_api import RemnaWaveAPI, test_api_connection
|
||||
from app.utils.cache import cache
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -45,6 +46,9 @@ class MaintenanceService:
|
||||
|
||||
def get_maintenance_message(self) -> str:
|
||||
if self._status.auto_enabled:
|
||||
last_check_display = format_local_datetime(
|
||||
self._status.last_check, "%H:%M:%S", "неизвестно"
|
||||
)
|
||||
return f"""
|
||||
🔧 Технические работы!
|
||||
|
||||
@@ -52,7 +56,7 @@ class MaintenanceService:
|
||||
|
||||
⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.
|
||||
|
||||
🔄 Последняя проверка: {self._status.last_check.strftime('%H:%M:%S') if self._status.last_check else 'неизвестно'}
|
||||
🔄 Последняя проверка: {last_check_display}
|
||||
"""
|
||||
else:
|
||||
return settings.get_maintenance_message()
|
||||
@@ -79,7 +83,12 @@ class MaintenanceService:
|
||||
}
|
||||
emoji = emoji_map.get(alert_type, "ℹ️")
|
||||
|
||||
formatted_message = f"{emoji} <b>ТЕХНИЧЕСКИЕ РАБОТЫ</b>\n\n{message}\n\n⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"
|
||||
timestamp = format_local_datetime(
|
||||
datetime.utcnow(), "%d.%m.%Y %H:%M:%S %Z"
|
||||
)
|
||||
formatted_message = (
|
||||
f"{emoji} <b>ТЕХНИЧЕСКИЕ РАБОТЫ</b>\n\n{message}\n\n⏰ <i>{timestamp}</i>"
|
||||
)
|
||||
|
||||
return await notification_service._send_message(formatted_message)
|
||||
|
||||
@@ -152,11 +161,14 @@ class MaintenanceService:
|
||||
|
||||
await self._save_status_to_cache()
|
||||
|
||||
enabled_time = format_local_datetime(
|
||||
self._status.enabled_at, "%d.%m.%Y %H:%M:%S %Z"
|
||||
)
|
||||
notification_msg = f"""Режим технических работ ВКЛЮЧЕН
|
||||
|
||||
📋 <b>Причина:</b> {self._status.reason}
|
||||
🤖 <b>Автоматически:</b> {'Да' if auto else 'Нет'}
|
||||
🕐 <b>Время:</b> {self._status.enabled_at.strftime('%d.%m.%Y %H:%M:%S')}
|
||||
🕐 <b>Время:</b> {enabled_time}
|
||||
|
||||
Обычные пользователи временно не смогут использовать бота."""
|
||||
|
||||
@@ -197,10 +209,13 @@ class MaintenanceService:
|
||||
else:
|
||||
duration_str = f"\n⏱️ <b>Длительность:</b> {minutes}мин"
|
||||
|
||||
notification_time = format_local_datetime(
|
||||
datetime.utcnow(), "%d.%m.%Y %H:%M:%S %Z"
|
||||
)
|
||||
notification_msg = f"""Режим технических работ ВЫКЛЮЧЕН
|
||||
|
||||
🤖 <b>Автоматически:</b> {'Да' if was_auto else 'Нет'}
|
||||
🕐 <b>Время:</b> {datetime.utcnow().strftime('%d.%m.%Y %H:%M:%S')}
|
||||
🕐 <b>Время:</b> {notification_time}
|
||||
{duration_str}
|
||||
|
||||
Сервис снова доступен для пользователей."""
|
||||
@@ -223,15 +238,23 @@ class MaintenanceService:
|
||||
await self._load_status_from_cache()
|
||||
|
||||
self._check_task = asyncio.create_task(self._monitoring_loop())
|
||||
logger.info(f"🔄 Запущен мониторинг API Remnawave (интервал: {settings.get_maintenance_check_interval()}с)")
|
||||
|
||||
await self._notify_admins(f"""Мониторинг технических работ запущен
|
||||
logger.info(
|
||||
"🔄 Запущен мониторинг API Remnawave (интервал: %sс, попыток: %s)",
|
||||
settings.get_maintenance_check_interval(),
|
||||
settings.get_maintenance_retry_attempts(),
|
||||
)
|
||||
|
||||
await self._notify_admins(
|
||||
f"""Мониторинг технических работ запущен
|
||||
|
||||
🔄 <b>Интервал проверки:</b> {settings.get_maintenance_check_interval()} секунд
|
||||
🤖 <b>Автовключение:</b> {'Включено' if settings.is_maintenance_auto_enable() else 'Отключено'}
|
||||
🎯 <b>Порог ошибок:</b> {self._max_consecutive_failures}
|
||||
🔁 <b>Повторных попыток:</b> {settings.get_maintenance_retry_attempts()}
|
||||
|
||||
Система будет следить за доступностью API.""", "info")
|
||||
Система будет следить за доступностью API.""",
|
||||
"info",
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -260,10 +283,10 @@ class MaintenanceService:
|
||||
try:
|
||||
if self._is_checking:
|
||||
return self._status.api_status
|
||||
|
||||
|
||||
self._is_checking = True
|
||||
self._status.last_check = datetime.utcnow()
|
||||
|
||||
|
||||
auth_params = settings.get_remnawave_auth_params()
|
||||
api = RemnaWaveAPI(
|
||||
base_url=auth_params["base_url"],
|
||||
@@ -272,63 +295,100 @@ class MaintenanceService:
|
||||
username=auth_params["username"],
|
||||
password=auth_params["password"]
|
||||
)
|
||||
|
||||
|
||||
attempts = settings.get_maintenance_retry_attempts()
|
||||
|
||||
async with api:
|
||||
is_connected = await test_api_connection(api)
|
||||
|
||||
if is_connected:
|
||||
if not self._status.api_status:
|
||||
await self._notify_admins(f"""API Remnawave восстановлено!
|
||||
for attempt in range(1, attempts + 1):
|
||||
is_connected = await test_api_connection(api)
|
||||
|
||||
if is_connected:
|
||||
if attempt > 1:
|
||||
logger.info(
|
||||
"API Remnawave ответило с %s попытки", attempt
|
||||
)
|
||||
|
||||
if not self._status.api_status:
|
||||
recovery_time = format_local_datetime(
|
||||
self._status.last_check, "%H:%M:%S %Z"
|
||||
)
|
||||
await self._notify_admins(
|
||||
f"""API Remnawave восстановлено!
|
||||
|
||||
✅ <b>Статус:</b> Доступно
|
||||
🕐 <b>Время восстановления:</b> {self._status.last_check.strftime('%H:%M:%S')}
|
||||
🕐 <b>Время восстановления:</b> {recovery_time}
|
||||
🔄 <b>Неудачных попыток было:</b> {self._status.consecutive_failures}
|
||||
|
||||
API снова отвечает на запросы.""", "success")
|
||||
|
||||
self._status.api_status = True
|
||||
self._status.consecutive_failures = 0
|
||||
|
||||
if self._status.is_active and self._status.auto_enabled:
|
||||
await self.disable_maintenance()
|
||||
logger.info("✅ API восстановился, режим техработ автоматически отключен")
|
||||
|
||||
return True
|
||||
else:
|
||||
was_available = self._status.api_status
|
||||
self._status.api_status = False
|
||||
self._status.consecutive_failures += 1
|
||||
|
||||
if was_available:
|
||||
await self._notify_admins(f"""API Remnawave недоступно!
|
||||
API снова отвечает на запросы.""",
|
||||
"success",
|
||||
)
|
||||
|
||||
self._status.api_status = True
|
||||
self._status.consecutive_failures = 0
|
||||
|
||||
if self._status.is_active and self._status.auto_enabled:
|
||||
await self.disable_maintenance()
|
||||
logger.info("✅ API восстановился, режим техработ автоматически отключен")
|
||||
|
||||
return True
|
||||
|
||||
if attempt < attempts:
|
||||
logger.warning(
|
||||
"API Remnawave недоступно (попытка %s/%s)",
|
||||
attempt,
|
||||
attempts,
|
||||
)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
was_available = self._status.api_status
|
||||
self._status.api_status = False
|
||||
self._status.consecutive_failures += 1
|
||||
|
||||
if was_available:
|
||||
detection_time = format_local_datetime(
|
||||
self._status.last_check, "%H:%M:%S %Z"
|
||||
)
|
||||
await self._notify_admins(
|
||||
f"""API Remnawave недоступно!
|
||||
|
||||
❌ <b>Статус:</b> Недоступно
|
||||
🕐 <b>Время обнаружения:</b> {self._status.last_check.strftime('%H:%M:%S')}
|
||||
🕐 <b>Время обнаружения:</b> {detection_time}
|
||||
🔄 <b>Попытка:</b> {self._status.consecutive_failures}
|
||||
|
||||
Началась серия неудачных проверок API.""", "error")
|
||||
|
||||
if (self._status.consecutive_failures >= self._max_consecutive_failures and
|
||||
not self._status.is_active and
|
||||
settings.is_maintenance_auto_enable()):
|
||||
|
||||
await self.enable_maintenance(
|
||||
reason=f"Автоматическое включение после {self._status.consecutive_failures} неудачных проверок API",
|
||||
auto=True
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
Началась серия неудачных проверок API.""",
|
||||
"error",
|
||||
)
|
||||
|
||||
if (
|
||||
self._status.consecutive_failures >= self._max_consecutive_failures
|
||||
and not self._status.is_active
|
||||
and settings.is_maintenance_auto_enable()
|
||||
):
|
||||
|
||||
await self.enable_maintenance(
|
||||
reason=(
|
||||
f"Автоматическое включение после {self._status.consecutive_failures} "
|
||||
"неудачных проверок API"
|
||||
),
|
||||
auto=True
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки API: {e}")
|
||||
|
||||
if self._status.api_status:
|
||||
await self._notify_admins(f"""Ошибка при проверке API Remnawave
|
||||
error_time = format_local_datetime(datetime.utcnow(), "%H:%M:%S %Z")
|
||||
await self._notify_admins(
|
||||
f"""Ошибка при проверке API Remnawave
|
||||
|
||||
❌ <b>Ошибка:</b> {str(e)}
|
||||
🕐 <b>Время:</b> {datetime.utcnow().strftime('%H:%M:%S')}
|
||||
🕐 <b>Время:</b> {error_time}
|
||||
|
||||
Не удалось выполнить проверку доступности API.""", "error")
|
||||
Не удалось выполнить проверку доступности API.""",
|
||||
"error",
|
||||
)
|
||||
|
||||
self._status.api_status = False
|
||||
self._status.consecutive_failures += 1
|
||||
@@ -398,6 +458,7 @@ API снова отвечает на запросы.""", "success")
|
||||
"api_status": self._status.api_status,
|
||||
"consecutive_failures": self._status.consecutive_failures,
|
||||
"monitoring_active": self._check_task is not None and not self._check_task.done(),
|
||||
"monitoring_configured": settings.is_maintenance_monitoring_enabled(),
|
||||
"auto_enable_configured": settings.is_maintenance_auto_enable(),
|
||||
"check_interval": settings.get_maintenance_check_interval(),
|
||||
"bot_connected": self._bot is not None
|
||||
|
||||
@@ -38,7 +38,19 @@ from app.database.crud.user import (
|
||||
subtract_user_balance,
|
||||
cleanup_expired_promo_offer_discounts,
|
||||
)
|
||||
from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus
|
||||
from app.utils.timezone import format_local_datetime
|
||||
from app.utils.subscription_utils import (
|
||||
resolve_hwid_device_limit_for_payload,
|
||||
)
|
||||
from app.database.models import (
|
||||
MonitoringLog,
|
||||
SubscriptionStatus,
|
||||
Subscription,
|
||||
User,
|
||||
Ticket,
|
||||
TicketStatus,
|
||||
UserPromoGroup,
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.notification_settings_service import NotificationSettingsService
|
||||
from app.services.payment_service import PaymentService
|
||||
@@ -241,9 +253,6 @@ class MonitoringService:
|
||||
await expire_subscription(db, subscription)
|
||||
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if user and user.remnawave_uuid:
|
||||
await self.subscription_service.disable_remnawave_user(user.remnawave_uuid)
|
||||
|
||||
if user and self.bot:
|
||||
await self._send_subscription_expired_notification(user)
|
||||
|
||||
@@ -282,21 +291,34 @@ class MonitoringService:
|
||||
is_active = False
|
||||
logger.info(f"📝 Статус подписки {subscription.id} обновлен на 'expired'")
|
||||
|
||||
async with self.api as api:
|
||||
updated_user = await api.update_user(
|
||||
if not self.subscription_service.is_configured:
|
||||
logger.warning(
|
||||
"RemnaWave API не настроен. Пропускаем обновление пользователя %s",
|
||||
subscription.user_id,
|
||||
)
|
||||
return None
|
||||
|
||||
async with self.subscription_service.get_api_client() as api:
|
||||
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
|
||||
|
||||
update_kwargs = dict(
|
||||
uuid=user.remnawave_uuid,
|
||||
status=UserStatus.ACTIVE if is_active else UserStatus.EXPIRED,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
)
|
||||
|
||||
if hwid_limit is not None:
|
||||
update_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
updated_user = await api.update_user(**update_kwargs)
|
||||
|
||||
subscription.subscription_url = updated_user.subscription_url
|
||||
subscription.subscription_crypto_link = updated_user.happ_crypto_link
|
||||
@@ -372,7 +394,12 @@ class MonitoringService:
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.options(
|
||||
selectinload(Subscription.user).selectinload(User.promo_group),
|
||||
selectinload(Subscription.user)
|
||||
.selectinload(User.user_promo_groups)
|
||||
.selectinload(UserPromoGroup.promo_group),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
@@ -554,7 +581,11 @@ class MonitoringService:
|
||||
)
|
||||
continue
|
||||
|
||||
if subscription.status == SubscriptionStatus.ACTIVE.value and not is_member:
|
||||
if (
|
||||
subscription.status == SubscriptionStatus.ACTIVE.value
|
||||
and subscription.is_trial
|
||||
and not is_member
|
||||
):
|
||||
subscription = await deactivate_subscription(db, subscription)
|
||||
disabled_count += 1
|
||||
logger.info(
|
||||
@@ -588,7 +619,11 @@ class MonitoringService:
|
||||
subscription.id,
|
||||
"trial_channel_unsubscribed",
|
||||
)
|
||||
elif subscription.status == SubscriptionStatus.DISABLED.value and is_member:
|
||||
elif (
|
||||
subscription.status == SubscriptionStatus.DISABLED.value
|
||||
and subscription.is_trial
|
||||
and is_member
|
||||
):
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
@@ -1026,7 +1061,7 @@ class MonitoringService:
|
||||
message = f"""
|
||||
⚠️ <b>Подписка истекает через {days_text}!</b>
|
||||
|
||||
Ваша платная подписка истекает {subscription.end_date.strftime("%d.%m.%Y %H:%M")}.
|
||||
Ваша платная подписка истекает {format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M")}.
|
||||
|
||||
💳 <b>Автоплатеж:</b> {autopay_status}
|
||||
|
||||
@@ -1070,6 +1105,56 @@ class MonitoringService:
|
||||
try:
|
||||
texts = get_texts(user.language)
|
||||
|
||||
# Рассчитываем минимальную цену за подписку с минимальной конфигурацией
|
||||
from app.config import settings, PERIOD_PRICES
|
||||
from app.utils.pricing_utils import apply_percentage_discount
|
||||
|
||||
# Базовая цена за 30 дней
|
||||
base_price_original = PERIOD_PRICES.get(30, settings.PRICE_30_DAYS)
|
||||
|
||||
# Применяем скидку промогруппы для категории "period"
|
||||
promo_group_discount = user.get_promo_discount("period", 30) if user else 0
|
||||
# Применяем пользовательскую промо-скидку (если есть)
|
||||
user_discount_percent = self._get_user_promo_offer_discount_percent(user)
|
||||
|
||||
# Общая скидка - максимальная из промогруппы и пользовательской
|
||||
total_discount_percent = max(promo_group_discount, user_discount_percent)
|
||||
|
||||
base_price, _ = apply_percentage_discount(base_price_original, total_discount_percent)
|
||||
|
||||
# Добавляем цену за трафик (если фиксированный трафик включён)
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_price = settings.get_traffic_price(settings.get_fixed_traffic_limit())
|
||||
# Применяем скидки на трафик
|
||||
traffic_discount = user.get_promo_discount("traffic", 30) if user else 0
|
||||
traffic_price, _ = apply_percentage_discount(traffic_price, traffic_discount)
|
||||
else:
|
||||
traffic_price = 0 # Трафик не фиксирован, цена включена в базовую
|
||||
|
||||
# Добавляем цену за серверы (предполагаем минимум 1 сервер по минимальной цене)
|
||||
# Вместо сложного запроса к БД, используем настройки
|
||||
# Для минимальной конфигурации - один сервер с минимальной ценой
|
||||
min_server_price = getattr(settings, 'MIN_SERVER_PRICE', 0) or 0
|
||||
if min_server_price == 0:
|
||||
# Если нет явной минимальной цены, используем базовую цену
|
||||
# В реальных условиях цена сервера будет определяться в ходе оформления подписки
|
||||
min_server_price = 0
|
||||
|
||||
# Добавляем цену за устройства (если больше базового лимита)
|
||||
# В минимальной конфигурации - базовый лимит, без доп. устройств
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
additional_devices = max(0, device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price = additional_devices * settings.PRICE_PER_DEVICE
|
||||
|
||||
# Для простоты и правильной работы без обращения к БД, рассчитываем минимальную цену как:
|
||||
# базовая цена + минимальная цена за трафик (если есть фиксированный)
|
||||
min_server_price = 0 # для минимальной конфигурации с 1 сервером используем 0 или минимальную известную
|
||||
|
||||
# Попробуем получить минимальную цену сервера из настроек или используем подходящее значение
|
||||
# Находим минимальную возможную цену из возможных цен серверов
|
||||
# В упрощенном варианте используем базовую конфигурацию: базовая цена + трафик
|
||||
min_total_price = base_price + traffic_price
|
||||
|
||||
message = f"""
|
||||
🎁 <b>Тестовая подписка скоро закончится!</b>
|
||||
|
||||
@@ -1079,7 +1164,7 @@ class MonitoringService:
|
||||
Переходите на полную подписку!
|
||||
|
||||
🔥 <b>Специальное предложение:</b>
|
||||
• 30 дней всего за {settings.format_price(settings.PRICE_30_DAYS)}
|
||||
• 30 дней всего за {settings.format_price(min_total_price)}
|
||||
• Безлимитный трафик
|
||||
• Все серверы доступны
|
||||
• Скорость до 1ГБит/сек
|
||||
@@ -1142,7 +1227,7 @@ class MonitoringService:
|
||||
|
||||
message = template.format(
|
||||
price=settings.format_price(settings.PRICE_30_DAYS),
|
||||
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
|
||||
end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"),
|
||||
)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
@@ -1258,7 +1343,7 @@ class MonitoringService:
|
||||
),
|
||||
)
|
||||
message = template.format(
|
||||
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
|
||||
end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"),
|
||||
price=settings.format_price(settings.PRICE_30_DAYS),
|
||||
)
|
||||
|
||||
@@ -1335,7 +1420,7 @@ class MonitoringService:
|
||||
|
||||
message = template.format(
|
||||
percent=percent,
|
||||
expires_at=expires_at.strftime("%d.%m.%Y %H:%M"),
|
||||
expires_at=format_local_datetime(expires_at, "%d.%m.%Y %H:%M"),
|
||||
trigger_days=trigger_days or "",
|
||||
)
|
||||
|
||||
@@ -1473,7 +1558,11 @@ class MonitoringService:
|
||||
if now.minute != 0:
|
||||
return
|
||||
|
||||
async with self.subscription_service.api as api:
|
||||
if not self.subscription_service.is_configured:
|
||||
logger.warning("RemnaWave API не настроен. Пропускаем синхронизацию")
|
||||
return
|
||||
|
||||
async with self.subscription_service.get_api_client() as api:
|
||||
system_stats = await api.get_system_stats()
|
||||
|
||||
await self._log_monitoring_event(
|
||||
@@ -1759,26 +1848,43 @@ class MonitoringService:
|
||||
return []
|
||||
|
||||
async def get_monitoring_logs_count(
|
||||
self,
|
||||
self,
|
||||
db: AsyncSession,
|
||||
event_type: Optional[str] = None
|
||||
) -> int:
|
||||
try:
|
||||
from sqlalchemy import select, func
|
||||
|
||||
|
||||
query = select(func.count(MonitoringLog.id))
|
||||
|
||||
|
||||
if event_type:
|
||||
query = query.where(MonitoringLog.event_type == event_type)
|
||||
|
||||
|
||||
result = await db.execute(query)
|
||||
count = result.scalar()
|
||||
|
||||
|
||||
return count or 0
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения количества логов: {e}")
|
||||
return 0
|
||||
|
||||
async def get_monitoring_event_types(self, db: AsyncSession) -> List[str]:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await db.execute(
|
||||
select(MonitoringLog.event_type)
|
||||
.where(MonitoringLog.event_type.isnot(None))
|
||||
.distinct()
|
||||
.order_by(MonitoringLog.event_type)
|
||||
)
|
||||
|
||||
return [row[0] for row in result.fetchall() if row[0]]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения списка типов событий мониторинга: {e}")
|
||||
return []
|
||||
|
||||
async def cleanup_old_logs(self, db: AsyncSession, days: int = 30) -> int:
|
||||
try:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
|
||||
import aiohttp
|
||||
|
||||
@@ -17,6 +19,10 @@ class MulenPayService:
|
||||
self.shop_id = settings.MULENPAY_SHOP_ID
|
||||
self.secret_key = settings.MULENPAY_SECRET_KEY
|
||||
self.base_url = settings.MULENPAY_BASE_URL.rstrip("/")
|
||||
self._timeout = aiohttp.ClientTimeout(total=30, connect=10, sock_read=25)
|
||||
self._max_retries = 3
|
||||
self._retry_delay = 0.5
|
||||
self._retryable_statuses = {500, 502, 503, 504}
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
@@ -45,31 +51,115 @@ class MulenPayService:
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=30)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.request(
|
||||
last_error: Optional[BaseException] = None
|
||||
|
||||
for attempt in range(1, self._max_retries + 1):
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=self._timeout) as session:
|
||||
async with session.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
json=json_data,
|
||||
params=params,
|
||||
) as response:
|
||||
data, raw_text = await self._deserialize_response(response)
|
||||
|
||||
if response.status >= 400:
|
||||
logger.error(
|
||||
"MulenPay API error %s %s: %s",
|
||||
response.status,
|
||||
endpoint,
|
||||
raw_text,
|
||||
)
|
||||
if (
|
||||
response.status in self._retryable_statuses
|
||||
and attempt < self._max_retries
|
||||
):
|
||||
await self._sleep_with_backoff(attempt)
|
||||
continue
|
||||
return None
|
||||
|
||||
if data is None:
|
||||
if raw_text:
|
||||
logger.warning(
|
||||
"MulenPay returned unexpected payload for %s: %s",
|
||||
endpoint,
|
||||
raw_text,
|
||||
)
|
||||
return None
|
||||
|
||||
return data
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("MulenPay request cancelled: %s %s", method, endpoint)
|
||||
raise
|
||||
except asyncio.TimeoutError as error:
|
||||
last_error = error
|
||||
logger.warning(
|
||||
"MulenPay request timeout (%s %s) attempt %s/%s",
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
json=json_data,
|
||||
params=params,
|
||||
) as response:
|
||||
data = await response.json(content_type=None)
|
||||
endpoint,
|
||||
attempt,
|
||||
self._max_retries,
|
||||
)
|
||||
except aiohttp.ClientError as error:
|
||||
last_error = error
|
||||
logger.warning(
|
||||
"MulenPay client error (%s %s) attempt %s/%s: %s",
|
||||
method,
|
||||
endpoint,
|
||||
attempt,
|
||||
self._max_retries,
|
||||
error,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - safety
|
||||
logger.error("Unexpected MulenPay error: %s", error, exc_info=True)
|
||||
return None
|
||||
|
||||
if response.status >= 400:
|
||||
logger.error(
|
||||
"MulenPay API error %s %s: %s", response.status, endpoint, data
|
||||
)
|
||||
return None
|
||||
if attempt < self._max_retries:
|
||||
await self._sleep_with_backoff(attempt)
|
||||
|
||||
return data
|
||||
except aiohttp.ClientError as error:
|
||||
logger.error("MulenPay API request error: %s", error)
|
||||
return None
|
||||
except Exception as error: # pragma: no cover - safety
|
||||
logger.error("Unexpected MulenPay error: %s", error, exc_info=True)
|
||||
return None
|
||||
if isinstance(last_error, asyncio.TimeoutError):
|
||||
logger.error(
|
||||
"MulenPay request timed out after %s attempts: %s %s",
|
||||
self._max_retries,
|
||||
method,
|
||||
endpoint,
|
||||
)
|
||||
elif last_error is not None:
|
||||
logger.error(
|
||||
"MulenPay request failed after %s attempts (%s %s): %s",
|
||||
self._max_retries,
|
||||
method,
|
||||
endpoint,
|
||||
last_error,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
async def _sleep_with_backoff(self, attempt: int) -> None:
|
||||
await asyncio.sleep(self._retry_delay * attempt)
|
||||
|
||||
async def _deserialize_response(
|
||||
self, response: aiohttp.ClientResponse
|
||||
) -> Tuple[Optional[Dict[str, Any]], str]:
|
||||
raw_text = await response.text()
|
||||
if not raw_text:
|
||||
return None, ""
|
||||
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
if "json" in content_type.lower() or not content_type:
|
||||
try:
|
||||
return json.loads(raw_text), raw_text
|
||||
except json.JSONDecodeError as error:
|
||||
logger.error(
|
||||
"Failed to decode MulenPay JSON response %s: %s",
|
||||
response.url,
|
||||
error,
|
||||
)
|
||||
return None, raw_text
|
||||
|
||||
return None, raw_text
|
||||
|
||||
@staticmethod
|
||||
def _format_amount(amount_kopeks: int) -> str:
|
||||
@@ -124,3 +214,21 @@ class MulenPayService:
|
||||
|
||||
async def get_payment(self, payment_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self._request("GET", f"/v2/payments/{payment_id}")
|
||||
|
||||
async def list_payments(
|
||||
self,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 100,
|
||||
uuid: Optional[str] = None,
|
||||
status: Optional[int] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
params = {
|
||||
"offset": max(0, offset),
|
||||
"limit": max(1, min(limit, 1000)),
|
||||
}
|
||||
if uuid:
|
||||
params["uuid"] = uuid
|
||||
if status is not None:
|
||||
params["status"] = status
|
||||
return await self._request("GET", "/v2/payments", params=params)
|
||||
|
||||
@@ -83,6 +83,12 @@ class Pal24Service:
|
||||
logger.debug("Запрашиваем статус Pal24 платежа %s", payment_id)
|
||||
return await self.client.get_payment_status(payment_id)
|
||||
|
||||
async def get_bill_payments(self, bill_id: str) -> Dict[str, Any]:
|
||||
"""Возвращает список платежей, связанных со счетом."""
|
||||
|
||||
logger.debug("Запрашиваем платежи Pal24 счёта %s", bill_id)
|
||||
return await self.client.get_bill_payments(bill_id)
|
||||
|
||||
@staticmethod
|
||||
def parse_postback(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
required_fields = ["InvId", "OutSum", "Status", "SignatureValue"]
|
||||
|
||||
@@ -9,6 +9,7 @@ from .stars import TelegramStarsMixin
|
||||
from .yookassa import YooKassaPaymentMixin
|
||||
from .tribute import TributePaymentMixin
|
||||
from .cryptobot import CryptoBotPaymentMixin
|
||||
from .heleket import HeleketPaymentMixin
|
||||
from .mulenpay import MulenPayPaymentMixin
|
||||
from .pal24 import Pal24PaymentMixin
|
||||
from .wata import WataPaymentMixin
|
||||
@@ -19,6 +20,7 @@ __all__ = [
|
||||
"YooKassaPaymentMixin",
|
||||
"TributePaymentMixin",
|
||||
"CryptoBotPaymentMixin",
|
||||
"HeleketPaymentMixin",
|
||||
"MulenPayPaymentMixin",
|
||||
"Pal24PaymentMixin",
|
||||
"WataPaymentMixin",
|
||||
|
||||
@@ -23,6 +23,7 @@ from app.services.subscription_checkout_service import (
|
||||
has_subscription_checkout_draft,
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -56,14 +57,32 @@ class PaymentCommonMixin:
|
||||
|
||||
# Если для пользователя есть незавершённый checkout, предлагаем вернуться к нему.
|
||||
if user:
|
||||
draft_exists = await has_subscription_checkout_draft(user.id)
|
||||
if should_offer_checkout_resume(user, draft_exists):
|
||||
try:
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
except Exception as cart_error:
|
||||
logger.warning(
|
||||
"Не удалось проверить наличие сохраненной корзины у пользователя %s: %s",
|
||||
user.id,
|
||||
cart_error,
|
||||
)
|
||||
has_saved_cart = False
|
||||
|
||||
if has_saved_cart:
|
||||
keyboard_rows.append([
|
||||
build_miniapp_or_callback_button(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout",
|
||||
callback_data="return_to_saved_cart",
|
||||
)
|
||||
])
|
||||
else:
|
||||
draft_exists = await has_subscription_checkout_draft(user.id)
|
||||
if should_offer_checkout_resume(user, draft_exists):
|
||||
keyboard_rows.append([
|
||||
build_miniapp_or_callback_button(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout",
|
||||
)
|
||||
])
|
||||
|
||||
# Стандартные кнопки быстрого доступа к балансу и главному меню.
|
||||
keyboard_rows.append([
|
||||
|
||||
@@ -11,6 +11,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.currency_converter import currency_converter
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
@@ -208,7 +211,7 @@ class CryptoBotPaymentMixin:
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = (
|
||||
@@ -294,9 +297,29 @@ class CryptoBotPaymentMixin:
|
||||
try:
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from aiogram import types
|
||||
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
auto_purchase_success = False
|
||||
if has_saved_cart:
|
||||
try:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as auto_error:
|
||||
logger.error(
|
||||
"Ошибка автоматической покупки подписки для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
@@ -309,7 +332,7 @@ class CryptoBotPaymentMixin:
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout"
|
||||
callback_data="return_to_saved_cart"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text="💰 Мой баланс",
|
||||
@@ -326,7 +349,10 @@ class CryptoBotPaymentMixin:
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
|
||||
logger.info(
|
||||
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
|
||||
user.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
|
||||
|
||||
@@ -337,3 +363,83 @@ class CryptoBotPaymentMixin:
|
||||
"Ошибка обработки CryptoBot webhook: %s", error, exc_info=True
|
||||
)
|
||||
return False
|
||||
|
||||
async def get_cryptobot_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
local_payment_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Запрашивает актуальный статус CryptoBot invoice и синхронизирует его."""
|
||||
|
||||
cryptobot_crud = import_module("app.database.crud.cryptobot")
|
||||
payment = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id)
|
||||
if not payment:
|
||||
logger.warning("CryptoBot платеж %s не найден", local_payment_id)
|
||||
return None
|
||||
|
||||
if not self.cryptobot_service:
|
||||
logger.warning("CryptoBot сервис не инициализирован для ручной проверки")
|
||||
return {"payment": payment}
|
||||
|
||||
invoice_id = payment.invoice_id
|
||||
try:
|
||||
invoices = await self.cryptobot_service.get_invoices(
|
||||
invoice_ids=[invoice_id]
|
||||
)
|
||||
except Exception as error: # pragma: no cover - network errors
|
||||
logger.error(
|
||||
"Ошибка запроса статуса CryptoBot invoice %s: %s",
|
||||
invoice_id,
|
||||
error,
|
||||
)
|
||||
return {"payment": payment}
|
||||
|
||||
remote_invoice: Optional[Dict[str, Any]] = None
|
||||
if invoices:
|
||||
for item in invoices:
|
||||
if str(item.get("invoice_id")) == str(invoice_id):
|
||||
remote_invoice = item
|
||||
break
|
||||
|
||||
if not remote_invoice:
|
||||
logger.info(
|
||||
"CryptoBot invoice %s не найден через API при ручной проверке",
|
||||
invoice_id,
|
||||
)
|
||||
refreshed = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id)
|
||||
return {"payment": refreshed or payment}
|
||||
|
||||
status = (remote_invoice.get("status") or "").lower()
|
||||
paid_at_str = remote_invoice.get("paid_at")
|
||||
paid_at = None
|
||||
if paid_at_str:
|
||||
try:
|
||||
paid_at = datetime.fromisoformat(paid_at_str.replace("Z", "+00:00")).replace(
|
||||
tzinfo=None
|
||||
)
|
||||
except Exception: # pragma: no cover - defensive parsing
|
||||
paid_at = None
|
||||
|
||||
if status == "paid":
|
||||
webhook_payload = {
|
||||
"update_type": "invoice_paid",
|
||||
"payload": {
|
||||
"invoice_id": remote_invoice.get("invoice_id") or invoice_id,
|
||||
"amount": remote_invoice.get("amount") or payment.amount,
|
||||
"asset": remote_invoice.get("asset") or payment.asset,
|
||||
"paid_at": paid_at_str,
|
||||
"payload": remote_invoice.get("payload") or payment.payload,
|
||||
},
|
||||
}
|
||||
await self.process_cryptobot_webhook(db, webhook_payload)
|
||||
else:
|
||||
if status and status != (payment.status or "").lower():
|
||||
await cryptobot_crud.update_cryptobot_payment_status(
|
||||
db,
|
||||
invoice_id,
|
||||
status,
|
||||
paid_at,
|
||||
)
|
||||
|
||||
refreshed = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id)
|
||||
return {"payment": refreshed or payment}
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
"""Mixin with Heleket payment flow implementation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from importlib import import_module
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HeleketPaymentMixin:
|
||||
"""Provides helpers to create and process Heleket payments."""
|
||||
|
||||
async def create_heleket_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
*,
|
||||
language: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not getattr(self, "heleket_service", None):
|
||||
logger.error("Heleket сервис не инициализирован")
|
||||
return None
|
||||
|
||||
if amount_kopeks <= 0:
|
||||
logger.error("Сумма Heleket должна быть положительной: %s", amount_kopeks)
|
||||
return None
|
||||
|
||||
amount_rubles = amount_kopeks / 100
|
||||
amount_str = f"{amount_rubles:.2f}"
|
||||
|
||||
order_id = f"heleket_{user_id}_{int(time.time())}_{secrets.token_hex(3)}"
|
||||
|
||||
markup_percent = settings.get_heleket_markup_percent()
|
||||
discount_percent: Optional[int] = None
|
||||
if markup_percent:
|
||||
try:
|
||||
rounded = int(round(markup_percent))
|
||||
if rounded != 0:
|
||||
discount_percent = -rounded
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Некорректная наценка Heleket: %s", markup_percent)
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"amount": amount_str,
|
||||
"currency": "RUB",
|
||||
"order_id": order_id,
|
||||
"lifetime": settings.get_heleket_lifetime(),
|
||||
}
|
||||
|
||||
to_currency = (settings.HELEKET_DEFAULT_CURRENCY or "").strip()
|
||||
if to_currency:
|
||||
payload["to_currency"] = to_currency
|
||||
|
||||
network = (settings.HELEKET_DEFAULT_NETWORK or "").strip()
|
||||
if network:
|
||||
payload["network"] = network
|
||||
|
||||
callback_url = settings.get_heleket_callback_url()
|
||||
if callback_url:
|
||||
payload["url_callback"] = callback_url
|
||||
|
||||
if settings.HELEKET_RETURN_URL:
|
||||
payload["url_return"] = settings.HELEKET_RETURN_URL
|
||||
if settings.HELEKET_SUCCESS_URL:
|
||||
payload["url_success"] = settings.HELEKET_SUCCESS_URL
|
||||
|
||||
if discount_percent is not None:
|
||||
payload["discount_percent"] = discount_percent
|
||||
|
||||
metadata: Dict[str, Any] = {
|
||||
"language": language or settings.DEFAULT_LANGUAGE,
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self.heleket_service.create_payment(payload) # type: ignore[union-attr]
|
||||
except Exception as error: # pragma: no cover - safety net
|
||||
logger.exception("Ошибка создания Heleket платежа: %s", error)
|
||||
return None
|
||||
|
||||
if not response:
|
||||
logger.error("Heleket API вернул пустой ответ при создании платежа")
|
||||
return None
|
||||
|
||||
payment_result = response.get("result") if isinstance(response, dict) else None
|
||||
if not payment_result:
|
||||
logger.error("Некорректный ответ Heleket API: %s", response)
|
||||
return None
|
||||
|
||||
uuid = str(payment_result.get("uuid"))
|
||||
response_order_id = payment_result.get("order_id")
|
||||
if response_order_id:
|
||||
order_id = str(response_order_id)
|
||||
|
||||
url = payment_result.get("url")
|
||||
status = payment_result.get("status") or payment_result.get("payment_status") or "check"
|
||||
payer_amount = payment_result.get("payer_amount")
|
||||
payer_currency = payment_result.get("payer_currency")
|
||||
exchange_rate = payment_result.get("payer_amount_exchange_rate")
|
||||
|
||||
try:
|
||||
exchange_rate_value = float(exchange_rate) if exchange_rate is not None else None
|
||||
except (TypeError, ValueError):
|
||||
exchange_rate_value = None
|
||||
|
||||
if exchange_rate_value is None and payer_amount:
|
||||
try:
|
||||
exchange_rate_value = float(payer_amount) / amount_rubles if amount_rubles else None
|
||||
except (TypeError, ValueError, ZeroDivisionError):
|
||||
exchange_rate_value = None
|
||||
|
||||
expires_at_raw = payment_result.get("expired_at")
|
||||
expires_at: Optional[datetime] = None
|
||||
if expires_at_raw:
|
||||
try:
|
||||
expires_at = datetime.fromtimestamp(int(expires_at_raw))
|
||||
except (TypeError, ValueError, OSError):
|
||||
expires_at = None
|
||||
|
||||
heleket_crud = import_module("app.database.crud.heleket")
|
||||
|
||||
local_payment = await heleket_crud.create_heleket_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
uuid=uuid,
|
||||
order_id=order_id,
|
||||
amount=amount_str,
|
||||
currency="RUB",
|
||||
status=status,
|
||||
payer_amount=payer_amount,
|
||||
payer_currency=payer_currency,
|
||||
exchange_rate=exchange_rate_value,
|
||||
discount_percent=discount_percent,
|
||||
payment_url=url,
|
||||
expires_at=expires_at,
|
||||
metadata={"raw_response": payment_result, **metadata},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Создан Heleket платеж %s на %s₽ для пользователя %s",
|
||||
uuid,
|
||||
amount_str,
|
||||
user_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"local_payment_id": local_payment.id,
|
||||
"uuid": uuid,
|
||||
"order_id": order_id,
|
||||
"amount": amount_str,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
"payment_url": url,
|
||||
"status": status,
|
||||
"payer_amount": payer_amount,
|
||||
"payer_currency": payer_currency,
|
||||
"exchange_rate": exchange_rate_value,
|
||||
"discount_percent": discount_percent,
|
||||
}
|
||||
|
||||
async def _process_heleket_payload(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
metadata_key: str,
|
||||
) -> Optional["HeleketPayment"]:
|
||||
if not isinstance(payload, dict):
|
||||
logger.error("Heleket webhook payload не является словарём: %s", payload)
|
||||
return None
|
||||
|
||||
heleket_crud = import_module("app.database.crud.heleket")
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
uuid = str(payload.get("uuid") or "").strip()
|
||||
order_id = str(payload.get("order_id") or "").strip()
|
||||
status = payload.get("status") or payload.get("payment_status")
|
||||
|
||||
if not uuid and not order_id:
|
||||
logger.error("Heleket webhook без uuid/order_id: %s", payload)
|
||||
return None
|
||||
|
||||
payment = None
|
||||
if uuid:
|
||||
payment = await heleket_crud.get_heleket_payment_by_uuid(db, uuid)
|
||||
if payment is None and order_id:
|
||||
payment = await heleket_crud.get_heleket_payment_by_order_id(db, order_id)
|
||||
|
||||
if not payment:
|
||||
logger.error(
|
||||
"Heleket платеж не найден (uuid=%s order_id=%s)",
|
||||
uuid,
|
||||
order_id,
|
||||
)
|
||||
return None
|
||||
|
||||
payer_amount = payload.get("payer_amount") or payload.get("payment_amount")
|
||||
payer_currency = payload.get("payer_currency") or payload.get("currency")
|
||||
discount_percent = payload.get("discount_percent")
|
||||
exchange_rate_raw = payload.get("payer_amount_exchange_rate")
|
||||
payment_url = payload.get("url")
|
||||
|
||||
exchange_rate: Optional[float] = None
|
||||
if exchange_rate_raw is not None:
|
||||
try:
|
||||
exchange_rate = float(exchange_rate_raw)
|
||||
except (TypeError, ValueError):
|
||||
exchange_rate = None
|
||||
|
||||
if exchange_rate is None and payer_amount:
|
||||
try:
|
||||
exchange_rate = float(payer_amount) / payment.amount_float if payment.amount_float else None
|
||||
except (TypeError, ValueError, ZeroDivisionError):
|
||||
exchange_rate = None
|
||||
|
||||
paid_at: Optional[datetime] = None
|
||||
paid_at_raw = payload.get("paid_at") or payload.get("updated_at")
|
||||
if paid_at_raw:
|
||||
try:
|
||||
if isinstance(paid_at_raw, (int, float)):
|
||||
paid_at = datetime.utcfromtimestamp(float(paid_at_raw))
|
||||
else:
|
||||
paid_at = datetime.fromisoformat(str(paid_at_raw).replace("Z", "+00:00"))
|
||||
except (ValueError, TypeError):
|
||||
paid_at = None
|
||||
|
||||
if paid_at and paid_at.tzinfo is not None:
|
||||
paid_at = paid_at.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
updated_payment = await heleket_crud.update_heleket_payment(
|
||||
db,
|
||||
payment.uuid,
|
||||
status=status,
|
||||
payer_amount=str(payer_amount) if payer_amount is not None else None,
|
||||
payer_currency=str(payer_currency) if payer_currency is not None else None,
|
||||
exchange_rate=exchange_rate,
|
||||
discount_percent=int(discount_percent) if isinstance(discount_percent, (int, float)) else None,
|
||||
paid_at=paid_at,
|
||||
payment_url=payment_url,
|
||||
metadata={metadata_key: payload},
|
||||
)
|
||||
|
||||
if updated_payment is None:
|
||||
return None
|
||||
|
||||
if updated_payment.transaction_id:
|
||||
logger.info(
|
||||
"Heleket платеж %s уже связан с транзакцией %s",
|
||||
updated_payment.uuid,
|
||||
updated_payment.transaction_id,
|
||||
)
|
||||
return updated_payment
|
||||
|
||||
status_normalized = (status or "").lower()
|
||||
if status_normalized not in {"paid", "paid_over"}:
|
||||
logger.info("Heleket платеж %s в статусе %s, зачисление не требуется", updated_payment.uuid, status)
|
||||
return updated_payment
|
||||
|
||||
amount_kopeks = updated_payment.amount_kopeks
|
||||
if amount_kopeks <= 0:
|
||||
logger.error("Heleket платеж %s имеет некорректную сумму: %s", updated_payment.uuid, updated_payment.amount)
|
||||
return None
|
||||
|
||||
transaction = await payment_module.create_transaction(
|
||||
db,
|
||||
user_id=updated_payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=(
|
||||
"Пополнение через Heleket"
|
||||
if not updated_payment.payer_currency
|
||||
else (
|
||||
"Пополнение через Heleket "
|
||||
f"({updated_payment.payer_amount} {updated_payment.payer_currency})"
|
||||
)
|
||||
),
|
||||
payment_method=PaymentMethod.HELEKET,
|
||||
external_id=updated_payment.uuid,
|
||||
is_completed=True,
|
||||
)
|
||||
|
||||
linked_payment = await heleket_crud.link_heleket_payment_to_transaction(
|
||||
db,
|
||||
updated_payment.uuid,
|
||||
transaction.id,
|
||||
)
|
||||
if linked_payment:
|
||||
updated_payment = linked_payment
|
||||
|
||||
get_user_by_id = payment_module.get_user_by_id
|
||||
user = await get_user_by_id(db, updated_payment.user_id)
|
||||
if not user:
|
||||
logger.error("Пользователь %s не найден для Heleket платежа", updated_payment.user_id)
|
||||
return None
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
amount_kopeks,
|
||||
getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive
|
||||
logger.error("Ошибка реферального начисления Heleket: %s", error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
referrer_info = format_referrer_info(user)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
promo_group = user.get_primary_promo_group()
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error: # pragma: no cover
|
||||
logger.error("Ошибка отправки админ-уведомления Heleket: %s", error)
|
||||
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
|
||||
exchange_rate_value = updated_payment.exchange_rate or 0
|
||||
rate_text = (
|
||||
f"💱 Курс: 1 RUB = {1 / exchange_rate_value:.4f} {updated_payment.payer_currency}"
|
||||
if exchange_rate_value and updated_payment.payer_currency
|
||||
else None
|
||||
)
|
||||
|
||||
message_lines = [
|
||||
"✅ <b>Пополнение успешно!</b>",
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}",
|
||||
"💳 Способ: Heleket",
|
||||
]
|
||||
if updated_payment.payer_amount and updated_payment.payer_currency:
|
||||
message_lines.append(
|
||||
f"🪙 Оплата: {updated_payment.payer_amount} {updated_payment.payer_currency}"
|
||||
)
|
||||
if rate_text:
|
||||
message_lines.append(rate_text)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text="\n".join(message_lines),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error: # pragma: no cover
|
||||
logger.error("Ошибка отправки уведомления пользователю Heleket: %s", error)
|
||||
|
||||
return updated_payment
|
||||
|
||||
async def process_heleket_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: Dict[str, Any],
|
||||
) -> bool:
|
||||
result = await self._process_heleket_payload(
|
||||
db,
|
||||
payload,
|
||||
metadata_key="last_webhook",
|
||||
)
|
||||
|
||||
return result is not None
|
||||
|
||||
async def sync_heleket_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
local_payment_id: int,
|
||||
) -> Optional["HeleketPayment"]:
|
||||
if not getattr(self, "heleket_service", None):
|
||||
logger.error("Heleket сервис не инициализирован")
|
||||
return None
|
||||
|
||||
heleket_crud = import_module("app.database.crud.heleket")
|
||||
|
||||
payment = await heleket_crud.get_heleket_payment_by_id(db, local_payment_id)
|
||||
if not payment:
|
||||
logger.error("Heleket платеж с id=%s не найден", local_payment_id)
|
||||
return None
|
||||
|
||||
payload: Optional[Dict[str, Any]] = None
|
||||
try:
|
||||
response = await self.heleket_service.get_payment_info( # type: ignore[union-attr]
|
||||
uuid=payment.uuid,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive
|
||||
logger.exception(
|
||||
"Ошибка получения статуса Heleket платежа %s: %s",
|
||||
payment.uuid,
|
||||
error,
|
||||
)
|
||||
else:
|
||||
if response:
|
||||
result = response.get("result") if isinstance(response, dict) else None
|
||||
if isinstance(result, dict):
|
||||
payload = dict(result)
|
||||
else:
|
||||
logger.error(
|
||||
"Некорректный ответ Heleket API при проверке платежа %s: %s",
|
||||
payment.uuid,
|
||||
response,
|
||||
)
|
||||
|
||||
if payload is None:
|
||||
fallback = await self._lookup_heleket_payment_history(payment)
|
||||
if not fallback:
|
||||
logger.warning(
|
||||
"Heleket API не вернул информацию по платежу %s",
|
||||
payment.uuid,
|
||||
)
|
||||
return payment
|
||||
payload = dict(fallback)
|
||||
|
||||
payload.setdefault("uuid", payment.uuid)
|
||||
payload.setdefault("order_id", payment.order_id)
|
||||
|
||||
updated_payment = await self._process_heleket_payload(
|
||||
db,
|
||||
payload,
|
||||
metadata_key="last_status_check",
|
||||
)
|
||||
|
||||
return updated_payment or payment
|
||||
|
||||
async def _lookup_heleket_payment_history(
|
||||
self,
|
||||
payment: "HeleketPayment",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
service = getattr(self, "heleket_service", None)
|
||||
if not service:
|
||||
return None
|
||||
|
||||
created_at = getattr(payment, "created_at", None)
|
||||
date_from_str: Optional[str] = None
|
||||
date_to_str: Optional[str] = None
|
||||
if isinstance(created_at, datetime):
|
||||
start = created_at - timedelta(days=2)
|
||||
end = created_at + timedelta(days=2)
|
||||
date_from_str = start.strftime("%Y-%m-%d %H:%M:%S")
|
||||
date_to_str = end.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
cursor: Optional[str] = None
|
||||
for _ in range(10):
|
||||
response = await service.list_payments(
|
||||
date_from=date_from_str,
|
||||
date_to=date_to_str,
|
||||
cursor=cursor,
|
||||
)
|
||||
if not response or not isinstance(response, dict):
|
||||
return None
|
||||
|
||||
result = response.get("result")
|
||||
if not isinstance(result, dict):
|
||||
return None
|
||||
|
||||
items = result.get("items")
|
||||
if isinstance(items, list):
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
uuid = str(item.get("uuid") or "").strip()
|
||||
order_id = str(item.get("order_id") or "").strip()
|
||||
if uuid and uuid == str(payment.uuid):
|
||||
return item
|
||||
if order_id and order_id == str(payment.order_id):
|
||||
return item
|
||||
|
||||
paginate = result.get("paginate")
|
||||
cursor = None
|
||||
if isinstance(paginate, dict):
|
||||
next_cursor = paginate.get("nextCursor")
|
||||
if isinstance(next_cursor, str) and next_cursor:
|
||||
cursor = next_cursor
|
||||
|
||||
if not cursor:
|
||||
break
|
||||
|
||||
return None
|
||||
@@ -11,6 +11,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -28,13 +31,16 @@ class MulenPayPaymentMixin:
|
||||
language: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Создаёт локальный платеж и инициализирует сессию в MulenPay."""
|
||||
display_name = settings.get_mulenpay_display_name()
|
||||
display_name_html = settings.get_mulenpay_display_name_html()
|
||||
if not getattr(self, "mulenpay_service", None):
|
||||
logger.error("MulenPay сервис не инициализирован")
|
||||
logger.error("%s сервис не инициализирован", display_name)
|
||||
return None
|
||||
|
||||
if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"Сумма MulenPay меньше минимальной: %s < %s",
|
||||
"Сумма %s меньше минимальной: %s < %s",
|
||||
display_name,
|
||||
amount_kopeks,
|
||||
settings.MULENPAY_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
@@ -42,7 +48,8 @@ class MulenPayPaymentMixin:
|
||||
|
||||
if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"Сумма MulenPay больше максимальной: %s > %s",
|
||||
"Сумма %s больше максимальной: %s > %s",
|
||||
display_name,
|
||||
amount_kopeks,
|
||||
settings.MULENPAY_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
@@ -74,7 +81,7 @@ class MulenPayPaymentMixin:
|
||||
)
|
||||
|
||||
if not response:
|
||||
logger.error("Ошибка создания MulenPay платежа")
|
||||
logger.error("Ошибка создания %s платежа", display_name)
|
||||
return None
|
||||
|
||||
mulen_payment_id = response.get("id")
|
||||
@@ -100,7 +107,8 @@ class MulenPayPaymentMixin:
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Создан MulenPay платеж %s на %s₽ для пользователя %s",
|
||||
"Создан %s платеж %s на %s₽ для пользователя %s",
|
||||
display_name,
|
||||
mulen_payment_id,
|
||||
amount_rubles,
|
||||
user_id,
|
||||
@@ -116,7 +124,7 @@ class MulenPayPaymentMixin:
|
||||
}
|
||||
|
||||
except Exception as error:
|
||||
logger.error("Ошибка создания MulenPay платежа: %s", error)
|
||||
logger.error("Ошибка создания %s платежа: %s", display_name, error)
|
||||
return None
|
||||
|
||||
async def process_mulenpay_callback(
|
||||
@@ -125,10 +133,17 @@ class MulenPayPaymentMixin:
|
||||
callback_data: Dict[str, Any],
|
||||
) -> bool:
|
||||
"""Обрабатывает callback от MulenPay, обновляет статус и начисляет баланс."""
|
||||
display_name = settings.get_mulenpay_display_name()
|
||||
display_name_html = settings.get_mulenpay_display_name_html()
|
||||
try:
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
uuid_value = callback_data.get("uuid")
|
||||
payment_status = (callback_data.get("payment_status") or "").lower()
|
||||
payment_status_raw = (
|
||||
callback_data.get("payment_status")
|
||||
or callback_data.get("status")
|
||||
or callback_data.get("paymentStatus")
|
||||
)
|
||||
payment_status = (payment_status_raw or "").lower()
|
||||
mulen_payment_id_raw = callback_data.get("id")
|
||||
mulen_payment_id_int: Optional[int] = None
|
||||
if mulen_payment_id_raw is not None:
|
||||
@@ -138,14 +153,15 @@ class MulenPayPaymentMixin:
|
||||
mulen_payment_id_int = None
|
||||
amount_value = callback_data.get("amount")
|
||||
logger.debug(
|
||||
"MulenPay callback: uuid=%s, status=%s, amount=%s",
|
||||
"%s callback: uuid=%s, status=%s, amount=%s",
|
||||
display_name,
|
||||
uuid_value,
|
||||
payment_status,
|
||||
amount_value,
|
||||
)
|
||||
|
||||
if not uuid_value and mulen_payment_id_raw is None:
|
||||
logger.error("MulenPay callback без uuid и id")
|
||||
logger.error("%s callback без uuid и id", display_name)
|
||||
return False
|
||||
|
||||
payment = None
|
||||
@@ -159,7 +175,8 @@ class MulenPayPaymentMixin:
|
||||
|
||||
if not payment:
|
||||
logger.error(
|
||||
"MulenPay платеж не найден (uuid=%s, id=%s)",
|
||||
"%s платеж не найден (uuid=%s, id=%s)",
|
||||
display_name,
|
||||
uuid_value,
|
||||
mulen_payment_id_raw,
|
||||
)
|
||||
@@ -167,7 +184,8 @@ class MulenPayPaymentMixin:
|
||||
|
||||
if payment.is_paid:
|
||||
logger.info(
|
||||
"MulenPay платеж %s уже обработан, игнорируем повторный callback",
|
||||
"%s платеж %s уже обработан, игнорируем повторный callback",
|
||||
display_name,
|
||||
payment.uuid,
|
||||
)
|
||||
return True
|
||||
@@ -183,7 +201,8 @@ class MulenPayPaymentMixin:
|
||||
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
"Для MulenPay платежа %s уже создана транзакция",
|
||||
"Для %s платежа %s уже создана транзакция",
|
||||
display_name,
|
||||
payment.uuid,
|
||||
)
|
||||
return True
|
||||
@@ -199,7 +218,7 @@ class MulenPayPaymentMixin:
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=f"Пополнение через MulenPay: {payment_description}",
|
||||
description=f"Пополнение через {display_name}: {payment_description}",
|
||||
payment_method=PaymentMethod.MULENPAY,
|
||||
external_id=payment.uuid,
|
||||
is_completed=True,
|
||||
@@ -214,8 +233,9 @@ class MulenPayPaymentMixin:
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error(
|
||||
"Пользователь %s не найден при обработке MulenPay",
|
||||
"Пользователь %s не найден при обработке %s",
|
||||
payment.user_id,
|
||||
display_name,
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -226,16 +246,42 @@ class MulenPayPaymentMixin:
|
||||
db,
|
||||
user,
|
||||
payment.amount_kopeks,
|
||||
f"Пополнение MulenPay: {payment.amount_kopeks // 100}₽",
|
||||
f"Пополнение {display_name}: {payment.amount_kopeks // 100}₽",
|
||||
create_transaction=False,
|
||||
)
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
payment.amount_kopeks,
|
||||
getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка обработки реферального пополнения %s: %s",
|
||||
display_name,
|
||||
error,
|
||||
)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
# После коммита отношения пользователя могли быть сброшены, поэтому
|
||||
# повторно загружаем пользователя с предзагрузкой зависимостей
|
||||
user = await payment_module.get_user_by_id(db, user.id)
|
||||
if not user:
|
||||
logger.error(
|
||||
"Пользователь %s не найден при повторной загрузке после %s",
|
||||
payment.user_id,
|
||||
display_name,
|
||||
)
|
||||
return False
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = (
|
||||
@@ -261,7 +307,8 @@ class MulenPayPaymentMixin:
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о пополнении MulenPay: %s",
|
||||
"Ошибка отправки уведомления о пополнении %s: %s",
|
||||
display_name,
|
||||
error,
|
||||
)
|
||||
|
||||
@@ -273,7 +320,7 @@ class MulenPayPaymentMixin:
|
||||
(
|
||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
|
||||
"🦊 Способ: Mulen Pay\n"
|
||||
f"🦊 Способ: {display_name_html}\n"
|
||||
f"🆔 Транзакция: {transaction.id}\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
@@ -282,7 +329,8 @@ class MulenPayPaymentMixin:
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления пользователю MulenPay: %s",
|
||||
"Ошибка отправки уведомления пользователю %s: %s",
|
||||
display_name,
|
||||
error,
|
||||
)
|
||||
|
||||
@@ -290,9 +338,29 @@ class MulenPayPaymentMixin:
|
||||
try:
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from aiogram import types
|
||||
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
auto_purchase_success = False
|
||||
if has_saved_cart:
|
||||
try:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as auto_error:
|
||||
logger.error(
|
||||
"Ошибка автоматической покупки подписки для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
@@ -307,7 +375,7 @@ class MulenPayPaymentMixin:
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout"
|
||||
callback_data="return_to_saved_cart"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text="💰 Мой баланс",
|
||||
@@ -324,12 +392,16 @@ class MulenPayPaymentMixin:
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
|
||||
logger.info(
|
||||
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
|
||||
user.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан MulenPay платеж %s для пользователя %s",
|
||||
"✅ Обработан %s платеж %s для пользователя %s",
|
||||
display_name,
|
||||
payment.uuid,
|
||||
payment.user_id,
|
||||
)
|
||||
@@ -343,7 +415,7 @@ class MulenPayPaymentMixin:
|
||||
callback_payload=callback_data,
|
||||
mulen_payment_id=mulen_payment_id_int,
|
||||
)
|
||||
logger.info("MulenPay платеж %s отменен", payment.uuid)
|
||||
logger.info("%s платеж %s отменен", display_name, payment.uuid)
|
||||
return True
|
||||
|
||||
await payment_module.update_mulenpay_payment_status(
|
||||
@@ -354,14 +426,20 @@ class MulenPayPaymentMixin:
|
||||
mulen_payment_id=mulen_payment_id_int,
|
||||
)
|
||||
logger.info(
|
||||
"Получен MulenPay callback со статусом %s для платежа %s",
|
||||
"Получен %s callback со статусом %s для платежа %s",
|
||||
display_name,
|
||||
payment_status,
|
||||
payment.uuid,
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error("Ошибка обработки MulenPay callback: %s", error, exc_info=True)
|
||||
logger.error(
|
||||
"Ошибка обработки %s callback: %s",
|
||||
display_name,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
def _map_mulenpay_status(self, status_code: Optional[int]) -> str:
|
||||
@@ -383,6 +461,7 @@ class MulenPayPaymentMixin:
|
||||
local_payment_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Возвращает текущее состояние платежа и при необходимости синхронизирует его."""
|
||||
display_name = settings.get_mulenpay_display_name()
|
||||
try:
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
@@ -397,35 +476,59 @@ class MulenPayPaymentMixin:
|
||||
response = await self.mulenpay_service.get_payment(
|
||||
payment.mulen_payment_id
|
||||
)
|
||||
if response and response.get("success"):
|
||||
remote_data = response.get("payment")
|
||||
if isinstance(remote_data, dict):
|
||||
remote_status_code = remote_data.get("status")
|
||||
mapped_status = self._map_mulenpay_status(remote_status_code)
|
||||
if response:
|
||||
if isinstance(response, dict) and response.get("success"):
|
||||
remote_data = response.get("payment")
|
||||
elif isinstance(response, dict) and "status" in response and "id" in response:
|
||||
remote_data = response
|
||||
if not remote_data and getattr(self, "mulenpay_service", None):
|
||||
list_response = await self.mulenpay_service.list_payments(
|
||||
limit=100,
|
||||
uuid=payment.uuid,
|
||||
)
|
||||
items = []
|
||||
if isinstance(list_response, dict):
|
||||
items = list_response.get("items") or []
|
||||
if items:
|
||||
for candidate in items:
|
||||
if not isinstance(candidate, dict):
|
||||
continue
|
||||
candidate_id = candidate.get("id")
|
||||
candidate_uuid = candidate.get("uuid")
|
||||
if (
|
||||
(candidate_id is not None and candidate_id == payment.mulen_payment_id)
|
||||
or (candidate_uuid and candidate_uuid == payment.uuid)
|
||||
):
|
||||
remote_data = candidate
|
||||
break
|
||||
|
||||
if mapped_status == "success" and not payment.is_paid:
|
||||
await self.process_mulenpay_callback(
|
||||
db,
|
||||
{
|
||||
"uuid": payment.uuid,
|
||||
"payment_status": "success",
|
||||
"id": remote_data.get("id"),
|
||||
"amount": remote_data.get("amount"),
|
||||
},
|
||||
)
|
||||
payment = await payment_module.get_mulenpay_payment_by_local_id(
|
||||
db, local_payment_id
|
||||
)
|
||||
elif mapped_status and mapped_status != payment.status:
|
||||
await payment_module.update_mulenpay_payment_status(
|
||||
db,
|
||||
payment=payment,
|
||||
status=mapped_status,
|
||||
mulen_payment_id=remote_data.get("id"),
|
||||
)
|
||||
payment = await payment_module.get_mulenpay_payment_by_local_id(
|
||||
db, local_payment_id
|
||||
)
|
||||
if isinstance(remote_data, dict):
|
||||
remote_status_code = remote_data.get("status")
|
||||
mapped_status = self._map_mulenpay_status(remote_status_code)
|
||||
|
||||
if mapped_status == "success" and not payment.is_paid:
|
||||
await self.process_mulenpay_callback(
|
||||
db,
|
||||
{
|
||||
"uuid": payment.uuid,
|
||||
"payment_status": "success",
|
||||
"id": remote_data.get("id"),
|
||||
"amount": remote_data.get("amount"),
|
||||
},
|
||||
)
|
||||
payment = await payment_module.get_mulenpay_payment_by_local_id(
|
||||
db, local_payment_id
|
||||
)
|
||||
elif mapped_status and mapped_status != payment.status:
|
||||
await payment_module.update_mulenpay_payment_status(
|
||||
db,
|
||||
payment=payment,
|
||||
status=mapped_status,
|
||||
mulen_payment_id=remote_data.get("id"),
|
||||
)
|
||||
payment = await payment_module.get_mulenpay_payment_by_local_id(
|
||||
db, local_payment_id
|
||||
)
|
||||
|
||||
return {
|
||||
"payment": payment,
|
||||
@@ -437,6 +540,9 @@ class MulenPayPaymentMixin:
|
||||
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка получения статуса MulenPay: %s", error, exc_info=True
|
||||
"Ошибка получения статуса %s: %s",
|
||||
display_name,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
+551
-175
@@ -6,13 +6,16 @@ import logging
|
||||
from datetime import datetime
|
||||
from importlib import import_module
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.pal24_service import Pal24APIError
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -63,7 +66,8 @@ class Pal24PaymentMixin:
|
||||
"language": language,
|
||||
}
|
||||
|
||||
normalized_payment_method = (payment_method or "SBP").upper()
|
||||
normalized_payment_method = self._normalize_payment_method(payment_method)
|
||||
api_payment_method = self._map_api_payment_method(normalized_payment_method)
|
||||
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
@@ -76,7 +80,7 @@ class Pal24PaymentMixin:
|
||||
ttl_seconds=ttl_seconds,
|
||||
custom_payload=custom_payload,
|
||||
payer_email=payer_email,
|
||||
payment_method=normalized_payment_method,
|
||||
payment_method=api_payment_method,
|
||||
)
|
||||
except Pal24APIError as error:
|
||||
logger.error("Ошибка Pal24 API при создании счета: %s", error)
|
||||
@@ -147,6 +151,7 @@ class Pal24PaymentMixin:
|
||||
"description": description,
|
||||
"links": metadata_links,
|
||||
"raw_response": response,
|
||||
"selected_method": normalized_payment_method,
|
||||
}
|
||||
|
||||
payment = await payment_module.create_pal24_payment(
|
||||
@@ -186,6 +191,10 @@ class Pal24PaymentMixin:
|
||||
"payment_method": normalized_payment_method,
|
||||
"metadata_links": metadata_links,
|
||||
"status": payment_status,
|
||||
"sbp_url": transfer_url,
|
||||
"transfer_url": transfer_url,
|
||||
"link_page_url": link_page_url,
|
||||
"payment_url": primary_link,
|
||||
}
|
||||
|
||||
async def process_pal24_postback(
|
||||
@@ -240,175 +249,58 @@ class Pal24PaymentMixin:
|
||||
logger.info("Pal24 платеж %s уже обработан", payment.bill_id)
|
||||
return True
|
||||
|
||||
if status in {"PAID", "SUCCESS"}:
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error(
|
||||
"Пользователь %s не найден для Pal24 платежа",
|
||||
payment.user_id,
|
||||
)
|
||||
return False
|
||||
|
||||
await payment_module.update_pal24_payment_status(
|
||||
if status in {"PAID", "SUCCESS", "OVERPAID"}:
|
||||
payment = await payment_module.update_pal24_payment_status(
|
||||
db,
|
||||
payment,
|
||||
status=status,
|
||||
is_paid=True,
|
||||
paid_at=datetime.utcnow(),
|
||||
postback_payload=postback,
|
||||
payment_id=payment_id,
|
||||
payment_status=postback.get("Status") or status,
|
||||
payment_method=(
|
||||
postback.get("payment_method")
|
||||
or postback.get("PaymentMethod")
|
||||
or (payment.metadata_json or {}).get("selected_method")
|
||||
or getattr(payment, "payment_method", None)
|
||||
),
|
||||
balance_amount=postback.get("BalanceAmount")
|
||||
or postback.get("balance_amount"),
|
||||
balance_currency=postback.get("BalanceCurrency")
|
||||
or postback.get("balance_currency"),
|
||||
payer_account=postback.get("AccountNumber")
|
||||
or postback.get("account")
|
||||
or postback.get("Account"),
|
||||
)
|
||||
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
"Для Pal24 платежа %s уже создана транзакция",
|
||||
payment.bill_id,
|
||||
)
|
||||
return True
|
||||
|
||||
transaction = await payment_module.create_transaction(
|
||||
return await self._finalize_pal24_payment(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=f"Пополнение через Pal24 ({payment_id})",
|
||||
payment_method=PaymentMethod.PAL24,
|
||||
external_id=str(payment_id) if payment_id else payment.bill_id,
|
||||
is_completed=True,
|
||||
payment,
|
||||
payment_id=payment_id,
|
||||
trigger="postback",
|
||||
)
|
||||
|
||||
await payment_module.link_pal24_payment_to_transaction(db, payment, transaction.id)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = (
|
||||
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db, user.id, payment.amount_kopeks, getattr(self, "bot", None)
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка обработки реферального пополнения Pal24: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.services.admin_notification_service import (
|
||||
AdminNotificationService,
|
||||
)
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки админ уведомления Pal24: %s", error
|
||||
)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
|
||||
"🦊 Способ: PayPalych\n"
|
||||
f"🆔 Транзакция: {transaction.id}\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления пользователю Pal24: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
||||
try:
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from aiogram import types
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.t(
|
||||
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
|
||||
"🛒 У вас есть неоформленный заказ.\n\n"
|
||||
"Вы можете продолжить оформление с теми же параметрами."
|
||||
)
|
||||
|
||||
# Создаем клавиатуру с кнопками
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text="💰 Мой баланс",
|
||||
callback_data="menu_balance"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text="🏠 Главное меню",
|
||||
callback_data="back_to_menu"
|
||||
)]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан Pal24 платеж %s для пользователя %s",
|
||||
payment.bill_id,
|
||||
payment.user_id,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
await payment_module.update_pal24_payment_status(
|
||||
db,
|
||||
payment,
|
||||
status=status or "UNKNOWN",
|
||||
is_paid=False,
|
||||
postback_payload=postback,
|
||||
payment_id=payment_id,
|
||||
payment_status=postback.get("Status") or status,
|
||||
payment_method=(
|
||||
postback.get("payment_method")
|
||||
or postback.get("PaymentMethod")
|
||||
or getattr(payment, "payment_method", None)
|
||||
),
|
||||
balance_amount=postback.get("BalanceAmount")
|
||||
or postback.get("balance_amount"),
|
||||
balance_currency=postback.get("BalanceCurrency")
|
||||
or postback.get("balance_currency"),
|
||||
payer_account=postback.get("AccountNumber")
|
||||
or postback.get("account")
|
||||
or postback.get("Account"),
|
||||
)
|
||||
logger.info(
|
||||
"Обновили Pal24 платеж %s до статуса %s",
|
||||
@@ -421,6 +313,213 @@ class Pal24PaymentMixin:
|
||||
logger.error("Ошибка обработки Pal24 postback: %s", error, exc_info=True)
|
||||
return False
|
||||
|
||||
async def _finalize_pal24_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
*,
|
||||
payment_id: Optional[str],
|
||||
trigger: str,
|
||||
) -> bool:
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления."""
|
||||
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
"Pal24 платеж %s уже привязан к транзакции (trigger=%s)",
|
||||
payment.bill_id,
|
||||
trigger,
|
||||
)
|
||||
return True
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error(
|
||||
"Пользователь %s не найден для Pal24 платежа %s (trigger=%s)",
|
||||
payment.user_id,
|
||||
payment.bill_id,
|
||||
trigger,
|
||||
)
|
||||
return False
|
||||
|
||||
transaction = await payment_module.create_transaction(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=f"Пополнение через Pal24 ({payment_id or payment.bill_id})",
|
||||
payment_method=PaymentMethod.PAL24,
|
||||
external_id=str(payment_id) if payment_id else payment.bill_id,
|
||||
is_completed=True,
|
||||
)
|
||||
|
||||
await payment_module.link_pal24_payment_to_transaction(db, payment, transaction.id)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db, user.id, payment.amount_kopeks, getattr(self, "bot", None)
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка обработки реферального пополнения Pal24: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(payment)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.services.admin_notification_service import (
|
||||
AdminNotificationService,
|
||||
)
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки админ уведомления Pal24: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
|
||||
"🦊 Способ: PayPalych\n"
|
||||
f"🆔 Транзакция: {transaction.id}\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления пользователю Pal24: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
try:
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from aiogram import types
|
||||
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
auto_purchase_success = False
|
||||
if has_saved_cart:
|
||||
try:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as auto_error:
|
||||
logger.error(
|
||||
"Ошибка автоматической покупки подписки для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.t(
|
||||
"BALANCE_TOPUP_CART_REMINDER",
|
||||
"У вас есть незавершенное оформление подписки. Вернуться?",
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"BALANCE_TOPUP_CART_BUTTON",
|
||||
"🛒 Продолжить оформление",
|
||||
),
|
||||
callback_data="return_to_saved_cart",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🏠 Главное меню",
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=(
|
||||
"✅ Баланс пополнен на "
|
||||
f"{settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}"
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
|
||||
user.id,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"У пользователя %s нет сохраненной корзины или автопокупка выполнена",
|
||||
user.id,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
|
||||
user.id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан Pal24 платеж %s для пользователя %s (trigger=%s)",
|
||||
payment.bill_id,
|
||||
payment.user_id,
|
||||
trigger,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def get_pal24_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -434,40 +533,317 @@ class Pal24PaymentMixin:
|
||||
if not payment:
|
||||
return None
|
||||
|
||||
remote_status = None
|
||||
remote_data = None
|
||||
remote_status: Optional[str] = None
|
||||
remote_payloads: Dict[str, Any] = {}
|
||||
payment_info_candidates: List[Dict[str, Optional[str]]] = []
|
||||
|
||||
service = getattr(self, "pal24_service", None)
|
||||
if service and payment.bill_id:
|
||||
bill_id_str = str(payment.bill_id)
|
||||
try:
|
||||
response = await service.get_bill_status(payment.bill_id)
|
||||
remote_data = response
|
||||
remote_status = response.get("status") or response.get(
|
||||
"bill", {}
|
||||
).get("status")
|
||||
|
||||
if remote_status and remote_status != payment.status:
|
||||
await payment_module.update_pal24_payment_status(
|
||||
db,
|
||||
payment,
|
||||
status=str(remote_status).upper(),
|
||||
)
|
||||
payment = await payment_module.get_pal24_payment_by_id(
|
||||
db, local_payment_id
|
||||
)
|
||||
response = await service.get_bill_status(bill_id_str)
|
||||
except Pal24APIError as error:
|
||||
logger.error("Ошибка Pal24 API при получении статуса счёта: %s", error)
|
||||
else:
|
||||
if response:
|
||||
remote_payloads["bill_status"] = response
|
||||
status_value = response.get("status") or (response.get("bill") or {}).get("status")
|
||||
if status_value:
|
||||
remote_status = str(status_value).upper()
|
||||
extracted = self._extract_remote_payment_info(response)
|
||||
if extracted:
|
||||
payment_info_candidates.append(extracted)
|
||||
|
||||
if payment.payment_id:
|
||||
payment_id_str = str(payment.payment_id)
|
||||
try:
|
||||
payment_response = await service.get_payment_status(payment_id_str)
|
||||
except Pal24APIError as error:
|
||||
logger.error("Ошибка Pal24 API при получении статуса платежа: %s", error)
|
||||
else:
|
||||
if payment_response:
|
||||
remote_payloads["payment_status"] = payment_response
|
||||
extracted = self._extract_remote_payment_info(payment_response)
|
||||
if extracted:
|
||||
payment_info_candidates.append(extracted)
|
||||
|
||||
try:
|
||||
payments_response = await service.get_bill_payments(bill_id_str)
|
||||
except Pal24APIError as error:
|
||||
logger.error("Ошибка Pal24 API при получении списка платежей: %s", error)
|
||||
else:
|
||||
if payments_response:
|
||||
remote_payloads["bill_payments"] = payments_response
|
||||
for candidate in self._collect_payment_candidates(payments_response):
|
||||
extracted = self._extract_remote_payment_info(candidate)
|
||||
if extracted:
|
||||
payment_info_candidates.append(extracted)
|
||||
|
||||
payment_info = self._select_best_payment_info(payment, payment_info_candidates)
|
||||
if payment_info:
|
||||
remote_payloads.setdefault("selected_payment", payment_info)
|
||||
|
||||
bill_success = getattr(service, "BILL_SUCCESS_STATES", {"SUCCESS"}) if service else {"SUCCESS"}
|
||||
bill_failed = getattr(service, "BILL_FAILED_STATES", {"FAIL"}) if service else {"FAIL"}
|
||||
bill_pending = getattr(service, "BILL_PENDING_STATES", {"NEW", "PROCESS"}) if service else {"NEW", "PROCESS"}
|
||||
|
||||
update_status = payment.status or "NEW"
|
||||
update_kwargs: Dict[str, Any] = {}
|
||||
is_paid_update: Optional[bool] = None
|
||||
|
||||
if remote_status:
|
||||
update_status = remote_status
|
||||
if remote_status in bill_success:
|
||||
is_paid_update = True
|
||||
elif remote_status in bill_failed:
|
||||
is_paid_update = False
|
||||
elif remote_status in bill_pending and is_paid_update is None:
|
||||
is_paid_update = False
|
||||
|
||||
payment_status_code: Optional[str] = None
|
||||
if payment_info:
|
||||
payment_status_code = (payment_info.get("status") or "").upper() or None
|
||||
if payment_status_code:
|
||||
existing_status = (getattr(payment, "payment_status", "") or "").upper()
|
||||
if payment_status_code != existing_status:
|
||||
update_kwargs["payment_status"] = payment_status_code
|
||||
|
||||
payment_id_value = payment_info.get("id")
|
||||
if payment_id_value and payment_id_value != (payment.payment_id or ""):
|
||||
update_kwargs["payment_id"] = payment_id_value
|
||||
|
||||
method_value = payment_info.get("method")
|
||||
if method_value:
|
||||
normalized_method = self._normalize_payment_method(method_value)
|
||||
if normalized_method != (payment.payment_method or ""):
|
||||
update_kwargs["payment_method"] = normalized_method
|
||||
|
||||
balance_amount = payment_info.get("balance_amount")
|
||||
if balance_amount and balance_amount != (payment.balance_amount or ""):
|
||||
update_kwargs["balance_amount"] = balance_amount
|
||||
|
||||
balance_currency = payment_info.get("balance_currency")
|
||||
if balance_currency and balance_currency != (payment.balance_currency or ""):
|
||||
update_kwargs["balance_currency"] = balance_currency
|
||||
|
||||
payer_account = payment_info.get("account")
|
||||
if payer_account and payer_account != (payment.payer_account or ""):
|
||||
update_kwargs["payer_account"] = payer_account
|
||||
|
||||
if payment_status_code:
|
||||
success_states = {"SUCCESS", "OVERPAID"}
|
||||
failed_states = {"FAIL"}
|
||||
pending_states = {"NEW", "PROCESS", "UNDERPAID"}
|
||||
if payment_status_code in success_states:
|
||||
is_paid_update = True
|
||||
elif payment_status_code in failed_states and is_paid_update is not True:
|
||||
is_paid_update = False
|
||||
elif payment_status_code in pending_states and is_paid_update is None:
|
||||
is_paid_update = False
|
||||
|
||||
if not remote_status and payment_status_code:
|
||||
update_status = payment_status_code
|
||||
|
||||
if is_paid_update is not None and is_paid_update != bool(payment.is_paid):
|
||||
update_kwargs["is_paid"] = is_paid_update
|
||||
if is_paid_update and not payment.paid_at:
|
||||
update_kwargs.setdefault("paid_at", datetime.utcnow())
|
||||
|
||||
current_status = payment.status or ""
|
||||
effective_status = update_status or current_status or "NEW"
|
||||
needs_update = bool(update_kwargs) or effective_status != current_status
|
||||
|
||||
if needs_update:
|
||||
payment = await payment_module.update_pal24_payment_status(
|
||||
db,
|
||||
payment,
|
||||
status=effective_status,
|
||||
**update_kwargs,
|
||||
)
|
||||
|
||||
remote_status_for_return = remote_status or payment_status_code
|
||||
remote_data = remote_payloads or None
|
||||
|
||||
if payment.is_paid and not payment.transaction_id:
|
||||
try:
|
||||
finalized = await self._finalize_pal24_payment(
|
||||
db,
|
||||
payment,
|
||||
payment_id=getattr(payment, "payment_id", None),
|
||||
trigger="status_check",
|
||||
)
|
||||
if finalized:
|
||||
payment = await payment_module.get_pal24_payment_by_id(db, local_payment_id)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка Pal24 API при получении статуса: %s", error
|
||||
"Ошибка автоматического начисления по Pal24 статусу: %s",
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return {
|
||||
"payment": payment,
|
||||
"status": payment.status,
|
||||
"is_paid": payment.is_paid,
|
||||
"remote_status": remote_status,
|
||||
"remote_status": remote_status_for_return,
|
||||
"remote_data": remote_data,
|
||||
}
|
||||
|
||||
except Exception as error:
|
||||
logger.error("Ошибка получения статуса Pal24: %s", error, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _extract_remote_payment_info(remote_data: Any) -> Dict[str, Optional[str]]:
|
||||
"""Извлекает данные о платеже из ответа Pal24."""
|
||||
|
||||
def _pick_candidate(value: Any) -> Optional[Dict[str, Any]]:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
if isinstance(item, dict):
|
||||
return item
|
||||
return None
|
||||
|
||||
def _normalize(candidate: Dict[str, Any]) -> Dict[str, Optional[str]]:
|
||||
def _stringify(value: Any) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
return str(value)
|
||||
|
||||
return {
|
||||
"id": _stringify(candidate.get("id") or candidate.get("payment_id")),
|
||||
"status": _stringify(candidate.get("status")),
|
||||
"method": _stringify(candidate.get("method") or candidate.get("payment_method")),
|
||||
"balance_amount": _stringify(
|
||||
candidate.get("balance_amount")
|
||||
or candidate.get("amount")
|
||||
or candidate.get("BalanceAmount")
|
||||
),
|
||||
"balance_currency": _stringify(
|
||||
candidate.get("balance_currency") or candidate.get("BalanceCurrency")
|
||||
),
|
||||
"account": _stringify(
|
||||
candidate.get("account")
|
||||
or candidate.get("payer_account")
|
||||
or candidate.get("AccountNumber")
|
||||
),
|
||||
"bill_id": _stringify(
|
||||
candidate.get("bill_id")
|
||||
or candidate.get("BillId")
|
||||
or candidate.get("billId")
|
||||
),
|
||||
}
|
||||
|
||||
if not isinstance(remote_data, dict):
|
||||
return {}
|
||||
|
||||
lower_keys = {str(key).lower() for key in remote_data.keys()}
|
||||
has_status = any(key in lower_keys for key in ("status", "payment_status"))
|
||||
has_identifier = any(
|
||||
key in lower_keys
|
||||
for key in ("payment_id", "from_card", "account_amount", "id")
|
||||
) or "bill_id" in lower_keys
|
||||
|
||||
if has_status and has_identifier and "bill" not in lower_keys:
|
||||
return _normalize(remote_data)
|
||||
|
||||
search_spaces = [remote_data]
|
||||
bill_section = remote_data.get("bill") or remote_data.get("Bill")
|
||||
if isinstance(bill_section, dict):
|
||||
search_spaces.append(bill_section)
|
||||
|
||||
for space in search_spaces:
|
||||
for key in ("payment", "Payment", "payment_info", "PaymentInfo"):
|
||||
candidate = _pick_candidate(space.get(key))
|
||||
if candidate:
|
||||
return _normalize(candidate)
|
||||
for key in ("payments", "Payments"):
|
||||
candidate = _pick_candidate(space.get(key))
|
||||
if candidate:
|
||||
return _normalize(candidate)
|
||||
|
||||
data_section = remote_data.get("data") or remote_data.get("Data")
|
||||
candidate = _pick_candidate(data_section)
|
||||
if candidate:
|
||||
return _normalize(candidate)
|
||||
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _collect_payment_candidates(remote_data: Any) -> List[Dict[str, Any]]:
|
||||
candidates: List[Dict[str, Any]] = []
|
||||
|
||||
def _visit(value: Any) -> None:
|
||||
if isinstance(value, dict):
|
||||
lower_keys = {str(key).lower() for key in value.keys()}
|
||||
has_status = any(key in lower_keys for key in ("status", "payment_status"))
|
||||
has_identifier = any(
|
||||
key in lower_keys
|
||||
for key in ("id", "payment_id", "bill_id", "from_card", "account_amount")
|
||||
)
|
||||
if has_status and has_identifier and value not in candidates:
|
||||
candidates.append(value)
|
||||
for nested in value.values():
|
||||
_visit(nested)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
_visit(item)
|
||||
|
||||
_visit(remote_data)
|
||||
return candidates
|
||||
|
||||
@staticmethod
|
||||
def _select_best_payment_info(
|
||||
payment: Any,
|
||||
candidates: List[Dict[str, Optional[str]]],
|
||||
) -> Dict[str, Optional[str]]:
|
||||
if not candidates:
|
||||
return {}
|
||||
|
||||
payment_id = str(getattr(payment, "payment_id", "") or "")
|
||||
bill_id = str(getattr(payment, "bill_id", "") or "")
|
||||
|
||||
for candidate in candidates:
|
||||
candidate_id = str(candidate.get("id") or "")
|
||||
if payment_id and candidate_id == payment_id:
|
||||
return candidate
|
||||
|
||||
for candidate in candidates:
|
||||
candidate_bill = str(candidate.get("bill_id") or "")
|
||||
if bill_id and candidate_bill == bill_id:
|
||||
return candidate
|
||||
|
||||
return candidates[0]
|
||||
|
||||
@staticmethod
|
||||
def _normalize_payment_method(payment_method: Optional[str]) -> str:
|
||||
mapping = {
|
||||
"sbp": "sbp",
|
||||
"fast": "sbp",
|
||||
"fastpay": "sbp",
|
||||
"fast_payment": "sbp",
|
||||
"card": "card",
|
||||
"bank_card": "card",
|
||||
"bankcard": "card",
|
||||
"bank-card": "card",
|
||||
}
|
||||
|
||||
if not payment_method:
|
||||
return "sbp"
|
||||
|
||||
normalized = payment_method.strip().lower()
|
||||
return mapping.get(normalized, "sbp")
|
||||
|
||||
@staticmethod
|
||||
def _map_api_payment_method(normalized_payment_method: str) -> Optional[str]:
|
||||
"""Преобразует нормализованный метод оплаты в значение для Pal24 API."""
|
||||
|
||||
api_mapping = {
|
||||
"sbp": "SBP",
|
||||
"card": "BANK_CARD",
|
||||
}
|
||||
|
||||
return api_mapping.get(normalized_payment_method)
|
||||
|
||||
+507
-171
@@ -7,6 +7,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, ROUND_FLOOR, ROUND_HALF_UP
|
||||
from typing import Optional
|
||||
@@ -19,11 +20,22 @@ from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _SimpleSubscriptionPayload:
|
||||
"""Данные для простой подписки, извлечённые из payload звёздного платежа."""
|
||||
|
||||
subscription_id: Optional[int]
|
||||
period_days: Optional[int]
|
||||
|
||||
|
||||
class TelegramStarsMixin:
|
||||
"""Mixin с операциями создания и обработки платежей через Telegram Stars."""
|
||||
|
||||
@@ -85,7 +97,6 @@ class TelegramStarsMixin:
|
||||
telegram_payment_charge_id: str,
|
||||
) -> bool:
|
||||
"""Финализирует платеж, пришедший из Telegram Stars, и обновляет баланс пользователя."""
|
||||
del payload # payload пока не используется, но оставляем аргумент для совместимости.
|
||||
try:
|
||||
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(
|
||||
stars_amount
|
||||
@@ -96,12 +107,28 @@ class TelegramStarsMixin:
|
||||
)
|
||||
)
|
||||
|
||||
simple_payload = self._parse_simple_subscription_payload(
|
||||
payload,
|
||||
user_id,
|
||||
)
|
||||
|
||||
transaction_description = (
|
||||
f"Оплата подписки через Telegram Stars ({stars_amount} ⭐)"
|
||||
if simple_payload
|
||||
else f"Пополнение через Telegram Stars ({stars_amount} ⭐)"
|
||||
)
|
||||
transaction_type = (
|
||||
TransactionType.SUBSCRIPTION_PAYMENT
|
||||
if simple_payload
|
||||
else TransactionType.DEPOSIT
|
||||
)
|
||||
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
type=transaction_type,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f"Пополнение через Telegram Stars ({stars_amount} ⭐)",
|
||||
description=transaction_description,
|
||||
payment_method=PaymentMethod.TELEGRAM_STARS,
|
||||
external_id=telegram_payment_charge_id,
|
||||
is_completed=True,
|
||||
@@ -115,178 +142,487 @@ class TelegramStarsMixin:
|
||||
)
|
||||
return False
|
||||
|
||||
# Запоминаем старые значения, чтобы корректно построить уведомления.
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
# Обновляем баланс в БД.
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = (
|
||||
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
description_for_referral = (
|
||||
f"Пополнение Stars: {settings.format_price(amount_kopeks)} ({stars_amount} ⭐)"
|
||||
)
|
||||
logger.info(
|
||||
"🔍 Проверка реферальной логики для описания: '%s'",
|
||||
description_for_referral,
|
||||
)
|
||||
|
||||
lower_description = description_for_referral.lower()
|
||||
contains_allowed_keywords = any(
|
||||
word in lower_description
|
||||
for word in ["пополнение", "stars", "yookassa", "topup"]
|
||||
)
|
||||
contains_forbidden_keywords = any(
|
||||
word in lower_description for word in ["комиссия", "бонус"]
|
||||
)
|
||||
allow_referral = contains_allowed_keywords and not contains_forbidden_keywords
|
||||
|
||||
if allow_referral:
|
||||
logger.info(
|
||||
"🔞 Вызов process_referral_topup для пользователя %s",
|
||||
user_id,
|
||||
)
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db, user_id, amount_kopeks, getattr(self, "bot", None)
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка обработки реферального пополнения: %s", error
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"❌ Описание '%s' не подходит для реферальной логики",
|
||||
description_for_referral,
|
||||
if simple_payload:
|
||||
return await self._finalize_simple_subscription_stars_payment(
|
||||
db=db,
|
||||
user=user,
|
||||
transaction=transaction,
|
||||
amount_kopeks=amount_kopeks,
|
||||
stars_amount=stars_amount,
|
||||
payload_data=simple_payload,
|
||||
telegram_payment_charge_id=telegram_payment_charge_id,
|
||||
)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(
|
||||
"💰 Баланс пользователя %s изменен: %s → %s (Δ +%s)",
|
||||
user.telegram_id,
|
||||
old_balance,
|
||||
user.balance_kopeks,
|
||||
amount_kopeks,
|
||||
return await self._finalize_stars_balance_topup(
|
||||
db=db,
|
||||
user=user,
|
||||
transaction=transaction,
|
||||
amount_kopeks=amount_kopeks,
|
||||
stars_amount=stars_amount,
|
||||
telegram_payment_charge_id=telegram_payment_charge_id,
|
||||
)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.services.admin_notification_service import (
|
||||
AdminNotificationService,
|
||||
)
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о пополнении Stars: %s",
|
||||
error,
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"⭐ Звезд: {stars_amount}\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
"🦊 Способ: Telegram Stars\n"
|
||||
f"🆔 Транзакция: {telegram_payment_charge_id[:8]}...\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
"✅ Отправлено уведомление пользователю %s о пополнении на %s",
|
||||
user.telegram_id,
|
||||
settings.format_price(amount_kopeks),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о пополнении Stars: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
||||
try:
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from aiogram import types
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.t(
|
||||
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
|
||||
"🛒 У вас есть неоформленный заказ.\n\n"
|
||||
"Вы можете продолжить оформление с теми же параметрами."
|
||||
)
|
||||
|
||||
# Создаем клавиатуру с кнопками
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text="💰 Мой баланс",
|
||||
callback_data="menu_balance"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text="🏠 Главное меню",
|
||||
callback_data="back_to_menu"
|
||||
)]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n{cart_message}",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан Stars платеж: пользователь %s, %s звезд → %s",
|
||||
user_id,
|
||||
stars_amount,
|
||||
settings.format_price(amount_kopeks),
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error("Ошибка обработки Stars платежа: %s", error, exc_info=True)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _parse_simple_subscription_payload(
|
||||
payload: str,
|
||||
expected_user_id: int,
|
||||
) -> Optional[_SimpleSubscriptionPayload]:
|
||||
"""Пытается извлечь параметры простой подписки из payload звёздного платежа."""
|
||||
|
||||
prefix = "simple_sub_"
|
||||
if not payload or not payload.startswith(prefix):
|
||||
return None
|
||||
|
||||
tail = payload[len(prefix) :]
|
||||
parts = tail.split("_", 2)
|
||||
if len(parts) < 3:
|
||||
logger.warning(
|
||||
"Payload Stars simple subscription имеет некорректный формат: %s",
|
||||
payload,
|
||||
)
|
||||
return None
|
||||
|
||||
user_part, subscription_part, period_part = parts
|
||||
|
||||
try:
|
||||
payload_user_id = int(user_part)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Не удалось разобрать user_id в payload Stars simple subscription: %s",
|
||||
payload,
|
||||
)
|
||||
return None
|
||||
|
||||
if payload_user_id != expected_user_id:
|
||||
logger.warning(
|
||||
"Получен payload Stars simple subscription с чужим user_id: %s (ожидался %s)",
|
||||
payload_user_id,
|
||||
expected_user_id,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
subscription_id = int(subscription_part)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Не удалось разобрать subscription_id в payload Stars simple subscription: %s",
|
||||
payload,
|
||||
)
|
||||
return None
|
||||
|
||||
period_days: Optional[int] = None
|
||||
try:
|
||||
period_days = int(period_part)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Не удалось разобрать период в payload Stars simple subscription: %s",
|
||||
payload,
|
||||
)
|
||||
|
||||
return _SimpleSubscriptionPayload(
|
||||
subscription_id=subscription_id,
|
||||
period_days=period_days,
|
||||
)
|
||||
|
||||
async def _finalize_simple_subscription_stars_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user,
|
||||
transaction,
|
||||
amount_kopeks: int,
|
||||
stars_amount: int,
|
||||
payload_data: _SimpleSubscriptionPayload,
|
||||
telegram_payment_charge_id: str,
|
||||
) -> bool:
|
||||
"""Активация простой подписки, оплаченной через Telegram Stars."""
|
||||
|
||||
period_days = payload_data.period_days or settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS
|
||||
pending_subscription = None
|
||||
|
||||
if payload_data.subscription_id is not None:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
from app.database.models import Subscription
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription).where(
|
||||
Subscription.id == payload_data.subscription_id,
|
||||
Subscription.user_id == user.id,
|
||||
)
|
||||
)
|
||||
pending_subscription = result.scalar_one_or_none()
|
||||
except Exception as lookup_error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка поиска pending подписки %s для пользователя %s: %s",
|
||||
payload_data.subscription_id,
|
||||
user.id,
|
||||
lookup_error,
|
||||
exc_info=True,
|
||||
)
|
||||
pending_subscription = None
|
||||
|
||||
if not pending_subscription:
|
||||
logger.error(
|
||||
"Не найдена pending подписка %s для пользователя %s",
|
||||
payload_data.subscription_id,
|
||||
user.id,
|
||||
)
|
||||
return False
|
||||
|
||||
if payload_data.period_days is None:
|
||||
start_point = pending_subscription.start_date or datetime.utcnow()
|
||||
end_point = pending_subscription.end_date or start_point
|
||||
computed_days = max(1, (end_point - start_point).days or 0)
|
||||
period_days = max(period_days, computed_days)
|
||||
|
||||
try:
|
||||
from app.database.crud.subscription import activate_pending_subscription
|
||||
|
||||
subscription = await activate_pending_subscription(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
period_days=period_days,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка активации pending подписки для пользователя %s: %s",
|
||||
user.id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
if not subscription:
|
||||
logger.error(
|
||||
"Не удалось активировать pending подписку пользователя %s",
|
||||
user.id,
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
remnawave_user = await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
)
|
||||
if remnawave_user:
|
||||
await db.refresh(subscription)
|
||||
except Exception as sync_error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка синхронизации подписки с RemnaWave для пользователя %s: %s",
|
||||
user.id,
|
||||
sync_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
period_display = period_days
|
||||
if not period_display and getattr(subscription, "start_date", None) and getattr(
|
||||
subscription, "end_date", None
|
||||
):
|
||||
period_display = max(1, (subscription.end_date - subscription.start_date).days or 0)
|
||||
if not period_display:
|
||||
period_display = settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from aiogram import types
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
traffic_limit = getattr(subscription, "traffic_limit_gb", 0) or 0
|
||||
traffic_label = (
|
||||
"Безлимит" if traffic_limit == 0 else f"{int(traffic_limit)} ГБ"
|
||||
)
|
||||
|
||||
success_message = (
|
||||
"✅ <b>Подписка успешно активирована!</b>\n\n"
|
||||
f"📅 Период: {period_display} дней\n"
|
||||
f"📱 Устройства: {getattr(subscription, 'device_limit', 1)}\n"
|
||||
f"📊 Трафик: {traffic_label}\n"
|
||||
f"⭐ Оплата: {stars_amount} ⭐ ({settings.format_price(amount_kopeks)})\n\n"
|
||||
"🔗 Для подключения перейдите в раздел 'Моя подписка'"
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="📱 Моя подписка",
|
||||
callback_data="menu_subscription",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🏠 Главное меню",
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=success_message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
logger.info(
|
||||
"✅ Пользователь %s получил уведомление об оплате подписки через Stars",
|
||||
user.telegram_id,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о подписке через Stars: %s",
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_subscription_purchase_notification(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
transaction,
|
||||
period_display,
|
||||
was_trial_conversion=False,
|
||||
)
|
||||
except Exception as admin_error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка уведомления администраторов о подписке через Stars: %s",
|
||||
admin_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан Stars платеж как покупка подписки: пользователь %s, %s звезд → %s",
|
||||
user.id,
|
||||
stars_amount,
|
||||
settings.format_price(amount_kopeks),
|
||||
)
|
||||
return True
|
||||
|
||||
async def _finalize_stars_balance_topup(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user,
|
||||
transaction,
|
||||
amount_kopeks: int,
|
||||
stars_amount: int,
|
||||
telegram_payment_charge_id: str,
|
||||
) -> bool:
|
||||
"""Начисляет баланс пользователю после оплаты Stars и запускает автопокупку."""
|
||||
|
||||
# Запоминаем старые значения, чтобы корректно построить уведомления.
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
# Обновляем баланс в БД.
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
|
||||
await db.commit()
|
||||
|
||||
description_for_referral = (
|
||||
f"Пополнение Stars: {settings.format_price(amount_kopeks)} ({stars_amount} ⭐)"
|
||||
)
|
||||
logger.info(
|
||||
"🔍 Проверка реферальной логики для описания: '%s'",
|
||||
description_for_referral,
|
||||
)
|
||||
|
||||
lower_description = description_for_referral.lower()
|
||||
contains_allowed_keywords = any(
|
||||
word in lower_description for word in ["пополнение", "stars", "yookassa", "topup"]
|
||||
)
|
||||
contains_forbidden_keywords = any(
|
||||
word in lower_description for word in ["комиссия", "бонус"]
|
||||
)
|
||||
allow_referral = contains_allowed_keywords and not contains_forbidden_keywords
|
||||
|
||||
if allow_referral:
|
||||
logger.info(
|
||||
"🔞 Вызов process_referral_topup для пользователя %s",
|
||||
user.id,
|
||||
)
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
amount_kopeks,
|
||||
getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка обработки реферального пополнения: %s",
|
||||
error,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"❌ Описание '%s' не подходит для реферальной логики",
|
||||
description_for_referral,
|
||||
)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(
|
||||
"💰 Баланс пользователя %s изменен: %s → %s (Δ +%s)",
|
||||
user.telegram_id,
|
||||
old_balance,
|
||||
user.balance_kopeks,
|
||||
amount_kopeks,
|
||||
)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о пополнении Stars: %s",
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
|
||||
charge_id_short = (telegram_payment_charge_id or getattr(transaction, "external_id", ""))[:8]
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"⭐ Звезд: {stars_amount}\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
"🦊 Способ: Telegram Stars\n"
|
||||
f"🆔 Транзакция: {charge_id_short}...\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
"✅ Отправлено уведомление пользователю %s о пополнении на %s",
|
||||
user.telegram_id,
|
||||
settings.format_price(amount_kopeks),
|
||||
)
|
||||
except Exception as error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о пополнении Stars: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
||||
try:
|
||||
from aiogram import types
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
auto_purchase_success = False
|
||||
if has_saved_cart:
|
||||
try:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as auto_error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка автоматической покупки подписки для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.t(
|
||||
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
|
||||
"🛒 У вас есть неоформленный заказ.\n\n"
|
||||
"Вы можете продолжить оформление с теми же параметрами.",
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="return_to_saved_cart",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="💰 Мой баланс",
|
||||
callback_data="menu_balance",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🏠 Главное меню",
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n{cart_message}",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
|
||||
user.id,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
|
||||
user.id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан Stars платеж: пользователь %s, %s звезд → %s",
|
||||
user.id,
|
||||
stars_amount,
|
||||
settings.format_price(amount_kopeks),
|
||||
)
|
||||
return True
|
||||
|
||||
+249
-17
@@ -12,12 +12,61 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.services.wata_service import WataAPIError, WataService
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _extract_transaction_id(payment: Any, remote_link: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||||
"""Try to find the remote WATA transaction identifier from stored payloads."""
|
||||
|
||||
def _from_mapping(mapping: Any) -> Optional[str]:
|
||||
if isinstance(mapping, str):
|
||||
try:
|
||||
import json
|
||||
|
||||
mapping = json.loads(mapping)
|
||||
except Exception: # pragma: no cover - defensive parsing
|
||||
return None
|
||||
if not isinstance(mapping, dict):
|
||||
return None
|
||||
for key in ("id", "transaction_id", "transactionId"):
|
||||
value = mapping.get(key)
|
||||
if not value:
|
||||
continue
|
||||
value_str = str(value)
|
||||
if "-" in value_str:
|
||||
return value_str
|
||||
return None
|
||||
|
||||
candidate = None
|
||||
|
||||
if hasattr(payment, "callback_payload"):
|
||||
candidate = _from_mapping(getattr(payment, "callback_payload"))
|
||||
if candidate:
|
||||
return candidate
|
||||
|
||||
metadata = getattr(payment, "metadata_json", None)
|
||||
if isinstance(metadata, dict):
|
||||
if "transaction" in metadata:
|
||||
candidate = _from_mapping(metadata.get("transaction"))
|
||||
if candidate:
|
||||
return candidate
|
||||
candidate = _from_mapping(metadata)
|
||||
if candidate:
|
||||
return candidate
|
||||
|
||||
candidate = _from_mapping(remote_link)
|
||||
if candidate:
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class WataPaymentMixin:
|
||||
"""Encapsulates creation and status handling for WATA payment links."""
|
||||
|
||||
@@ -120,6 +169,99 @@ class WataPaymentMixin:
|
||||
"order_id": order_id,
|
||||
}
|
||||
|
||||
async def process_wata_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: Dict[str, Any],
|
||||
) -> bool:
|
||||
"""Handles asynchronous webhook notifications from WATA."""
|
||||
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
logger.error("WATA webhook payload не является словарём: %s", payload)
|
||||
return False
|
||||
|
||||
order_id_raw = payload.get("orderId")
|
||||
payment_link_raw = payload.get("paymentLinkId") or payload.get("id")
|
||||
transaction_status_raw = payload.get("transactionStatus")
|
||||
|
||||
order_id = str(order_id_raw) if order_id_raw else None
|
||||
payment_link_id = str(payment_link_raw) if payment_link_raw else None
|
||||
transaction_status = (transaction_status_raw or "").strip()
|
||||
|
||||
if not order_id and not payment_link_id:
|
||||
logger.error(
|
||||
"WATA webhook без orderId и paymentLinkId: %s",
|
||||
payload,
|
||||
)
|
||||
return False
|
||||
|
||||
if not transaction_status:
|
||||
logger.error("WATA webhook без статуса транзакции: %s", payload)
|
||||
return False
|
||||
|
||||
payment = None
|
||||
if order_id:
|
||||
payment = await payment_module.get_wata_payment_by_order_id(db, order_id)
|
||||
if not payment and payment_link_id:
|
||||
payment = await payment_module.get_wata_payment_by_link_id(db, payment_link_id)
|
||||
|
||||
if not payment:
|
||||
logger.error(
|
||||
"WATA платеж не найден (order_id=%s, payment_link_id=%s)",
|
||||
order_id,
|
||||
payment_link_id,
|
||||
)
|
||||
return False
|
||||
|
||||
status_lower = transaction_status.lower()
|
||||
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||
metadata["last_webhook"] = payload
|
||||
terminal_public_id = (
|
||||
payload.get("terminalPublicId")
|
||||
or payload.get("terminal_public_id")
|
||||
or payload.get("terminalPublicID")
|
||||
)
|
||||
|
||||
update_kwargs: Dict[str, Any] = {
|
||||
"metadata": metadata,
|
||||
"callback_payload": payload,
|
||||
"terminal_public_id": terminal_public_id,
|
||||
}
|
||||
|
||||
if transaction_status:
|
||||
update_kwargs["status"] = transaction_status
|
||||
update_kwargs["last_status"] = transaction_status
|
||||
|
||||
if status_lower != "paid" and not payment.is_paid:
|
||||
update_kwargs["is_paid"] = False
|
||||
|
||||
payment = await payment_module.update_wata_payment_status(
|
||||
db,
|
||||
payment=payment,
|
||||
**update_kwargs,
|
||||
)
|
||||
|
||||
if status_lower == "paid":
|
||||
if payment.is_paid:
|
||||
logger.info(
|
||||
"WATA платеж %s уже помечен как оплачен",
|
||||
payment.payment_link_id,
|
||||
)
|
||||
return True
|
||||
|
||||
await self._finalize_wata_payment(db, payment, payload)
|
||||
return True
|
||||
|
||||
if status_lower == "declined":
|
||||
logger.info(
|
||||
"WATA платеж %s отклонён",
|
||||
payment.payment_link_id,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def get_wata_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -133,6 +275,7 @@ class WataPaymentMixin:
|
||||
|
||||
remote_link: Optional[Dict[str, Any]] = None
|
||||
transaction_payload: Optional[Dict[str, Any]] = None
|
||||
transaction_id: Optional[str] = None
|
||||
|
||||
if getattr(self, "wata_service", None) and payment.payment_link_id:
|
||||
try:
|
||||
@@ -160,29 +303,84 @@ class WataPaymentMixin:
|
||||
|
||||
remote_status_normalized = (remote_status or "").lower()
|
||||
if remote_status_normalized in {"closed", "paid"} and not payment.is_paid:
|
||||
transaction_id = _extract_transaction_id(payment, remote_link)
|
||||
if transaction_id:
|
||||
try:
|
||||
transaction_payload = await self.wata_service.get_transaction( # type: ignore[union-attr]
|
||||
transaction_id
|
||||
)
|
||||
except WataAPIError as error:
|
||||
logger.error(
|
||||
"Ошибка получения WATA транзакции %s: %s",
|
||||
transaction_id,
|
||||
error,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - safety net
|
||||
logger.exception(
|
||||
"Непредвиденная ошибка при запросе WATA транзакции %s: %s",
|
||||
transaction_id,
|
||||
error,
|
||||
)
|
||||
if not transaction_payload:
|
||||
try:
|
||||
tx_response = await self.wata_service.search_transactions( # type: ignore[union-attr]
|
||||
order_id=payment.order_id,
|
||||
payment_link_id=payment.payment_link_id,
|
||||
status="Paid",
|
||||
limit=5,
|
||||
)
|
||||
items = tx_response.get("items") or []
|
||||
for item in items:
|
||||
if (item or {}).get("status") == "Paid":
|
||||
transaction_payload = item
|
||||
break
|
||||
except WataAPIError as error:
|
||||
logger.error(
|
||||
"Ошибка поиска WATA транзакций для %s: %s",
|
||||
payment.payment_link_id,
|
||||
error,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - safety net
|
||||
logger.exception("Непредвиденная ошибка при поиске WATA транзакции: %s", error)
|
||||
|
||||
if (
|
||||
not transaction_payload
|
||||
and not payment.is_paid
|
||||
and getattr(self, "wata_service", None)
|
||||
):
|
||||
fallback_transaction_id = transaction_id or _extract_transaction_id(payment)
|
||||
if fallback_transaction_id:
|
||||
try:
|
||||
tx_response = await self.wata_service.search_transactions( # type: ignore[union-attr]
|
||||
order_id=payment.order_id,
|
||||
payment_link_id=payment.payment_link_id,
|
||||
status="Paid",
|
||||
limit=5,
|
||||
transaction_payload = await self.wata_service.get_transaction( # type: ignore[union-attr]
|
||||
fallback_transaction_id
|
||||
)
|
||||
items = tx_response.get("items") or []
|
||||
for item in items:
|
||||
if (item or {}).get("status") == "Paid":
|
||||
transaction_payload = item
|
||||
break
|
||||
except WataAPIError as error:
|
||||
logger.error(
|
||||
"Ошибка поиска WATA транзакций для %s: %s",
|
||||
payment.payment_link_id,
|
||||
"Ошибка повторного запроса WATA транзакции %s: %s",
|
||||
fallback_transaction_id,
|
||||
error,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - safety net
|
||||
logger.exception("Непредвиденная ошибка при поиске WATA транзакции: %s", error)
|
||||
logger.exception(
|
||||
"Непредвиденная ошибка при повторном запросе WATA транзакции %s: %s",
|
||||
fallback_transaction_id,
|
||||
error,
|
||||
)
|
||||
|
||||
if transaction_payload and not payment.is_paid:
|
||||
payment = await self._finalize_wata_payment(db, payment, transaction_payload)
|
||||
normalized_status = None
|
||||
if isinstance(transaction_payload, dict):
|
||||
raw_status = transaction_payload.get("status") or transaction_payload.get("statusName")
|
||||
if raw_status:
|
||||
normalized_status = str(raw_status).lower()
|
||||
if normalized_status == "paid":
|
||||
payment = await self._finalize_wata_payment(db, payment, transaction_payload)
|
||||
else:
|
||||
logger.debug(
|
||||
"WATA транзакция %s в статусе %s, повторная обработка не требуется",
|
||||
transaction_id or getattr(payment, "payment_link_id", ""),
|
||||
normalized_status or "unknown",
|
||||
)
|
||||
|
||||
return {
|
||||
"payment": payment,
|
||||
@@ -200,7 +398,22 @@ class WataPaymentMixin:
|
||||
) -> Any:
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
paid_at = WataService._parse_datetime(transaction_payload.get("paymentTime"))
|
||||
if isinstance(transaction_payload, dict):
|
||||
paid_status = transaction_payload.get("status") or transaction_payload.get("statusName")
|
||||
else:
|
||||
paid_status = None
|
||||
if paid_status and str(paid_status).lower() not in {"paid", "declined", "pending"}:
|
||||
logger.debug(
|
||||
"Неизвестный статус WATA транзакции %s: %s",
|
||||
getattr(payment, "payment_link_id", ""),
|
||||
paid_status,
|
||||
)
|
||||
|
||||
paid_at = None
|
||||
if isinstance(transaction_payload, dict):
|
||||
paid_at = WataService._parse_datetime(transaction_payload.get("paymentTime"))
|
||||
if not paid_at and getattr(payment, "paid_at", None):
|
||||
paid_at = payment.paid_at
|
||||
existing_metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||
existing_metadata["transaction"] = transaction_payload
|
||||
|
||||
@@ -251,7 +464,7 @@ class WataPaymentMixin:
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
@@ -314,6 +527,25 @@ class WataPaymentMixin:
|
||||
from aiogram import types
|
||||
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
auto_purchase_success = False
|
||||
if has_saved_cart:
|
||||
try:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as auto_error:
|
||||
logger.error(
|
||||
"Ошибка автоматической покупки подписки для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
@@ -329,7 +561,7 @@ class WataPaymentMixin:
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout",
|
||||
callback_data="return_to_saved_cart",
|
||||
)
|
||||
],
|
||||
[
|
||||
|
||||
+628
-127
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from importlib import import_module
|
||||
from typing import Any, Dict, Optional, TYPE_CHECKING
|
||||
|
||||
@@ -15,6 +16,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -172,7 +176,7 @@ class YooKassaPaymentMixin:
|
||||
currency="RUB",
|
||||
description=description,
|
||||
status=yookassa_response["status"],
|
||||
confirmation_url=yookassa_response.get("confirmation_url"),
|
||||
confirmation_url=yookassa_response.get("confirmation_url"), # Используем confirmation URL
|
||||
metadata_json=payment_metadata,
|
||||
payment_method_type="bank_card",
|
||||
yookassa_created_at=None,
|
||||
@@ -193,7 +197,8 @@ class YooKassaPaymentMixin:
|
||||
return {
|
||||
"local_payment_id": local_payment.id,
|
||||
"yookassa_payment_id": yookassa_response["id"],
|
||||
"confirmation_url": yookassa_response.get("confirmation_url"),
|
||||
"confirmation_url": yookassa_response.get("confirmation_url"), # URL для подтверждения
|
||||
"qr_confirmation_data": yookassa_response.get("qr_confirmation_data"), # Данные для QR-кода
|
||||
"confirmation_token": confirmation_token,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
"amount_rubles": amount_rubles,
|
||||
@@ -205,6 +210,108 @@ class YooKassaPaymentMixin:
|
||||
logger.error("Ошибка создания платежа YooKassa СБП: %s", error)
|
||||
return None
|
||||
|
||||
async def get_yookassa_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
local_payment_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Запрашивает статус платежа в YooKassa и синхронизирует локальные данные."""
|
||||
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
payment = await payment_module.get_yookassa_payment_by_local_id(db, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
|
||||
remote_data: Optional[Dict[str, Any]] = None
|
||||
|
||||
if getattr(self, "yookassa_service", None):
|
||||
try:
|
||||
remote_data = await self.yookassa_service.get_payment_info( # type: ignore[union-attr]
|
||||
payment.yookassa_payment_id
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Ошибка получения статуса YooKassa %s: %s",
|
||||
payment.yookassa_payment_id,
|
||||
error,
|
||||
)
|
||||
|
||||
if remote_data:
|
||||
status = remote_data.get("status") or payment.status
|
||||
paid = bool(remote_data.get("paid", getattr(payment, "is_paid", False)))
|
||||
captured_raw = remote_data.get("captured_at")
|
||||
captured_at = None
|
||||
if captured_raw:
|
||||
try:
|
||||
captured_at = datetime.fromisoformat(
|
||||
str(captured_raw).replace("Z", "+00:00")
|
||||
).replace(tzinfo=None)
|
||||
except Exception as parse_error: # pragma: no cover - diagnostic log
|
||||
logger.debug(
|
||||
"Не удалось распарсить captured_at %s: %s",
|
||||
captured_raw,
|
||||
parse_error,
|
||||
)
|
||||
captured_at = None
|
||||
|
||||
payment_method_type = remote_data.get("payment_method_type")
|
||||
|
||||
updated_payment = await payment_module.update_yookassa_payment_status(
|
||||
db,
|
||||
payment.yookassa_payment_id,
|
||||
status=status,
|
||||
is_paid=paid,
|
||||
is_captured=paid and status == "succeeded",
|
||||
captured_at=captured_at,
|
||||
payment_method_type=payment_method_type,
|
||||
)
|
||||
|
||||
if updated_payment:
|
||||
payment = updated_payment
|
||||
|
||||
transaction_id = getattr(payment, "transaction_id", None)
|
||||
|
||||
if (
|
||||
payment.status == "succeeded"
|
||||
and getattr(payment, "is_paid", False)
|
||||
):
|
||||
if not transaction_id:
|
||||
try:
|
||||
await db.refresh(payment)
|
||||
transaction_id = getattr(payment, "transaction_id", None)
|
||||
except Exception as refresh_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Не удалось обновить состояние платежа YooKassa %s перед повторной обработкой: %s",
|
||||
payment.yookassa_payment_id,
|
||||
refresh_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if transaction_id:
|
||||
logger.info(
|
||||
"Пропускаем повторную обработку платежа YooKassa %s: уже связан с транзакцией %s",
|
||||
payment.yookassa_payment_id,
|
||||
transaction_id,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
await self._process_successful_yookassa_payment(db, payment)
|
||||
except Exception as process_error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Ошибка обработки успешного платежа YooKassa %s: %s",
|
||||
payment.yookassa_payment_id,
|
||||
process_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return {
|
||||
"payment": payment,
|
||||
"status": payment.status,
|
||||
"is_paid": getattr(payment, "is_paid", False),
|
||||
"remote_data": remote_data,
|
||||
}
|
||||
|
||||
async def _process_successful_yookassa_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -212,166 +319,385 @@ class YooKassaPaymentMixin:
|
||||
) -> bool:
|
||||
"""Переносит успешный платёж YooKassa в транзакции и начисляет баланс пользователю."""
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
payment_description = getattr(payment, "description", "YooKassa платеж")
|
||||
|
||||
payment_metadata: Dict[str, Any] = {}
|
||||
try:
|
||||
if hasattr(payment, "metadata_json") and payment.metadata_json:
|
||||
import json
|
||||
|
||||
if isinstance(payment.metadata_json, str):
|
||||
payment_metadata = json.loads(payment.metadata_json)
|
||||
elif isinstance(payment.metadata_json, dict):
|
||||
payment_metadata = payment.metadata_json
|
||||
logger.info(f"Метаданные платежа: {payment_metadata}")
|
||||
except Exception as parse_error:
|
||||
logger.error(f"Ошибка парсинга метаданных платежа: {parse_error}")
|
||||
|
||||
payment_purpose = payment_metadata.get("payment_purpose", "")
|
||||
is_simple_subscription = payment_purpose == "simple_subscription_purchase"
|
||||
|
||||
transaction_type = (
|
||||
TransactionType.SUBSCRIPTION_PAYMENT
|
||||
if is_simple_subscription
|
||||
else TransactionType.DEPOSIT
|
||||
)
|
||||
transaction_description = (
|
||||
f"Оплата подписки через YooKassa: {payment_description}"
|
||||
if is_simple_subscription
|
||||
else f"Пополнение через YooKassa: {payment_description}"
|
||||
)
|
||||
|
||||
transaction = await payment_module.create_transaction(
|
||||
db=db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
type=transaction_type,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=f"Пополнение через YooKassa: {payment_description}",
|
||||
description=transaction_description,
|
||||
payment_method=PaymentMethod.YOOKASSA,
|
||||
external_id=payment.yookassa_payment_id,
|
||||
is_completed=True,
|
||||
)
|
||||
|
||||
await payment_module.link_yookassa_payment_to_transaction(
|
||||
linked_payment = await payment_module.link_yookassa_payment_to_transaction(
|
||||
db,
|
||||
payment.yookassa_payment_id,
|
||||
transaction.id,
|
||||
)
|
||||
|
||||
if linked_payment:
|
||||
payment.transaction_id = getattr(linked_payment, "transaction_id", transaction.id)
|
||||
if hasattr(linked_payment, "transaction"):
|
||||
payment.transaction = linked_payment.transaction
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if user:
|
||||
old_balance = getattr(user, "balance_kopeks", 0)
|
||||
was_first_topup = not getattr(user, "has_made_first_topup", False)
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = ("🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение")
|
||||
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
if is_simple_subscription:
|
||||
logger.info(
|
||||
"YooKassa платеж %s обработан как покупка подписки. Баланс пользователя %s не изменяется.",
|
||||
payment.yookassa_payment_id,
|
||||
user.id,
|
||||
payment.amount_kopeks,
|
||||
getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка обработки реферального пополнения YooKassa: %s",
|
||||
error,
|
||||
else:
|
||||
old_balance = getattr(user, "balance_kopeks", 0)
|
||||
was_first_topup = not getattr(user, "has_made_first_topup", False)
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
# Обновляем пользователя с нужными связями, чтобы избежать проблем с ленивой загрузкой
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from sqlalchemy.orm import selectinload
|
||||
from app.database.models import User, Subscription as SubscriptionModel
|
||||
|
||||
# Загружаем пользователя с подпиской и промо-группой
|
||||
full_user_result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.options(selectinload(User.user_promo_groups))
|
||||
.where(User.id == user.id)
|
||||
)
|
||||
full_user = full_user_result.scalar_one_or_none()
|
||||
|
||||
# Используем обновленные данные или исходные, если не удалось обновить
|
||||
subscription = full_user.subscription if full_user else getattr(user, "subscription", None)
|
||||
promo_group = full_user.get_primary_promo_group() if full_user else (user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None)
|
||||
|
||||
# Используем full_user для форматирования реферальной информации, чтобы избежать проблем с ленивой загрузкой
|
||||
user_for_referrer = full_user if full_user else user
|
||||
referrer_info = format_referrer_info(user_for_referrer)
|
||||
topup_status = (
|
||||
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
)
|
||||
|
||||
if was_first_topup and not getattr(user, "has_made_first_topup", False):
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
# Отправляем уведомления админам
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.services.admin_notification_service import (
|
||||
AdminNotificationService,
|
||||
)
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
logger.info("Уведомление админам о пополнении отправлено успешно")
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления админам о YooKassa пополнении: %s",
|
||||
error,
|
||||
exc_info=True # Добавляем полный стек вызовов для отладки
|
||||
)
|
||||
|
||||
# Отправляем уведомление пользователю
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
# Передаем только простые данные, чтобы избежать проблем с ленивой загрузкой
|
||||
await self._send_payment_success_notification(
|
||||
user.telegram_id,
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
payment.amount_kopeks,
|
||||
user=None, # Передаем None, чтобы _ensure_user_snapshot загрузил данные сам
|
||||
db=db,
|
||||
payment_method_title="Банковская карта (YooKassa)",
|
||||
getattr(self, "bot", None),
|
||||
)
|
||||
logger.info("Уведомление пользователю о платеже отправлено успешно")
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о платеже: %s",
|
||||
"Ошибка обработки реферального пополнения YooKassa: %s",
|
||||
error,
|
||||
exc_info=True # Добавляем полный стек вызовов для отладки
|
||||
)
|
||||
|
||||
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
||||
# ВАЖНО: этот код должен выполняться даже при ошибках в уведомлениях
|
||||
logger.info(f"Проверяем наличие сохраненной корзины для пользователя {user.id}")
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
try:
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
logger.info(f"Результат проверки корзины для пользователя {user.id}: {has_saved_cart}")
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from app.localization.texts import get_texts
|
||||
from aiogram import types
|
||||
if was_first_topup and not getattr(user, "has_made_first_topup", False):
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
# Отправляем уведомления админам
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.services.admin_notification_service import (
|
||||
AdminNotificationService,
|
||||
)
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
|
||||
# Обновляем пользователя, чтобы избежать проблем с ленивой загрузкой
|
||||
from app.database.crud.user import get_user_by_id
|
||||
refreshed_user = await get_user_by_id(db, user.id)
|
||||
|
||||
await notification_service.send_balance_topup_notification(
|
||||
refreshed_user or user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
logger.info("Уведомление админам о пополнении отправлено успешно")
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления админам о YooKassa пополнении: %s",
|
||||
error,
|
||||
exc_info=True, # Добавляем полный стек вызовов для отладки
|
||||
)
|
||||
|
||||
# Отправляем уведомление пользователю
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
# Передаем только простые данные, чтобы избежать проблем с ленивой загрузкой
|
||||
await self._send_payment_success_notification(
|
||||
user.telegram_id,
|
||||
payment.amount_kopeks,
|
||||
user=None, # Передаем None, чтобы _ensure_user_snapshot загрузил данные сам
|
||||
db=db,
|
||||
payment_method_title="Банковская карта (YooKassa)",
|
||||
)
|
||||
logger.info("Уведомление пользователю о платеже отправлено успешно")
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о платеже: %s",
|
||||
error,
|
||||
exc_info=True, # Добавляем полный стек вызовов для отладки
|
||||
)
|
||||
|
||||
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
||||
# ВАЖНО: этот код должен выполняться даже при ошибках в уведомлениях
|
||||
logger.info(f"Проверяем наличие сохраненной корзины для пользователя {user.id}")
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
try:
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
logger.info(
|
||||
"Результат проверки корзины для пользователя %s: %s",
|
||||
user.id,
|
||||
has_saved_cart,
|
||||
)
|
||||
|
||||
auto_purchase_success = False
|
||||
if has_saved_cart:
|
||||
try:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as auto_error:
|
||||
logger.error(
|
||||
"Ошибка автоматической покупки подписки для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from app.localization.texts import get_texts
|
||||
from aiogram import types
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
|
||||
total_amount=settings.format_price(payment.amount_kopeks)
|
||||
)
|
||||
|
||||
# Создаем клавиатуру с кнопками
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="return_to_saved_cart",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="💰 Мой баланс",
|
||||
callback_data="menu_balance",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🏠 Главное меню",
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"У пользователя %s нет сохраненной корзины, бот недоступен или покупка уже выполнена",
|
||||
user.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Критическая ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if is_simple_subscription:
|
||||
logger.info(f"Обнаружен платеж простой покупки подписки для пользователя {user.id}")
|
||||
try:
|
||||
# Активируем подписку
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
|
||||
total_amount=settings.format_price(payment.amount_kopeks)
|
||||
# Получаем параметры подписки из метаданных
|
||||
subscription_period = int(payment_metadata.get("subscription_period", 30))
|
||||
order_id = payment_metadata.get("order_id")
|
||||
|
||||
logger.info(f"Активация подписки: период={subscription_period} дней, заказ={order_id}")
|
||||
|
||||
# Активируем pending подписку пользователя
|
||||
from app.database.crud.subscription import activate_pending_subscription
|
||||
subscription = await activate_pending_subscription(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
period_days=subscription_period
|
||||
)
|
||||
|
||||
# Создаем клавиатуру с кнопками
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text="💰 Мой баланс",
|
||||
callback_data="menu_balance"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text="🏠 Главное меню",
|
||||
callback_data="back_to_menu"
|
||||
)]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
|
||||
else:
|
||||
logger.info(f"У пользователя {user.id} нет сохраненной корзины или бот недоступен")
|
||||
except Exception as e:
|
||||
logger.error(f"Критическая ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
|
||||
if subscription:
|
||||
logger.info(f"Подписка успешно активирована для пользователя {user.id}")
|
||||
|
||||
logger.info(
|
||||
"Успешно обработан платеж YooKassa %s: пользователь %s получил %s₽",
|
||||
payment.yookassa_payment_id,
|
||||
payment.user_id,
|
||||
payment.amount_kopeks / 100,
|
||||
)
|
||||
# Обновляем данные подписки в RemnaWave, чтобы получить актуальные ссылки
|
||||
try:
|
||||
remnawave_user = await subscription_service.create_remnawave_user(db, subscription)
|
||||
if remnawave_user:
|
||||
await db.refresh(subscription)
|
||||
except Exception as sync_error:
|
||||
logger.error(
|
||||
"Ошибка синхронизации подписки с RemnaWave для пользователя %s: %s",
|
||||
user.id,
|
||||
sync_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Отправляем уведомление пользователю об активации подписки
|
||||
if getattr(self, "bot", None):
|
||||
from app.localization.texts import get_texts
|
||||
from aiogram import types
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
success_message = (
|
||||
f"✅ <b>Подписка успешно активирована!</b>\n\n"
|
||||
f"📅 Период: {subscription_period} дней\n"
|
||||
f"📱 Устройства: 1\n"
|
||||
f"📊 Трафик: Безлимит\n"
|
||||
f"💳 Оплата: {settings.format_price(payment.amount_kopeks)} (YooKassa)\n\n"
|
||||
f"🔗 Для подключения перейдите в раздел 'Моя подписка'"
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
|
||||
[types.InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=success_message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Успешно обработан платеж YooKassa %s: пользователь %s получил %s₽",
|
||||
payment.yookassa_payment_id,
|
||||
payment.user_id,
|
||||
payment.amount_kopeks / 100,
|
||||
)
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.services.admin_notification_service import (
|
||||
AdminNotificationService,
|
||||
)
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
|
||||
# Обновляем пользователя с нужными связями, чтобы избежать проблем с ленивой загрузкой
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from app.database.models import User, Subscription as SubscriptionModel
|
||||
|
||||
# Загружаем пользователя с подпиской и промо-группой
|
||||
full_user_result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.options(selectinload(User.user_promo_groups))
|
||||
.where(User.id == user.id)
|
||||
)
|
||||
full_user = full_user_result.scalar_one_or_none()
|
||||
|
||||
# Загружаем подписку отдельно, если нужно
|
||||
subscription_result = await db.execute(
|
||||
select(SubscriptionModel)
|
||||
.where(SubscriptionModel.user_id == user.id)
|
||||
)
|
||||
subscription_db = subscription_result.scalar_one_or_none()
|
||||
|
||||
await notification_service.send_subscription_purchase_notification(
|
||||
db,
|
||||
full_user or user,
|
||||
subscription_db or subscription,
|
||||
transaction,
|
||||
subscription_period,
|
||||
was_trial_conversion=False,
|
||||
)
|
||||
except Exception as admin_error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления админам о покупке подписки через YooKassa: %s",
|
||||
admin_error,
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
logger.error(f"Ошибка активации подписки для пользователя {user.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка активации подписки для пользователя {user.id}: {e}", exc_info=True)
|
||||
|
||||
if is_simple_subscription:
|
||||
logger.info(
|
||||
"Успешно обработан платеж YooKassa %s как покупка подписки: пользователь %s, сумма %s₽",
|
||||
payment.yookassa_payment_id,
|
||||
payment.user_id,
|
||||
payment.amount_kopeks / 100,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Успешно обработан платеж YooKassa %s: пользователь %s пополнил баланс на %s₽",
|
||||
payment.yookassa_payment_id,
|
||||
payment.user_id,
|
||||
payment.amount_kopeks / 100,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -403,18 +729,44 @@ class YooKassaPaymentMixin:
|
||||
logger.warning(
|
||||
"Локальный платеж для YooKassa id %s не найден", yookassa_payment_id
|
||||
)
|
||||
return False
|
||||
payment = await self._restore_missing_yookassa_payment(db, event_object)
|
||||
|
||||
if not payment:
|
||||
logger.error(
|
||||
"Не удалось восстановить локальную запись платежа YooKassa %s",
|
||||
yookassa_payment_id,
|
||||
)
|
||||
return False
|
||||
|
||||
payment.status = event_object.get("status", payment.status)
|
||||
payment.confirmation_url = event_object.get("confirmation_url")
|
||||
payment.confirmation_url = self._extract_confirmation_url(event_object)
|
||||
|
||||
current_paid = getattr(payment, "paid", False)
|
||||
payment.paid = event_object.get("paid", current_paid)
|
||||
payment.payment_method_type = (
|
||||
(event_object.get("payment_method") or {}).get("type")
|
||||
or payment.payment_method_type
|
||||
)
|
||||
payment.refundable = event_object.get("refundable", getattr(payment, "refundable", False))
|
||||
|
||||
current_paid = bool(getattr(payment, "is_paid", getattr(payment, "paid", False)))
|
||||
payment.is_paid = bool(event_object.get("paid", current_paid))
|
||||
|
||||
captured_at_raw = event_object.get("captured_at")
|
||||
if captured_at_raw:
|
||||
try:
|
||||
payment.captured_at = datetime.fromisoformat(
|
||||
captured_at_raw.replace("Z", "+00:00")
|
||||
).replace(tzinfo=None)
|
||||
except Exception as error:
|
||||
logger.debug(
|
||||
"Не удалось распарсить captured_at=%s: %s",
|
||||
captured_at_raw,
|
||||
error,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
if payment.status == "succeeded" and payment.paid:
|
||||
if payment.status == "succeeded" and payment.is_paid:
|
||||
return await self._process_successful_yookassa_payment(db, payment)
|
||||
|
||||
logger.info(
|
||||
@@ -423,3 +775,152 @@ class YooKassaPaymentMixin:
|
||||
payment.status,
|
||||
)
|
||||
return True
|
||||
|
||||
async def _restore_missing_yookassa_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
event_object: Dict[str, Any],
|
||||
) -> Optional["YooKassaPayment"]:
|
||||
"""Создает локальную запись платежа на основе данных webhook, если она отсутствует."""
|
||||
|
||||
yookassa_payment_id = event_object.get("id")
|
||||
if not yookassa_payment_id:
|
||||
return None
|
||||
|
||||
metadata = self._normalise_yookassa_metadata(event_object.get("metadata"))
|
||||
user_id_raw = metadata.get("user_id") or metadata.get("userId")
|
||||
|
||||
if user_id_raw is None:
|
||||
logger.error(
|
||||
"Webhook YooKassa %s не содержит user_id в metadata. Невозможно восстановить платеж.",
|
||||
yookassa_payment_id,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
user_id = int(user_id_raw)
|
||||
except (TypeError, ValueError):
|
||||
logger.error(
|
||||
"Webhook YooKassa %s содержит некорректный user_id=%s",
|
||||
yookassa_payment_id,
|
||||
user_id_raw,
|
||||
)
|
||||
return None
|
||||
|
||||
amount_info = event_object.get("amount") or {}
|
||||
amount_value = amount_info.get("value")
|
||||
currency = (amount_info.get("currency") or "RUB").upper()
|
||||
|
||||
if amount_value is None:
|
||||
logger.error(
|
||||
"Webhook YooKassa %s не содержит сумму платежа",
|
||||
yookassa_payment_id,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
amount_kopeks = int((Decimal(str(amount_value)) * 100).quantize(Decimal("1")))
|
||||
except (InvalidOperation, ValueError) as error:
|
||||
logger.error(
|
||||
"Некорректная сумма в webhook YooKassa %s: %s (%s)",
|
||||
yookassa_payment_id,
|
||||
amount_value,
|
||||
error,
|
||||
)
|
||||
return None
|
||||
|
||||
description = event_object.get("description") or metadata.get("description") or "YooKassa платеж"
|
||||
payment_method_type = (event_object.get("payment_method") or {}).get("type")
|
||||
|
||||
yookassa_created_at = None
|
||||
created_at_raw = event_object.get("created_at")
|
||||
if created_at_raw:
|
||||
try:
|
||||
yookassa_created_at = datetime.fromisoformat(
|
||||
created_at_raw.replace("Z", "+00:00")
|
||||
).replace(tzinfo=None)
|
||||
except Exception as error: # pragma: no cover - диагностический лог
|
||||
logger.debug(
|
||||
"Не удалось распарсить created_at=%s для YooKassa %s: %s",
|
||||
created_at_raw,
|
||||
yookassa_payment_id,
|
||||
error,
|
||||
)
|
||||
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
local_payment = await payment_module.create_yookassa_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
yookassa_payment_id=yookassa_payment_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
status=event_object.get("status", "pending"),
|
||||
confirmation_url=self._extract_confirmation_url(event_object),
|
||||
metadata_json=metadata,
|
||||
payment_method_type=payment_method_type,
|
||||
yookassa_created_at=yookassa_created_at,
|
||||
test_mode=bool(event_object.get("test") or event_object.get("test_mode")),
|
||||
)
|
||||
|
||||
if not local_payment:
|
||||
return None
|
||||
|
||||
await payment_module.update_yookassa_payment_status(
|
||||
db=db,
|
||||
yookassa_payment_id=yookassa_payment_id,
|
||||
status=event_object.get("status", local_payment.status),
|
||||
is_paid=bool(event_object.get("paid")),
|
||||
is_captured=event_object.get("status") == "succeeded",
|
||||
captured_at=self._parse_datetime(event_object.get("captured_at")),
|
||||
payment_method_type=payment_method_type,
|
||||
)
|
||||
|
||||
return await payment_module.get_yookassa_payment_by_id(db, yookassa_payment_id)
|
||||
|
||||
@staticmethod
|
||||
def _normalise_yookassa_metadata(metadata: Any) -> Dict[str, Any]:
|
||||
if isinstance(metadata, dict):
|
||||
return metadata
|
||||
|
||||
if isinstance(metadata, list):
|
||||
normalised: Dict[str, Any] = {}
|
||||
for item in metadata:
|
||||
key = item.get("key") if isinstance(item, dict) else None
|
||||
if key:
|
||||
normalised[key] = item.get("value")
|
||||
return normalised
|
||||
|
||||
if isinstance(metadata, str):
|
||||
try:
|
||||
import json
|
||||
|
||||
parsed = json.loads(metadata)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except json.JSONDecodeError:
|
||||
logger.debug("Не удалось распарсить metadata webhook YooKassa: %s", metadata)
|
||||
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _extract_confirmation_url(event_object: Dict[str, Any]) -> Optional[str]:
|
||||
if "confirmation_url" in event_object:
|
||||
return event_object.get("confirmation_url")
|
||||
|
||||
confirmation = event_object.get("confirmation")
|
||||
if isinstance(confirmation, dict):
|
||||
return confirmation.get("confirmation_url") or confirmation.get("return_url")
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_datetime(raw_value: Optional[str]) -> Optional[datetime]:
|
||||
if not raw_value:
|
||||
return None
|
||||
|
||||
try:
|
||||
return datetime.fromisoformat(raw_value.replace("Z", "+00:00")).replace(tzinfo=None)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -11,11 +11,13 @@ from aiogram import Bot
|
||||
from app.config import settings
|
||||
from app.utils.currency_converter import currency_converter # noqa: F401
|
||||
from app.external.cryptobot import CryptoBotService
|
||||
from app.external.heleket import HeleketService
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
from app.services.mulenpay_service import MulenPayService
|
||||
from app.services.pal24_service import Pal24Service
|
||||
from app.services.payment import (
|
||||
CryptoBotPaymentMixin,
|
||||
HeleketPaymentMixin,
|
||||
MulenPayPaymentMixin,
|
||||
Pal24PaymentMixin,
|
||||
PaymentCommonMixin,
|
||||
@@ -38,6 +40,11 @@ async def create_yookassa_payment(*args, **kwargs):
|
||||
return await yk_crud.create_yookassa_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_yookassa_payment_status(*args, **kwargs):
|
||||
yk_crud = import_module("app.database.crud.yookassa")
|
||||
return await yk_crud.update_yookassa_payment_status(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_yookassa_payment_to_transaction(*args, **kwargs):
|
||||
yk_crud = import_module("app.database.crud.yookassa")
|
||||
return await yk_crud.link_yookassa_payment_to_transaction(*args, **kwargs)
|
||||
@@ -48,6 +55,11 @@ async def get_yookassa_payment_by_id(*args, **kwargs):
|
||||
return await yk_crud.get_yookassa_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_yookassa_payment_by_local_id(*args, **kwargs):
|
||||
yk_crud = import_module("app.database.crud.yookassa")
|
||||
return await yk_crud.get_yookassa_payment_by_local_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_transaction(*args, **kwargs):
|
||||
transaction_crud = import_module("app.database.crud.transaction")
|
||||
return await transaction_crud.create_transaction(*args, **kwargs)
|
||||
@@ -178,12 +190,38 @@ async def link_cryptobot_payment_to_transaction(*args, **kwargs):
|
||||
return await crypto_crud.link_cryptobot_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_heleket_payment(*args, **kwargs):
|
||||
heleket_crud = import_module("app.database.crud.heleket")
|
||||
return await heleket_crud.create_heleket_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_heleket_payment_by_uuid(*args, **kwargs):
|
||||
heleket_crud = import_module("app.database.crud.heleket")
|
||||
return await heleket_crud.get_heleket_payment_by_uuid(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_heleket_payment_by_id(*args, **kwargs):
|
||||
heleket_crud = import_module("app.database.crud.heleket")
|
||||
return await heleket_crud.get_heleket_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_heleket_payment(*args, **kwargs):
|
||||
heleket_crud = import_module("app.database.crud.heleket")
|
||||
return await heleket_crud.update_heleket_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_heleket_payment_to_transaction(*args, **kwargs):
|
||||
heleket_crud = import_module("app.database.crud.heleket")
|
||||
return await heleket_crud.link_heleket_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
class PaymentService(
|
||||
PaymentCommonMixin,
|
||||
TelegramStarsMixin,
|
||||
YooKassaPaymentMixin,
|
||||
TributePaymentMixin,
|
||||
CryptoBotPaymentMixin,
|
||||
HeleketPaymentMixin,
|
||||
MulenPayPaymentMixin,
|
||||
Pal24PaymentMixin,
|
||||
WataPaymentMixin,
|
||||
@@ -201,6 +239,9 @@ class PaymentService(
|
||||
self.cryptobot_service = (
|
||||
CryptoBotService() if settings.is_cryptobot_enabled() else None
|
||||
)
|
||||
self.heleket_service = (
|
||||
HeleketService() if settings.is_heleket_enabled() else None
|
||||
)
|
||||
self.mulenpay_service = (
|
||||
MulenPayService() if settings.is_mulenpay_enabled() else None
|
||||
)
|
||||
@@ -209,12 +250,14 @@ class PaymentService(
|
||||
)
|
||||
self.wata_service = WataService() if settings.is_wata_enabled() else None
|
||||
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
logger.debug(
|
||||
"PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, "
|
||||
"MulenPay=%s, Pal24=%s, Wata=%s)",
|
||||
"PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, Heleket=%s, %s=%s, Pal24=%s, Wata=%s)",
|
||||
bool(self.yookassa_service),
|
||||
bool(self.stars_service),
|
||||
bool(self.cryptobot_service),
|
||||
bool(self.heleket_service),
|
||||
mulenpay_name,
|
||||
bool(self.mulenpay_service),
|
||||
bool(self.pal24_service),
|
||||
bool(self.wata_service),
|
||||
|
||||
@@ -0,0 +1,767 @@
|
||||
"""Helpers for inspecting and manually checking pending top-up payments."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional
|
||||
|
||||
from sqlalchemy import desc, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import (
|
||||
CryptoBotPayment,
|
||||
HeleketPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PaymentMethod,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
WataPayment,
|
||||
YooKassaPayment,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
PENDING_MAX_AGE = timedelta(hours=24)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PendingPayment:
|
||||
"""Normalized representation of a provider specific payment entry."""
|
||||
|
||||
method: PaymentMethod
|
||||
local_id: int
|
||||
identifier: str
|
||||
amount_kopeks: int
|
||||
status: str
|
||||
is_paid: bool
|
||||
created_at: datetime
|
||||
user: User
|
||||
payment: Any
|
||||
expires_at: Optional[datetime] = None
|
||||
|
||||
def is_recent(self, max_age: timedelta = PENDING_MAX_AGE) -> bool:
|
||||
return (datetime.utcnow() - self.created_at) <= max_age
|
||||
|
||||
|
||||
SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
|
||||
{
|
||||
PaymentMethod.YOOKASSA,
|
||||
PaymentMethod.MULENPAY,
|
||||
PaymentMethod.PAL24,
|
||||
PaymentMethod.WATA,
|
||||
PaymentMethod.HELEKET,
|
||||
PaymentMethod.CRYPTOBOT,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
|
||||
{
|
||||
PaymentMethod.YOOKASSA,
|
||||
PaymentMethod.MULENPAY,
|
||||
PaymentMethod.PAL24,
|
||||
PaymentMethod.WATA,
|
||||
PaymentMethod.CRYPTOBOT,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def method_display_name(method: PaymentMethod) -> str:
|
||||
if method == PaymentMethod.MULENPAY:
|
||||
return settings.get_mulenpay_display_name()
|
||||
if method == PaymentMethod.PAL24:
|
||||
return "PayPalych"
|
||||
if method == PaymentMethod.YOOKASSA:
|
||||
return "YooKassa"
|
||||
if method == PaymentMethod.WATA:
|
||||
return "WATA"
|
||||
if method == PaymentMethod.CRYPTOBOT:
|
||||
return "CryptoBot"
|
||||
if method == PaymentMethod.HELEKET:
|
||||
return "Heleket"
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
return "Telegram Stars"
|
||||
return method.value
|
||||
|
||||
|
||||
def _method_is_enabled(method: PaymentMethod) -> bool:
|
||||
if method == PaymentMethod.YOOKASSA:
|
||||
return settings.is_yookassa_enabled()
|
||||
if method == PaymentMethod.MULENPAY:
|
||||
return settings.is_mulenpay_enabled()
|
||||
if method == PaymentMethod.PAL24:
|
||||
return settings.is_pal24_enabled()
|
||||
if method == PaymentMethod.WATA:
|
||||
return settings.is_wata_enabled()
|
||||
if method == PaymentMethod.CRYPTOBOT:
|
||||
return settings.is_cryptobot_enabled()
|
||||
if method == PaymentMethod.HELEKET:
|
||||
return settings.is_heleket_enabled()
|
||||
return False
|
||||
|
||||
|
||||
def get_enabled_auto_methods() -> List[PaymentMethod]:
|
||||
return [
|
||||
method
|
||||
for method in SUPPORTED_AUTO_CHECK_METHODS
|
||||
if _method_is_enabled(method)
|
||||
]
|
||||
|
||||
|
||||
class AutoPaymentVerificationService:
|
||||
"""Background checker that periodically refreshes pending payments."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._task: Optional[asyncio.Task[None]] = None
|
||||
self._payment_service: Optional["PaymentService"] = None
|
||||
|
||||
def set_payment_service(self, payment_service: "PaymentService") -> None:
|
||||
self._payment_service = payment_service
|
||||
|
||||
def is_running(self) -> bool:
|
||||
return self._task is not None and not self._task.done()
|
||||
|
||||
async def start(self) -> None:
|
||||
await self.stop()
|
||||
|
||||
if not settings.is_payment_verification_auto_check_enabled():
|
||||
logger.info("Автопроверка пополнений отключена настройками")
|
||||
return
|
||||
|
||||
if not self._payment_service:
|
||||
logger.warning(
|
||||
"Автопроверка пополнений не запущена: PaymentService не инициализирован"
|
||||
)
|
||||
return
|
||||
|
||||
methods = get_enabled_auto_methods()
|
||||
if not methods:
|
||||
logger.info(
|
||||
"Автопроверка пополнений не запущена: нет активных провайдеров"
|
||||
)
|
||||
return
|
||||
|
||||
display_names = ", ".join(
|
||||
sorted(method_display_name(method) for method in methods)
|
||||
)
|
||||
interval_minutes = settings.get_payment_verification_auto_check_interval()
|
||||
|
||||
self._task = asyncio.create_task(self._auto_check_loop())
|
||||
logger.info(
|
||||
"🔄 Автопроверка пополнений запущена (каждые %s мин) для: %s",
|
||||
interval_minutes,
|
||||
display_names,
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
async def _auto_check_loop(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
interval_minutes = settings.get_payment_verification_auto_check_interval()
|
||||
try:
|
||||
if (
|
||||
settings.is_payment_verification_auto_check_enabled()
|
||||
and self._payment_service
|
||||
):
|
||||
methods = get_enabled_auto_methods()
|
||||
if methods:
|
||||
await self._run_checks(methods)
|
||||
else:
|
||||
logger.debug(
|
||||
"Автопроверка пополнений: активных провайдеров нет"
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Автопроверка пополнений: отключена настройками или сервис не готов"
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as error: # noqa: BLE001 - логируем непредвиденные ошибки
|
||||
logger.error(
|
||||
"Ошибка автопроверки пополнений: %s",
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
await asyncio.sleep(max(1, interval_minutes) * 60)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Автопроверка пополнений остановлена")
|
||||
raise
|
||||
|
||||
async def _run_checks(self, methods: List[PaymentMethod]) -> None:
|
||||
if not self._payment_service:
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
pending = await list_recent_pending_payments(session)
|
||||
candidates = [
|
||||
record
|
||||
for record in pending
|
||||
if record.method in methods and not record.is_paid
|
||||
]
|
||||
|
||||
if not candidates:
|
||||
logger.debug(
|
||||
"Автопроверка пополнений: подходящих ожидающих платежей нет"
|
||||
)
|
||||
return
|
||||
|
||||
counts = Counter(record.method for record in candidates)
|
||||
summary = ", ".join(
|
||||
f"{method_display_name(method)}: {count}"
|
||||
for method, count in sorted(
|
||||
counts.items(), key=lambda item: method_display_name(item[0])
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
"🔄 Автопроверка пополнений: найдено %s инвойсов (%s)",
|
||||
len(candidates),
|
||||
summary,
|
||||
)
|
||||
|
||||
for record in candidates:
|
||||
refreshed = await run_manual_check(
|
||||
session,
|
||||
record.method,
|
||||
record.local_id,
|
||||
self._payment_service,
|
||||
)
|
||||
|
||||
if not refreshed:
|
||||
logger.debug(
|
||||
"Автопроверка пополнений: не удалось обновить %s %s",
|
||||
method_display_name(record.method),
|
||||
record.identifier,
|
||||
)
|
||||
continue
|
||||
|
||||
if refreshed.is_paid and not record.is_paid:
|
||||
logger.info(
|
||||
"✅ %s %s отмечен как оплаченный после автопроверки",
|
||||
method_display_name(refreshed.method),
|
||||
refreshed.identifier,
|
||||
)
|
||||
elif refreshed.status != record.status:
|
||||
logger.info(
|
||||
"ℹ️ %s %s обновлён: %s → %s",
|
||||
method_display_name(refreshed.method),
|
||||
refreshed.identifier,
|
||||
record.status or "—",
|
||||
refreshed.status or "—",
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Автопроверка пополнений: %s %s без изменений (%s)",
|
||||
method_display_name(refreshed.method),
|
||||
refreshed.identifier,
|
||||
refreshed.status or "—",
|
||||
)
|
||||
|
||||
if session.in_transaction():
|
||||
await session.commit()
|
||||
except Exception:
|
||||
if session.in_transaction():
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
auto_payment_verification_service = AutoPaymentVerificationService()
|
||||
|
||||
def _is_pal24_pending(payment: Pal24Payment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or "").upper()
|
||||
return status in {"NEW", "PROCESS"}
|
||||
|
||||
|
||||
def _is_mulenpay_pending(payment: MulenPayPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or "").lower()
|
||||
return status in {"created", "processing", "hold"}
|
||||
|
||||
|
||||
def _is_wata_pending(payment: WataPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or "").lower()
|
||||
return status not in {
|
||||
"paid",
|
||||
"closed",
|
||||
"declined",
|
||||
"canceled",
|
||||
"cancelled",
|
||||
"expired",
|
||||
}
|
||||
|
||||
|
||||
def _is_heleket_pending(payment: HeleketPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or "").lower()
|
||||
return status not in {"paid", "paid_over", "cancel", "canceled", "failed", "fail", "expired"}
|
||||
|
||||
|
||||
def _is_yookassa_pending(payment: YooKassaPayment) -> bool:
|
||||
if getattr(payment, "is_paid", False) and payment.status == "succeeded":
|
||||
return False
|
||||
status = (payment.status or "").lower()
|
||||
return status in {"pending", "waiting_for_capture"}
|
||||
|
||||
|
||||
def _is_cryptobot_pending(payment: CryptoBotPayment) -> bool:
|
||||
status = (payment.status or "").lower()
|
||||
return status == "active"
|
||||
|
||||
|
||||
def _parse_cryptobot_amount_kopeks(payment: CryptoBotPayment) -> int:
|
||||
payload = payment.payload or ""
|
||||
match = re.search(r"_(\d+)$", payload)
|
||||
if match:
|
||||
try:
|
||||
return int(match.group(1))
|
||||
except ValueError:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
def _metadata_is_balance(payment: YooKassaPayment) -> bool:
|
||||
metadata = getattr(payment, "metadata_json", {}) or {}
|
||||
payment_type = str(metadata.get("type") or metadata.get("payment_type") or "").lower()
|
||||
return payment_type.startswith("balance_topup")
|
||||
|
||||
|
||||
def _build_record(method: PaymentMethod, payment: Any, *, identifier: str, amount_kopeks: int,
|
||||
status: str, is_paid: bool, expires_at: Optional[datetime] = None) -> Optional[PendingPayment]:
|
||||
user = getattr(payment, "user", None)
|
||||
if user is None:
|
||||
logger.debug("Skipping %s payment %s without linked user", method.value, identifier)
|
||||
return None
|
||||
|
||||
created_at = getattr(payment, "created_at", None)
|
||||
if not isinstance(created_at, datetime):
|
||||
logger.debug("Skipping %s payment %s without valid created_at", method.value, identifier)
|
||||
return None
|
||||
|
||||
local_id = getattr(payment, "id", None)
|
||||
if local_id is None:
|
||||
logger.debug("Skipping %s payment without local id", method.value)
|
||||
return None
|
||||
|
||||
return PendingPayment(
|
||||
method=method,
|
||||
local_id=int(local_id),
|
||||
identifier=identifier,
|
||||
amount_kopeks=amount_kopeks,
|
||||
status=status,
|
||||
is_paid=is_paid,
|
||||
created_at=created_at,
|
||||
user=user,
|
||||
payment=payment,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_pal24_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
|
||||
stmt = (
|
||||
select(Pal24Payment)
|
||||
.options(selectinload(Pal24Payment.user))
|
||||
.where(Pal24Payment.created_at >= cutoff)
|
||||
.order_by(desc(Pal24Payment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: List[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_pal24_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.PAL24,
|
||||
payment,
|
||||
identifier=payment.bill_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, "expires_at", None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_mulenpay_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
|
||||
stmt = (
|
||||
select(MulenPayPayment)
|
||||
.options(selectinload(MulenPayPayment.user))
|
||||
.where(MulenPayPayment.created_at >= cutoff)
|
||||
.order_by(desc(MulenPayPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: List[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_mulenpay_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.MULENPAY,
|
||||
payment,
|
||||
identifier=payment.uuid,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_wata_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
|
||||
stmt = (
|
||||
select(WataPayment)
|
||||
.options(selectinload(WataPayment.user))
|
||||
.where(WataPayment.created_at >= cutoff)
|
||||
.order_by(desc(WataPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: List[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_wata_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.WATA,
|
||||
payment,
|
||||
identifier=payment.payment_link_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, "expires_at", None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_heleket_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
|
||||
stmt = (
|
||||
select(HeleketPayment)
|
||||
.options(selectinload(HeleketPayment.user))
|
||||
.where(HeleketPayment.created_at >= cutoff)
|
||||
.order_by(desc(HeleketPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: List[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_heleket_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.HELEKET,
|
||||
payment,
|
||||
identifier=payment.uuid,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, "expires_at", None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_yookassa_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
|
||||
stmt = (
|
||||
select(YooKassaPayment)
|
||||
.options(selectinload(YooKassaPayment.user))
|
||||
.where(YooKassaPayment.created_at >= cutoff)
|
||||
.order_by(desc(YooKassaPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: List[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if payment.transaction_id:
|
||||
continue
|
||||
if not _metadata_is_balance(payment):
|
||||
continue
|
||||
if not _is_yookassa_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.YOOKASSA,
|
||||
payment,
|
||||
identifier=payment.yookassa_payment_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(getattr(payment, "is_paid", False)),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_cryptobot_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
|
||||
stmt = (
|
||||
select(CryptoBotPayment)
|
||||
.options(selectinload(CryptoBotPayment.user))
|
||||
.where(CryptoBotPayment.created_at >= cutoff)
|
||||
.order_by(desc(CryptoBotPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: List[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
status = (payment.status or "").lower()
|
||||
if not _is_cryptobot_pending(payment) and status != "paid":
|
||||
continue
|
||||
amount_kopeks = _parse_cryptobot_amount_kopeks(payment)
|
||||
record = _build_record(
|
||||
PaymentMethod.CRYPTOBOT,
|
||||
payment,
|
||||
identifier=payment.invoice_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_stars_transactions(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
|
||||
stmt = (
|
||||
select(Transaction)
|
||||
.options(selectinload(Transaction.user))
|
||||
.where(
|
||||
Transaction.created_at >= cutoff,
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.payment_method == PaymentMethod.TELEGRAM_STARS.value,
|
||||
)
|
||||
.order_by(desc(Transaction.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: List[PendingPayment] = []
|
||||
for transaction in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.TELEGRAM_STARS,
|
||||
transaction,
|
||||
identifier=transaction.external_id or str(transaction.id),
|
||||
amount_kopeks=transaction.amount_kopeks,
|
||||
status="paid" if transaction.is_completed else "pending",
|
||||
is_paid=bool(transaction.is_completed),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def list_recent_pending_payments(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
max_age: timedelta = PENDING_MAX_AGE,
|
||||
) -> List[PendingPayment]:
|
||||
"""Return pending payments (top-ups) from supported providers within the age window."""
|
||||
|
||||
cutoff = datetime.utcnow() - max_age
|
||||
|
||||
tasks: Iterable[List[PendingPayment]] = (
|
||||
await _fetch_yookassa_payments(db, cutoff),
|
||||
await _fetch_pal24_payments(db, cutoff),
|
||||
await _fetch_mulenpay_payments(db, cutoff),
|
||||
await _fetch_wata_payments(db, cutoff),
|
||||
await _fetch_heleket_payments(db, cutoff),
|
||||
await _fetch_cryptobot_payments(db, cutoff),
|
||||
await _fetch_stars_transactions(db, cutoff),
|
||||
)
|
||||
|
||||
records: List[PendingPayment] = []
|
||||
for batch in tasks:
|
||||
records.extend(batch)
|
||||
|
||||
records.sort(key=lambda item: item.created_at, reverse=True)
|
||||
return records
|
||||
|
||||
|
||||
async def get_payment_record(
|
||||
db: AsyncSession,
|
||||
method: PaymentMethod,
|
||||
local_payment_id: int,
|
||||
) -> Optional[PendingPayment]:
|
||||
"""Load single payment record and normalize it to :class:`PendingPayment`."""
|
||||
|
||||
cutoff = datetime.utcnow() - PENDING_MAX_AGE
|
||||
|
||||
if method == PaymentMethod.PAL24:
|
||||
payment = await db.get(Pal24Payment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=["user"])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.bill_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, "expires_at", None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.MULENPAY:
|
||||
payment = await db.get(MulenPayPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=["user"])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.uuid,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.WATA:
|
||||
payment = await db.get(WataPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=["user"])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.payment_link_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, "expires_at", None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.HELEKET:
|
||||
payment = await db.get(HeleketPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=["user"])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.uuid,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, "expires_at", None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.YOOKASSA:
|
||||
payment = await db.get(YooKassaPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=["user"])
|
||||
if payment.created_at < cutoff:
|
||||
logger.debug("YooKassa payment %s is older than cutoff", payment.id)
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.yookassa_payment_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(getattr(payment, "is_paid", False)),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.CRYPTOBOT:
|
||||
payment = await db.get(CryptoBotPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=["user"])
|
||||
amount_kopeks = _parse_cryptobot_amount_kopeks(payment)
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.invoice_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
transaction = await db.get(Transaction, local_payment_id)
|
||||
if not transaction:
|
||||
return None
|
||||
await db.refresh(transaction, attribute_names=["user"])
|
||||
if transaction.payment_method != PaymentMethod.TELEGRAM_STARS.value:
|
||||
return None
|
||||
return _build_record(
|
||||
method,
|
||||
transaction,
|
||||
identifier=transaction.external_id or str(transaction.id),
|
||||
amount_kopeks=transaction.amount_kopeks,
|
||||
status="paid" if transaction.is_completed else "pending",
|
||||
is_paid=bool(transaction.is_completed),
|
||||
)
|
||||
|
||||
logger.debug("Unsupported payment method requested: %s", method)
|
||||
return None
|
||||
|
||||
|
||||
async def run_manual_check(
|
||||
db: AsyncSession,
|
||||
method: PaymentMethod,
|
||||
local_payment_id: int,
|
||||
payment_service: "PaymentService",
|
||||
) -> Optional[PendingPayment]:
|
||||
"""Trigger provider specific status refresh and return the updated record."""
|
||||
|
||||
try:
|
||||
if method == PaymentMethod.PAL24:
|
||||
result = await payment_service.get_pal24_payment_status(db, local_payment_id)
|
||||
payment = result.get("payment") if result else None
|
||||
elif method == PaymentMethod.MULENPAY:
|
||||
result = await payment_service.get_mulenpay_payment_status(db, local_payment_id)
|
||||
payment = result.get("payment") if result else None
|
||||
elif method == PaymentMethod.WATA:
|
||||
result = await payment_service.get_wata_payment_status(db, local_payment_id)
|
||||
payment = result.get("payment") if result else None
|
||||
elif method == PaymentMethod.HELEKET:
|
||||
payment = await payment_service.sync_heleket_payment_status(
|
||||
db, local_payment_id=local_payment_id
|
||||
)
|
||||
elif method == PaymentMethod.YOOKASSA:
|
||||
result = await payment_service.get_yookassa_payment_status(db, local_payment_id)
|
||||
payment = result.get("payment") if result else None
|
||||
elif method == PaymentMethod.CRYPTOBOT:
|
||||
result = await payment_service.get_cryptobot_payment_status(db, local_payment_id)
|
||||
payment = result.get("payment") if result else None
|
||||
else:
|
||||
logger.warning("Manual check requested for unsupported method %s", method)
|
||||
return None
|
||||
|
||||
if not payment:
|
||||
return None
|
||||
|
||||
return await get_payment_record(db, method, local_payment_id)
|
||||
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Manual status check failed for %s payment %s: %s",
|
||||
method.value,
|
||||
local_payment_id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from typing import Iterable
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.user import add_user_balance
|
||||
from app.database.models import (
|
||||
Poll,
|
||||
PollOption,
|
||||
PollQuestion,
|
||||
PollResponse,
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_poll_invitation_text(poll: Poll, language: str) -> str:
|
||||
texts = get_texts(language)
|
||||
|
||||
lines: list[str] = [f"🗳️ <b>{poll.title}</b>"]
|
||||
if poll.description:
|
||||
lines.append(poll.description)
|
||||
|
||||
if poll.reward_enabled and poll.reward_amount_kopeks > 0:
|
||||
reward_line = texts.t(
|
||||
"POLL_INVITATION_REWARD",
|
||||
"🎁 За участие вы получите {amount}.",
|
||||
).format(amount=settings.format_price(poll.reward_amount_kopeks))
|
||||
lines.append(reward_line)
|
||||
|
||||
lines.append(
|
||||
texts.t(
|
||||
"POLL_INVITATION_START",
|
||||
"Нажмите кнопку ниже, чтобы пройти опрос.",
|
||||
)
|
||||
)
|
||||
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
def build_start_keyboard(response_id: int, language: str) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("POLL_START_BUTTON", "📝 Пройти опрос"),
|
||||
callback_data=f"poll_start:{response_id}",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def send_poll_to_users(
|
||||
bot: Bot,
|
||||
db: AsyncSession,
|
||||
poll: Poll,
|
||||
users: Iterable[User],
|
||||
) -> dict:
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
sent = 0
|
||||
failed = 0
|
||||
skipped = 0
|
||||
|
||||
poll_id = poll.id
|
||||
|
||||
user_snapshots = [
|
||||
SimpleNamespace(
|
||||
id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
language=user.language,
|
||||
)
|
||||
for user in users
|
||||
]
|
||||
|
||||
# Получаем список пользователей, которые уже прошли опрос, за один запрос
|
||||
user_ids = [user_snapshot.id for user_snapshot in user_snapshots]
|
||||
existing_responses_result = await db.execute(
|
||||
select(PollResponse.user_id).where(
|
||||
and_(
|
||||
PollResponse.poll_id == poll_id,
|
||||
PollResponse.user_id.in_(user_ids)
|
||||
)
|
||||
)
|
||||
)
|
||||
existing_user_ids = set(existing_responses_result.scalars().all())
|
||||
|
||||
# Используем умеренный семафор, чтобы не превышать лимиты подключений к БД
|
||||
semaphore = asyncio.Semaphore(30) # Баланс между производительностью и нагрузкой на БД
|
||||
|
||||
# Создаем отдельную функцию для создания отдельной сессии для каждой отправки
|
||||
async def send_poll_invitation(user_snapshot):
|
||||
"""Отправляет приглашение к опросу одному пользователю"""
|
||||
async with semaphore:
|
||||
# Пропускаем пользователей, которые уже прошли опрос
|
||||
if user_snapshot.id in existing_user_ids:
|
||||
return "skipped"
|
||||
|
||||
# Создаем новую сессию для изоляции транзакции
|
||||
async with AsyncSessionLocal() as new_db:
|
||||
try:
|
||||
# Проверяем еще раз в новой сессии на случай гонки
|
||||
existing_response = await new_db.execute(
|
||||
select(PollResponse.id).where(
|
||||
and_(
|
||||
PollResponse.poll_id == poll_id,
|
||||
PollResponse.user_id == user_snapshot.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
existing_id = existing_response.scalar_one_or_none()
|
||||
if existing_id:
|
||||
return "skipped"
|
||||
|
||||
response = PollResponse(
|
||||
poll_id=poll_id,
|
||||
user_id=user_snapshot.id,
|
||||
)
|
||||
new_db.add(response)
|
||||
|
||||
await new_db.flush()
|
||||
|
||||
text = _build_poll_invitation_text(poll, user_snapshot.language)
|
||||
keyboard = build_start_keyboard(response.id, user_snapshot.language)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user_snapshot.telegram_id,
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
|
||||
await new_db.commit()
|
||||
return "sent"
|
||||
except TelegramBadRequest as error:
|
||||
error_text = str(error).lower()
|
||||
if "chat not found" in error_text or "bot was blocked by the user" in error_text:
|
||||
await new_db.rollback()
|
||||
return "skipped"
|
||||
else: # pragma: no cover - unexpected telegram error
|
||||
await new_db.rollback()
|
||||
return "failed"
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
await new_db.rollback()
|
||||
# Проверяем, является ли ошибка связанной с лимитом подключений
|
||||
if "too many clients" in str(error).lower():
|
||||
logger.warning(
|
||||
"⚠️ Ограничение на количество подключений к БД: %s пользователю %s",
|
||||
poll_id,
|
||||
user_snapshot.telegram_id,
|
||||
)
|
||||
# Уменьшаем вероятность переполнения, делая небольшую задержку
|
||||
await asyncio.sleep(0.1)
|
||||
else:
|
||||
logger.error(
|
||||
"❌ Ошибка отправки опроса %s пользователю %s: %s",
|
||||
poll_id,
|
||||
user_snapshot.telegram_id,
|
||||
error,
|
||||
)
|
||||
return "failed"
|
||||
|
||||
# Отправляем все приглашения одновременно без задержек для максимальной скорости
|
||||
tasks = [send_poll_invitation(user_snapshot) for user_snapshot in user_snapshots]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for result in results:
|
||||
if isinstance(result, str): # Успешно выполненная задача
|
||||
if result == "sent":
|
||||
sent += 1
|
||||
elif result == "failed":
|
||||
failed += 1
|
||||
elif result == "skipped":
|
||||
skipped += 1
|
||||
elif isinstance(result, Exception): # Ошибка выполнения задачи
|
||||
failed += 1
|
||||
|
||||
return {
|
||||
"sent": sent,
|
||||
"failed": failed,
|
||||
"skipped": skipped,
|
||||
"total": sent + failed + skipped,
|
||||
}
|
||||
|
||||
|
||||
async def reward_user_for_poll(
|
||||
db: AsyncSession,
|
||||
response: PollResponse,
|
||||
) -> int:
|
||||
await db.refresh(response, with_for_update=True)
|
||||
|
||||
poll = response.poll
|
||||
if not poll.reward_enabled or poll.reward_amount_kopeks <= 0:
|
||||
return 0
|
||||
|
||||
if response.reward_given:
|
||||
return response.reward_amount_kopeks
|
||||
|
||||
user = response.user
|
||||
description = f"Награда за участие в опросе \"{poll.title}\""
|
||||
|
||||
response.reward_given = True
|
||||
response.reward_amount_kopeks = poll.reward_amount_kopeks
|
||||
|
||||
success = await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
poll.reward_amount_kopeks,
|
||||
description,
|
||||
transaction_type=TransactionType.POLL_REWARD,
|
||||
)
|
||||
|
||||
if not success:
|
||||
return 0
|
||||
|
||||
await db.refresh(
|
||||
response,
|
||||
attribute_names=["reward_given", "reward_amount_kopeks"],
|
||||
)
|
||||
|
||||
return poll.reward_amount_kopeks
|
||||
|
||||
|
||||
async def get_next_question(response: PollResponse) -> tuple[int | None, PollQuestion | None]:
|
||||
if not response.poll or not response.poll.questions:
|
||||
return None, None
|
||||
|
||||
answered_question_ids = {answer.question_id for answer in response.answers}
|
||||
ordered_questions = sorted(response.poll.questions, key=lambda q: q.order)
|
||||
|
||||
for index, question in enumerate(ordered_questions, start=1):
|
||||
if question.id not in answered_question_ids:
|
||||
return index, question
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
async def get_question_option(question: PollQuestion, option_id: int) -> PollOption | None:
|
||||
for option in question.options:
|
||||
if option.id == option_id:
|
||||
return option
|
||||
return None
|
||||
@@ -92,18 +92,19 @@ async def maybe_assign_promo_group_by_total_spent(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> Optional[PromoGroup]:
|
||||
from app.database.crud.user_promo_group import (
|
||||
add_user_to_promo_group,
|
||||
has_user_promo_group,
|
||||
sync_user_primary_promo_group,
|
||||
)
|
||||
|
||||
user = await db.get(User, user_id)
|
||||
if not user:
|
||||
logger.debug("Не удалось найти пользователя %s для автовыдачи промогруппы", user_id)
|
||||
return None
|
||||
|
||||
old_group = None
|
||||
if user.promo_group_id:
|
||||
try:
|
||||
await db.refresh(user, attribute_names=["promo_group"])
|
||||
except Exception:
|
||||
pass
|
||||
old_group = getattr(user, "promo_group", None)
|
||||
# Получаем текущую primary промогруппу
|
||||
old_group = user.get_primary_promo_group()
|
||||
|
||||
total_spent = await get_user_total_spent_kopeks(db, user_id)
|
||||
if total_spent <= 0:
|
||||
@@ -120,7 +121,6 @@ async def maybe_assign_promo_group_by_total_spent(
|
||||
return None
|
||||
|
||||
try:
|
||||
previous_group_id = user.promo_group_id
|
||||
target_threshold = target_group.auto_assign_total_spent_kopeks or 0
|
||||
|
||||
if target_threshold <= previous_threshold:
|
||||
@@ -133,12 +133,16 @@ async def maybe_assign_promo_group_by_total_spent(
|
||||
)
|
||||
return None
|
||||
|
||||
if user.auto_promo_group_assigned and target_group.id == previous_group_id:
|
||||
# Проверяем, есть ли уже эта группа у пользователя
|
||||
already_has_group = await has_user_promo_group(db, user_id, target_group.id)
|
||||
|
||||
if user.auto_promo_group_assigned and already_has_group:
|
||||
logger.debug(
|
||||
"Пользователь %s уже находится в актуальной промогруппе '%s', повторная выдача не требуется",
|
||||
"Пользователь %s уже имеет промогруппу '%s', повторная выдача не требуется",
|
||||
user.telegram_id,
|
||||
target_group.name,
|
||||
)
|
||||
await sync_user_primary_promo_group(db, user_id)
|
||||
if target_threshold > previous_threshold:
|
||||
user.auto_promo_group_threshold_kopeks = target_threshold
|
||||
user.updated_at = datetime.utcnow()
|
||||
@@ -150,18 +154,18 @@ async def maybe_assign_promo_group_by_total_spent(
|
||||
user.auto_promo_group_threshold_kopeks = target_threshold
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
if target_group.id != previous_group_id:
|
||||
user.promo_group_id = target_group.id
|
||||
user.promo_group = target_group
|
||||
if not already_has_group:
|
||||
# Добавляем новую промогруппу к существующим
|
||||
await add_user_to_promo_group(db, user_id, target_group.id, assigned_by="auto")
|
||||
logger.info(
|
||||
"🤖 Пользователь %s автоматически переведен в промогруппу '%s' за траты %s ₽",
|
||||
"🤖 Пользователю %s добавлена промогруппа '%s' за траты %s ₽",
|
||||
user.telegram_id,
|
||||
target_group.name,
|
||||
total_spent / 100,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"🤖 Пользователь %s уже находится в подходящей промогруппе '%s', отмечаем автоприсвоение",
|
||||
"🤖 Пользователь %s уже имеет промогруппу '%s', отмечаем автоприсвоение",
|
||||
user.telegram_id,
|
||||
target_group.name,
|
||||
)
|
||||
@@ -169,7 +173,7 @@ async def maybe_assign_promo_group_by_total_spent(
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if target_group.id != previous_group_id:
|
||||
if not already_has_group:
|
||||
await _notify_admins_about_auto_assignment(
|
||||
db,
|
||||
user,
|
||||
|
||||
@@ -9,6 +9,10 @@ from app.database.crud.promocode import (
|
||||
)
|
||||
from app.database.crud.user import add_user_balance, get_user_by_id
|
||||
from app.database.crud.subscription import extend_subscription, get_subscription_by_user_id
|
||||
from app.database.crud.user_promo_group import (
|
||||
has_user_promo_group, add_user_to_promo_group
|
||||
)
|
||||
from app.database.crud.promo_group import get_promo_group_by_id
|
||||
from app.database.models import PromoCodeType, SubscriptionStatus, User, PromoCode
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
@@ -56,6 +60,47 @@ class PromoCodeService:
|
||||
|
||||
logger.info(f"🎯 Пользователь {user.telegram_id} получил платную подписку через промокод {code}")
|
||||
|
||||
# Assign promo group if promocode has one
|
||||
if promocode.promo_group_id:
|
||||
try:
|
||||
# Check if user already has this promo group
|
||||
has_group = await has_user_promo_group(db, user_id, promocode.promo_group_id)
|
||||
|
||||
if not has_group:
|
||||
# Get promo group details
|
||||
promo_group = await get_promo_group_by_id(db, promocode.promo_group_id)
|
||||
|
||||
if promo_group:
|
||||
# Add promo group to user
|
||||
await add_user_to_promo_group(
|
||||
db,
|
||||
user_id,
|
||||
promocode.promo_group_id,
|
||||
assigned_by="promocode"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"🎯 Пользователю {user.telegram_id} назначена промогруппа '{promo_group.name}' "
|
||||
f"(приоритет: {promo_group.priority}) через промокод {code}"
|
||||
)
|
||||
|
||||
# Add to result description
|
||||
result_description += f"\n🎁 Назначена промогруппа: {promo_group.name}"
|
||||
else:
|
||||
logger.warning(
|
||||
f"⚠️ Промогруппа ID {promocode.promo_group_id} не найдена для промокода {code}"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"ℹ️ Пользователь {user.telegram_id} уже имеет промогруппу ID {promocode.promo_group_id}"
|
||||
)
|
||||
except Exception as pg_error:
|
||||
logger.error(
|
||||
f"❌ Ошибка назначения промогруппы для пользователя {user.telegram_id} "
|
||||
f"при активации промокода {code}: {pg_error}"
|
||||
)
|
||||
# Don't fail the whole promocode activation if promo group assignment fails
|
||||
|
||||
await create_promocode_use(db, promocode.id, user_id)
|
||||
|
||||
promocode.current_uses += 1
|
||||
@@ -71,6 +116,7 @@ class PromoCodeService:
|
||||
"max_uses": promocode.max_uses,
|
||||
"current_uses": promocode.current_uses,
|
||||
"valid_until": promocode.valid_until,
|
||||
"promo_group_id": promocode.promo_group_id,
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -131,12 +177,20 @@ class PromoCodeService:
|
||||
if getattr(settings, 'TRIAL_SQUAD_UUID', None):
|
||||
trial_squads = [settings.TRIAL_SQUAD_UUID]
|
||||
|
||||
forced_devices = None
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_devices = settings.get_disabled_mode_device_limit()
|
||||
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
if forced_devices is not None:
|
||||
device_limit = forced_devices
|
||||
|
||||
new_subscription = await create_paid_subscription(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
duration_days=promocode.subscription_days,
|
||||
traffic_limit_gb=0,
|
||||
device_limit=1,
|
||||
device_limit=device_limit,
|
||||
connected_squads=trial_squads,
|
||||
update_server_counters=True,
|
||||
)
|
||||
@@ -155,10 +209,15 @@ class PromoCodeService:
|
||||
if not subscription:
|
||||
trial_days = promocode.subscription_days if promocode.subscription_days > 0 else settings.TRIAL_DURATION_DAYS
|
||||
|
||||
forced_devices = None
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_devices = settings.get_disabled_mode_device_limit()
|
||||
|
||||
trial_subscription = await create_trial_subscription(
|
||||
db,
|
||||
user.id,
|
||||
duration_days=trial_days
|
||||
duration_days=trial_days,
|
||||
device_limit=forced_devices,
|
||||
)
|
||||
|
||||
await self.subscription_service.create_remnawave_user(db, trial_subscription)
|
||||
|
||||
@@ -89,16 +89,64 @@ async def process_referral_topup(
|
||||
logger.info(f"Пользователь {user_id} не является рефералом")
|
||||
return True
|
||||
|
||||
if topup_amount_kopeks < settings.REFERRAL_MINIMUM_TOPUP_KOPEKS:
|
||||
logger.info(f"Пополнение {user_id} на {topup_amount_kopeks/100}₽ меньше минимума")
|
||||
return True
|
||||
|
||||
referrer = await get_user_by_id(db, user.referred_by_id)
|
||||
if not referrer:
|
||||
logger.error(f"Реферер {user.referred_by_id} не найден")
|
||||
return False
|
||||
|
||||
|
||||
qualifies_for_first_bonus = (
|
||||
topup_amount_kopeks >= settings.REFERRAL_MINIMUM_TOPUP_KOPEKS
|
||||
)
|
||||
commission_amount = 0
|
||||
if settings.REFERRAL_COMMISSION_PERCENT > 0:
|
||||
commission_amount = int(
|
||||
topup_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100
|
||||
)
|
||||
|
||||
if not user.has_made_first_topup:
|
||||
if not qualifies_for_first_bonus:
|
||||
logger.info(
|
||||
"Пополнение %s на %s₽ меньше минимума для первого бонуса, но комиссия будет начислена",
|
||||
user_id,
|
||||
topup_amount_kopeks / 100,
|
||||
)
|
||||
|
||||
if commission_amount > 0:
|
||||
await add_user_balance(
|
||||
db,
|
||||
referrer,
|
||||
commission_amount,
|
||||
f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}",
|
||||
bot=bot,
|
||||
)
|
||||
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer.id,
|
||||
referral_id=user.id,
|
||||
amount_kopeks=commission_amount,
|
||||
reason="referral_commission_topup",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"💰 Комиссия с пополнения: %s получил %s₽ (до первого бонуса)",
|
||||
referrer.telegram_id,
|
||||
commission_amount / 100,
|
||||
)
|
||||
|
||||
if bot:
|
||||
commission_notification = (
|
||||
f"💰 <b>Реферальная комиссия!</b>\n\n"
|
||||
f"Ваш реферал <b>{user.full_name}</b> пополнил баланс на "
|
||||
f"{settings.format_price(topup_amount_kopeks)}\n\n"
|
||||
f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): "
|
||||
f"{settings.format_price(commission_amount)}\n\n"
|
||||
f"💎 Средства зачислены на ваш баланс."
|
||||
)
|
||||
await send_referral_notification(bot, referrer.telegram_id, commission_notification)
|
||||
|
||||
return True
|
||||
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
@@ -161,36 +209,33 @@ async def process_referral_topup(
|
||||
await send_referral_notification(bot, referrer.telegram_id, inviter_bonus_notification)
|
||||
|
||||
else:
|
||||
if settings.REFERRAL_COMMISSION_PERCENT > 0:
|
||||
commission_amount = int(topup_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100)
|
||||
|
||||
if commission_amount > 0:
|
||||
await add_user_balance(
|
||||
db, referrer, commission_amount,
|
||||
f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}",
|
||||
bot=bot
|
||||
if commission_amount > 0:
|
||||
await add_user_balance(
|
||||
db, referrer, commission_amount,
|
||||
f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}",
|
||||
bot=bot
|
||||
)
|
||||
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer.id,
|
||||
referral_id=user.id,
|
||||
amount_kopeks=commission_amount,
|
||||
reason="referral_commission_topup"
|
||||
)
|
||||
|
||||
logger.info(f"💰 Комиссия с пополнения: {referrer.telegram_id} получил {commission_amount/100}₽")
|
||||
|
||||
if bot:
|
||||
commission_notification = (
|
||||
f"💰 <b>Реферальная комиссия!</b>\n\n"
|
||||
f"Ваш реферал <b>{user.full_name}</b> пополнил баланс на "
|
||||
f"{settings.format_price(topup_amount_kopeks)}\n\n"
|
||||
f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): "
|
||||
f"{settings.format_price(commission_amount)}\n\n"
|
||||
f"💎 Средства зачислены на ваш баланс."
|
||||
)
|
||||
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer.id,
|
||||
referral_id=user.id,
|
||||
amount_kopeks=commission_amount,
|
||||
reason="referral_commission_topup"
|
||||
)
|
||||
|
||||
logger.info(f"💰 Комиссия с пополнения: {referrer.telegram_id} получил {commission_amount/100}₽")
|
||||
|
||||
if bot:
|
||||
commission_notification = (
|
||||
f"💰 <b>Реферальная комиссия!</b>\n\n"
|
||||
f"Ваш реферал <b>{user.full_name}</b> пополнил баланс на "
|
||||
f"{settings.format_price(topup_amount_kopeks)}\n\n"
|
||||
f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): "
|
||||
f"{settings.format_price(commission_amount)}\n\n"
|
||||
f"💎 Средства зачислены на ваш баланс."
|
||||
)
|
||||
await send_referral_notification(bot, referrer.telegram_id, commission_notification)
|
||||
await send_referral_notification(bot, referrer.telegram_id, commission_notification)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user