Compare commits
946 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 813193102c | |||
| 42112d6c8a | |||
| dfe5e36fe0 | |||
| b5d4a54e97 | |||
| 1eaf7204ce | |||
| 8cc37dc439 | |||
| ccafb18122 | |||
| 654b132ae6 | |||
| 815fa0560c | |||
| c4d19810a4 | |||
| 9fbb32eb5b | |||
| 3ce3cde859 | |||
| 4e71dcd2b1 | |||
| acf21fa6d2 | |||
| f2b724a78d | |||
| 03473360c1 | |||
| 27dc68be41 | |||
| b15d78f8af | |||
| d232d14937 | |||
| 927d469db2 | |||
| e20e9e7cec | |||
| 0f097c3108 | |||
| eaa3c80d59 | |||
| 0c49eddfde | |||
| 33882f1e93 | |||
| f94666b68d | |||
| d32a49f801 | |||
| 9596f82ea6 | |||
| 0e3fdf7d61 | |||
| 54cfec6029 | |||
| 9e88eae416 | |||
| 8d32efac73 | |||
| 9f07b6007e | |||
| 2fb3b6c5dc | |||
| 0637576425 | |||
| ee7a445610 | |||
| ba4c7ee7ac | |||
| 4da169eb0f | |||
| 28937abab4 | |||
| b1dcf2779e | |||
| 3219062ce2 | |||
| 2ac72951f8 | |||
| cefb8cba78 | |||
| e869655028 | |||
| ae03ede209 | |||
| a72bb25ea2 | |||
| 0b22bc7056 | |||
| cde84ff20b | |||
| f76114d7dc | |||
| 9708a56fd7 | |||
| a360337660 | |||
| 9f78335651 | |||
| ef8142a99b | |||
| 06d51f9fcf | |||
| 4d94347179 | |||
| 2f6aa0b1e6 | |||
| 536a58d4d3 | |||
| 62f7c07059 | |||
| 0648edb3c4 | |||
| 9b6c235424 | |||
| 2fc35950cf | |||
| d2a5d74239 | |||
| 6b04c6748e | |||
| 10b44cbdb7 | |||
| dde04c716c | |||
| 819f19a7ea | |||
| e7201597ab | |||
| ea61aa7053 | |||
| 14546a8485 | |||
| f55455761f | |||
| b1a7767827 | |||
| 43c04b7e64 | |||
| add679cf6f | |||
| ee173190a0 | |||
| a6e21b47f6 | |||
| cd627e5840 | |||
| fc096e6867 | |||
| b591844c4e | |||
| 9557acb546 | |||
| 1fa6e75bd3 | |||
| 8965e43a55 | |||
| 8e7a6beece | |||
| e9f4fd8007 | |||
| a991c59d58 | |||
| dd0624adf8 | |||
| ab51cbe96a | |||
| 2f7a08deb0 | |||
| a0fa7f986b | |||
| b2d3eebe39 | |||
| a043fc0e46 | |||
| d6eec8787e | |||
| 95eee42047 | |||
| c673d6db4f | |||
| bb32a63d48 | |||
| af5acb272c | |||
| 5cd5ac8710 | |||
| 8bb58b44b3 | |||
| 511ecd506f | |||
| 62fc014bc3 | |||
| 961f564274 | |||
| 984870c78c | |||
| 6196c135d2 | |||
| bf1b8315a8 | |||
| b7eabcf7e7 | |||
| e83ebc4f02 | |||
| 1a4758f4cc | |||
| b80e8b3804 | |||
| e036173670 | |||
| bd728c6466 | |||
| e3aec310a3 | |||
| 1c59ce601e | |||
| 0563f7524f | |||
| cedbe07759 | |||
| 0746b2a5a7 | |||
| e8a72860dc | |||
| abbfe4a7d3 | |||
| 1ad5d9123b | |||
| 57031e4afe | |||
| b54d2434c0 | |||
| 7c2d7b6b4f | |||
| 9e731a5f18 | |||
| cd3832fef0 | |||
| cb6d51ed3d | |||
| 8b75768296 | |||
| 3f0a26b1c7 | |||
| de4f608d09 | |||
| 618327b9f1 | |||
| 54fdad8f0c | |||
| e9bbd68aa1 | |||
| 5df4837087 | |||
| 025e74c462 | |||
| 3208303c23 | |||
| 4cf0c5a971 | |||
| 7519352e79 | |||
| e7f94f9661 | |||
| cb68a612e9 | |||
| 44d4deb5a6 | |||
| 23c73d1f21 | |||
| 607beca837 | |||
| 31a5f8766f | |||
| cfdc829f48 | |||
| c44cefb983 | |||
| 79c2307c92 | |||
| 25f126a5cb | |||
| 45bd54e7b0 | |||
| 4ce43f80b8 | |||
| f7a4cedbbf | |||
| 6f83122e42 | |||
| dbcf3541a8 | |||
| b7adf3e971 | |||
| 037b1f876b | |||
| 4495658fad | |||
| 017b758e6c | |||
| edff6d5102 | |||
| 979fbe305a | |||
| d1edb8f892 | |||
| 0eeda6b9ac | |||
| 7c31c3616e | |||
| fba80b1a0d | |||
| 86dbb317f3 | |||
| 7432d43b55 | |||
| 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 | |||
| d7aab2c3da | |||
| f59e04b231 | |||
| a2ac04a57a | |||
| 4526df2342 | |||
| d0c65f201f | |||
| 4fd736ecbf | |||
| 351700f886 | |||
| 453d1dbf92 | |||
| 0f358a9691 | |||
| ab890ea958 | |||
| e0b0498f81 | |||
| af8dc119a5 | |||
| 7ad98ceebb | |||
| 004aad4f06 | |||
| ec5524d74f | |||
| c1211ee025 | |||
| bf6528128f | |||
| 34c22b8955 | |||
| d48488338e | |||
| a37fd89d8b | |||
| 4553b1e038 | |||
| 8c39f5aecf | |||
| ce5ceb2f89 | |||
| 8ca7750d25 | |||
| 1045cf42f9 | |||
| 427a8594d5 | |||
| e007e8142a | |||
| 55e47e8e00 | |||
| f84c54da03 | |||
| 9e5279454c | |||
| e935d13ccd | |||
| 2c17ce5c3a | |||
| 947ad48acd | |||
| aefae5818e | |||
| 47191342f6 | |||
| 0c841cbf86 | |||
| 8ef4c74b4a | |||
| feb5e92fc2 | |||
| 27338809c1 | |||
| 53231ca074 | |||
| 1c177660c0 | |||
| 041f1d09d7 | |||
| 8e13f644b0 | |||
| fc0b7591f9 | |||
| 308c645096 | |||
| fd41d22322 | |||
| 694d85ec10 | |||
| 790ffecade | |||
| 5c5cc42af7 | |||
| 0784f56f7c | |||
| 775da62e00 | |||
| 436efcdd3e | |||
| 086660ebef | |||
| c546d10d93 | |||
| 169bdae0d1 | |||
| 56e0ec4cd2 | |||
| f62e2794b2 | |||
| f19db8f184 | |||
| 41d4c9b507 | |||
| 73e4abb29f | |||
| f7cd8c9fc3 | |||
| 6497f52a11 | |||
| a4de6a8330 | |||
| d27e625ee6 | |||
| e7a34152fc | |||
| e332fc41fc | |||
| 6bf0ac7fbf | |||
| 0effaa5240 | |||
| 89b0544f86 | |||
| b1626d32cd | |||
| 8cdb4289c9 | |||
| d4e3adddfc | |||
| 1baeb50322 | |||
| 7ed635600e | |||
| cb81fae3d8 | |||
| 93cb99d50c | |||
| 5a4f59dfb2 | |||
| 59f39e18a0 | |||
| d3dd423042 | |||
| b90b270a9e | |||
| 7846b54a6f | |||
| 77083e8349 | |||
| f121a5733b | |||
| ad6bb95df7 | |||
| 58588e37d0 | |||
| 9717876fe7 | |||
| badf44a9e7 | |||
| a320de1cbb | |||
| aebfd8933a | |||
| 482fb26d48 | |||
| 8542473c6a | |||
| 66e56c68cb | |||
| 135f3ccb92 | |||
| d082e8a61c | |||
| 23ad622fcd | |||
| 9b639c3e1d | |||
| 75e9b8e27d | |||
| 893736b240 | |||
| f5403802cc | |||
| 1c2325ca70 | |||
| e032369110 | |||
| 9435a82add | |||
| 7478167065 | |||
| de5206ccd0 | |||
| 9d24522fab | |||
| 7be8757964 | |||
| dec3aefdec | |||
| 30645d633e | |||
| 2dcf3ef087 |
+103
-8
@@ -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 - только деактивировать пользователя
|
||||
@@ -77,7 +85,8 @@ REMNAWAVE_USER_DELETE_MODE=delete
|
||||
TRIAL_DURATION_DAYS=3
|
||||
TRIAL_TRAFFIC_LIMIT_GB=10
|
||||
TRIAL_DEVICE_LIMIT=1
|
||||
TRIAL_SQUAD_UUID=
|
||||
TRIAL_PAYMENT_ENABLED=false
|
||||
TRIAL_ACTIVATION_PRICE=0
|
||||
|
||||
# ===== ПЛАТНАЯ ПОДПИСКА =====
|
||||
# Сколько устройств доступно по дефолту при покупке платной подписки
|
||||
@@ -113,12 +122,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,12 +151,17 @@ 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
|
||||
REFERRAL_MINIMUM_TOPUP_KOPEKS=10000
|
||||
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
|
||||
REFERRAL_INVITER_BONUS_KOPEKS=10000
|
||||
@@ -154,8 +180,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
|
||||
@@ -163,6 +189,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)
|
||||
@@ -215,7 +243,6 @@ YOOKASSA_PAYMENT_SUBJECT=service
|
||||
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
|
||||
YOOKASSA_WEBHOOK_HOST=0.0.0.0
|
||||
YOOKASSA_WEBHOOK_PORT=8082
|
||||
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
|
||||
|
||||
# Лимиты сумм пополнения через YooKassa (в копейках)
|
||||
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
|
||||
@@ -224,8 +251,16 @@ YOOKASSA_MAX_AMOUNT_KOPEKS=1000000
|
||||
# Быстрый выбор суммы пополнения через YooKassa
|
||||
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=Пополнение баланса
|
||||
@@ -240,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=
|
||||
@@ -273,6 +326,26 @@ PAL24_PAYMENT_DESCRIPTION="Пополнение баланса"
|
||||
PAL24_MIN_AMOUNT_KOPEKS=10000
|
||||
PAL24_MAX_AMOUNT_KOPEKS=100000000
|
||||
PAL24_REQUEST_TIMEOUT=30
|
||||
# Отображать кнопку СБП в PayPalych (true - отображать, false - скрывать)
|
||||
PAL24_SBP_BUTTON_VISIBLE=true
|
||||
# Отображать кнопку оплаты картой в PayPalych (true - отображать, false - скрывать)
|
||||
PAL24_CARD_BUTTON_VISIBLE=true
|
||||
|
||||
# PLATEGA
|
||||
PLATEGA_ENABLED=false
|
||||
PLATEGA_MERCHANT_ID=
|
||||
PLATEGA_SECRET=
|
||||
PLATEGA_BASE_URL=https://app.platega.io
|
||||
PLATEGA_RETURN_URL=
|
||||
PLATEGA_FAILED_URL=
|
||||
PLATEGA_CURRENCY=RUB
|
||||
# Список ID активных методов из кабинета Platega (через запятую)
|
||||
PLATEGA_ACTIVE_METHODS=2,10,11,12,13
|
||||
PLATEGA_MIN_AMOUNT_KOPEKS=10000
|
||||
PLATEGA_MAX_AMOUNT_KOPEKS=100000000
|
||||
PLATEGA_WEBHOOK_PATH=/platega-webhook
|
||||
PLATEGA_WEBHOOK_HOST=0.0.0.0
|
||||
PLATEGA_WEBHOOK_PORT=8086
|
||||
|
||||
# ===== ИНТЕРФЕЙС И UX =====
|
||||
|
||||
@@ -296,6 +369,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
|
||||
@@ -347,6 +421,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=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже.
|
||||
|
||||
# ===== ЛОКАЛИЗАЦИЯ =====
|
||||
@@ -355,6 +431,8 @@ DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en
|
||||
# Включить выбор языка при старте и отображение кнопки в меню
|
||||
LANGUAGE_SELECTION_ENABLED=true
|
||||
# Часовой пояс
|
||||
TZ=Europe/Moscow # или UTC, America/New_York и т.д.
|
||||
|
||||
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
|
||||
# Конфигурация приложений для гайда подключения
|
||||
@@ -392,3 +470,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.0-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.6.2-$(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.0-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.6.2-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.0-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.6.2-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.0-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.6.2-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.5.0-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.6.2-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.5.0-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.6.2-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
# Игнорируем все файлы и папки по умолчанию
|
||||
*
|
||||
docker-compose.override.yml
|
||||
|
||||
# Исключения: разрешаем только нужные файлы
|
||||
!.dockerignore
|
||||
@@ -15,8 +16,8 @@
|
||||
# Разрешаем папку app/ и все её содержимое рекурсивно
|
||||
!app/
|
||||
!app/**
|
||||
!locales/
|
||||
!locales/**
|
||||
!tests/
|
||||
!tests/**
|
||||
|
||||
# Дополнительно разрешаем README и лицензию (опционально)
|
||||
!README.md
|
||||
|
||||
+3
-3
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.5.0"
|
||||
ARG VERSION="v2.6.2"
|
||||
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
-4
@@ -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 сервер доступен только из внутренней сети
|
||||
```
|
||||
|
||||
### 📊 Мониторинг безопасности
|
||||
@@ -239,7 +238,6 @@ REMNAWAVE_API_KEY=your_api_key
|
||||
ADMIN_IDS=123456789,987654321
|
||||
|
||||
# Webhook секреты - генерируйте случайные значения
|
||||
YOOKASSA_WEBHOOK_SECRET=random_secret_here
|
||||
TRIBUTE_WEBHOOK_SECRET=another_random_secret
|
||||
CRYPTOBOT_WEBHOOK_SECRET=yet_another_secret
|
||||
```
|
||||
@@ -283,7 +281,7 @@ services:
|
||||
|
||||
# Не пробрасывайте порты наружу без необходимости
|
||||
ports:
|
||||
- "127.0.0.1:8081:8081" # Только localhost
|
||||
- "127.0.0.1:8080:8080" # Только localhost
|
||||
|
||||
# Read-only root filesystem
|
||||
read_only: true
|
||||
|
||||
+29
-8
@@ -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
|
||||
|
||||
@@ -127,7 +139,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
start.register_handlers(dp)
|
||||
menu.register_handlers(dp)
|
||||
subscription.register_handlers(dp)
|
||||
balance.register_handlers(dp)
|
||||
balance.register_balance_handlers(dp)
|
||||
promocode.register_handlers(dp)
|
||||
referral.register_handlers(dp)
|
||||
support.register_handlers(dp)
|
||||
@@ -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("Бот успешно настроен")
|
||||
|
||||
+426
-38
@@ -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,15 +76,19 @@ 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"
|
||||
|
||||
TRIAL_DURATION_DAYS: int = 3
|
||||
TRIAL_TRAFFIC_LIMIT_GB: int = 10
|
||||
TRIAL_DEVICE_LIMIT: int = 2
|
||||
TRIAL_ADD_REMAINING_DAYS_TO_PAID: bool = False
|
||||
TRIAL_PAYMENT_ENABLED: bool = False
|
||||
TRIAL_ACTIVATION_PRICE: int = 0
|
||||
DEFAULT_TRAFFIC_LIMIT_GB: int = 100
|
||||
DEFAULT_DEVICE_LIMIT: int = 1
|
||||
TRIAL_SQUAD_UUID: Optional[str] = None
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH"
|
||||
RESET_TRAFFIC_ON_PAYMENT: bool = False
|
||||
MAX_DEVICES_LIMIT: int = 20
|
||||
@@ -118,10 +128,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 = ""
|
||||
@@ -134,9 +146,9 @@ class Settings(BaseSettings):
|
||||
REFERRAL_INVITER_BONUS_KOPEKS: int = 10000
|
||||
REFERRAL_COMMISSION_PERCENT: int = 25
|
||||
|
||||
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"
|
||||
|
||||
@@ -148,8 +160,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
|
||||
@@ -174,10 +188,22 @@ class Settings(BaseSettings):
|
||||
YOOKASSA_WEBHOOK_PATH: str = "/yookassa-webhook"
|
||||
YOOKASSA_WEBHOOK_HOST: str = "0.0.0.0"
|
||||
YOOKASSA_WEBHOOK_PORT: int = 8082
|
||||
YOOKASSA_WEBHOOK_SECRET: Optional[str] = None
|
||||
YOOKASSA_TRUSTED_PROXY_NETWORKS: str = ""
|
||||
YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000
|
||||
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 = "Интернет-сервис"
|
||||
@@ -195,12 +221,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
|
||||
@@ -226,10 +268,45 @@ class Settings(BaseSettings):
|
||||
PAL24_REQUEST_TIMEOUT: int = 30
|
||||
PAL24_SBP_BUTTON_TEXT: Optional[str] = None
|
||||
PAL24_CARD_BUTTON_TEXT: Optional[str] = None
|
||||
PAL24_SBP_BUTTON_VISIBLE: bool = True
|
||||
PAL24_CARD_BUTTON_VISIBLE: bool = True
|
||||
|
||||
PLATEGA_ENABLED: bool = False
|
||||
PLATEGA_MERCHANT_ID: Optional[str] = None
|
||||
PLATEGA_SECRET: Optional[str] = None
|
||||
PLATEGA_BASE_URL: str = "https://app.platega.io"
|
||||
PLATEGA_RETURN_URL: Optional[str] = None
|
||||
PLATEGA_FAILED_URL: Optional[str] = None
|
||||
PLATEGA_CURRENCY: str = "RUB"
|
||||
PLATEGA_ACTIVE_METHODS: str = "2,10,11,12,13"
|
||||
PLATEGA_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
PLATEGA_MAX_AMOUNT_KOPEKS: int = 100000000
|
||||
PLATEGA_WEBHOOK_PATH: str = "/platega-webhook"
|
||||
PLATEGA_WEBHOOK_HOST: str = "0.0.0.0"
|
||||
PLATEGA_WEBHOOK_PORT: int = 8086
|
||||
|
||||
WATA_ENABLED: bool = False
|
||||
WATA_BASE_URL: str = "https://api.wata.pro/api/h2h"
|
||||
WATA_ACCESS_TOKEN: Optional[str] = None
|
||||
WATA_TERMINAL_PUBLIC_ID: Optional[str] = None
|
||||
WATA_PAYMENT_DESCRIPTION: str = "Пополнение баланса"
|
||||
WATA_PAYMENT_TYPE: str = "OneTime"
|
||||
WATA_SUCCESS_REDIRECT_URL: Optional[str] = None
|
||||
WATA_FAIL_REDIRECT_URL: Optional[str] = None
|
||||
WATA_LINK_TTL_MINUTES: Optional[int] = None
|
||||
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"
|
||||
@@ -258,6 +335,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"
|
||||
@@ -458,6 +542,12 @@ class Settings(BaseSettings):
|
||||
value = (self.PAL24_CARD_BUTTON_TEXT or "").strip()
|
||||
return value or fallback
|
||||
|
||||
def is_pal24_sbp_button_visible(self) -> bool:
|
||||
return self.PAL24_SBP_BUTTON_VISIBLE
|
||||
|
||||
def is_pal24_card_button_visible(self) -> bool:
|
||||
return self.PAL24_CARD_BUTTON_VISIBLE
|
||||
|
||||
def get_remnawave_user_delete_mode(self) -> str:
|
||||
"""Возвращает режим удаления пользователей: 'delete' или 'disable'"""
|
||||
mode = self.REMNAWAVE_USER_DELETE_MODE.lower().strip()
|
||||
@@ -490,6 +580,70 @@ 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:
|
||||
return []
|
||||
|
||||
segments = re.split(r"[\s,;]+", raw_value.strip())
|
||||
seen: set[tuple[int, int]] = set()
|
||||
parsed: List[time] = []
|
||||
|
||||
for segment in segments:
|
||||
if not segment:
|
||||
continue
|
||||
|
||||
try:
|
||||
hours_str, minutes_str = segment.split(":", 1)
|
||||
hours = int(hours_str)
|
||||
minutes = int(minutes_str)
|
||||
except (ValueError, AttributeError):
|
||||
continue
|
||||
|
||||
if not (0 <= hours < 24 and 0 <= minutes < 60):
|
||||
continue
|
||||
|
||||
key = (hours, minutes)
|
||||
if key in seen:
|
||||
continue
|
||||
|
||||
seen.add(key)
|
||||
parsed.append(time(hour=hours, minute=minutes))
|
||||
|
||||
parsed.sort()
|
||||
return parsed
|
||||
|
||||
def get_remnawave_auto_sync_times(self) -> List[time]:
|
||||
return self.parse_daily_time_list(self.REMNAWAVE_AUTO_SYNC_TIMES)
|
||||
|
||||
def get_display_name_banned_keywords(self) -> List[str]:
|
||||
raw_value = self.DISPLAY_NAME_BANNED_KEYWORDS
|
||||
if raw_value is None:
|
||||
@@ -532,6 +686,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:
|
||||
@@ -692,9 +855,53 @@ 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_trial_paid_activation_enabled(self) -> bool:
|
||||
return bool(self.TRIAL_PAYMENT_ENABLED)
|
||||
|
||||
def get_trial_activation_price(self) -> int:
|
||||
try:
|
||||
value = int(self.TRIAL_ACTIVATION_PRICE)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Некорректное значение TRIAL_ACTIVATION_PRICE: %s",
|
||||
self.TRIAL_ACTIVATION_PRICE,
|
||||
)
|
||||
return 0
|
||||
|
||||
if value < 0:
|
||||
return 0
|
||||
|
||||
return value
|
||||
|
||||
def is_yookassa_enabled(self) -> bool:
|
||||
return (self.YOOKASSA_ENABLED and
|
||||
@@ -712,6 +919,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
|
||||
@@ -720,6 +934,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
|
||||
@@ -727,6 +950,99 @@ class Settings(BaseSettings):
|
||||
and self.PAL24_SHOP_ID is not None
|
||||
)
|
||||
|
||||
def is_platega_enabled(self) -> bool:
|
||||
return (
|
||||
self.PLATEGA_ENABLED
|
||||
and self.PLATEGA_MERCHANT_ID is not None
|
||||
and self.PLATEGA_SECRET is not None
|
||||
)
|
||||
|
||||
def get_platega_return_url(self) -> Optional[str]:
|
||||
if self.PLATEGA_RETURN_URL:
|
||||
return self.PLATEGA_RETURN_URL
|
||||
if self.WEBHOOK_URL:
|
||||
return f"{self.WEBHOOK_URL}/payment-success"
|
||||
return None
|
||||
|
||||
def get_platega_failed_url(self) -> Optional[str]:
|
||||
if self.PLATEGA_FAILED_URL:
|
||||
return self.PLATEGA_FAILED_URL
|
||||
if self.WEBHOOK_URL:
|
||||
return f"{self.WEBHOOK_URL}/payment-failed"
|
||||
return None
|
||||
|
||||
def get_platega_active_methods(self) -> List[int]:
|
||||
raw_value = str(self.PLATEGA_ACTIVE_METHODS or "")
|
||||
normalized = raw_value.replace(";", ",")
|
||||
methods: list[int] = []
|
||||
seen: set[int] = set()
|
||||
for part in normalized.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
method_code = int(part)
|
||||
except ValueError:
|
||||
logger.warning("Некорректный код метода Platega: %s", part)
|
||||
continue
|
||||
if method_code in {2, 10, 11, 12, 13} and method_code not in seen:
|
||||
methods.append(method_code)
|
||||
seen.add(method_code)
|
||||
|
||||
if not methods:
|
||||
return [2]
|
||||
|
||||
return methods
|
||||
|
||||
@staticmethod
|
||||
def get_platega_method_definitions() -> Dict[int, Dict[str, str]]:
|
||||
return {
|
||||
2: {"name": "СБП (QR)", "title": "🏦 СБП (QR)"},
|
||||
10: {"name": "Банковские карты (RUB)", "title": "💳 Карты (RUB)"},
|
||||
11: {"name": "Банковские карты", "title": "💳 Банковские карты"},
|
||||
12: {"name": "Международные карты", "title": "🌍 Международные карты"},
|
||||
13: {"name": "Криптовалюта", "title": "🪙 Криптовалюта"},
|
||||
}
|
||||
|
||||
def get_platega_method_display_name(self, method_code: int) -> str:
|
||||
definitions = self.get_platega_method_definitions()
|
||||
info = definitions.get(method_code)
|
||||
if info and info.get("name"):
|
||||
return info["name"]
|
||||
return f"Метод {method_code}"
|
||||
|
||||
def get_platega_method_display_title(self, method_code: int) -> str:
|
||||
definitions = self.get_platega_method_definitions()
|
||||
info = definitions.get(method_code)
|
||||
if not info:
|
||||
return f"Platega {method_code}"
|
||||
return info.get("title") or info.get("name") or f"Platega {method_code}"
|
||||
|
||||
def is_wata_enabled(self) -> bool:
|
||||
return (
|
||||
self.WATA_ENABLED
|
||||
and self.WATA_ACCESS_TOKEN is not None
|
||||
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"
|
||||
@@ -744,6 +1060,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"
|
||||
|
||||
@@ -788,6 +1124,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
|
||||
|
||||
@@ -830,6 +1173,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
|
||||
@@ -921,44 +1267,19 @@ 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,
|
||||
"first_topup_bonus_kopeks": self.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS,
|
||||
"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:
|
||||
return bool(self.REFERRAL_PROGRAM_ENABLED)
|
||||
|
||||
def is_referral_notifications_enabled(self) -> bool:
|
||||
return self.REFERRAL_NOTIFICATIONS_ENABLED
|
||||
|
||||
@@ -1211,15 +1532,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,23 @@
|
||||
"""Database package exports."""
|
||||
|
||||
from .database import (
|
||||
DatabaseManager,
|
||||
batch_ops,
|
||||
close_db,
|
||||
db_manager,
|
||||
get_db,
|
||||
get_db_read_only,
|
||||
get_pool_metrics,
|
||||
init_db,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DatabaseManager",
|
||||
"batch_ops",
|
||||
"close_db",
|
||||
"db_manager",
|
||||
"get_db",
|
||||
"get_db_read_only",
|
||||
"get_pool_metrics",
|
||||
"init_db",
|
||||
]
|
||||
@@ -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,156 @@
|
||||
"""CRUD-операции для платежей Platega."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import PlategaPayment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_platega_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
currency: str,
|
||||
description: Optional[str],
|
||||
status: str,
|
||||
payment_method_code: int,
|
||||
correlation_id: str,
|
||||
platega_transaction_id: Optional[str],
|
||||
redirect_url: Optional[str],
|
||||
return_url: Optional[str],
|
||||
failed_url: Optional[str],
|
||||
payload: Optional[str],
|
||||
metadata: Optional[dict[str, Any]] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
) -> PlategaPayment:
|
||||
payment = PlategaPayment(
|
||||
user_id=user_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
status=status,
|
||||
payment_method_code=payment_method_code,
|
||||
correlation_id=correlation_id,
|
||||
platega_transaction_id=platega_transaction_id,
|
||||
redirect_url=redirect_url,
|
||||
return_url=return_url,
|
||||
failed_url=failed_url,
|
||||
payload=payload,
|
||||
metadata_json=metadata or {},
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Создан Platega платеж #%s (tx=%s) на сумму %s копеек для пользователя %s",
|
||||
payment.id,
|
||||
platega_transaction_id,
|
||||
amount_kopeks,
|
||||
user_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def get_platega_payment_by_id(
|
||||
db: AsyncSession, payment_id: int
|
||||
) -> Optional[PlategaPayment]:
|
||||
result = await db.execute(
|
||||
select(PlategaPayment).where(PlategaPayment.id == payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_platega_payment_by_id_for_update(
|
||||
db: AsyncSession, payment_id: int
|
||||
) -> Optional[PlategaPayment]:
|
||||
result = await db.execute(
|
||||
select(PlategaPayment)
|
||||
.where(PlategaPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_platega_payment_by_transaction_id(
|
||||
db: AsyncSession, transaction_id: str
|
||||
) -> Optional[PlategaPayment]:
|
||||
result = await db.execute(
|
||||
select(PlategaPayment).where(
|
||||
PlategaPayment.platega_transaction_id == transaction_id
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_platega_payment_by_correlation_id(
|
||||
db: AsyncSession, correlation_id: str
|
||||
) -> Optional[PlategaPayment]:
|
||||
result = await db.execute(
|
||||
select(PlategaPayment).where(
|
||||
PlategaPayment.correlation_id == correlation_id
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_platega_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: PlategaPayment,
|
||||
status: Optional[str] = None,
|
||||
is_paid: Optional[bool] = None,
|
||||
paid_at: Optional[datetime] = None,
|
||||
platega_transaction_id: Optional[str] = None,
|
||||
redirect_url: Optional[str] = None,
|
||||
callback_payload: Optional[dict[str, Any]] = None,
|
||||
metadata: Optional[dict[str, Any]] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
) -> PlategaPayment:
|
||||
if status is not None:
|
||||
payment.status = status
|
||||
if is_paid is not None:
|
||||
payment.is_paid = is_paid
|
||||
if paid_at is not None:
|
||||
payment.paid_at = paid_at
|
||||
if platega_transaction_id and not payment.platega_transaction_id:
|
||||
payment.platega_transaction_id = platega_transaction_id
|
||||
if redirect_url is not None:
|
||||
payment.redirect_url = redirect_url
|
||||
if callback_payload is not None:
|
||||
payment.callback_payload = callback_payload
|
||||
if metadata is not None:
|
||||
payment.metadata_json = metadata
|
||||
if expires_at is not None:
|
||||
payment.expires_at = expires_at
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
|
||||
|
||||
async def link_platega_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: PlategaPayment,
|
||||
transaction_id: int,
|
||||
) -> PlategaPayment:
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -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}
|
||||
@@ -454,7 +515,6 @@ async def choose_random_trial_server_squad(
|
||||
|
||||
async def get_random_trial_squad_uuid(
|
||||
db: AsyncSession,
|
||||
fallback_uuid: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
|
||||
squad = await choose_random_trial_server_squad(db)
|
||||
@@ -462,7 +522,7 @@ async def get_random_trial_squad_uuid(
|
||||
if squad:
|
||||
return squad.squad_uuid
|
||||
|
||||
return fallback_uuid
|
||||
return None
|
||||
|
||||
|
||||
def _generate_display_name(original_name: str) -> str:
|
||||
|
||||
@@ -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,21 +43,19 @@ 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
|
||||
|
||||
squad_uuid = await get_random_trial_squad_uuid(
|
||||
db,
|
||||
settings.TRIAL_SQUAD_UUID,
|
||||
)
|
||||
squad_uuid = await get_random_trial_squad_uuid(db)
|
||||
|
||||
if squad_uuid:
|
||||
logger.debug(
|
||||
@@ -68,7 +69,6 @@ async def create_trial_subscription(
|
||||
user_id,
|
||||
error,
|
||||
)
|
||||
squad_uuid = settings.TRIAL_SQUAD_UUID
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
|
||||
@@ -125,13 +125,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 +152,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 +192,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,31 +221,36 @@ 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)
|
||||
# УДАЛЕНО: Автоматическая конвертация триала по длительности
|
||||
# Теперь триал конвертируется ТОЛЬКО после успешного коммита продления
|
||||
# и ТОЛЬКО вызывающей функцией (например, _auto_extend_subscription)
|
||||
|
||||
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
|
||||
|
||||
if subscription.status == SubscriptionStatus.EXPIRED.value and days > 0:
|
||||
# Логируем статус подписки перед проверкой
|
||||
logger.info(f"🔄 Продление подписки {subscription.id}, текущий статус: {subscription.status}, дни: {days}")
|
||||
|
||||
if days > 0 and subscription.status in (
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
):
|
||||
previous_status = subscription.status
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
logger.info(f"🔄 Статус изменён с EXPIRED на ACTIVE")
|
||||
logger.info(
|
||||
"🔄 Статус подписки %s изменён с %s на ACTIVE",
|
||||
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
|
||||
@@ -892,7 +917,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)
|
||||
)
|
||||
@@ -1109,6 +1134,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):
|
||||
|
||||
@@ -1119,9 +1152,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,
|
||||
@@ -1175,3 +1267,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
|
||||
@@ -0,0 +1,168 @@
|
||||
"""CRUD helpers for WATA payment records."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import WataPayment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_wata_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
payment_link_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str,
|
||||
description: Optional[str],
|
||||
status: str,
|
||||
type_: Optional[str],
|
||||
url: Optional[str],
|
||||
order_id: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
terminal_public_id: Optional[str] = None,
|
||||
success_redirect_url: Optional[str] = None,
|
||||
fail_redirect_url: Optional[str] = None,
|
||||
) -> WataPayment:
|
||||
payment = WataPayment(
|
||||
user_id=user_id,
|
||||
payment_link_id=payment_link_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
status=status,
|
||||
type=type_,
|
||||
url=url,
|
||||
metadata_json=metadata or {},
|
||||
expires_at=expires_at,
|
||||
terminal_public_id=terminal_public_id,
|
||||
success_redirect_url=success_redirect_url,
|
||||
fail_redirect_url=fail_redirect_url,
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Создан Wata платеж #%s для пользователя %s: %s копеек (статус %s)",
|
||||
payment.id,
|
||||
user_id,
|
||||
amount_kopeks,
|
||||
status,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def get_wata_payment_by_id(
|
||||
db: AsyncSession,
|
||||
payment_id: int,
|
||||
) -> Optional[WataPayment]:
|
||||
result = await db.execute(
|
||||
select(WataPayment).where(WataPayment.id == payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_wata_payment_by_link_id(
|
||||
db: AsyncSession,
|
||||
payment_link_id: str,
|
||||
) -> Optional[WataPayment]:
|
||||
result = await db.execute(
|
||||
select(WataPayment).where(WataPayment.payment_link_id == payment_link_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_wata_payment_by_order_id(
|
||||
db: AsyncSession,
|
||||
order_id: str,
|
||||
) -> Optional[WataPayment]:
|
||||
result = await db.execute(
|
||||
select(WataPayment).where(WataPayment.order_id == order_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_wata_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: WataPayment,
|
||||
*,
|
||||
status: Optional[str] = None,
|
||||
is_paid: Optional[bool] = None,
|
||||
paid_at: Optional[datetime] = None,
|
||||
last_status: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
callback_payload: Optional[Dict[str, Any]] = None,
|
||||
terminal_public_id: Optional[str] = None,
|
||||
) -> WataPayment:
|
||||
update_values: Dict[str, Any] = {}
|
||||
|
||||
if status is not None:
|
||||
update_values["status"] = status
|
||||
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 last_status is not None:
|
||||
update_values["last_status"] = last_status
|
||||
if url is not None:
|
||||
update_values["url"] = url
|
||||
if metadata is not None:
|
||||
update_values["metadata_json"] = metadata
|
||||
if callback_payload is not None:
|
||||
update_values["callback_payload"] = callback_payload
|
||||
if terminal_public_id is not None:
|
||||
update_values["terminal_public_id"] = terminal_public_id
|
||||
|
||||
if not update_values:
|
||||
return payment
|
||||
|
||||
await db.execute(
|
||||
update(WataPayment)
|
||||
.where(WataPayment.id == payment.id)
|
||||
.values(**update_values)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Обновлен Wata платеж %s: статус=%s, is_paid=%s",
|
||||
payment.payment_link_id,
|
||||
payment.status,
|
||||
payment.is_paid,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def link_wata_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
payment: WataPayment,
|
||||
transaction_id: int,
|
||||
) -> WataPayment:
|
||||
await db.execute(
|
||||
update(WataPayment)
|
||||
.where(WataPayment.id == payment.id)
|
||||
.values(transaction_id=transaction_id)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Wata платеж %s привязан к транзакции %s",
|
||||
payment.payment_link_id,
|
||||
transaction_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
+360
-14
@@ -1,31 +1,167 @@
|
||||
import logging
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from typing import AsyncGenerator, Optional
|
||||
from contextlib import asynccontextmanager
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
create_async_engine,
|
||||
async_sessionmaker,
|
||||
AsyncEngine
|
||||
)
|
||||
from sqlalchemy.pool import NullPool, AsyncAdaptedQueuePool
|
||||
from sqlalchemy import event, text, bindparam, inspect
|
||||
from sqlalchemy.engine import Engine
|
||||
import time
|
||||
from app.config import settings
|
||||
from app.database.models import Base
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
# PRODUCTION-GRADE CONNECTION POOLING
|
||||
# ============================================================================
|
||||
|
||||
if settings.get_database_url().startswith("sqlite"):
|
||||
poolclass = NullPool
|
||||
pool_kwargs = {}
|
||||
else:
|
||||
poolclass = AsyncAdaptedQueuePool
|
||||
pool_kwargs = {
|
||||
"pool_size": 20,
|
||||
"max_overflow": 30,
|
||||
"pool_timeout": 30,
|
||||
"pool_recycle": 3600,
|
||||
"pool_pre_ping": True,
|
||||
# 🔥 Агрессивная очистка мертвых соединений
|
||||
"pool_reset_on_return": "rollback",
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# ENGINE WITH ADVANCED OPTIMIZATIONS
|
||||
# ============================================================================
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.get_database_url(),
|
||||
poolclass=NullPool,
|
||||
poolclass=poolclass,
|
||||
echo=settings.DEBUG,
|
||||
future=True
|
||||
future=True,
|
||||
**pool_kwargs,
|
||||
|
||||
connect_args={
|
||||
"server_settings": {
|
||||
"application_name": "remnawave_bot",
|
||||
"jit": "on",
|
||||
"statement_timeout": "60000", # 60 секунд
|
||||
"idle_in_transaction_session_timeout": "300000", # 5 минут
|
||||
},
|
||||
"command_timeout": 60,
|
||||
"timeout": 10,
|
||||
} if not settings.get_database_url().startswith("sqlite") else {},
|
||||
|
||||
execution_options={
|
||||
"isolation_level": "READ COMMITTED", # Оптимальный для большинства случаев
|
||||
"compiled_cache_size": 500, # Кеш скомпилированных запросов
|
||||
}
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# SESSION FACTORY WITH OPTIMIZATIONS
|
||||
# ============================================================================
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=True,
|
||||
autocommit=False
|
||||
autoflush=False, # 🔥 Критично для производительности
|
||||
autocommit=False,
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# QUERY PERFORMANCE MONITORING
|
||||
# ============================================================================
|
||||
|
||||
if settings.DEBUG:
|
||||
@event.listens_for(Engine, "before_cursor_execute")
|
||||
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
||||
conn.info.setdefault("query_start_time", []).append(time.time())
|
||||
logger.debug(f"🔍 Executing query: {statement[:100]}...")
|
||||
|
||||
@event.listens_for(Engine, "after_cursor_execute")
|
||||
def after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
||||
total = time.time() - conn.info["query_start_time"].pop(-1)
|
||||
if total > 0.1: # Логируем медленные запросы > 100ms
|
||||
logger.warning(f"🐌 Slow query ({total:.3f}s): {statement[:100]}...")
|
||||
else:
|
||||
logger.debug(f"⚡ Query executed in {total:.3f}s")
|
||||
|
||||
# ============================================================================
|
||||
# ADVANCED SESSION MANAGER WITH READ REPLICAS
|
||||
# ============================================================================
|
||||
|
||||
class DatabaseManager:
|
||||
"""Продвинутый менеджер БД с поддержкой реплик и кеширования"""
|
||||
|
||||
def __init__(self):
|
||||
self.engine = engine
|
||||
self.read_replica_engine: Optional[AsyncEngine] = None
|
||||
|
||||
if hasattr(settings, 'DATABASE_READ_REPLICA_URL') and settings.DATABASE_READ_REPLICA_URL:
|
||||
self.read_replica_engine = create_async_engine(
|
||||
settings.DATABASE_READ_REPLICA_URL,
|
||||
poolclass=poolclass,
|
||||
pool_size=30, # Больше для read операций
|
||||
max_overflow=50,
|
||||
pool_pre_ping=True,
|
||||
echo=False,
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def session(self, read_only: bool = False):
|
||||
target_engine = self.read_replica_engine if (read_only and self.read_replica_engine) else self.engine
|
||||
|
||||
async_session = async_sessionmaker(
|
||||
bind=target_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
async with async_session() as session:
|
||||
try:
|
||||
yield session
|
||||
if not read_only:
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
async def health_check(self) -> dict:
|
||||
pool = self.engine.pool
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
start = time.time()
|
||||
await session.execute(text("SELECT 1"))
|
||||
latency = (time.time() - start) * 1000
|
||||
status = "healthy"
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Database health check failed: {e}")
|
||||
status = "unhealthy"
|
||||
latency = None
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"latency_ms": round(latency, 2) if latency else None,
|
||||
"pool": _collect_health_pool_metrics(pool),
|
||||
}
|
||||
|
||||
db_manager = DatabaseManager()
|
||||
|
||||
# ============================================================================
|
||||
# SESSION DEPENDENCY FOR FASTAPI/AIOGRAM
|
||||
# ============================================================================
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Стандартная dependency для FastAPI"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
@@ -33,19 +169,229 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
async def get_db_read_only() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Read-only dependency для тяжелых SELECT запросов"""
|
||||
async with db_manager.session(read_only=True) as session:
|
||||
yield session
|
||||
|
||||
# ============================================================================
|
||||
# BATCH OPERATIONS FOR PERFORMANCE
|
||||
# ============================================================================
|
||||
|
||||
class BatchOperations:
|
||||
"""Утилиты для массовых операций"""
|
||||
|
||||
@staticmethod
|
||||
async def bulk_insert(session: AsyncSession, model, data: list[dict], chunk_size: int = 1000):
|
||||
"""Массовая вставка с чанками"""
|
||||
for i in range(0, len(data), chunk_size):
|
||||
chunk = data[i:i + chunk_size]
|
||||
session.add_all([model(**item) for item in chunk])
|
||||
await session.flush()
|
||||
await session.commit()
|
||||
|
||||
@staticmethod
|
||||
async def bulk_update(session: AsyncSession, model, data: list[dict], chunk_size: int = 1000):
|
||||
"""Массовое обновление с чанками"""
|
||||
if not data:
|
||||
return
|
||||
|
||||
primary_keys = [column.name for column in model.__table__.primary_key.columns]
|
||||
if not primary_keys:
|
||||
raise ValueError("Model must have a primary key for bulk_update")
|
||||
|
||||
updatable_columns = [
|
||||
column.name
|
||||
for column in model.__table__.columns
|
||||
if column.name not in primary_keys
|
||||
]
|
||||
|
||||
if not updatable_columns:
|
||||
raise ValueError("No columns available for update in bulk_update")
|
||||
|
||||
stmt = (
|
||||
model.__table__.update()
|
||||
.where(
|
||||
*[
|
||||
getattr(model.__table__.c, pk) == bindparam(pk)
|
||||
for pk in primary_keys
|
||||
]
|
||||
)
|
||||
.values(
|
||||
**{
|
||||
column: bindparam(column, required=False)
|
||||
for column in updatable_columns
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
for i in range(0, len(data), chunk_size):
|
||||
chunk = data[i:i + chunk_size]
|
||||
filtered_chunk = []
|
||||
for item in chunk:
|
||||
missing_keys = [pk for pk in primary_keys if pk not in item]
|
||||
if missing_keys:
|
||||
raise ValueError(
|
||||
f"Missing primary key values {missing_keys} for bulk_update"
|
||||
)
|
||||
|
||||
filtered_item = {
|
||||
key: value
|
||||
for key, value in item.items()
|
||||
if key in primary_keys or key in updatable_columns
|
||||
}
|
||||
filtered_chunk.append(filtered_item)
|
||||
|
||||
await session.execute(stmt, filtered_chunk)
|
||||
await session.commit()
|
||||
|
||||
batch_ops = BatchOperations()
|
||||
|
||||
# ============================================================================
|
||||
# INITIALIZATION AND CLEANUP
|
||||
# ============================================================================
|
||||
|
||||
async def init_db():
|
||||
logger.info("Создание таблиц базы данных...")
|
||||
"""Инициализация БД с оптимизациями"""
|
||||
logger.info("🚀 Создание таблиц базы данных...")
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
if not settings.get_database_url().startswith("sqlite"):
|
||||
logger.info("📊 Создание индексов для оптимизации...")
|
||||
|
||||
async with engine.begin() as conn:
|
||||
indexes = [
|
||||
("users", "CREATE INDEX IF NOT EXISTS idx_users_telegram_id ON users(telegram_id)"),
|
||||
(
|
||||
"subscriptions",
|
||||
"CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id)",
|
||||
),
|
||||
(
|
||||
"subscriptions",
|
||||
"CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status) WHERE status = 'active'",
|
||||
),
|
||||
(
|
||||
"payments",
|
||||
"CREATE INDEX IF NOT EXISTS idx_payments_created_at ON payments(created_at DESC)",
|
||||
),
|
||||
]
|
||||
|
||||
for table_name, index_sql in indexes:
|
||||
table_exists = await conn.run_sync(lambda sync_conn: inspect(sync_conn).has_table(table_name))
|
||||
|
||||
if not table_exists:
|
||||
logger.debug(
|
||||
"Пропускаем создание индекса %s: таблица %s отсутствует",
|
||||
index_sql,
|
||||
table_name,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
await conn.execute(text(index_sql))
|
||||
except Exception as e:
|
||||
logger.debug("Index creation skipped for %s: %s", table_name, e)
|
||||
|
||||
logger.info("✅ База данных успешно инициализирована")
|
||||
|
||||
|
||||
health = await db_manager.health_check()
|
||||
logger.info(f"📊 Database health: {health}")
|
||||
|
||||
async def close_db():
|
||||
"""Корректное закрытие всех соединений"""
|
||||
logger.info("🔄 Закрытие соединений с БД...")
|
||||
|
||||
await engine.dispose()
|
||||
logger.info("✅ Подключение к базе данных закрыто")
|
||||
|
||||
if db_manager.read_replica_engine:
|
||||
await db_manager.read_replica_engine.dispose()
|
||||
|
||||
logger.info("✅ Все подключения к базе данных закрыты")
|
||||
|
||||
# ============================================================================
|
||||
# CONNECTION POOL METRICS (для мониторинга)
|
||||
# ============================================================================
|
||||
|
||||
def _pool_counters(pool):
|
||||
"""Return basic pool counters or ``None`` when unsupported."""
|
||||
|
||||
required_methods = ("size", "checkedin", "checkedout", "overflow")
|
||||
|
||||
for method_name in required_methods:
|
||||
method = getattr(pool, method_name, None)
|
||||
if method is None or not callable(method):
|
||||
return None
|
||||
|
||||
size = pool.size()
|
||||
checked_in = pool.checkedin()
|
||||
checked_out = pool.checkedout()
|
||||
overflow = pool.overflow()
|
||||
|
||||
total_connections = size + overflow
|
||||
|
||||
return {
|
||||
"size": size,
|
||||
"checked_in": checked_in,
|
||||
"checked_out": checked_out,
|
||||
"overflow": overflow,
|
||||
"total_connections": total_connections,
|
||||
"utilization_percent": (checked_out / total_connections * 100) if total_connections else 0.0,
|
||||
}
|
||||
|
||||
|
||||
def _collect_health_pool_metrics(pool) -> dict:
|
||||
counters = _pool_counters(pool)
|
||||
|
||||
if counters is None:
|
||||
return {
|
||||
"metrics_available": False,
|
||||
"size": 0,
|
||||
"checked_in": 0,
|
||||
"checked_out": 0,
|
||||
"overflow": 0,
|
||||
"total_connections": 0,
|
||||
"utilization": "0.0%",
|
||||
}
|
||||
|
||||
return {
|
||||
"metrics_available": True,
|
||||
"size": counters["size"],
|
||||
"checked_in": counters["checked_in"],
|
||||
"checked_out": counters["checked_out"],
|
||||
"overflow": counters["overflow"],
|
||||
"total_connections": counters["total_connections"],
|
||||
"utilization": f"{counters['utilization_percent']:.1f}%",
|
||||
}
|
||||
|
||||
|
||||
async def get_pool_metrics() -> dict:
|
||||
"""Детальные метрики пула для Prometheus/Grafana"""
|
||||
pool = engine.pool
|
||||
|
||||
counters = _pool_counters(pool)
|
||||
|
||||
if counters is None:
|
||||
return {
|
||||
"metrics_available": False,
|
||||
"pool_size": 0,
|
||||
"checked_in_connections": 0,
|
||||
"checked_out_connections": 0,
|
||||
"overflow_connections": 0,
|
||||
"total_connections": 0,
|
||||
"max_possible_connections": 0,
|
||||
"pool_utilization_percent": 0.0,
|
||||
}
|
||||
|
||||
return {
|
||||
"metrics_available": True,
|
||||
"pool_size": counters["size"],
|
||||
"checked_in_connections": counters["checked_in"],
|
||||
"checked_out_connections": counters["checked_out"],
|
||||
"overflow_connections": counters["overflow"],
|
||||
"total_connections": counters["total_connections"],
|
||||
"max_possible_connections": counters["total_connections"] + (getattr(pool, "_max_overflow", 0) or 0),
|
||||
"pool_utilization_percent": round(counters["utilization_percent"], 2),
|
||||
}
|
||||
|
||||
+340
-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,8 +78,11 @@ class PaymentMethod(Enum):
|
||||
TRIBUTE = "tribute"
|
||||
YOOKASSA = "yookassa"
|
||||
CRYPTOBOT = "cryptobot"
|
||||
HELEKET = "heleket"
|
||||
MULENPAY = "mulenpay"
|
||||
PAL24 = "pal24"
|
||||
WATA = "wata"
|
||||
PLATEGA = "platega"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
@@ -188,6 +194,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"
|
||||
|
||||
@@ -293,11 +365,112 @@ class Pal24Payment(Base):
|
||||
)
|
||||
|
||||
|
||||
class WataPayment(Base):
|
||||
__tablename__ = "wata_payments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
payment_link_id = Column(String(64), unique=True, nullable=False, index=True)
|
||||
order_id = Column(String(255), nullable=True, index=True)
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default="RUB")
|
||||
description = Column(Text, nullable=True)
|
||||
type = Column(String(50), nullable=True)
|
||||
|
||||
status = Column(String(50), nullable=False, default="Opened")
|
||||
is_paid = Column(Boolean, default=False)
|
||||
paid_at = Column(DateTime, nullable=True)
|
||||
last_status = Column(String(50), nullable=True)
|
||||
terminal_public_id = Column(String(64), nullable=True)
|
||||
|
||||
url = Column(Text, nullable=True)
|
||||
success_redirect_url = Column(Text, nullable=True)
|
||||
fail_redirect_url = Column(Text, nullable=True)
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, 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="wata_payments")
|
||||
transaction = relationship("Transaction", backref="wata_payment")
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return (
|
||||
"<WataPayment(id={0}, link_id={1}, amount={2}₽, status={3})>".format(
|
||||
self.id,
|
||||
self.payment_link_id,
|
||||
self.amount_rubles,
|
||||
self.status,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class PlategaPayment(Base):
|
||||
__tablename__ = "platega_payments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
platega_transaction_id = Column(String(255), unique=True, nullable=True, index=True)
|
||||
correlation_id = Column(String(64), unique=True, nullable=False, index=True)
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default="RUB")
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
payment_method_code = Column(Integer, nullable=False)
|
||||
status = Column(String(50), nullable=False, default="PENDING")
|
||||
is_paid = Column(Boolean, default=False)
|
||||
paid_at = Column(DateTime, nullable=True)
|
||||
|
||||
redirect_url = Column(Text, nullable=True)
|
||||
return_url = Column(Text, nullable=True)
|
||||
failed_url = Column(Text, nullable=True)
|
||||
payload = Column(String(255), nullable=True)
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, 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="platega_payments")
|
||||
transaction = relationship("Transaction", backref="platega_payment")
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return (
|
||||
"<PlategaPayment(id={0}, transaction_id={1}, amount={2}₽, status={3}, method={4})>".format(
|
||||
self.id,
|
||||
self.platega_transaction_id,
|
||||
self.amount_rubles,
|
||||
self.status,
|
||||
self.payment_method_code,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class PromoGroup(Base):
|
||||
__tablename__ = "promo_groups"
|
||||
|
||||
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)
|
||||
@@ -309,6 +482,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,
|
||||
@@ -372,6 +546,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"
|
||||
|
||||
@@ -409,22 +599,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
|
||||
@@ -672,13 +891,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:
|
||||
@@ -942,9 +1163,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)
|
||||
@@ -960,6 +1181,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"
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Tuple
|
||||
|
||||
from sqlalchemy import inspect, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -268,6 +269,210 @@ async def check_index_exists(table_name: str, index_name: str) -> bool:
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def fetch_duplicate_payment_links(conn) -> List[Tuple[str, int]]:
|
||||
result = await conn.execute(
|
||||
text(
|
||||
"SELECT payment_link_id, COUNT(*) AS cnt "
|
||||
"FROM wata_payments "
|
||||
"WHERE payment_link_id IS NOT NULL AND payment_link_id <> '' "
|
||||
"GROUP BY payment_link_id "
|
||||
"HAVING COUNT(*) > 1"
|
||||
)
|
||||
)
|
||||
return [(row[0], row[1]) for row in result.fetchall()]
|
||||
|
||||
|
||||
def _build_dedup_suffix(base_suffix: str, record_id: int, max_length: int = 64) -> Tuple[str, int]:
|
||||
suffix = f"{base_suffix}{record_id}"
|
||||
trimmed_length = max_length - len(suffix)
|
||||
if trimmed_length < 1:
|
||||
# Fallback: use the record id only to stay within the limit.
|
||||
suffix = f"dup-{record_id}"
|
||||
trimmed_length = max_length - len(suffix)
|
||||
return suffix, trimmed_length
|
||||
|
||||
|
||||
async def resolve_duplicate_payment_links(conn, db_type: str) -> bool:
|
||||
duplicates = await fetch_duplicate_payment_links(conn)
|
||||
|
||||
if not duplicates:
|
||||
return True
|
||||
|
||||
logger.warning(
|
||||
"Найдены дубликаты payment_link_id в wata_payments: %s",
|
||||
", ".join(f"{link}×{count}" for link, count in duplicates[:5]),
|
||||
)
|
||||
|
||||
for payment_link_id, _ in duplicates:
|
||||
result = await conn.execute(
|
||||
text(
|
||||
"SELECT id, payment_link_id FROM wata_payments "
|
||||
"WHERE payment_link_id = :payment_link_id "
|
||||
"ORDER BY id"
|
||||
),
|
||||
{"payment_link_id": payment_link_id},
|
||||
)
|
||||
|
||||
rows = result.fetchall()
|
||||
|
||||
if not rows:
|
||||
continue
|
||||
|
||||
# Skip the first occurrence to preserve the original link value.
|
||||
for duplicate_row in rows[1:]:
|
||||
record_id = duplicate_row[0]
|
||||
original_link = duplicate_row[1] or ""
|
||||
suffix, trimmed_length = _build_dedup_suffix("-dup-", record_id)
|
||||
new_base = original_link[:trimmed_length] if trimmed_length > 0 else ""
|
||||
new_link = f"{new_base}{suffix}" if new_base else suffix
|
||||
|
||||
await conn.execute(
|
||||
text(
|
||||
"UPDATE wata_payments SET payment_link_id = :new_link "
|
||||
"WHERE id = :record_id"
|
||||
),
|
||||
{"new_link": new_link, "record_id": record_id},
|
||||
)
|
||||
|
||||
remaining_duplicates = await fetch_duplicate_payment_links(conn)
|
||||
|
||||
if remaining_duplicates:
|
||||
logger.error(
|
||||
"Не удалось устранить дубликаты payment_link_id: %s",
|
||||
", ".join(f"{link}×{count}" for link, count in remaining_duplicates[:5]),
|
||||
)
|
||||
return False
|
||||
|
||||
logger.info("✅ Дубликаты payment_link_id устранены")
|
||||
return True
|
||||
|
||||
|
||||
async def enforce_wata_payment_link_constraints(
|
||||
conn,
|
||||
db_type: str,
|
||||
unique_index_exists: bool,
|
||||
legacy_index_exists: bool,
|
||||
) -> Tuple[bool, bool]:
|
||||
try:
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(
|
||||
text(
|
||||
"UPDATE wata_payments "
|
||||
"SET payment_link_id = 'legacy-' || id "
|
||||
"WHERE payment_link_id IS NULL OR payment_link_id = ''"
|
||||
)
|
||||
)
|
||||
|
||||
if not await resolve_duplicate_payment_links(conn, db_type):
|
||||
return unique_index_exists, legacy_index_exists
|
||||
|
||||
if not unique_index_exists:
|
||||
await conn.execute(
|
||||
text(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_wata_payment_link "
|
||||
"ON wata_payments(payment_link_id)"
|
||||
)
|
||||
)
|
||||
logger.info("✅ Создан уникальный индекс uq_wata_payment_link для payment_link_id")
|
||||
unique_index_exists = True
|
||||
else:
|
||||
logger.info("ℹ️ Уникальный индекс для payment_link_id уже существует")
|
||||
|
||||
if legacy_index_exists and unique_index_exists:
|
||||
await conn.execute(text("DROP INDEX IF EXISTS idx_wata_link_id"))
|
||||
logger.info("ℹ️ Удалён устаревший индекс idx_wata_link_id")
|
||||
legacy_index_exists = False
|
||||
|
||||
return unique_index_exists, legacy_index_exists
|
||||
|
||||
if db_type == "postgresql":
|
||||
await conn.execute(
|
||||
text(
|
||||
"UPDATE wata_payments "
|
||||
"SET payment_link_id = 'legacy-' || id::text "
|
||||
"WHERE payment_link_id IS NULL OR payment_link_id = ''"
|
||||
)
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE wata_payments "
|
||||
"ALTER COLUMN payment_link_id SET NOT NULL"
|
||||
)
|
||||
)
|
||||
logger.info("✅ Колонка payment_link_id теперь NOT NULL")
|
||||
|
||||
if not await resolve_duplicate_payment_links(conn, db_type):
|
||||
return unique_index_exists, legacy_index_exists
|
||||
|
||||
if not unique_index_exists:
|
||||
await conn.execute(
|
||||
text(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_wata_payment_link "
|
||||
"ON wata_payments(payment_link_id)"
|
||||
)
|
||||
)
|
||||
logger.info("✅ Создан уникальный индекс uq_wata_payment_link для payment_link_id")
|
||||
unique_index_exists = True
|
||||
else:
|
||||
logger.info("ℹ️ Уникальный индекс для payment_link_id уже существует")
|
||||
|
||||
if legacy_index_exists and unique_index_exists:
|
||||
await conn.execute(text("DROP INDEX IF EXISTS idx_wata_link_id"))
|
||||
logger.info("ℹ️ Удалён устаревший индекс idx_wata_link_id")
|
||||
legacy_index_exists = False
|
||||
|
||||
return unique_index_exists, legacy_index_exists
|
||||
|
||||
if db_type == "mysql":
|
||||
await conn.execute(
|
||||
text(
|
||||
"UPDATE wata_payments "
|
||||
"SET payment_link_id = CONCAT('legacy-', id) "
|
||||
"WHERE payment_link_id IS NULL OR payment_link_id = ''"
|
||||
)
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE wata_payments "
|
||||
"MODIFY COLUMN payment_link_id VARCHAR(64) NOT NULL"
|
||||
)
|
||||
)
|
||||
logger.info("✅ Колонка payment_link_id теперь NOT NULL")
|
||||
|
||||
if not await resolve_duplicate_payment_links(conn, db_type):
|
||||
return unique_index_exists, legacy_index_exists
|
||||
|
||||
if not unique_index_exists:
|
||||
await conn.execute(
|
||||
text(
|
||||
"CREATE UNIQUE INDEX uq_wata_payment_link "
|
||||
"ON wata_payments(payment_link_id)"
|
||||
)
|
||||
)
|
||||
logger.info("✅ Создан уникальный индекс uq_wata_payment_link для payment_link_id")
|
||||
unique_index_exists = True
|
||||
else:
|
||||
logger.info("ℹ️ Уникальный индекс для payment_link_id уже существует")
|
||||
|
||||
if legacy_index_exists and unique_index_exists:
|
||||
await conn.execute(text("DROP INDEX idx_wata_link_id ON wata_payments"))
|
||||
logger.info("ℹ️ Удалён устаревший индекс idx_wata_link_id")
|
||||
legacy_index_exists = False
|
||||
|
||||
return unique_index_exists, legacy_index_exists
|
||||
|
||||
logger.warning(
|
||||
"⚠️ Неизвестный тип БД %s — не удалось усилить ограничения payment_link_id", db_type
|
||||
)
|
||||
return unique_index_exists, legacy_index_exists
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка настройки ограничений payment_link_id: {e}")
|
||||
return unique_index_exists, legacy_index_exists
|
||||
|
||||
async def create_cryptobot_payments_table():
|
||||
table_exists = await check_table_exists('cryptobot_payments')
|
||||
if table_exists:
|
||||
@@ -371,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:
|
||||
@@ -706,6 +1025,270 @@ async def create_pal24_payments_table():
|
||||
return False
|
||||
|
||||
|
||||
async def create_wata_payments_table():
|
||||
table_exists = await check_table_exists('wata_payments')
|
||||
if table_exists:
|
||||
logger.info("Таблица wata_payments уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
create_sql = """
|
||||
CREATE TABLE wata_payments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
payment_link_id VARCHAR(64) NOT NULL UNIQUE,
|
||||
order_id VARCHAR(255) NULL,
|
||||
amount_kopeks INTEGER NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
|
||||
description TEXT NULL,
|
||||
type VARCHAR(50) NULL,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'Opened',
|
||||
is_paid BOOLEAN NOT NULL DEFAULT 0,
|
||||
paid_at DATETIME NULL,
|
||||
last_status VARCHAR(50) NULL,
|
||||
terminal_public_id VARCHAR(64) NULL,
|
||||
url TEXT NULL,
|
||||
success_redirect_url TEXT NULL,
|
||||
fail_redirect_url TEXT NULL,
|
||||
metadata_json JSON NULL,
|
||||
callback_payload JSON 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 UNIQUE INDEX idx_wata_link_id ON wata_payments(payment_link_id);
|
||||
CREATE INDEX idx_wata_order_id ON wata_payments(order_id);
|
||||
"""
|
||||
|
||||
elif db_type == 'postgresql':
|
||||
create_sql = """
|
||||
CREATE TABLE wata_payments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
payment_link_id VARCHAR(64) NOT NULL UNIQUE,
|
||||
order_id VARCHAR(255) NULL,
|
||||
amount_kopeks INTEGER NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
|
||||
description TEXT NULL,
|
||||
type VARCHAR(50) NULL,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'Opened',
|
||||
is_paid BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
paid_at TIMESTAMP NULL,
|
||||
last_status VARCHAR(50) NULL,
|
||||
terminal_public_id VARCHAR(64) NULL,
|
||||
url TEXT NULL,
|
||||
success_redirect_url TEXT NULL,
|
||||
fail_redirect_url TEXT NULL,
|
||||
metadata_json JSON NULL,
|
||||
callback_payload JSON 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 UNIQUE INDEX idx_wata_link_id ON wata_payments(payment_link_id);
|
||||
CREATE INDEX idx_wata_order_id ON wata_payments(order_id);
|
||||
"""
|
||||
|
||||
elif db_type == 'mysql':
|
||||
create_sql = """
|
||||
CREATE TABLE wata_payments (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
payment_link_id VARCHAR(64) NOT NULL UNIQUE,
|
||||
order_id VARCHAR(255) NULL,
|
||||
amount_kopeks INT NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
|
||||
description TEXT NULL,
|
||||
type VARCHAR(50) NULL,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'Opened',
|
||||
is_paid BOOLEAN NOT NULL DEFAULT 0,
|
||||
paid_at DATETIME NULL,
|
||||
last_status VARCHAR(50) NULL,
|
||||
terminal_public_id VARCHAR(64) NULL,
|
||||
url TEXT NULL,
|
||||
success_redirect_url TEXT NULL,
|
||||
fail_redirect_url TEXT NULL,
|
||||
metadata_json JSON NULL,
|
||||
callback_payload JSON 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 UNIQUE INDEX idx_wata_link_id ON wata_payments(payment_link_id);
|
||||
CREATE INDEX idx_wata_order_id ON wata_payments(order_id);
|
||||
"""
|
||||
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для таблицы wata_payments: {db_type}")
|
||||
return False
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("Таблица wata_payments успешно создана")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания таблицы wata_payments: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def ensure_wata_payment_schema() -> bool:
|
||||
try:
|
||||
table_exists = await check_table_exists("wata_payments")
|
||||
if not table_exists:
|
||||
logger.warning("⚠️ Таблица wata_payments отсутствует — создаём заново")
|
||||
return await create_wata_payments_table()
|
||||
|
||||
db_type = await get_database_type()
|
||||
|
||||
legacy_link_index_exists = await check_index_exists(
|
||||
"wata_payments", "idx_wata_link_id"
|
||||
)
|
||||
unique_link_index_exists = await check_index_exists(
|
||||
"wata_payments", "uq_wata_payment_link"
|
||||
)
|
||||
builtin_unique_index_exists = await check_index_exists(
|
||||
"wata_payments", "wata_payments_payment_link_id_key"
|
||||
)
|
||||
sqlite_auto_unique_exists = (
|
||||
await check_index_exists("wata_payments", "sqlite_autoindex_wata_payments_1")
|
||||
if db_type == "sqlite"
|
||||
else False
|
||||
)
|
||||
order_index_exists = await check_index_exists("wata_payments", "idx_wata_order_id")
|
||||
|
||||
payment_link_column_exists = await check_column_exists(
|
||||
"wata_payments", "payment_link_id"
|
||||
)
|
||||
order_id_column_exists = await check_column_exists("wata_payments", "order_id")
|
||||
|
||||
unique_index_exists = (
|
||||
unique_link_index_exists
|
||||
or builtin_unique_index_exists
|
||||
or sqlite_auto_unique_exists
|
||||
)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
if not payment_link_column_exists:
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE wata_payments "
|
||||
"ADD COLUMN payment_link_id VARCHAR(64) NOT NULL DEFAULT ''"
|
||||
)
|
||||
)
|
||||
payment_link_column_exists = True
|
||||
unique_index_exists = False
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE wata_payments "
|
||||
"ADD COLUMN IF NOT EXISTS payment_link_id VARCHAR(64)"
|
||||
)
|
||||
)
|
||||
payment_link_column_exists = True
|
||||
elif db_type == "mysql":
|
||||
await conn.execute(
|
||||
text("ALTER TABLE wata_payments ADD COLUMN payment_link_id VARCHAR(64)")
|
||||
)
|
||||
payment_link_column_exists = True
|
||||
else:
|
||||
logger.warning(
|
||||
"⚠️ Неизвестный тип БД %s — пропущено добавление payment_link_id",
|
||||
db_type,
|
||||
)
|
||||
|
||||
if payment_link_column_exists:
|
||||
logger.info("✅ Добавлена колонка payment_link_id в wata_payments")
|
||||
|
||||
if payment_link_column_exists:
|
||||
unique_index_exists, legacy_link_index_exists = (
|
||||
await enforce_wata_payment_link_constraints(
|
||||
conn,
|
||||
db_type,
|
||||
unique_index_exists,
|
||||
legacy_link_index_exists,
|
||||
)
|
||||
)
|
||||
|
||||
if not order_id_column_exists:
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(
|
||||
text("ALTER TABLE wata_payments ADD COLUMN order_id VARCHAR(255)")
|
||||
)
|
||||
order_id_column_exists = True
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE wata_payments "
|
||||
"ADD COLUMN IF NOT EXISTS order_id VARCHAR(255)"
|
||||
)
|
||||
)
|
||||
order_id_column_exists = True
|
||||
elif db_type == "mysql":
|
||||
await conn.execute(
|
||||
text("ALTER TABLE wata_payments ADD COLUMN order_id VARCHAR(255)")
|
||||
)
|
||||
order_id_column_exists = True
|
||||
else:
|
||||
logger.warning(
|
||||
"⚠️ Неизвестный тип БД %s — пропущено добавление order_id",
|
||||
db_type,
|
||||
)
|
||||
|
||||
if order_id_column_exists:
|
||||
logger.info("✅ Добавлена колонка order_id в wata_payments")
|
||||
|
||||
if not order_index_exists:
|
||||
if not order_id_column_exists:
|
||||
logger.warning(
|
||||
"⚠️ Пропущено создание индекса idx_wata_order_id — колонка order_id отсутствует"
|
||||
)
|
||||
else:
|
||||
index_created = False
|
||||
if db_type in {"sqlite", "postgresql"}:
|
||||
await conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_wata_order_id ON wata_payments(order_id)"
|
||||
)
|
||||
)
|
||||
index_created = True
|
||||
elif db_type == "mysql":
|
||||
await conn.execute(
|
||||
text("CREATE INDEX idx_wata_order_id ON wata_payments(order_id)")
|
||||
)
|
||||
index_created = True
|
||||
else:
|
||||
logger.warning(
|
||||
"⚠️ Неизвестный тип БД %s — пропущено создание индекса idx_wata_order_id",
|
||||
db_type,
|
||||
)
|
||||
|
||||
if index_created:
|
||||
logger.info("✅ Создан индекс idx_wata_order_id")
|
||||
else:
|
||||
logger.info("ℹ️ Индекс idx_wata_order_id уже существует")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления схемы wata_payments: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_discount_offers_table():
|
||||
table_exists = await check_table_exists('discount_offers')
|
||||
if table_exists:
|
||||
@@ -1524,6 +2107,9 @@ async def ensure_promo_groups_setup():
|
||||
addon_discount_column_exists = await check_column_exists(
|
||||
"promo_groups", "apply_discounts_to_addons"
|
||||
)
|
||||
priority_column_exists = await check_column_exists(
|
||||
"promo_groups", "priority"
|
||||
)
|
||||
|
||||
if not addon_discount_column_exists:
|
||||
if db_type == "sqlite":
|
||||
@@ -1706,7 +2292,23 @@ async def ensure_promo_groups_setup():
|
||||
"is_default": True,
|
||||
}
|
||||
|
||||
if addon_discount_column_exists:
|
||||
if priority_column_exists:
|
||||
insert_params["priority"] = 0
|
||||
|
||||
if addon_discount_column_exists and priority_column_exists:
|
||||
insert_sql = """
|
||||
INSERT INTO promo_groups (
|
||||
name,
|
||||
priority,
|
||||
server_discount_percent,
|
||||
traffic_discount_percent,
|
||||
device_discount_percent,
|
||||
apply_discounts_to_addons,
|
||||
is_default
|
||||
) VALUES (:name, :priority, 0, 0, 0, :apply_discounts_to_addons, :is_default)
|
||||
"""
|
||||
insert_params["apply_discounts_to_addons"] = True
|
||||
elif addon_discount_column_exists:
|
||||
insert_sql = """
|
||||
INSERT INTO promo_groups (
|
||||
name,
|
||||
@@ -1718,6 +2320,17 @@ async def ensure_promo_groups_setup():
|
||||
) VALUES (:name, 0, 0, 0, :apply_discounts_to_addons, :is_default)
|
||||
"""
|
||||
insert_params["apply_discounts_to_addons"] = True
|
||||
elif priority_column_exists:
|
||||
insert_sql = """
|
||||
INSERT INTO promo_groups (
|
||||
name,
|
||||
priority,
|
||||
server_discount_percent,
|
||||
traffic_discount_percent,
|
||||
device_discount_percent,
|
||||
is_default
|
||||
) VALUES (:name, :priority, 0, 0, 0, :is_default)
|
||||
"""
|
||||
else:
|
||||
insert_sql = """
|
||||
INSERT INTO promo_groups (
|
||||
@@ -2876,6 +3489,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("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
|
||||
|
||||
@@ -2958,18 +3784,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()
|
||||
@@ -2978,6 +3812,19 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей Pal24 payments")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ WATA ===")
|
||||
wata_created = await create_wata_payments_table()
|
||||
if wata_created:
|
||||
logger.info("✅ Таблица Wata payments готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей Wata payments")
|
||||
|
||||
wata_schema_ok = await ensure_wata_payment_schema()
|
||||
if wata_schema_ok:
|
||||
logger.info("✅ Схема Wata payments актуальна")
|
||||
else:
|
||||
logger.warning("⚠️ Не удалось обновить схему Wata payments")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ DISCOUNT_OFFERS ===")
|
||||
discount_created = await create_discount_offers_table()
|
||||
if discount_created:
|
||||
@@ -3016,6 +3863,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:
|
||||
@@ -3212,6 +4087,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 добавлены")
|
||||
@@ -3229,6 +4105,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,
|
||||
@@ -3262,6 +4139,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')
|
||||
@@ -3314,6 +4192,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
+16
-9
@@ -1,8 +1,9 @@
|
||||
"""Flask webhook server for PayPalych postbacks."""
|
||||
"""Flask webhook server for PayPalych callbacks."""
|
||||
|
||||
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,27 +57,33 @@ 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")
|
||||
return jsonify({"status": "error", "reason": "empty_payload"}), 400
|
||||
|
||||
try:
|
||||
parsed_payload = pal24_service.parse_postback(payload)
|
||||
parsed_payload = pal24_service.parse_callback(payload)
|
||||
except Pal24APIError as error:
|
||||
logger.error("Ошибка валидации Pal24 webhook: %s", error)
|
||||
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()
|
||||
return await payment_service.process_pal24_callback(db, parsed_payload)
|
||||
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
|
||||
@@ -105,7 +112,7 @@ def create_pal24_flask_app(
|
||||
|
||||
|
||||
class Pal24WebhookServer:
|
||||
"""Threaded Flask server for Pal24 postbacks."""
|
||||
"""Threaded Flask server for Pal24 callbacks."""
|
||||
|
||||
def __init__(self, payment_service: PaymentService, loop: AbstractEventLoop) -> None:
|
||||
self.app = create_pal24_flask_app(payment_service, loop)
|
||||
|
||||
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:
|
||||
|
||||
Vendored
+268
-127
@@ -1,12 +1,16 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import json
|
||||
import hashlib
|
||||
import hmac
|
||||
import base64
|
||||
from typing import Optional, Dict, Any
|
||||
from ipaddress import (
|
||||
IPv4Address,
|
||||
IPv4Network,
|
||||
IPv6Address,
|
||||
IPv6Network,
|
||||
ip_address,
|
||||
ip_network,
|
||||
)
|
||||
from typing import Iterable, Optional, Dict, Any, List, Union, Tuple
|
||||
from aiohttp import web
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.services.payment_service import PaymentService
|
||||
@@ -15,155 +19,292 @@ from app.database.database import get_db
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
IPAddress = Union[IPv4Address, IPv6Address]
|
||||
IPNetwork = Union[IPv4Network, IPv6Network]
|
||||
|
||||
YOOKASSA_ALLOWED_IP_NETWORKS: tuple[IPNetwork, ...] = (
|
||||
ip_network("185.71.76.0/27"),
|
||||
ip_network("185.71.77.0/27"),
|
||||
ip_network("77.75.153.0/25"),
|
||||
ip_network("77.75.154.128/25"),
|
||||
ip_network("77.75.156.11/32"),
|
||||
ip_network("77.75.156.35/32"),
|
||||
ip_network("2a02:5180::/32"),
|
||||
)
|
||||
|
||||
|
||||
CLOUDFLARE_TRUSTED_NETWORKS: tuple[IPNetwork, ...] = (
|
||||
ip_network("173.245.48.0/20"),
|
||||
ip_network("103.21.244.0/22"),
|
||||
ip_network("103.22.200.0/22"),
|
||||
ip_network("103.31.4.0/22"),
|
||||
ip_network("141.101.64.0/18"),
|
||||
ip_network("108.162.192.0/18"),
|
||||
ip_network("190.93.240.0/20"),
|
||||
ip_network("188.114.96.0/20"),
|
||||
ip_network("197.234.240.0/22"),
|
||||
ip_network("198.41.128.0/17"),
|
||||
ip_network("162.158.0.0/15"),
|
||||
ip_network("104.16.0.0/13"),
|
||||
ip_network("104.24.0.0/14"),
|
||||
ip_network("172.64.0.0/13"),
|
||||
ip_network("131.0.72.0/22"),
|
||||
ip_network("2400:cb00::/32"),
|
||||
ip_network("2606:4700::/32"),
|
||||
ip_network("2803:f800::/32"),
|
||||
ip_network("2405:b500::/32"),
|
||||
ip_network("2405:8100::/32"),
|
||||
ip_network("2a06:98c0::/29"),
|
||||
ip_network("2c0f:f248::/32"),
|
||||
)
|
||||
|
||||
|
||||
YOOKASSA_ALLOWED_EVENTS: tuple[str, ...] = (
|
||||
"payment.succeeded",
|
||||
"payment.waiting_for_capture",
|
||||
"payment.canceled",
|
||||
)
|
||||
|
||||
|
||||
def collect_yookassa_ip_candidates(*values: Optional[str]) -> List[str]:
|
||||
candidates: List[str] = []
|
||||
for value in values:
|
||||
if not value:
|
||||
continue
|
||||
for part in value.split(","):
|
||||
normalized = part.strip()
|
||||
if normalized:
|
||||
candidates.append(normalized)
|
||||
return candidates
|
||||
|
||||
|
||||
def _parse_candidate_ip(candidate: str) -> Optional[IPAddress]:
|
||||
value = candidate.strip()
|
||||
if not value:
|
||||
return None
|
||||
|
||||
if value.startswith("[") and "]" in value:
|
||||
value = value[1:value.index("]")]
|
||||
|
||||
if "%" in value:
|
||||
value = value.split("%", 1)[0]
|
||||
|
||||
if value.count(":") == 1 and "." in value:
|
||||
host, _, port = value.rpartition(":")
|
||||
if port.isdigit():
|
||||
value = host
|
||||
|
||||
try:
|
||||
return ip_address(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _should_trust_forwarded_headers(remote_ip: Optional[IPAddress]) -> bool:
|
||||
if remote_ip is None:
|
||||
return True
|
||||
|
||||
if _is_trusted_proxy_ip(remote_ip):
|
||||
return True
|
||||
|
||||
return any(
|
||||
getattr(remote_ip, attribute)
|
||||
for attribute in ("is_private", "is_loopback", "is_link_local", "is_reserved")
|
||||
)
|
||||
|
||||
|
||||
_TRUSTED_PROXY_NETWORKS_CACHE: Tuple[str, Tuple[IPNetwork, ...]] = ("", ())
|
||||
|
||||
|
||||
def _get_trusted_proxy_networks() -> Tuple[IPNetwork, ...]:
|
||||
global _TRUSTED_PROXY_NETWORKS_CACHE
|
||||
|
||||
raw_value = getattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "") or ""
|
||||
cached_raw, cached_networks = _TRUSTED_PROXY_NETWORKS_CACHE
|
||||
|
||||
if raw_value == cached_raw:
|
||||
return cached_networks
|
||||
|
||||
networks: List[IPNetwork] = []
|
||||
for part in raw_value.split(","):
|
||||
candidate = part.strip()
|
||||
if not candidate:
|
||||
continue
|
||||
|
||||
try:
|
||||
networks.append(ip_network(candidate, strict=False))
|
||||
except ValueError:
|
||||
logger.warning("Неверная сеть доверенного прокси YooKassa: %s", candidate)
|
||||
|
||||
cached_networks = tuple(networks)
|
||||
_TRUSTED_PROXY_NETWORKS_CACHE = (raw_value, cached_networks)
|
||||
return cached_networks
|
||||
|
||||
|
||||
def _is_trusted_proxy_ip(ip_object: IPAddress) -> bool:
|
||||
if any(
|
||||
getattr(ip_object, attribute)
|
||||
for attribute in ("is_private", "is_loopback", "is_link_local", "is_reserved")
|
||||
):
|
||||
return True
|
||||
|
||||
if any(ip_object in network for network in CLOUDFLARE_TRUSTED_NETWORKS):
|
||||
return True
|
||||
|
||||
return any(ip_object in network for network in _get_trusted_proxy_networks())
|
||||
|
||||
|
||||
def resolve_yookassa_ip(
|
||||
candidates: Iterable[str],
|
||||
*,
|
||||
remote: Optional[str] = None,
|
||||
) -> Optional[IPAddress]:
|
||||
remote_ip = _parse_candidate_ip(remote) if remote else None
|
||||
|
||||
if (
|
||||
remote_ip is not None
|
||||
and remote_ip.is_global
|
||||
and not _is_trusted_proxy_ip(remote_ip)
|
||||
):
|
||||
return remote_ip
|
||||
|
||||
candidate_list = list(candidates)
|
||||
|
||||
if _should_trust_forwarded_headers(remote_ip):
|
||||
last_hop = remote_ip
|
||||
for candidate in reversed(candidate_list):
|
||||
ip_object = _parse_candidate_ip(candidate)
|
||||
if ip_object is not None:
|
||||
if last_hop is None or _is_trusted_proxy_ip(last_hop):
|
||||
if _is_trusted_proxy_ip(ip_object):
|
||||
last_hop = ip_object
|
||||
continue
|
||||
return ip_object
|
||||
break
|
||||
|
||||
if last_hop is not None and not _is_trusted_proxy_ip(last_hop):
|
||||
return last_hop
|
||||
|
||||
return remote_ip if remote_ip is not None else next(
|
||||
(ip for ip in (_parse_candidate_ip(value) for value in candidate_list) if ip is not None),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def is_yookassa_ip_allowed(ip_object: IPAddress) -> bool:
|
||||
return any(ip_object in network for network in YOOKASSA_ALLOWED_IP_NETWORKS)
|
||||
|
||||
|
||||
class YooKassaWebhookHandler:
|
||||
|
||||
@staticmethod
|
||||
def verify_webhook_signature(body: str, signature: str, secret: str) -> bool:
|
||||
try:
|
||||
signature_parts = signature.strip().split(' ')
|
||||
|
||||
if len(signature_parts) < 4:
|
||||
logger.error(f"Неверный формат подписи YooKassa: {signature}")
|
||||
return False
|
||||
|
||||
version = signature_parts[0]
|
||||
payment_id = signature_parts[1]
|
||||
timestamp = signature_parts[2]
|
||||
received_signature = signature_parts[3]
|
||||
|
||||
if version != "v1":
|
||||
logger.error(f"Неподдерживаемая версия подписи: {version}")
|
||||
return False
|
||||
|
||||
logger.info(f"Проверка подписи v1 для платежа {payment_id}, timestamp: {timestamp}")
|
||||
|
||||
|
||||
expected_signature_1 = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
body.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).digest()
|
||||
expected_signature_1_b64 = base64.b64encode(expected_signature_1).decode('utf-8')
|
||||
|
||||
signed_payload_2 = f"{payment_id}.{timestamp}.{body}"
|
||||
expected_signature_2 = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
signed_payload_2.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).digest()
|
||||
expected_signature_2_b64 = base64.b64encode(expected_signature_2).decode('utf-8')
|
||||
|
||||
signed_payload_3 = f"{timestamp}.{body}"
|
||||
expected_signature_3 = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
signed_payload_3.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).digest()
|
||||
expected_signature_3_b64 = base64.b64encode(expected_signature_3).decode('utf-8')
|
||||
|
||||
logger.debug(f"Получена подпись: {received_signature}")
|
||||
logger.debug(f"Ожидаемая подпись (вариант 1): {expected_signature_1_b64}")
|
||||
logger.debug(f"Ожидаемая подпись (вариант 2): {expected_signature_2_b64}")
|
||||
logger.debug(f"Ожидаемая подпись (вариант 3): {expected_signature_3_b64}")
|
||||
|
||||
is_valid = (
|
||||
hmac.compare_digest(received_signature, expected_signature_1_b64) or
|
||||
hmac.compare_digest(received_signature, expected_signature_2_b64) or
|
||||
hmac.compare_digest(received_signature, expected_signature_3_b64)
|
||||
)
|
||||
|
||||
if is_valid:
|
||||
logger.info("✅ Подпись YooKassa webhook проверена успешно")
|
||||
else:
|
||||
logger.warning("⚠️ Подпись YooKassa webhook не совпадает ни с одним вариантом")
|
||||
|
||||
return is_valid
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки подписи YooKassa: {e}")
|
||||
return False
|
||||
|
||||
def __init__(self, payment_service: PaymentService):
|
||||
self.payment_service = payment_service
|
||||
|
||||
|
||||
async def handle_webhook(self, request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
try:
|
||||
logger.info(f"📥 Получен YooKassa webhook: {request.method} {request.path}")
|
||||
logger.info(f"📋 Headers: {dict(request.headers)}")
|
||||
|
||||
|
||||
header_ip_candidates = collect_yookassa_ip_candidates(
|
||||
request.headers.get("X-Forwarded-For"),
|
||||
request.headers.get("X-Real-IP"),
|
||||
request.headers.get("Cf-Connecting-Ip"),
|
||||
)
|
||||
client_ip = resolve_yookassa_ip(
|
||||
header_ip_candidates,
|
||||
remote=request.remote,
|
||||
)
|
||||
|
||||
if client_ip is None:
|
||||
logger.warning(
|
||||
"🚫 Не удалось определить IP-адрес отправителя YooKassa webhook. Кандидаты: %s",
|
||||
header_ip_candidates + ([request.remote] if request.remote else []),
|
||||
)
|
||||
return web.Response(status=403, text="Forbidden")
|
||||
|
||||
if not is_yookassa_ip_allowed(client_ip):
|
||||
logger.warning(
|
||||
"🚫 YooKassa webhook отклонён: IP %s не входит в доверенные диапазоны (%s)",
|
||||
client_ip,
|
||||
", ".join(str(network) for network in YOOKASSA_ALLOWED_IP_NETWORKS),
|
||||
)
|
||||
return web.Response(status=403, text="Forbidden")
|
||||
|
||||
logger.info("🌐 IP-адрес YooKassa подтверждён: %s", client_ip)
|
||||
|
||||
body = await request.text()
|
||||
|
||||
|
||||
if not body:
|
||||
logger.warning("⚠️ Получен пустой webhook от YooKassa")
|
||||
return web.Response(status=400, text="Empty body")
|
||||
|
||||
|
||||
logger.info(f"📄 Body: {body}")
|
||||
|
||||
|
||||
signature = request.headers.get('Signature') or request.headers.get('X-YooKassa-Signature')
|
||||
|
||||
if settings.YOOKASSA_WEBHOOK_SECRET and signature:
|
||||
logger.info(f"🔐 Получена подпись: {signature}")
|
||||
|
||||
if not YooKassaWebhookHandler.verify_webhook_signature(body, signature, settings.YOOKASSA_WEBHOOK_SECRET):
|
||||
logger.warning("❌ Подпись не совпала, но продолжаем обработку (режим отладки)")
|
||||
else:
|
||||
logger.info("✅ Подпись webhook проверена успешно")
|
||||
|
||||
elif settings.YOOKASSA_WEBHOOK_SECRET and not signature:
|
||||
logger.warning("⚠️ Webhook без подписи, но секрет настроен")
|
||||
|
||||
elif signature and not settings.YOOKASSA_WEBHOOK_SECRET:
|
||||
logger.info("ℹ️ Подпись получена, но проверка отключена (YOOKASSA_WEBHOOK_SECRET не настроен)")
|
||||
|
||||
else:
|
||||
logger.info("ℹ️ Проверка подписи отключена")
|
||||
|
||||
if signature:
|
||||
logger.info("ℹ️ Получена подпись YooKassa: %s", signature)
|
||||
|
||||
try:
|
||||
webhook_data = json.loads(body)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"❌ Ошибка парсинга JSON webhook YooKassa: {e}")
|
||||
return web.Response(status=400, text="Invalid JSON")
|
||||
|
||||
|
||||
logger.info(f"📊 Обработка webhook YooKassa: {webhook_data.get('event', 'unknown_event')}")
|
||||
logger.debug(f"🔍 Полные данные webhook: {webhook_data}")
|
||||
|
||||
|
||||
# Извлекаем ID платежа из вебхука для предотвращения дублирования
|
||||
yookassa_payment_id = webhook_data.get("object", {}).get("id")
|
||||
if not yookassa_payment_id:
|
||||
logger.warning("⚠️ Webhook YooKassa без ID платежа")
|
||||
return web.Response(status=400, text="No payment ID")
|
||||
|
||||
event_type = webhook_data.get("event")
|
||||
if not event_type:
|
||||
logger.warning("⚠️ Webhook YooKassa без типа события")
|
||||
return web.Response(status=400, text="No event type")
|
||||
|
||||
if event_type not in ["payment.succeeded", "payment.waiting_for_capture"]:
|
||||
|
||||
if event_type not in YOOKASSA_ALLOWED_EVENTS:
|
||||
logger.info(f"ℹ️ Игнорируем событие YooKassa: {event_type}")
|
||||
return web.Response(status=200, text="OK")
|
||||
|
||||
|
||||
async for db in get_db():
|
||||
try:
|
||||
success = await self.payment_service.process_yookassa_webhook(db, webhook_data)
|
||||
# Проверяем, не обрабатывается ли этот платеж уже (защита от дублирования)
|
||||
from app.database.models import PaymentMethod
|
||||
from app.database.crud.transaction import get_transaction_by_external_id
|
||||
existing_transaction = await get_transaction_by_external_id(db, yookassa_payment_id, PaymentMethod.YOOKASSA)
|
||||
|
||||
if existing_transaction and event_type == "payment.succeeded":
|
||||
logger.info(f"ℹ️ Платеж YooKassa {yookassa_payment_id} уже был обработан. Пропускаем дублирующий вебхук.")
|
||||
return web.Response(status=200, text="OK")
|
||||
|
||||
success = await self.payment_service.process_yookassa_webhook(db, webhook_data)
|
||||
|
||||
if success:
|
||||
logger.info(f"✅ Успешно обработан webhook YooKassa: {event_type}")
|
||||
logger.info(f"✅ Успешно обработан webhook YooKassa: {event_type} для платежа {yookassa_payment_id}")
|
||||
return web.Response(status=200, text="OK")
|
||||
else:
|
||||
logger.error(f"❌ Ошибка обработки webhook YooKassa: {event_type}")
|
||||
logger.error(f"❌ Ошибка обработки webhook YooKassa: {event_type} для платежа {yookassa_payment_id}")
|
||||
return web.Response(status=500, text="Processing error")
|
||||
|
||||
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Критическая ошибка обработки webhook YooKassa: {e}", exc_info=True)
|
||||
return web.Response(status=500, text="Internal server error")
|
||||
|
||||
|
||||
def setup_routes(self, app: web.Application) -> None:
|
||||
|
||||
|
||||
webhook_path = settings.YOOKASSA_WEBHOOK_PATH
|
||||
app.router.add_post(webhook_path, self.handle_webhook)
|
||||
app.router.add_get(webhook_path, self._get_handler)
|
||||
app.router.add_options(webhook_path, self._options_handler)
|
||||
|
||||
app.router.add_get(webhook_path, self._get_handler)
|
||||
app.router.add_options(webhook_path, self._options_handler)
|
||||
|
||||
logger.info(f"✅ Настроен YooKassa webhook на пути: POST {webhook_path}")
|
||||
|
||||
|
||||
async def _get_handler(self, request: web.Request) -> web.Response:
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
@@ -172,7 +313,7 @@ class YooKassaWebhookHandler:
|
||||
"path": request.path,
|
||||
"note": "Use POST method for actual webhooks"
|
||||
})
|
||||
|
||||
|
||||
async def _options_handler(self, request: web.Request) -> web.Response:
|
||||
return web.Response(
|
||||
status=200,
|
||||
@@ -185,46 +326,46 @@ class YooKassaWebhookHandler:
|
||||
|
||||
|
||||
def create_yookassa_webhook_app(payment_service: PaymentService) -> web.Application:
|
||||
|
||||
|
||||
app = web.Application()
|
||||
|
||||
|
||||
webhook_handler = YooKassaWebhookHandler(payment_service)
|
||||
webhook_handler.setup_routes(app)
|
||||
|
||||
|
||||
async def health_check(request):
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"status": "ok",
|
||||
"service": "yookassa_webhook",
|
||||
"port": settings.YOOKASSA_WEBHOOK_PORT,
|
||||
"path": settings.YOOKASSA_WEBHOOK_PATH,
|
||||
"enabled": settings.is_yookassa_enabled()
|
||||
})
|
||||
|
||||
|
||||
app.router.add_get("/health", health_check)
|
||||
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def start_yookassa_webhook_server(payment_service: PaymentService) -> None:
|
||||
|
||||
|
||||
if not settings.is_yookassa_enabled():
|
||||
logger.info("ℹ️ YooKassa отключена, webhook сервер не запускается")
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
app = create_yookassa_webhook_app(payment_service)
|
||||
|
||||
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
|
||||
|
||||
site = web.TCPSite(
|
||||
runner,
|
||||
host=settings.YOOKASSA_WEBHOOK_HOST,
|
||||
port=settings.YOOKASSA_WEBHOOK_PORT
|
||||
)
|
||||
|
||||
|
||||
await site.start()
|
||||
|
||||
|
||||
logger.info(
|
||||
"✅ YooKassa webhook сервер запущен на %s:%s",
|
||||
settings.YOOKASSA_WEBHOOK_HOST,
|
||||
@@ -236,7 +377,7 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None
|
||||
settings.YOOKASSA_WEBHOOK_PORT,
|
||||
settings.YOOKASSA_WEBHOOK_PATH,
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
@@ -246,7 +387,7 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None
|
||||
await site.stop()
|
||||
await runner.cleanup()
|
||||
logger.info("✅ YooKassa webhook сервер остановлен")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка запуска YooKassa webhook сервера: {e}", exc_info=True)
|
||||
raise
|
||||
raise
|
||||
@@ -0,0 +1,3 @@
|
||||
from . import balance
|
||||
|
||||
__all__ = ["balance"]
|
||||
@@ -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,15 +61,35 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
},
|
||||
"payments": {
|
||||
"title": "💳 Платежные системы",
|
||||
"description": "YooKassa, CryptoBot, MulenPay, PAL24, Tribute и Telegram Stars.",
|
||||
"description": "YooKassa, CryptoBot, Heleket, MulenPay, PAL24, Wata, Platega, Tribute и Telegram Stars.",
|
||||
"icon": "💳",
|
||||
"categories": ("PAYMENT", "YOOKASSA", "CRYPTOBOT", "MULENPAY", "PAL24", "TRIBUTE", "TELEGRAM"),
|
||||
"categories": (
|
||||
"PAYMENT",
|
||||
"PAYMENT_VERIFICATION",
|
||||
"YOOKASSA",
|
||||
"CRYPTOBOT",
|
||||
"HELEKET",
|
||||
"MULENPAY",
|
||||
"PAL24",
|
||||
"WATA",
|
||||
"PLATEGA",
|
||||
"TRIBUTE",
|
||||
"TELEGRAM",
|
||||
),
|
||||
},
|
||||
"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": "🎁 Пробный период",
|
||||
@@ -76,21 +111,19 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
},
|
||||
"interface": {
|
||||
"title": "🎨 Интерфейс и брендинг",
|
||||
"description": "Логотип, тексты, языки, miniapp и deep links.",
|
||||
"description": "Логотип, тексты, языки, главное меню, miniapp и deep links.",
|
||||
"icon": "🎨",
|
||||
"categories": ("INTERFACE_BRANDING", "INTERFACE_SUBSCRIPTION", "CONNECT_BUTTON", "MINIAPP", "HAPP", "SKIP", "LOCALIZATION", "ADDITIONAL"),
|
||||
},
|
||||
"database": {
|
||||
"title": "💾 База данных",
|
||||
"description": "Режим базы, параметры PostgreSQL, SQLite и Redis.",
|
||||
"icon": "💾",
|
||||
"categories": ("DATABASE", "POSTGRES", "SQLITE", "REDIS"),
|
||||
},
|
||||
"remnawave": {
|
||||
"title": "🌐 RemnaWave API",
|
||||
"description": "Интеграция с RemnaWave: URL, ключи и способы авторизации.",
|
||||
"icon": "🌐",
|
||||
"categories": ("REMNAWAVE",),
|
||||
"categories": (
|
||||
"INTERFACE",
|
||||
"INTERFACE_BRANDING",
|
||||
"INTERFACE_SUBSCRIPTION",
|
||||
"CONNECT_BUTTON",
|
||||
"MINIAPP",
|
||||
"HAPP",
|
||||
"SKIP",
|
||||
"LOCALIZATION",
|
||||
"ADDITIONAL",
|
||||
),
|
||||
},
|
||||
"server": {
|
||||
"title": "📊 Статус серверов",
|
||||
@@ -106,15 +139,16 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
},
|
||||
"advanced": {
|
||||
"title": "⚡ Расширенные",
|
||||
"description": "Web API, webhook, логирование и режим отладки.",
|
||||
"description": "Web API, webhook, логирование, модерация и режим отладки.",
|
||||
"icon": "⚡",
|
||||
"categories": ("WEB_API", "WEBHOOK", "LOG", "DEBUG"),
|
||||
},
|
||||
"external_admin": {
|
||||
"title": "🛡️ Внешняя админка",
|
||||
"description": "Токен, по которому внешняя админка проверяет запросы.",
|
||||
"icon": "🛡️",
|
||||
"categories": ("EXTERNAL_ADMIN",),
|
||||
"categories": (
|
||||
"WEB_API",
|
||||
"WEBHOOK",
|
||||
"LOG",
|
||||
"MODERATION",
|
||||
"DEBUG",
|
||||
"EXTERNAL_ADMIN",
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -127,12 +161,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(
|
||||
@@ -223,6 +254,7 @@ def _get_group_status(group_key: str) -> Tuple[str, str]:
|
||||
payment_statuses = {
|
||||
"YooKassa": settings.is_yookassa_enabled(),
|
||||
"CryptoBot": settings.is_cryptobot_enabled(),
|
||||
"Platega": settings.is_platega_enabled(),
|
||||
"MulenPay": settings.is_mulenpay_enabled(),
|
||||
"PAL24": settings.is_pal24_enabled(),
|
||||
"Tribute": settings.TRIBUTE_ENABLED,
|
||||
@@ -279,7 +311,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 ("⚪", "Бонусы не заданы")
|
||||
|
||||
@@ -353,25 +384,22 @@ def _render_dashboard_overview() -> str:
|
||||
)
|
||||
|
||||
lines: List[str] = [
|
||||
"⚙️ <b>Панель управления ботом</b>",
|
||||
"⚙️ <b>ПАНЕЛЬ УПРАВЛЕНИЯ БОТОМ</b>",
|
||||
"",
|
||||
f"Всего параметров: <b>{total_settings}</b> • Переопределено: <b>{total_overrides}</b>",
|
||||
"",
|
||||
"Выберите категорию ниже или используйте быстрые действия:",
|
||||
"<b>Группы настроек</b>",
|
||||
"",
|
||||
]
|
||||
|
||||
for group_key, title, items in grouped:
|
||||
status_icon, status_text = _get_group_status(group_key)
|
||||
description = _get_group_description(group_key) if group_key != CATEGORY_FALLBACK_KEY else "Настройки без категории."
|
||||
total = sum(count for _, _, count in items)
|
||||
lines.append(f"{status_icon} <b>{title}</b> — {status_text}")
|
||||
if description:
|
||||
lines.append(f" {description}")
|
||||
lines.append(f" Настроек: {total}")
|
||||
lines.append(f"└ Настроек: {total}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("🔍 Кнопка поиска поможет найти параметр по названию, описанию или ключу.")
|
||||
lines.append("🔍 Используйте поиск, чтобы быстро найти нужный параметр по ключу или названию.")
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
@@ -1109,8 +1137,8 @@ def _build_groups_keyboard() -> types.InlineKeyboardMarkup:
|
||||
|
||||
for group_key, title, items in grouped:
|
||||
total = sum(count for _, _, count in items)
|
||||
status_icon, _ = _get_group_status(group_key)
|
||||
button_text = f"{status_icon} {title} ({total})"
|
||||
status_icon, status_text = _get_group_status(group_key)
|
||||
button_text = f"{status_icon} {title} — {status_text}"
|
||||
rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
@@ -1162,7 +1190,7 @@ def _build_groups_keyboard() -> types.InlineKeyboardMarkup:
|
||||
rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
text="⬅️ Назад в админку",
|
||||
callback_data="admin_submenu_settings",
|
||||
)
|
||||
]
|
||||
@@ -1185,19 +1213,6 @@ def _build_categories_keyboard(
|
||||
sliced = categories[start:end]
|
||||
|
||||
rows: list[list[types.InlineKeyboardButton]] = []
|
||||
status_icon, _status_text = (
|
||||
_get_group_status(group_key)
|
||||
if group_key != CATEGORY_FALLBACK_KEY
|
||||
else ("⚪", "Прочие настройки")
|
||||
)
|
||||
rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{status_icon} {group_title}",
|
||||
callback_data="botcfg_group:noop",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
buttons: List[types.InlineKeyboardButton] = []
|
||||
for category_key, label, count in sliced:
|
||||
@@ -1205,7 +1220,7 @@ def _build_categories_keyboard(
|
||||
for definition in bot_configuration_service.get_settings_for_category(category_key):
|
||||
if bot_configuration_service.has_override(definition.key):
|
||||
overrides += 1
|
||||
badge = "✳️" if overrides else "•"
|
||||
badge = "✳️ •" if overrides else "•"
|
||||
button_text = f"{badge} {label} ({count})"
|
||||
buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
@@ -1228,7 +1243,7 @@ def _build_categories_keyboard(
|
||||
)
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{page}/{total_pages}",
|
||||
text=f"[{page}/{total_pages}]",
|
||||
callback_data="botcfg_group:noop",
|
||||
)
|
||||
)
|
||||
@@ -1300,8 +1315,14 @@ 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)")
|
||||
test_payment_buttons.append([_test_button(f"{label} · тест", "wata")])
|
||||
elif category_key == "PAL24":
|
||||
label = texts.t("PAYMENT_CARD_PAL24", "💳 Банковская карта (PayPalych)")
|
||||
test_payment_buttons.append([_test_button(f"{label} · тест", "pal24")])
|
||||
@@ -1350,7 +1371,7 @@ def _build_settings_keyboard(
|
||||
)
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{page}/{total_pages}", callback_data="botcfg_cat_page:noop"
|
||||
text=f"[{page}/{total_pages}]", callback_data="botcfg_cat_page:noop"
|
||||
)
|
||||
)
|
||||
if page < total_pages:
|
||||
@@ -1408,6 +1429,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(
|
||||
@@ -1462,42 +1493,57 @@ def _render_setting_text(key: str) -> str:
|
||||
summary = bot_configuration_service.get_setting_summary(key)
|
||||
guidance = bot_configuration_service.get_setting_guidance(key)
|
||||
|
||||
definition = bot_configuration_service.get_definition(key)
|
||||
|
||||
description = guidance.get("description") or "—"
|
||||
format_hint = guidance.get("format") or "—"
|
||||
example = guidance.get("example") or "—"
|
||||
warning = guidance.get("warning") or "—"
|
||||
dependencies = guidance.get("dependencies") or "—"
|
||||
type_label = guidance.get("type") or summary.get("type") or definition.type_label
|
||||
|
||||
lines = [
|
||||
f"🧩 <b>{summary['name']}</b>",
|
||||
f"🔑 <b>Ключ:</b> <code>{summary['key']}</code>",
|
||||
f"📁 <b>Категория:</b> {summary['category_label']}",
|
||||
f"📝 <b>Тип:</b> {guidance['type']}",
|
||||
f"📌 <b>Текущее:</b> {summary['current']}",
|
||||
f"📦 <b>По умолчанию:</b> {summary['original']}",
|
||||
f"✳️ <b>Переопределено:</b> {'Да' if summary['has_override'] else 'Нет'}",
|
||||
*(
|
||||
["🔒 <b>Режим:</b> Только для чтения (управляется автоматически)"]
|
||||
if summary.get("is_read_only")
|
||||
else []
|
||||
),
|
||||
"",
|
||||
f"📘 <b>Описание:</b> {guidance['description']}",
|
||||
f"📐 <b>Формат:</b> {guidance['format']}",
|
||||
f"💡 <b>Пример:</b> {guidance['example']}",
|
||||
f"⚠️ <b>Важно:</b> {guidance['warning']}",
|
||||
f"🔗 <b>Связанные настройки:</b> {guidance['dependencies']}",
|
||||
f"🔑 Ключ: <code>{summary['key']}</code>",
|
||||
f"📁 Категория: {summary['category_label']}",
|
||||
f"📝 Тип: {type_label}",
|
||||
f"📌 Текущее: {summary['current']}",
|
||||
]
|
||||
|
||||
original_value = summary.get("original")
|
||||
if original_value not in {None, ""}:
|
||||
lines.append(f"📦 По умолчанию: {original_value}")
|
||||
|
||||
lines.append(f"✳️ Переопределено: {'Да' if summary['has_override'] else 'Нет'}")
|
||||
|
||||
if summary.get("is_read_only"):
|
||||
lines.append("🔒 Режим: Только для чтения (управляется автоматически)")
|
||||
|
||||
lines.append("")
|
||||
if description:
|
||||
lines.append(f"📘 Описание: {description}")
|
||||
if format_hint:
|
||||
lines.append(f"📐 Формат: {format_hint}")
|
||||
if example:
|
||||
lines.append(f"💡 Пример: {example}")
|
||||
if warning:
|
||||
lines.append(f"⚠️ Важно: {warning}")
|
||||
if dependencies:
|
||||
lines.append(f"🔗 Связанные: {dependencies}")
|
||||
|
||||
choices = bot_configuration_service.get_choice_options(key)
|
||||
if choices:
|
||||
current_raw = bot_configuration_service.get_current_value(key)
|
||||
lines.append("")
|
||||
lines.append("📋 <b>Доступные значения:</b>")
|
||||
lines.append("📋 Доступные значения:")
|
||||
for option in choices:
|
||||
marker = "✅" if current_raw == option.value else "•"
|
||||
value_display = bot_configuration_service.format_value_human(key, option.value)
|
||||
description = option.description or ""
|
||||
base_line = f"{marker} {option.label} — <code>{value_display}</code>"
|
||||
if description:
|
||||
lines.append(
|
||||
f"{marker} {option.label} — <code>{value_display}</code>\n {description}"
|
||||
)
|
||||
else:
|
||||
lines.append(f"{marker} {option.label} — <code>{value_display}</code>")
|
||||
base_line += f"\n└ {description}"
|
||||
lines.append(base_line)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -1540,13 +1586,25 @@ async def show_bot_config_group(
|
||||
keyboard = _build_categories_keyboard(group_key, group_title, items, page)
|
||||
status_icon, status_text = _get_group_status(group_key)
|
||||
description = _get_group_description(group_key)
|
||||
lines = [f"{status_icon} <b>{group_title}</b>"]
|
||||
if description:
|
||||
lines.append(description)
|
||||
icon = _get_group_icon(group_key)
|
||||
raw_title = str(group_title).strip()
|
||||
clean_title = raw_title
|
||||
if icon and raw_title.startswith(icon):
|
||||
clean_title = raw_title[len(icon) :].strip()
|
||||
elif " " in raw_title:
|
||||
possible_icon, remainder = raw_title.split(" ", 1)
|
||||
if possible_icon:
|
||||
icon = possible_icon
|
||||
clean_title = remainder.strip()
|
||||
lines = [f"{icon} <b>{clean_title}</b>"]
|
||||
if status_text:
|
||||
lines.append(f"Статус: {status_text}")
|
||||
lines.append(f"Статус: {status_icon} {status_text}")
|
||||
lines.append(f"🏠 → {clean_title}")
|
||||
if description:
|
||||
lines.append("")
|
||||
lines.append(description)
|
||||
lines.append("")
|
||||
lines.append("📂 Выберите категорию настроек:")
|
||||
lines.append("📂 Категории группы:")
|
||||
await callback.message.edit_text(
|
||||
"\n".join(lines),
|
||||
reply_markup=keyboard,
|
||||
@@ -1575,6 +1633,17 @@ async def show_bot_config_category(
|
||||
category_description = bot_configuration_service.get_category_description(category_key)
|
||||
group_meta = _get_group_meta(group_key)
|
||||
group_title = str(group_meta.get("title", group_key))
|
||||
group_icon = _get_group_icon(group_key)
|
||||
raw_group_title = group_title.strip()
|
||||
if group_icon and raw_group_title.startswith(group_icon):
|
||||
group_plain_title = raw_group_title[len(group_icon) :].strip()
|
||||
elif " " in raw_group_title:
|
||||
possible_icon, remainder = raw_group_title.split(" ", 1)
|
||||
group_plain_title = remainder.strip()
|
||||
if possible_icon:
|
||||
group_icon = possible_icon
|
||||
else:
|
||||
group_plain_title = raw_group_title
|
||||
keyboard = _build_settings_keyboard(
|
||||
category_key,
|
||||
group_key,
|
||||
@@ -1584,12 +1653,12 @@ async def show_bot_config_category(
|
||||
)
|
||||
text_lines = [
|
||||
f"🗂 <b>{category_label}</b>",
|
||||
f"Навигация: 🏠 Главное → {group_title} → {category_label}",
|
||||
f"🏠 → {group_plain_title} → {category_label}",
|
||||
]
|
||||
if category_description:
|
||||
text_lines.append(category_description)
|
||||
text_lines.append("")
|
||||
text_lines.append("📋 Выберите настройку для просмотра или редактирования:")
|
||||
text_lines.append("📋 Список настроек категории:")
|
||||
await callback.message.edit_text(
|
||||
"\n".join(text_lines),
|
||||
reply_markup=keyboard,
|
||||
@@ -1598,6 +1667,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(
|
||||
@@ -1790,8 +2070,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
|
||||
@@ -1799,18 +2084,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']}"
|
||||
)
|
||||
@@ -1831,7 +2119,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
|
||||
|
||||
@@ -2461,6 +2752,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:"),
|
||||
@@ -2499,4 +2798,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,612 @@
|
||||
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.PLATEGA:
|
||||
return "Platega"
|
||||
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.PLATEGA:
|
||||
mapping = {
|
||||
"pending": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"inprogress": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
|
||||
"confirmed": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"failed": ("❌", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
|
||||
"canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
"cancelled": ("❌", 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.PLATEGA:
|
||||
return status in {"pending", "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
@@ -96,6 +96,24 @@ TRIAL_ENTRIES: Tuple[SettingEntry, ...] = (
|
||||
label_en="📱 Device limit",
|
||||
action="input",
|
||||
),
|
||||
SettingEntry(
|
||||
key="TRIAL_PAYMENT_ENABLED",
|
||||
section="trial",
|
||||
label_ru="💳 Платная активация",
|
||||
label_en="💳 Paid activation",
|
||||
action="toggle",
|
||||
description_ru="Если включено — за активацию триала будет списываться указанная сумма.",
|
||||
description_en="When enabled, the configured amount is charged during trial activation.",
|
||||
),
|
||||
SettingEntry(
|
||||
key="TRIAL_ACTIVATION_PRICE",
|
||||
section="trial",
|
||||
label_ru="💰 Стоимость активации",
|
||||
label_en="💰 Activation price",
|
||||
action="price",
|
||||
description_ru="Указывается в копейках. 0 — бесплатная активация.",
|
||||
description_en="Amount in kopeks. 0 — free activation.",
|
||||
),
|
||||
SettingEntry(
|
||||
key="TRIAL_ADD_REMAINING_DAYS_TO_PAID",
|
||||
section="trial",
|
||||
@@ -105,15 +123,6 @@ TRIAL_ENTRIES: Tuple[SettingEntry, ...] = (
|
||||
description_ru="Если включено — при покупке платной подписки оставшиеся дни триала будут добавлены к сроку.",
|
||||
description_en="When enabled, remaining trial days are added to paid subscription duration.",
|
||||
),
|
||||
SettingEntry(
|
||||
key="TRIAL_SQUAD_UUID",
|
||||
section="trial",
|
||||
label_ru="🆔 Squad UUID",
|
||||
label_en="🆔 Squad UUID",
|
||||
action="input",
|
||||
description_ru="Можно оставить пустым, если не требуется назначать пробные подписки в конкретный Squad.",
|
||||
description_en="Leave empty if trial subscriptions shouldn't be bound to a specific Squad.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -290,11 +299,14 @@ def _format_trial_summary(lang_code: str) -> str:
|
||||
duration = settings.TRIAL_DURATION_DAYS
|
||||
traffic = settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
devices = settings.TRIAL_DEVICE_LIMIT
|
||||
price_note = ""
|
||||
if settings.is_trial_paid_activation_enabled():
|
||||
price_note = f", 💳 {settings.format_price(settings.get_trial_activation_price())}"
|
||||
|
||||
traffic_label = _format_traffic_label(traffic, lang_code, short=True)
|
||||
devices_label = f"{devices}📱" if lang_code == "ru" else f"{devices}📱"
|
||||
days_suffix = "д" if lang_code == "ru" else "d"
|
||||
return f"{duration}{days_suffix}, {traffic_label}, {devices_label}"
|
||||
return f"{duration}{days_suffix}, {traffic_label}, {devices_label}{price_note}"
|
||||
|
||||
|
||||
def _format_core_summary(lang_code: str) -> str:
|
||||
|
||||
@@ -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}%
|
||||
|
||||
+466
-23
@@ -1,8 +1,16 @@
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from app.states import SquadRenameStates, SquadCreateStates, SquadMigrationStates
|
||||
from app.states import (
|
||||
RemnaWaveSyncStates,
|
||||
SquadRenameStates,
|
||||
SquadCreateStates,
|
||||
SquadMigrationStates,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -19,6 +27,11 @@ from app.keyboards.admin import (
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.remnawave_service import RemnaWaveService, RemnaWaveConfigurationError
|
||||
from app.services.remnawave_sync_service import (
|
||||
RemnaWaveAutoSyncStatus,
|
||||
remnawave_sync_service,
|
||||
)
|
||||
from app.services.system_settings_service import bot_configuration_service
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_bytes, format_datetime
|
||||
|
||||
@@ -30,6 +43,149 @@ squad_create_data = {}
|
||||
MIGRATION_PAGE_SIZE = 8
|
||||
|
||||
|
||||
def _format_duration(seconds: float) -> str:
|
||||
if seconds < 1:
|
||||
return "<1с"
|
||||
|
||||
minutes, sec = divmod(int(seconds), 60)
|
||||
if minutes:
|
||||
if sec:
|
||||
return f"{minutes} мин {sec} с"
|
||||
return f"{minutes} мин"
|
||||
return f"{sec} с"
|
||||
|
||||
|
||||
def _format_user_stats(stats: Optional[Dict[str, Any]]) -> str:
|
||||
if not stats:
|
||||
return "—"
|
||||
|
||||
created = stats.get("created", 0)
|
||||
updated = stats.get("updated", 0)
|
||||
deleted = stats.get("deleted", stats.get("deactivated", 0))
|
||||
errors = stats.get("errors", 0)
|
||||
|
||||
return (
|
||||
f"• Создано: {created}\n"
|
||||
f"• Обновлено: {updated}\n"
|
||||
f"• Деактивировано: {deleted}\n"
|
||||
f"• Ошибок: {errors}"
|
||||
)
|
||||
|
||||
|
||||
def _format_server_stats(stats: Optional[Dict[str, Any]]) -> str:
|
||||
if not stats:
|
||||
return "—"
|
||||
|
||||
created = stats.get("created", 0)
|
||||
updated = stats.get("updated", 0)
|
||||
removed = stats.get("removed", 0)
|
||||
total = stats.get("total", 0)
|
||||
|
||||
return (
|
||||
f"• Создано: {created}\n"
|
||||
f"• Обновлено: {updated}\n"
|
||||
f"• Удалено: {removed}\n"
|
||||
f"• Всего в панели: {total}"
|
||||
)
|
||||
|
||||
|
||||
def _build_auto_sync_view(status: RemnaWaveAutoSyncStatus) -> tuple[str, types.InlineKeyboardMarkup]:
|
||||
times_text = ", ".join(t.strftime("%H:%M") for t in status.times) if status.times else "—"
|
||||
next_run_text = format_datetime(status.next_run) if status.next_run else "—"
|
||||
|
||||
if status.last_run_finished_at:
|
||||
finished_text = format_datetime(status.last_run_finished_at)
|
||||
started_text = (
|
||||
format_datetime(status.last_run_started_at)
|
||||
if status.last_run_started_at
|
||||
else "—"
|
||||
)
|
||||
duration = (
|
||||
status.last_run_finished_at - status.last_run_started_at
|
||||
if status.last_run_started_at
|
||||
else None
|
||||
)
|
||||
duration_text = f" ({_format_duration(duration.total_seconds())})" if duration else ""
|
||||
reason_map = {
|
||||
"manual": "вручную",
|
||||
"auto": "по расписанию",
|
||||
"immediate": "при включении",
|
||||
}
|
||||
reason_text = reason_map.get(status.last_run_reason or "", "—")
|
||||
result_icon = "✅" if status.last_run_success else "❌"
|
||||
result_label = "успешно" if status.last_run_success else "с ошибками"
|
||||
error_block = (
|
||||
f"\n⚠️ Ошибка: {status.last_run_error}"
|
||||
if status.last_run_error
|
||||
else ""
|
||||
)
|
||||
last_run_text = (
|
||||
f"{result_icon} {result_label}\n"
|
||||
f"• Старт: {started_text}\n"
|
||||
f"• Завершено: {finished_text}{duration_text}\n"
|
||||
f"• Причина запуска: {reason_text}{error_block}"
|
||||
)
|
||||
elif status.last_run_started_at:
|
||||
last_run_text = (
|
||||
"⏳ Синхронизация началась, но еще не завершилась"
|
||||
if status.is_running
|
||||
else f"ℹ️ Последний запуск: {format_datetime(status.last_run_started_at)}"
|
||||
)
|
||||
else:
|
||||
last_run_text = "—"
|
||||
|
||||
running_text = "⏳ Выполняется сейчас" if status.is_running else "Ожидание"
|
||||
toggle_text = "❌ Отключить" if status.enabled else "✅ Включить"
|
||||
|
||||
text = f"""🔄 <b>Автосинхронизация RemnaWave</b>
|
||||
|
||||
⚙️ <b>Статус:</b> {'✅ Включена' if status.enabled else '❌ Отключена'}
|
||||
🕒 <b>Расписание:</b> {times_text}
|
||||
📅 <b>Следующий запуск:</b> {next_run_text if status.enabled else '—'}
|
||||
⏱️ <b>Состояние:</b> {running_text}
|
||||
|
||||
📊 <b>Последний запуск:</b>
|
||||
{last_run_text}
|
||||
|
||||
👥 <b>Пользователи:</b>
|
||||
{_format_user_stats(status.last_user_stats)}
|
||||
|
||||
🌐 <b>Серверы:</b>
|
||||
{_format_server_stats(status.last_server_stats)}
|
||||
"""
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🔁 Запустить сейчас",
|
||||
callback_data="remnawave_auto_sync_run",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=toggle_text,
|
||||
callback_data="remnawave_auto_sync_toggle",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🕒 Изменить расписание",
|
||||
callback_data="remnawave_auto_sync_times",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data="admin_rw_sync",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
return text, keyboard
|
||||
|
||||
|
||||
def _format_migration_server_label(texts, server) -> str:
|
||||
status = (
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE", "✅ Доступен")
|
||||
@@ -2096,34 +2252,310 @@ async def show_sync_options(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
text = """
|
||||
🔄 <b>Синхронизация с Remnawave</b>
|
||||
status = remnawave_sync_service.get_status()
|
||||
times_text = ", ".join(t.strftime("%H:%M") for t in status.times) if status.times else "—"
|
||||
next_run_text = format_datetime(status.next_run) if status.next_run else "—"
|
||||
last_result = "—"
|
||||
|
||||
🔄 <b>Полная синхронизация выполняет:</b>
|
||||
• Создание новых пользователей из панели в боте
|
||||
• Обновление данных существующих пользователей
|
||||
• Деактивация подписок пользователей, отсутствующих в панели
|
||||
• Сохранение балансов пользователей
|
||||
• ⏱️ Время выполнения: 2-5 минут
|
||||
if status.last_run_finished_at:
|
||||
result_icon = "✅" if status.last_run_success else "❌"
|
||||
result_label = "успешно" if status.last_run_success else "с ошибками"
|
||||
finished_text = format_datetime(status.last_run_finished_at)
|
||||
last_result = f"{result_icon} {result_label} ({finished_text})"
|
||||
elif status.last_run_started_at:
|
||||
last_result = f"⏳ Запущено {format_datetime(status.last_run_started_at)}"
|
||||
|
||||
⚠️ <b>Важно:</b>
|
||||
• Во время синхронизации не выполняйте другие операции
|
||||
• При полной синхронизации подписки пользователей, отсутствующих в панели, будут деактивированы
|
||||
• Рекомендуется делать полную синхронизацию ежедневно
|
||||
• Баланс пользователей НЕ удаляется
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[types.InlineKeyboardButton(text="🔄 Запустить полную синхронизацию", callback_data="sync_all_users")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")]
|
||||
status_lines = [
|
||||
f"⚙️ Статус: {'✅ Включена' if status.enabled else '❌ Отключена'}",
|
||||
f"🕒 Расписание: {times_text}",
|
||||
f"📅 Следующий запуск: {next_run_text if status.enabled else '—'}",
|
||||
f"📊 Последний запуск: {last_result}",
|
||||
]
|
||||
|
||||
|
||||
text = (
|
||||
"🔄 <b>Синхронизация с Remnawave</b>\n\n"
|
||||
"🔄 <b>Полная синхронизация выполняет:</b>\n"
|
||||
"• Создание новых пользователей из панели в боте\n"
|
||||
"• Обновление данных существующих пользователей\n"
|
||||
"• Деактивация подписок пользователей, отсутствующих в панели\n"
|
||||
"• Сохранение балансов пользователей\n"
|
||||
"• ⏱️ Время выполнения: 2-5 минут\n\n"
|
||||
"⚠️ <b>Важно:</b>\n"
|
||||
"• Во время синхронизации не выполняйте другие операции\n"
|
||||
"• При полной синхронизации подписки пользователей, отсутствующих в панели, будут деактивированы\n"
|
||||
"• Рекомендуется делать полную синхронизацию ежедневно\n"
|
||||
"• Баланс пользователей НЕ удаляется\n\n"
|
||||
+ "\n".join(status_lines)
|
||||
)
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🔄 Запустить полную синхронизацию",
|
||||
callback_data="sync_all_users",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⚙️ Настройки автосинхронизации",
|
||||
callback_data="admin_rw_auto_sync",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")],
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_auto_sync_settings(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await state.clear()
|
||||
status = remnawave_sync_service.get_status()
|
||||
text, keyboard = _build_auto_sync_view(status)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_auto_sync_setting(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await state.clear()
|
||||
new_value = not bool(settings.REMNAWAVE_AUTO_SYNC_ENABLED)
|
||||
await bot_configuration_service.set_value(
|
||||
db,
|
||||
"REMNAWAVE_AUTO_SYNC_ENABLED",
|
||||
new_value,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
status = remnawave_sync_service.get_status()
|
||||
text, keyboard = _build_auto_sync_view(status)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer(
|
||||
f"Автосинхронизация {'включена' if new_value else 'отключена'}"
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def prompt_auto_sync_schedule(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
status = remnawave_sync_service.get_status()
|
||||
current_schedule = ", ".join(t.strftime("%H:%M") for t in status.times) if status.times else "—"
|
||||
|
||||
instructions = (
|
||||
"🕒 <b>Настройка расписания автосинхронизации</b>\n\n"
|
||||
"Укажите время запуска через запятую или с новой строки в формате HH:MM.\n"
|
||||
f"Текущее расписание: <code>{current_schedule}</code>\n\n"
|
||||
"Примеры: <code>03:00, 15:30</code> или <code>00:15\n06:00\n18:45</code>\n\n"
|
||||
"Отправьте <b>отмена</b>, чтобы вернуться без изменений."
|
||||
)
|
||||
|
||||
await state.set_state(RemnaWaveSyncStates.waiting_for_schedule)
|
||||
await state.update_data(
|
||||
auto_sync_message_id=callback.message.message_id,
|
||||
auto_sync_message_chat_id=callback.message.chat.id,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
instructions,
|
||||
parse_mode="HTML",
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="❌ Отмена",
|
||||
callback_data="remnawave_auto_sync_cancel",
|
||||
)
|
||||
]
|
||||
]
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def cancel_auto_sync_schedule(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await state.clear()
|
||||
status = remnawave_sync_service.get_status()
|
||||
text, keyboard = _build_auto_sync_view(status)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer("Изменение расписания отменено")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def run_auto_sync_now(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
if remnawave_sync_service.get_status().is_running:
|
||||
await callback.answer("Синхронизация уже выполняется", show_alert=True)
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
await callback.message.edit_text(
|
||||
"🔄 Запуск автосинхронизации...\n\nПодождите, это может занять несколько минут.",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer("Автосинхронизация запущена")
|
||||
|
||||
result = await remnawave_sync_service.run_sync_now(reason="manual")
|
||||
status = remnawave_sync_service.get_status()
|
||||
base_text, keyboard = _build_auto_sync_view(status)
|
||||
|
||||
if not result.get("started"):
|
||||
await callback.message.edit_text(
|
||||
"⚠️ <b>Синхронизация уже выполняется</b>\n\n" + base_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
if result.get("success"):
|
||||
user_stats = result.get("user_stats") or {}
|
||||
server_stats = result.get("server_stats") or {}
|
||||
summary = (
|
||||
"✅ <b>Синхронизация завершена</b>\n"
|
||||
f"👥 Пользователи: создано {user_stats.get('created', 0)}, обновлено {user_stats.get('updated', 0)}, "
|
||||
f"деактивировано {user_stats.get('deleted', user_stats.get('deactivated', 0))}, ошибок {user_stats.get('errors', 0)}\n"
|
||||
f"🌐 Серверы: создано {server_stats.get('created', 0)}, обновлено {server_stats.get('updated', 0)}, удалено {server_stats.get('removed', 0)}\n\n"
|
||||
)
|
||||
final_text = summary + base_text
|
||||
await callback.message.edit_text(
|
||||
final_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
else:
|
||||
error_text = result.get("error") or "Неизвестная ошибка"
|
||||
summary = (
|
||||
"❌ <b>Синхронизация завершилась с ошибкой</b>\n"
|
||||
f"Причина: {error_text}\n\n"
|
||||
)
|
||||
await callback.message.edit_text(
|
||||
summary + base_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def save_auto_sync_schedule(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
text = (message.text or "").strip()
|
||||
data = await state.get_data()
|
||||
|
||||
if text.lower() in {"отмена", "cancel"}:
|
||||
await state.clear()
|
||||
status = remnawave_sync_service.get_status()
|
||||
view_text, keyboard = _build_auto_sync_view(status)
|
||||
message_id = data.get("auto_sync_message_id")
|
||||
chat_id = data.get("auto_sync_message_chat_id", message.chat.id)
|
||||
if message_id:
|
||||
await message.bot.edit_message_text(
|
||||
view_text,
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
else:
|
||||
await message.answer(
|
||||
view_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await message.answer("Настройка расписания отменена")
|
||||
return
|
||||
|
||||
parsed_times = settings.parse_daily_time_list(text)
|
||||
|
||||
if not parsed_times:
|
||||
await message.answer(
|
||||
"❌ Не удалось распознать время. Используйте формат HH:MM, например 03:00 или 18:45.",
|
||||
)
|
||||
return
|
||||
|
||||
normalized_value = ", ".join(t.strftime("%H:%M") for t in parsed_times)
|
||||
await bot_configuration_service.set_value(
|
||||
db,
|
||||
"REMNAWAVE_AUTO_SYNC_TIMES",
|
||||
normalized_value,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
status = remnawave_sync_service.get_status()
|
||||
view_text, keyboard = _build_auto_sync_view(status)
|
||||
message_id = data.get("auto_sync_message_id")
|
||||
chat_id = data.get("auto_sync_message_chat_id", message.chat.id)
|
||||
|
||||
if message_id:
|
||||
await message.bot.edit_message_text(
|
||||
view_text,
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
else:
|
||||
await message.answer(
|
||||
view_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
await message.answer("✅ Расписание автосинхронизации обновлено")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def sync_all_users(
|
||||
@@ -2688,6 +3120,11 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(manage_node, F.data.startswith("node_restart_"))
|
||||
dp.callback_query.register(restart_all_nodes, F.data == "admin_restart_all_nodes")
|
||||
dp.callback_query.register(show_sync_options, F.data == "admin_rw_sync")
|
||||
dp.callback_query.register(show_auto_sync_settings, F.data == "admin_rw_auto_sync")
|
||||
dp.callback_query.register(toggle_auto_sync_setting, F.data == "remnawave_auto_sync_toggle")
|
||||
dp.callback_query.register(prompt_auto_sync_schedule, F.data == "remnawave_auto_sync_times")
|
||||
dp.callback_query.register(cancel_auto_sync_schedule, F.data == "remnawave_auto_sync_cancel")
|
||||
dp.callback_query.register(run_auto_sync_now, F.data == "remnawave_auto_sync_run")
|
||||
dp.callback_query.register(sync_all_users, F.data == "sync_all_users")
|
||||
dp.callback_query.register(show_squad_migration_menu, F.data == "admin_rw_migration")
|
||||
dp.callback_query.register(paginate_migration_source, F.data.startswith("admin_migration_source_page_"))
|
||||
@@ -2716,13 +3153,19 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(finish_squad_creation, F.data == "create_squad_finish")
|
||||
|
||||
dp.message.register(
|
||||
process_squad_new_name,
|
||||
process_squad_new_name,
|
||||
SquadRenameStates.waiting_for_new_name,
|
||||
F.text
|
||||
)
|
||||
|
||||
|
||||
dp.message.register(
|
||||
process_squad_name,
|
||||
SquadCreateStates.waiting_for_name,
|
||||
F.text
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
save_auto_sync_schedule,
|
||||
RemnaWaveSyncStates.waiting_for_schedule,
|
||||
F.text,
|
||||
)
|
||||
|
||||
@@ -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_"))
|
||||
|
||||
# Ответы на тикеты
|
||||
|
||||
+709
-88
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
from .main import register_balance_handlers
|
||||
|
||||
__all__ = ["register_balance_handlers"]
|
||||
@@ -0,0 +1,241 @@
|
||||
import logging
|
||||
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.utils.decorators import error_handler
|
||||
from app.states import BalanceStates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_cryptobot_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_cryptobot_enabled():
|
||||
await callback.answer("❌ Оплата криптовалютой временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
from app.utils.currency_converter import currency_converter
|
||||
try:
|
||||
current_rate = await currency_converter.get_usd_to_rub_rate()
|
||||
rate_text = f"💱 Текущий курс: 1 USD = {current_rate:.2f} ₽"
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось получить курс валют: {e}")
|
||||
current_rate = 95.0
|
||||
rate_text = f"💱 Курс: 1 USD ≈ {current_rate:.0f} ₽"
|
||||
|
||||
available_assets = settings.get_cryptobot_assets()
|
||||
assets_text = ", ".join(available_assets)
|
||||
|
||||
# Формируем текст сообщения в зависимости от настройки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
message_text = (
|
||||
f"🪙 <b>Пополнение криптовалютой</b>\n\n"
|
||||
f"Выберите сумму пополнения или введите вручную сумму "
|
||||
f"от 100 до 100,000 ₽:\n\n"
|
||||
f"💰 Доступные активы: {assets_text}\n"
|
||||
f"⚡ Мгновенное зачисление на баланс\n"
|
||||
f"🔒 Безопасная оплата через CryptoBot\n\n"
|
||||
f"{rate_text}\n"
|
||||
f"Сумма будет автоматически конвертирована в USD для оплаты."
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"🪙 <b>Пополнение криптовалютой</b>\n\n"
|
||||
f"Введите сумму для пополнения от 100 до 100,000 ₽:\n\n"
|
||||
f"💰 Доступные активы: {assets_text}\n"
|
||||
f"⚡ Мгновенное зачисление на баланс\n"
|
||||
f"🔒 Безопасная оплата через CryptoBot\n\n"
|
||||
f"{rate_text}\n"
|
||||
f"Сумма будет автоматически конвертирована в USD для оплаты."
|
||||
)
|
||||
|
||||
# Создаем клавиатуру
|
||||
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="cryptobot", current_rate=current_rate)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_cryptobot_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_cryptobot_enabled():
|
||||
await message.answer("❌ Оплата криптовалютой временно недоступна")
|
||||
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
|
||||
|
||||
try:
|
||||
data = await state.get_data()
|
||||
current_rate = data.get('current_rate')
|
||||
|
||||
if not current_rate:
|
||||
from app.utils.currency_converter import currency_converter
|
||||
current_rate = await currency_converter.get_usd_to_rub_rate()
|
||||
|
||||
amount_usd = amount_rubles / current_rate
|
||||
|
||||
amount_usd = round(amount_usd, 2)
|
||||
|
||||
if amount_usd < 1:
|
||||
await message.answer("❌ Минимальная сумма для оплаты в USD: 1.00 USD")
|
||||
return
|
||||
|
||||
if amount_usd > 1000:
|
||||
await message.answer("❌ Максимальная сумма для оплаты в USD: 1,000 USD")
|
||||
return
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
|
||||
payment_result = await payment_service.create_cryptobot_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_usd=amount_usd,
|
||||
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
|
||||
description=f"Пополнение баланса на {amount_rubles:.0f} ₽ ({amount_usd:.2f} USD)",
|
||||
payload=f"balance_{db_user.id}_{amount_kopeks}"
|
||||
)
|
||||
|
||||
if not payment_result:
|
||||
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
bot_invoice_url = payment_result.get("bot_invoice_url")
|
||||
mini_app_invoice_url = payment_result.get("mini_app_invoice_url")
|
||||
|
||||
payment_url = bot_invoice_url or mini_app_invoice_url
|
||||
|
||||
if not payment_url:
|
||||
await message.answer("❌ Ошибка получения ссылки для оплаты. Обратитесь в поддержку.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🪙 Оплатить", url=payment_url)],
|
||||
[types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_cryptobot_{payment_result['local_payment_id']}")],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await message.answer(
|
||||
f"🪙 <b>Оплата криптовалютой</b>\n\n"
|
||||
f"💰 Сумма к зачислению: {amount_rubles:.0f} ₽\n"
|
||||
f"💵 К оплате: {amount_usd:.2f} USD\n"
|
||||
f"🪙 Актив: {payment_result['asset']}\n"
|
||||
f"💱 Курс: 1 USD = {current_rate:.2f} ₽\n"
|
||||
f"🆔 ID платежа: {payment_result['invoice_id'][:8]}...\n\n"
|
||||
f"📱 <b>Инструкция:</b>\n"
|
||||
f"1. Нажмите кнопку 'Оплатить'\n"
|
||||
f"2. Выберите удобный актив\n"
|
||||
f"3. Переведите указанную сумму\n"
|
||||
f"4. Деньги поступят на баланс автоматически\n\n"
|
||||
f"🔒 Оплата проходит через защищенную систему CryptoBot\n"
|
||||
f"⚡ Поддерживаемые активы: USDT, TON, BTC, ETH\n\n"
|
||||
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
logger.info(f"Создан CryptoBot платеж для пользователя {db_user.telegram_id}: "
|
||||
f"{amount_rubles:.0f} ₽ ({amount_usd:.2f} USD), ID: {payment_result['invoice_id']}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания CryptoBot платежа: {e}")
|
||||
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
|
||||
await state.clear()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_cryptobot_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.split('_')[-1])
|
||||
|
||||
from app.database.crud.cryptobot import get_cryptobot_payment_by_id
|
||||
payment = await get_cryptobot_payment_by_id(db, local_payment_id)
|
||||
|
||||
if not payment:
|
||||
await callback.answer("❌ Платеж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
status_emoji = {
|
||||
"active": "⏳",
|
||||
"paid": "✅",
|
||||
"expired": "❌"
|
||||
}
|
||||
|
||||
status_text = {
|
||||
"active": "Ожидает оплаты",
|
||||
"paid": "Оплачен",
|
||||
"expired": "Истек"
|
||||
}
|
||||
|
||||
emoji = status_emoji.get(payment.status, "❓")
|
||||
status = status_text.get(payment.status, "Неизвестно")
|
||||
|
||||
message_text = (f"🪙 Статус платежа:\n\n"
|
||||
f"🆔 ID: {payment.invoice_id[:8]}...\n"
|
||||
f"💰 Сумма: {payment.amount} {payment.asset}\n"
|
||||
f"📊 Статус: {emoji} {status}\n"
|
||||
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n")
|
||||
|
||||
if payment.is_paid:
|
||||
message_text += "\n✅ Платеж успешно завершен!\n\nСредства зачислены на баланс."
|
||||
elif payment.is_pending:
|
||||
message_text += "\n⏳ Платеж ожидает оплаты. Нажмите кнопку 'Оплатить' выше."
|
||||
elif payment.is_expired:
|
||||
message_text += (
|
||||
f"\n❌ Платеж истек. Обратитесь в {settings.get_support_contact_display()}"
|
||||
)
|
||||
|
||||
await callback.answer(message_text, show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки статуса CryptoBot платежа: {e}")
|
||||
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,965 @@
|
||||
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
|
||||
)
|
||||
from app.database.models import User, TransactionType, PaymentMethod
|
||||
from app.keyboards.inline import (
|
||||
get_balance_keyboard, get_payment_methods_keyboard,
|
||||
get_back_keyboard, get_pagination_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TRANSACTIONS_PER_PAGE = 10
|
||||
|
||||
|
||||
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] # Limit to 6 periods
|
||||
|
||||
for period in periods:
|
||||
price_attr = f"PRICE_{period}_DAYS"
|
||||
if hasattr(settings, price_attr):
|
||||
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=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
|
||||
|
||||
|
||||
@error_handler
|
||||
async def show_balance_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
balance_text = texts.BALANCE_INFO.format(
|
||||
balance=texts.format_price(db_user.balance_kopeks)
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
balance_text,
|
||||
reply_markup=get_balance_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def show_balance_history(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
page: int = 1
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
offset = (page - 1) * TRANSACTIONS_PER_PAGE
|
||||
|
||||
raw_transactions = await get_user_transactions(
|
||||
db, db_user.id,
|
||||
limit=TRANSACTIONS_PER_PAGE * 3,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
seen_transactions = set()
|
||||
unique_transactions = []
|
||||
|
||||
for transaction in raw_transactions:
|
||||
rounded_time = transaction.created_at.replace(second=0, microsecond=0)
|
||||
transaction_key = (
|
||||
transaction.amount_kopeks,
|
||||
transaction.description,
|
||||
rounded_time
|
||||
)
|
||||
|
||||
if transaction_key not in seen_transactions:
|
||||
seen_transactions.add(transaction_key)
|
||||
unique_transactions.append(transaction)
|
||||
|
||||
if len(unique_transactions) >= TRANSACTIONS_PER_PAGE:
|
||||
break
|
||||
|
||||
all_transactions = await get_user_transactions(db, db_user.id, limit=1000)
|
||||
seen_all = set()
|
||||
total_unique = 0
|
||||
|
||||
for transaction in all_transactions:
|
||||
rounded_time = transaction.created_at.replace(second=0, microsecond=0)
|
||||
transaction_key = (
|
||||
transaction.amount_kopeks,
|
||||
transaction.description,
|
||||
rounded_time
|
||||
)
|
||||
if transaction_key not in seen_all:
|
||||
seen_all.add(transaction_key)
|
||||
total_unique += 1
|
||||
|
||||
if not unique_transactions:
|
||||
await callback.message.edit_text(
|
||||
"📊 История операций пуста",
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
text = "📊 <b>История операций</b>\n\n"
|
||||
|
||||
for transaction in unique_transactions:
|
||||
emoji = "💰" if transaction.type == TransactionType.DEPOSIT.value else "💸"
|
||||
amount_text = f"+{texts.format_price(transaction.amount_kopeks)}" if transaction.type == TransactionType.DEPOSIT.value else f"-{texts.format_price(transaction.amount_kopeks)}"
|
||||
|
||||
text += f"{emoji} {amount_text}\n"
|
||||
text += f"📝 {transaction.description}\n"
|
||||
text += f"📅 {transaction.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
|
||||
keyboard = []
|
||||
total_pages = (total_unique + TRANSACTIONS_PER_PAGE - 1) // TRANSACTIONS_PER_PAGE
|
||||
|
||||
if total_pages > 1:
|
||||
pagination_row = get_pagination_keyboard(
|
||||
page, total_pages, "balance_history", db_user.language
|
||||
)
|
||||
keyboard.extend(pagination_row)
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_balance_history_pagination(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
page = int(callback.data.split('_')[-1])
|
||||
await show_balance_history(callback, db_user, db, page)
|
||||
|
||||
|
||||
@error_handler
|
||||
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)
|
||||
|
||||
# Добавляем информацию о текущем тарифе пользователя
|
||||
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
|
||||
)
|
||||
|
||||
traffic_value = current_traffic or 0
|
||||
if traffic_value <= 0:
|
||||
traffic_display = texts.t("TRAFFIC_UNLIMITED_SHORT", "Безлимит")
|
||||
else:
|
||||
traffic_display = texts.format_traffic(traffic_value)
|
||||
|
||||
current_tariff_desc = (
|
||||
f"📱 Подписка: {len(current_connected_squads)} серверов, "
|
||||
f"{traffic_display}, {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()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_payment_methods_unavailable(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT",
|
||||
"⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.",
|
||||
),
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_successful_topup_with_cart(
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
bot,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.storage.base import StorageKey
|
||||
from app.bot import dp
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return
|
||||
|
||||
storage = dp.storage
|
||||
key = StorageKey(bot_id=bot.id, chat_id=user.telegram_id, user_id=user.telegram_id)
|
||||
|
||||
try:
|
||||
state_data = await storage.get_data(key)
|
||||
current_state = await storage.get_state(key)
|
||||
|
||||
if (current_state == "SubscriptionStates:cart_saved_for_topup" and
|
||||
state_data.get('saved_cart')):
|
||||
|
||||
texts = get_texts(user.language)
|
||||
total_price = state_data.get('total_price', 0)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text="🛒 Вернуться к оформлению подписки",
|
||||
callback_data="return_to_saved_cart"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text="💰 Мой баланс",
|
||||
callback_data="menu_balance"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text="🏠 Главное меню",
|
||||
callback_data="back_to_menu"
|
||||
)]
|
||||
])
|
||||
|
||||
success_text = (
|
||||
f"✅ Баланс пополнен на {texts.format_price(amount_kopeks)}!\n\n"
|
||||
f"💰 Текущий баланс: {texts.format_price(user.balance_kopeks)}\n\n"
|
||||
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
f"Обязательно активируйте подписку отдельно!\n\n"
|
||||
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
f"подписка будет приобретена автоматически после пополнения баланса.\n\n"
|
||||
f"🛒 У вас есть сохраненная корзина подписки\n"
|
||||
f"Стоимость: {texts.format_price(total_price)}\n\n"
|
||||
f"Хотите продолжить оформление?"
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=success_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки успешного пополнения с корзиной: {e}")
|
||||
|
||||
|
||||
@error_handler
|
||||
async def request_support_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
support_text = f"""
|
||||
🛠️ <b>Пополнение через поддержку</b>
|
||||
|
||||
Для пополнения баланса обратитесь в техподдержку:
|
||||
{settings.get_support_contact_display_html()}
|
||||
|
||||
Укажите:
|
||||
• ID: {db_user.telegram_id}
|
||||
• Сумму пополнения
|
||||
• Способ оплаты
|
||||
|
||||
⏰ Время обработки: 1-24 часа
|
||||
|
||||
<b>Доступные способы:</b>
|
||||
• Криптовалюта
|
||||
• Переводы между банками
|
||||
• Другие платежные системы
|
||||
"""
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text="💬 Написать в поддержку",
|
||||
url=settings.get_support_contact_url() or "https://t.me/"
|
||||
)],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
support_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_topup_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
try:
|
||||
if not message.text:
|
||||
if message.successful_payment:
|
||||
logger.info(
|
||||
"Получено сообщение об успешном платеже без текста, "
|
||||
"обработчик суммы пополнения завершает работу"
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await message.answer(
|
||||
texts.INVALID_AMOUNT,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
amount_text = message.text.strip()
|
||||
if not amount_text:
|
||||
await message.answer(
|
||||
texts.INVALID_AMOUNT,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
amount_rubles = float(amount_text.replace(',', '.'))
|
||||
|
||||
if amount_rubles < 1:
|
||||
await message.answer("Минимальная сумма пополнения: 1 ₽")
|
||||
return
|
||||
|
||||
if amount_rubles > 50000:
|
||||
await message.answer("Максимальная сумма пополнения: 50,000 ₽")
|
||||
return
|
||||
|
||||
amount_kopeks = int(amount_rubles * 100)
|
||||
data = await state.get_data()
|
||||
payment_method = data.get("payment_method", "stars")
|
||||
|
||||
if payment_method in ["yookassa", "yookassa_sbp"]:
|
||||
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
|
||||
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f"❌ Минимальная сумма для оплаты через YooKassa: {min_rubles:.0f} ₽")
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
|
||||
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f"❌ Максимальная сумма для оплаты через YooKassa: {max_rubles:,.0f} ₽".replace(',', ' '))
|
||||
return
|
||||
|
||||
if payment_method == "stars":
|
||||
from .stars import process_stars_payment_amount
|
||||
await process_stars_payment_amount(message, db_user, amount_kopeks, state)
|
||||
elif payment_method == "yookassa":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .yookassa import process_yookassa_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "yookassa_sbp":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .yookassa import process_yookassa_sbp_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_sbp_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "mulenpay":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .mulenpay import process_mulenpay_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_mulenpay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "platega":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .platega import process_platega_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_platega_payment_amount(
|
||||
message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "wata":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .wata import process_wata_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_wata_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "pal24":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .pal24 import process_pal24_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_pal24_payment_amount(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(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("Неизвестный способ оплаты")
|
||||
|
||||
except ValueError:
|
||||
await message.answer(
|
||||
texts.INVALID_AMOUNT,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_sbp_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.split('_')[-1])
|
||||
|
||||
from app.database.crud.yookassa import get_yookassa_payment_by_local_id
|
||||
payment = await get_yookassa_payment_by_local_id(db, local_payment_id)
|
||||
|
||||
if not payment:
|
||||
await callback.answer("❌ Платеж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
import json
|
||||
metadata = json.loads(payment.metadata_json) if payment.metadata_json else {}
|
||||
confirmation_token = metadata.get("confirmation_token")
|
||||
|
||||
if not confirmation_token:
|
||||
await callback.answer("❌ Токен подтверждения не найден", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.answer(
|
||||
f"Для оплаты через СБП откройте приложение вашего банка и подтвердите платеж.\\n\\n"
|
||||
f"Если у вас не открылось банковское приложение автоматически, вы можете:\\n"
|
||||
f"1. Скопировать этот токен: <code>{confirmation_token}</code>\\n"
|
||||
f"2. Открыть приложение вашего банка\\n"
|
||||
f"3. Найти функцию оплаты по токену\\n"
|
||||
f"4. Вставить токен и подтвердить платеж",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer("Информация об оплате отправлена", show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки embedded платежа СБП: {e}")
|
||||
await callback.answer("❌ Ошибка обработки платежа", show_alert=True)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_quick_amount_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
"""
|
||||
Обработчик выбора суммы через кнопки быстрого выбора
|
||||
"""
|
||||
# Извлекаем сумму из callback_data
|
||||
try:
|
||||
amount_kopeks = int(callback.data.split('_')[-1])
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
# Получаем метод оплаты из состояния
|
||||
data = await state.get_data()
|
||||
payment_method = data.get("payment_method", "yookassa")
|
||||
|
||||
# Проверяем, какой метод оплаты был выбран и вызываем соответствующий обработчик
|
||||
if payment_method == "yookassa":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .yookassa import process_yookassa_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "yookassa_sbp":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .yookassa import process_yookassa_sbp_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_sbp_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "mulenpay":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .mulenpay import process_mulenpay_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_mulenpay_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "platega":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .platega import process_platega_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_platega_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "wata":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .wata import process_wata_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_wata_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "pal24":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .pal24 import process_pal24_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
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:
|
||||
logger.error(f"Ошибка обработки быстрого выбора суммы: {e}")
|
||||
await callback.answer("❌ Ошибка обработки запроса", show_alert=True)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_topup_amount_callback(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
try:
|
||||
_, method, amount_str = callback.data.split("|", 2)
|
||||
amount_kopeks = int(amount_str)
|
||||
except ValueError:
|
||||
await callback.answer("❌ Некорректный запрос", show_alert=True)
|
||||
return
|
||||
|
||||
if amount_kopeks <= 0:
|
||||
await callback.answer("❌ Некорректная сумма", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
if method == "yookassa":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .yookassa import process_yookassa_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "yookassa_sbp":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .yookassa import process_yookassa_sbp_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_sbp_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "mulenpay":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .mulenpay import process_mulenpay_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_mulenpay_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "platega":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .platega import process_platega_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_platega_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "pal24":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .pal24 import process_pal24_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_pal24_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif 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 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 == "wata":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .wata import process_wata_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_wata_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(
|
||||
callback.message, db_user, amount_kopeks, state
|
||||
)
|
||||
elif method == "tribute":
|
||||
from .tribute import start_tribute_payment
|
||||
await start_tribute_payment(callback, db_user)
|
||||
return
|
||||
else:
|
||||
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка быстрого пополнения: {error}")
|
||||
await callback.answer("❌ Ошибка обработки запроса", show_alert=True)
|
||||
|
||||
|
||||
def register_balance_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_balance_menu,
|
||||
F.data == "menu_balance"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_balance_history,
|
||||
F.data == "balance_history"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_balance_history_pagination,
|
||||
F.data.startswith("balance_history_page_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_payment_methods,
|
||||
F.data == "balance_topup"
|
||||
)
|
||||
|
||||
from .stars import start_stars_payment
|
||||
dp.callback_query.register(
|
||||
start_stars_payment,
|
||||
F.data == "topup_stars"
|
||||
)
|
||||
|
||||
from .yookassa import start_yookassa_payment
|
||||
dp.callback_query.register(
|
||||
start_yookassa_payment,
|
||||
F.data == "topup_yookassa"
|
||||
)
|
||||
|
||||
from .yookassa import start_yookassa_sbp_payment
|
||||
dp.callback_query.register(
|
||||
start_yookassa_sbp_payment,
|
||||
F.data == "topup_yookassa_sbp"
|
||||
)
|
||||
|
||||
from .mulenpay import start_mulenpay_payment
|
||||
dp.callback_query.register(
|
||||
start_mulenpay_payment,
|
||||
F.data == "topup_mulenpay"
|
||||
)
|
||||
|
||||
from .wata import start_wata_payment
|
||||
dp.callback_query.register(
|
||||
start_wata_payment,
|
||||
F.data == "topup_wata"
|
||||
)
|
||||
|
||||
from .pal24 import start_pal24_payment
|
||||
dp.callback_query.register(
|
||||
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 .platega import start_platega_payment, handle_platega_method_selection
|
||||
dp.callback_query.register(
|
||||
start_platega_payment,
|
||||
F.data == "topup_platega"
|
||||
)
|
||||
dp.callback_query.register(
|
||||
handle_platega_method_selection,
|
||||
F.data.startswith("platega_method_"),
|
||||
)
|
||||
|
||||
from .yookassa import check_yookassa_payment_status
|
||||
dp.callback_query.register(
|
||||
check_yookassa_payment_status,
|
||||
F.data.startswith("check_yookassa_")
|
||||
)
|
||||
|
||||
from .tribute import start_tribute_payment
|
||||
dp.callback_query.register(
|
||||
start_tribute_payment,
|
||||
F.data == "topup_tribute"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
request_support_topup,
|
||||
F.data == "topup_support"
|
||||
)
|
||||
|
||||
from .yookassa import check_yookassa_payment_status
|
||||
dp.callback_query.register(
|
||||
check_yookassa_payment_status,
|
||||
F.data.startswith("check_yookassa_")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_topup_amount,
|
||||
BalanceStates.waiting_for_amount
|
||||
)
|
||||
|
||||
from .cryptobot import start_cryptobot_payment
|
||||
dp.callback_query.register(
|
||||
start_cryptobot_payment,
|
||||
F.data == "topup_cryptobot"
|
||||
)
|
||||
|
||||
from .cryptobot import check_cryptobot_payment_status
|
||||
dp.callback_query.register(
|
||||
check_cryptobot_payment_status,
|
||||
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,
|
||||
F.data.startswith("check_mulenpay_")
|
||||
)
|
||||
|
||||
from .wata import check_wata_payment_status
|
||||
dp.callback_query.register(
|
||||
check_wata_payment_status,
|
||||
F.data.startswith("check_wata_")
|
||||
)
|
||||
|
||||
from .pal24 import check_pal24_payment_status
|
||||
dp.callback_query.register(
|
||||
check_pal24_payment_status,
|
||||
F.data.startswith("check_pal24_")
|
||||
)
|
||||
|
||||
from .platega import check_platega_payment_status
|
||||
dp.callback_query.register(
|
||||
check_platega_payment_status,
|
||||
F.data.startswith("check_platega_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_payment_methods_unavailable,
|
||||
F.data == "payment_methods_unavailable"
|
||||
)
|
||||
|
||||
# Регистрируем обработчик для кнопок быстрого выбора суммы
|
||||
dp.callback_query.register(
|
||||
handle_quick_amount_selection,
|
||||
F.data.startswith("quick_amount_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_topup_amount_callback,
|
||||
F.data.startswith("topup_amount|")
|
||||
)
|
||||
@@ -0,0 +1,255 @@
|
||||
import logging
|
||||
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.utils.decorators import error_handler
|
||||
from app.states import BalanceStates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_mulenpay_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
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(
|
||||
f"❌ Оплата через {mulenpay_name} временно недоступна",
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
message_template = texts.t(
|
||||
"MULENPAY_TOPUP_PROMPT",
|
||||
(
|
||||
"💳 <b>Оплата через {mulenpay_name_html}</b>\n\n"
|
||||
"Введите сумму для пополнения от 100 до 100 000 ₽.\n"
|
||||
"Оплата происходит через защищенную платформу {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, 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="mulenpay")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_mulenpay_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
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(f"❌ Оплата через {mulenpay_name} временно недоступна")
|
||||
return
|
||||
|
||||
if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS:
|
||||
await message.answer(
|
||||
f"Минимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MIN_AMOUNT_KOPEKS)}"
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS:
|
||||
await message.answer(
|
||||
f"Максимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MAX_AMOUNT_KOPEKS)}"
|
||||
)
|
||||
return
|
||||
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(message.bot)
|
||||
payment_result = await payment_service.create_mulenpay_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not payment_result or not payment_result.get("payment_url"):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"MULENPAY_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа {mulenpay_name}. Попробуйте позже или обратитесь в поддержку.",
|
||||
).format(mulenpay_name=mulenpay_name)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
payment_url = payment_result.get("payment_url")
|
||||
mulen_payment_id = payment_result.get("mulen_payment_id")
|
||||
local_payment_id = payment_result.get("local_payment_id")
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"MULENPAY_PAY_BUTTON",
|
||||
"💳 Оплатить через {mulenpay_name}",
|
||||
).format(mulenpay_name=mulenpay_name),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_mulenpay_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
]
|
||||
)
|
||||
|
||||
payment_id_display = mulen_payment_id if mulen_payment_id is not None else local_payment_id
|
||||
|
||||
message_template = texts.t(
|
||||
"MULENPAY_PAYMENT_INSTRUCTIONS",
|
||||
(
|
||||
"💳 <b>Оплата через {mulenpay_name_html}</b>\n\n"
|
||||
"💰 Сумма: {amount}\n"
|
||||
"🆔 ID платежа: {payment_id}\n\n"
|
||||
"📱 <b>Инструкция:</b>\n"
|
||||
"1. Нажмите кнопку 'Оплатить через {mulenpay_name}'\n"
|
||||
"2. Следуйте подсказкам платежной системы\n"
|
||||
"3. Подтвердите перевод\n"
|
||||
"4. Средства зачислятся автоматически\n\n"
|
||||
"❓ Если возникнут проблемы, обратитесь в {support}"
|
||||
),
|
||||
)
|
||||
|
||||
message_text = message_template.format(
|
||||
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(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
logger.info(
|
||||
"Создан %s платеж для пользователя %s: %s₽, ID: %s",
|
||||
mulenpay_name,
|
||||
db_user.telegram_id,
|
||||
amount_rubles,
|
||||
payment_id_display,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания {mulenpay_name} платежа: {e}")
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"MULENPAY_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа {mulenpay_name}. Попробуйте позже или обратитесь в поддержку.",
|
||||
).format(mulenpay_name=mulenpay_name)
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_mulenpay_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.split('_')[-1])
|
||||
payment_service = PaymentService(callback.bot)
|
||||
status_info = await payment_service.get_mulenpay_payment_status(db, local_payment_id)
|
||||
|
||||
if not status_info:
|
||||
await callback.answer("❌ Платеж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
payment = status_info["payment"]
|
||||
|
||||
status_labels = {
|
||||
"created": ("⏳", "Ожидает оплаты"),
|
||||
"processing": ("⌛", "Обрабатывается"),
|
||||
"success": ("✅", "Оплачен"),
|
||||
"canceled": ("❌", "Отменен"),
|
||||
"error": ("⚠️", "Ошибка"),
|
||||
"hold": ("🔒", "Холд"),
|
||||
"unknown": ("❓", "Неизвестно"),
|
||||
}
|
||||
|
||||
emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно"))
|
||||
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
message_lines = [
|
||||
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",
|
||||
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n",
|
||||
]
|
||||
|
||||
if payment.is_paid:
|
||||
message_lines.append("\n✅ Платеж успешно завершен! Средства уже на балансе.")
|
||||
elif payment.status in {"created", "processing"}:
|
||||
message_lines.append(
|
||||
"\n⏳ Платеж еще не завершен. Завершите оплату по ссылке и проверьте статус позже."
|
||||
)
|
||||
if payment.payment_url:
|
||||
message_lines.append(f"\n🔗 Ссылка на оплату: {payment.payment_url}")
|
||||
elif payment.status in {"canceled", "error"}:
|
||||
message_lines.append(
|
||||
f"\n❌ Платеж не был завершен. Попробуйте создать новый платеж или обратитесь в {settings.get_support_contact_display()}"
|
||||
)
|
||||
|
||||
message_text = "".join(message_lines)
|
||||
|
||||
if len(message_text) > 190:
|
||||
await callback.message.answer(message_text)
|
||||
await callback.answer("ℹ️ Статус платежа отправлен в чат", show_alert=True)
|
||||
else:
|
||||
await callback.answer(message_text, show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка проверки статуса {settings.get_mulenpay_display_name()}: {e}"
|
||||
)
|
||||
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
|
||||
@@ -0,0 +1,596 @@
|
||||
import html
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
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
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.utils.decorators import error_handler
|
||||
from app.states import BalanceStates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(message.bot)
|
||||
payment_result = await payment_service.create_pal24_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
language=db_user.language,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
|
||||
if not payment_result:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAL24_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
sbp_url = (
|
||||
payment_result.get("sbp_url")
|
||||
or payment_result.get("transfer_url")
|
||||
)
|
||||
card_url = payment_result.get("card_url")
|
||||
fallback_url = (
|
||||
payment_result.get("link_page_url")
|
||||
or payment_result.get("link_url")
|
||||
)
|
||||
|
||||
if not (sbp_url or card_url or fallback_url):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAL24_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
if not sbp_url:
|
||||
sbp_url = fallback_url
|
||||
|
||||
bill_id = payment_result.get("bill_id")
|
||||
local_payment_id = payment_result.get("local_payment_id")
|
||||
|
||||
pay_buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
steps: list[str] = []
|
||||
step_counter = 1
|
||||
|
||||
default_sbp_text = texts.t(
|
||||
"PAL24_SBP_PAY_BUTTON",
|
||||
"🏦 Оплатить через PayPalych (СБП)",
|
||||
)
|
||||
sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text)
|
||||
|
||||
if sbp_url and settings.is_pal24_sbp_button_visible():
|
||||
pay_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=sbp_button_text,
|
||||
url=sbp_url,
|
||||
)
|
||||
]
|
||||
)
|
||||
steps.append(
|
||||
texts.t(
|
||||
"PAL24_INSTRUCTION_BUTTON",
|
||||
"{step}. Нажмите кнопку «{button}»",
|
||||
).format(step=step_counter, button=html.escape(sbp_button_text))
|
||||
)
|
||||
step_counter += 1
|
||||
|
||||
default_card_text = texts.t(
|
||||
"PAL24_CARD_PAY_BUTTON",
|
||||
"💳 Оплатить банковской картой (PayPalych)",
|
||||
)
|
||||
card_button_text = settings.get_pal24_card_button_text(default_card_text)
|
||||
|
||||
if card_url and card_url != sbp_url and settings.is_pal24_card_button_visible():
|
||||
pay_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=card_button_text,
|
||||
url=card_url,
|
||||
)
|
||||
]
|
||||
)
|
||||
steps.append(
|
||||
texts.t(
|
||||
"PAL24_INSTRUCTION_BUTTON",
|
||||
"{step}. Нажмите кнопку «{button}»",
|
||||
).format(step=step_counter, button=html.escape(card_button_text))
|
||||
)
|
||||
step_counter += 1
|
||||
|
||||
if not pay_buttons and fallback_url and settings.is_pal24_sbp_button_visible():
|
||||
pay_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=sbp_button_text,
|
||||
url=fallback_url,
|
||||
)
|
||||
]
|
||||
)
|
||||
steps.append(
|
||||
texts.t(
|
||||
"PAL24_INSTRUCTION_BUTTON",
|
||||
"{step}. Нажмите кнопку «{button}»",
|
||||
).format(step=step_counter, button=html.escape(sbp_button_text))
|
||||
)
|
||||
step_counter += 1
|
||||
|
||||
follow_template = texts.t(
|
||||
"PAL24_INSTRUCTION_FOLLOW",
|
||||
"{step}. Следуйте подсказкам платёжной системы",
|
||||
)
|
||||
steps.append(follow_template.format(step=step_counter))
|
||||
step_counter += 1
|
||||
|
||||
confirm_template = texts.t(
|
||||
"PAL24_INSTRUCTION_CONFIRM",
|
||||
"{step}. Подтвердите перевод",
|
||||
)
|
||||
steps.append(confirm_template.format(step=step_counter))
|
||||
step_counter += 1
|
||||
|
||||
success_template = texts.t(
|
||||
"PAL24_INSTRUCTION_COMPLETE",
|
||||
"{step}. Средства зачислятся автоматически",
|
||||
)
|
||||
steps.append(success_template.format(step=step_counter))
|
||||
|
||||
message_template = texts.t(
|
||||
"PAL24_PAYMENT_INSTRUCTIONS",
|
||||
(
|
||||
"🏦 <b>Оплата через PayPalych</b>\n\n"
|
||||
"💰 Сумма: {amount}\n"
|
||||
"🆔 ID счета: {bill_id}\n\n"
|
||||
"📱 <b>Инструкция:</b>\n{steps}\n\n"
|
||||
"❓ Если возникнут проблемы, обратитесь в {support}"
|
||||
),
|
||||
)
|
||||
|
||||
keyboard_rows = pay_buttons + [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_pal24_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
]
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
message_text = message_template.format(
|
||||
amount=settings.format_price(amount_kopeks),
|
||||
bill_id=bill_id,
|
||||
steps="\n".join(steps),
|
||||
support=settings.get_support_contact_display_html(),
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
logger.info(
|
||||
"Создан PayPalych счет для пользователя %s: %s₽, ID: %s, метод: %s",
|
||||
db_user.telegram_id,
|
||||
amount_kopeks / 100,
|
||||
bill_id,
|
||||
payment_method,
|
||||
)
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка создания PayPalych платежа: {error}")
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAL24_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
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(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.split('_')[-1])
|
||||
payment_service = PaymentService(callback.bot)
|
||||
status_info = await payment_service.get_pal24_payment_status(db, local_payment_id)
|
||||
|
||||
if not status_info:
|
||||
await callback.answer("❌ Платеж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
payment = status_info["payment"]
|
||||
|
||||
status_labels = {
|
||||
"NEW": ("⏳", "Ожидает оплаты"),
|
||||
"PROCESS": ("⌛", "Обрабатывается"),
|
||||
"SUCCESS": ("✅", "Оплачен"),
|
||||
"FAIL": ("❌", "Отменен"),
|
||||
"UNDERPAID": ("⚠️", "Недоплата"),
|
||||
"OVERPAID": ("⚠️", "Переплата"),
|
||||
}
|
||||
|
||||
emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно"))
|
||||
|
||||
metadata = payment.metadata_json or {}
|
||||
links_meta = metadata.get("links") if isinstance(metadata, dict) else None
|
||||
if not isinstance(links_meta, dict):
|
||||
links_meta = {}
|
||||
|
||||
links_info = status_info.get("links") or {}
|
||||
|
||||
def _extract_link(source: Any, keys: tuple[str, ...]) -> Optional[str]:
|
||||
stack: list[Any] = [source]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
if isinstance(current, dict):
|
||||
for key in keys:
|
||||
value = current.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
stack.extend(current.values())
|
||||
elif isinstance(current, list):
|
||||
stack.extend(current)
|
||||
return None
|
||||
|
||||
raw_response = metadata.get("raw_response") if isinstance(metadata, dict) else None
|
||||
remote_data = status_info.get("remote_data")
|
||||
transfer_keys = (
|
||||
"transfer_url",
|
||||
"transferUrl",
|
||||
"transfer_link",
|
||||
"transferLink",
|
||||
"transfer",
|
||||
"sbp_url",
|
||||
"sbpUrl",
|
||||
"sbp_link",
|
||||
"sbpLink",
|
||||
)
|
||||
card_keys = (
|
||||
"link_url",
|
||||
"linkUrl",
|
||||
"link",
|
||||
"card_url",
|
||||
"cardUrl",
|
||||
"card_link",
|
||||
"cardLink",
|
||||
"payment_url",
|
||||
"paymentUrl",
|
||||
"url",
|
||||
)
|
||||
|
||||
extra_sbp_link = (
|
||||
_extract_link(raw_response, transfer_keys)
|
||||
if raw_response
|
||||
else None
|
||||
)
|
||||
if not extra_sbp_link and remote_data:
|
||||
extra_sbp_link = _extract_link(remote_data, transfer_keys)
|
||||
|
||||
extra_card_link = (
|
||||
_extract_link(raw_response, card_keys)
|
||||
if raw_response
|
||||
else None
|
||||
)
|
||||
if not extra_card_link and remote_data:
|
||||
extra_card_link = _extract_link(remote_data, card_keys)
|
||||
|
||||
sbp_link = (
|
||||
links_info.get("sbp")
|
||||
or links_meta.get("sbp")
|
||||
or status_info.get("sbp_url")
|
||||
or extra_sbp_link
|
||||
or payment.link_url
|
||||
)
|
||||
card_link = (
|
||||
links_info.get("card")
|
||||
or links_meta.get("card")
|
||||
or status_info.get("card_url")
|
||||
or extra_card_link
|
||||
)
|
||||
|
||||
if not card_link and payment.link_page_url and payment.link_page_url != sbp_link:
|
||||
card_link = payment.link_page_url
|
||||
|
||||
message_lines = [
|
||||
"🏦 Статус платежа PayPalych:",
|
||||
"",
|
||||
f"🆔 ID счета: {payment.bill_id}",
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}",
|
||||
f"📊 Статус: {emoji} {status_text}",
|
||||
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}",
|
||||
]
|
||||
|
||||
if payment.is_paid:
|
||||
message_lines.append("")
|
||||
message_lines.append("✅ Платеж успешно завершен! Средства уже на балансе.")
|
||||
elif payment.status in {"NEW", "PROCESS"}:
|
||||
message_lines.append("")
|
||||
message_lines.append("⏳ Платеж еще не завершен. Оплатите счет и проверьте статус позже.")
|
||||
if sbp_link:
|
||||
message_lines.append("")
|
||||
message_lines.append(f"🏦 СБП: {sbp_link}")
|
||||
if card_link and card_link != sbp_link:
|
||||
message_lines.append(f"💳 Банковская карта: {card_link}")
|
||||
elif payment.status in {"FAIL", "UNDERPAID", "OVERPAID"}:
|
||||
message_lines.append("")
|
||||
message_lines.append(
|
||||
f"❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}"
|
||||
)
|
||||
|
||||
from app.localization.texts import get_texts
|
||||
db_user = getattr(callback, 'db_user', None)
|
||||
texts = get_texts(db_user.language if db_user else 'ru') if db_user else get_texts('ru')
|
||||
|
||||
pay_rows: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
if not payment.is_paid and payment.status in {"NEW", "PROCESS"}:
|
||||
default_sbp_text = texts.t(
|
||||
"PAL24_SBP_PAY_BUTTON",
|
||||
"🏦 Оплатить через PayPalych (СБП)",
|
||||
)
|
||||
sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text)
|
||||
|
||||
if sbp_link and settings.is_pal24_sbp_button_visible():
|
||||
pay_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=sbp_button_text,
|
||||
url=sbp_link,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
default_card_text = texts.t(
|
||||
"PAL24_CARD_PAY_BUTTON",
|
||||
"💳 Оплатить банковской картой (PayPalych)",
|
||||
)
|
||||
card_button_text = settings.get_pal24_card_button_text(default_card_text)
|
||||
|
||||
if card_link and settings.is_pal24_card_button_visible():
|
||||
if not pay_rows or pay_rows[-1][0].url != card_link:
|
||||
pay_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=card_button_text,
|
||||
url=card_link,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard_rows = pay_rows + [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_pal24_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
]
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
await callback.answer()
|
||||
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}")
|
||||
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
|
||||
@@ -0,0 +1,333 @@
|
||||
"""Handlers for Platega balance interactions."""
|
||||
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
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__)
|
||||
|
||||
|
||||
def _get_active_methods() -> List[int]:
|
||||
methods = settings.get_platega_active_methods()
|
||||
return [code for code in methods if code in {2, 10, 11, 12, 13}]
|
||||
|
||||
|
||||
async def _prompt_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
method_code: int,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
method_name = settings.get_platega_method_display_title(method_code)
|
||||
|
||||
min_amount_label = settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS)
|
||||
max_amount_kopeks = settings.PLATEGA_MAX_AMOUNT_KOPEKS
|
||||
max_amount_label = (
|
||||
settings.format_price(max_amount_kopeks)
|
||||
if max_amount_kopeks and max_amount_kopeks > 0
|
||||
else ""
|
||||
)
|
||||
|
||||
default_prompt_body = (
|
||||
"Введите сумму для пополнения от {min_amount} до {max_amount}.\n"
|
||||
if max_amount_kopeks and max_amount_kopeks > 0
|
||||
else "Введите сумму для пополнения от {min_amount}.\n"
|
||||
)
|
||||
|
||||
prompt_template = texts.t(
|
||||
"PLATEGA_TOPUP_PROMPT",
|
||||
(
|
||||
"💳 <b>Оплата через Platega ({method_name})</b>\n\n"
|
||||
f"{default_prompt_body}"
|
||||
"Оплата происходит через Platega."
|
||||
),
|
||||
)
|
||||
|
||||
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 message.edit_text(
|
||||
prompt_template.format(
|
||||
method_name=method_name,
|
||||
min_amount=min_amount_label,
|
||||
max_amount=max_amount_label,
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="platega", platega_method=method_code)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_platega_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_platega_enabled():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PLATEGA_TEMPORARILY_UNAVAILABLE",
|
||||
"❌ Оплата через Platega временно недоступна",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
active_methods = _get_active_methods()
|
||||
if not active_methods:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PLATEGA_METHODS_NOT_CONFIGURED",
|
||||
"⚠️ На стороне Platega нет доступных методов оплаты",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(payment_method="platega")
|
||||
|
||||
if len(active_methods) == 1:
|
||||
await _prompt_amount(callback.message, db_user, state, active_methods[0])
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
method_buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
for method_code in active_methods:
|
||||
label = settings.get_platega_method_display_title(method_code)
|
||||
method_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=label,
|
||||
callback_data=f"platega_method_{method_code}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
method_buttons.append(
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"PLATEGA_SELECT_PAYMENT_METHOD",
|
||||
"Выберите способ оплаты Platega:",
|
||||
),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=method_buttons),
|
||||
)
|
||||
await state.set_state(BalanceStates.waiting_for_platega_method)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_platega_method_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
try:
|
||||
method_code = int(callback.data.rsplit("_", 1)[-1])
|
||||
except ValueError:
|
||||
await callback.answer("❌ Некорректный способ оплаты", show_alert=True)
|
||||
return
|
||||
|
||||
if method_code not in _get_active_methods():
|
||||
await callback.answer("⚠️ Этот способ сейчас недоступен", show_alert=True)
|
||||
return
|
||||
|
||||
await _prompt_amount(callback.message, db_user, state, method_code)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_platega_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_platega_enabled():
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PLATEGA_TEMPORARILY_UNAVAILABLE",
|
||||
"❌ Оплата через Platega временно недоступна",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
method_code = int(data.get("platega_method", 0))
|
||||
if method_code not in _get_active_methods():
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PLATEGA_METHOD_SELECTION_REQUIRED",
|
||||
"⚠️ Выберите способ оплаты Platega перед вводом суммы",
|
||||
)
|
||||
)
|
||||
await state.set_state(BalanceStates.waiting_for_platega_method)
|
||||
return
|
||||
|
||||
if amount_kopeks < settings.PLATEGA_MIN_AMOUNT_KOPEKS:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PLATEGA_AMOUNT_TOO_LOW",
|
||||
"Минимальная сумма для оплаты через Platega: {amount}",
|
||||
).format(amount=settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS))
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.PLATEGA_MAX_AMOUNT_KOPEKS:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PLATEGA_AMOUNT_TOO_HIGH",
|
||||
"Максимальная сумма для оплаты через Platega: {amount}",
|
||||
).format(amount=settings.format_price(settings.PLATEGA_MAX_AMOUNT_KOPEKS))
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(message.bot)
|
||||
payment_result = await payment_service.create_platega_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
language=db_user.language,
|
||||
payment_method_code=method_code,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.exception("Ошибка создания платежа Platega: %s", error)
|
||||
payment_result = None
|
||||
|
||||
if not payment_result or not payment_result.get("redirect_url"):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PLATEGA_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа Platega. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
redirect_url = payment_result.get("redirect_url")
|
||||
local_payment_id = payment_result.get("local_payment_id")
|
||||
transaction_id = payment_result.get("transaction_id")
|
||||
method_title = settings.get_platega_method_display_title(method_code)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"PLATEGA_PAY_BUTTON",
|
||||
"💳 Оплатить через {method}",
|
||||
).format(method=method_title),
|
||||
url=redirect_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_platega_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
]
|
||||
)
|
||||
|
||||
instructions_template = texts.t(
|
||||
"PLATEGA_PAYMENT_INSTRUCTIONS",
|
||||
(
|
||||
"💳 <b>Оплата через Platega ({method})</b>\n\n"
|
||||
"💰 Сумма: {amount}\n"
|
||||
"🆔 ID транзакции: {transaction}\n\n"
|
||||
"📱 <b>Инструкция:</b>\n"
|
||||
"1. Нажмите кнопку «Оплатить»\n"
|
||||
"2. Следуйте подсказкам платёжной системы\n"
|
||||
"3. Подтвердите перевод\n"
|
||||
"4. Средства зачислятся автоматически\n\n"
|
||||
"❓ Если возникнут проблемы, обратитесь в {support}"
|
||||
),
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
instructions_template.format(
|
||||
method=method_title,
|
||||
amount=settings.format_price(amount_kopeks),
|
||||
transaction=transaction_id or local_payment_id,
|
||||
support=settings.get_support_contact_display_html(),
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_platega_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.split("_")[-1])
|
||||
except ValueError:
|
||||
await callback.answer("❌ Некорректный идентификатор платежа", show_alert=True)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(callback.bot)
|
||||
|
||||
try:
|
||||
status_info = await payment_service.get_platega_payment_status(db, local_payment_id)
|
||||
except Exception as error:
|
||||
logger.exception("Ошибка проверки статуса Platega: %s", error)
|
||||
await callback.answer("⚠️ Ошибка проверки статуса", show_alert=True)
|
||||
return
|
||||
|
||||
if not status_info:
|
||||
await callback.answer("⚠️ Платёж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
payment = status_info.get("payment")
|
||||
status = status_info.get("status")
|
||||
is_paid = status_info.get("is_paid")
|
||||
|
||||
language = "ru"
|
||||
user = getattr(payment, "user", None)
|
||||
if user and getattr(user, "language", None):
|
||||
language = user.language
|
||||
|
||||
texts = get_texts(language)
|
||||
|
||||
if is_paid:
|
||||
await callback.answer(texts.t("PLATEGA_PAYMENT_ALREADY_CONFIRMED", "✅ Платёж уже зачислен"), show_alert=True)
|
||||
else:
|
||||
await callback.answer(
|
||||
texts.t("PLATEGA_PAYMENT_STATUS", "Текущий статус платежа: {status}").format(status=status),
|
||||
show_alert=True,
|
||||
)
|
||||
@@ -0,0 +1,104 @@
|
||||
import logging
|
||||
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, get_payment_methods_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
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_stars_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True)
|
||||
return
|
||||
|
||||
# Формируем текст сообщения в зависимости от настройки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
message_text = (
|
||||
f"⭐ <b>Пополнение через Telegram Stars</b>\n\n"
|
||||
f"Выберите сумму пополнения или введите вручную:"
|
||||
)
|
||||
else:
|
||||
message_text = texts.TOP_UP_AMOUNT
|
||||
|
||||
# Создаем клавиатуру
|
||||
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
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="stars")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_stars_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
await message.answer("⚠️ Оплата Stars временно недоступна")
|
||||
return
|
||||
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles)
|
||||
stars_rate = settings.get_stars_rate()
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
invoice_link = await payment_service.create_stars_invoice(
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}",
|
||||
payload=f"balance_{db_user.id}_{amount_kopeks}"
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await message.answer(
|
||||
f"⭐ <b>Оплата через Telegram Stars</b>\n\n"
|
||||
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
|
||||
f"⭐ К оплате: {stars_amount} звезд\n"
|
||||
f"📊 Курс: {stars_rate}₽ за звезду\n\n"
|
||||
f"Нажмите кнопку ниже для оплаты:",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Stars invoice: {e}")
|
||||
await message.answer("⚠️ Ошибка создания платежа")
|
||||
@@ -0,0 +1,59 @@
|
||||
import logging
|
||||
from aiogram import types
|
||||
|
||||
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.utils.decorators import error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_tribute_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.TRIBUTE_ENABLED:
|
||||
await callback.answer("❌ Оплата картой временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
from app.services.tribute_service import TributeService
|
||||
|
||||
tribute_service = TributeService(callback.bot)
|
||||
payment_url = await tribute_service.create_payment_link(
|
||||
user_id=db_user.telegram_id,
|
||||
amount_kopeks=0,
|
||||
description="Пополнение баланса VPN"
|
||||
)
|
||||
|
||||
if not payment_url:
|
||||
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
|
||||
return
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="💳 Перейти к оплате", url=payment_url)],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"💳 <b>Пополнение банковской картой</b>\n\n"
|
||||
f"• Введите любую сумму от 100₽\n"
|
||||
f"• Безопасная оплата через Tribute\n"
|
||||
f"• Мгновенное зачисление на баланс\n"
|
||||
f"• Принимаем карты Visa, MasterCard, МИР\n\n"
|
||||
f"• 🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!\n\n"
|
||||
f"Нажмите кнопку для перехода к оплате:",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Tribute платежа: {e}")
|
||||
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
|
||||
|
||||
await callback.answer()
|
||||
@@ -0,0 +1,234 @@
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
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, get_user_by_id as fetch_user_by_id
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_wata_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_wata_enabled():
|
||||
await callback.answer("❌ Оплата через WATA временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
message_text = texts.t(
|
||||
"WATA_TOPUP_PROMPT",
|
||||
(
|
||||
"💳 <b>Оплата через WATA</b>\n\n"
|
||||
"Введите сумму пополнения. Минимальная сумма — {min_amount}, максимальная — {max_amount}.\n"
|
||||
"Оплата происходит через защищенную форму WATA."
|
||||
),
|
||||
).format(
|
||||
min_amount=settings.format_price(settings.WATA_MIN_AMOUNT_KOPEKS),
|
||||
max_amount=settings.format_price(settings.WATA_MAX_AMOUNT_KOPEKS),
|
||||
)
|
||||
|
||||
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="wata")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_wata_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_wata_enabled():
|
||||
await message.answer("❌ Оплата через WATA временно недоступна")
|
||||
return
|
||||
|
||||
if amount_kopeks < settings.WATA_MIN_AMOUNT_KOPEKS:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"WATA_AMOUNT_TOO_LOW",
|
||||
"Минимальная сумма пополнения: {amount}",
|
||||
).format(amount=settings.format_price(settings.WATA_MIN_AMOUNT_KOPEKS))
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.WATA_MAX_AMOUNT_KOPEKS:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"WATA_AMOUNT_TOO_HIGH",
|
||||
"Максимальная сумма пополнения: {amount}",
|
||||
).format(amount=settings.format_price(settings.WATA_MAX_AMOUNT_KOPEKS))
|
||||
)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
|
||||
try:
|
||||
result = await payment_service.create_wata_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
language=db_user.language,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - handled by decorator logs
|
||||
logger.exception("Ошибка создания WATA платежа: %s", error)
|
||||
result = None
|
||||
|
||||
if not result or not result.get("payment_url"):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"WATA_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа WATA. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
payment_url = result["payment_url"]
|
||||
payment_link_id = result["payment_link_id"]
|
||||
local_payment_id = result["local_payment_id"]
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("WATA_PAY_BUTTON", "💳 Оплатить через WATA"),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_wata_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
]
|
||||
)
|
||||
|
||||
message_template = texts.t(
|
||||
"WATA_PAYMENT_INSTRUCTIONS",
|
||||
(
|
||||
"💳 <b>Оплата через WATA</b>\n\n"
|
||||
"💰 Сумма: {amount}\n"
|
||||
"🆔 ID платежа: {payment_id}\n\n"
|
||||
"📱 <b>Инструкция:</b>\n"
|
||||
"1. Нажмите кнопку 'Оплатить через WATA'\n"
|
||||
"2. Следуйте подсказкам платежной системы\n"
|
||||
"3. Подтвердите перевод\n"
|
||||
"4. Средства зачислятся автоматически\n\n"
|
||||
"❓ Если возникнут проблемы, обратитесь в {support}"
|
||||
),
|
||||
)
|
||||
|
||||
message_text = message_template.format(
|
||||
amount=settings.format_price(amount_kopeks),
|
||||
payment_id=payment_link_id,
|
||||
support=settings.get_support_contact_display_html(),
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
logger.info(
|
||||
"Создан WATA платеж для пользователя %s: %s₽, ссылка: %s",
|
||||
db_user.telegram_id,
|
||||
amount_kopeks / 100,
|
||||
payment_link_id,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_wata_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.split("_")[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Некорректный идентификатор платежа", show_alert=True)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(callback.bot)
|
||||
status_info = await payment_service.get_wata_payment_status(db, local_payment_id)
|
||||
|
||||
if not status_info:
|
||||
await callback.answer("❌ Платеж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
payment = status_info["payment"]
|
||||
|
||||
user_language = "ru"
|
||||
try:
|
||||
user = await fetch_user_by_id(db, payment.user_id)
|
||||
if user and getattr(user, "language", None):
|
||||
user_language = user.language
|
||||
except Exception as error:
|
||||
logger.debug("Не удалось получить пользователя для WATA статуса: %s", error)
|
||||
|
||||
texts = get_texts(user_language)
|
||||
|
||||
status_labels: Dict[str, Dict[str, str]] = {
|
||||
"Opened": {"emoji": "⏳", "label": texts.t("WATA_STATUS_OPENED", "Ожидает оплаты")},
|
||||
"Closed": {"emoji": "⌛", "label": texts.t("WATA_STATUS_CLOSED", "Обрабатывается")},
|
||||
"Paid": {"emoji": "✅", "label": texts.t("WATA_STATUS_PAID", "Оплачен")},
|
||||
"Declined": {"emoji": "❌", "label": texts.t("WATA_STATUS_DECLINED", "Отклонен")},
|
||||
}
|
||||
|
||||
label_info = status_labels.get(payment.status, {"emoji": "❓", "label": texts.t("WATA_STATUS_UNKNOWN", "Неизвестно")})
|
||||
|
||||
message_lines = [
|
||||
texts.t("WATA_STATUS_TITLE", "💳 <b>Статус платежа WATA</b>"),
|
||||
"",
|
||||
f"🆔 ID: {payment.payment_link_id}",
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}",
|
||||
f"📊 Статус: {label_info['emoji']} {label_info['label']}",
|
||||
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M') if payment.created_at else '—'}",
|
||||
]
|
||||
|
||||
if payment.is_paid:
|
||||
message_lines.append("\n✅ Платеж успешно завершен! Средства уже на балансе.")
|
||||
elif payment.status in {"Opened", "Closed"}:
|
||||
message_lines.append(
|
||||
"\n⏳ Платеж еще не завершен. Завершите оплату по ссылке и проверьте статус позже."
|
||||
)
|
||||
|
||||
await callback.message.answer("\n".join(message_lines), parse_mode="HTML")
|
||||
await callback.answer()
|
||||
@@ -0,0 +1,434 @@
|
||||
import logging
|
||||
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.utils.decorators import error_handler
|
||||
from app.states import BalanceStates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_yookassa_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_yookassa_enabled():
|
||||
await callback.answer("❌ Оплата картой через YooKassa временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
min_amount_rub = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
|
||||
max_amount_rub = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
|
||||
|
||||
# Формируем текст сообщения в зависимости от настройки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
message_text = (
|
||||
f"💳 <b>Оплата банковской картой</b>\n\n"
|
||||
f"Выберите сумму пополнения или введите вручную сумму "
|
||||
f"от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"💳 <b>Оплата банковской картой</b>\n\n"
|
||||
f"Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:"
|
||||
)
|
||||
|
||||
# Создаем клавиатуру
|
||||
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="yookassa")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_yookassa_sbp_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_yookassa_enabled() or not settings.YOOKASSA_SBP_ENABLED:
|
||||
await callback.answer("❌ Оплата через СБП временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
min_amount_rub = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
|
||||
max_amount_rub = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
|
||||
|
||||
# Формируем текст сообщения в зависимости от настройки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
message_text = (
|
||||
f"🏦 <b>Оплата через СБП</b>\n\n"
|
||||
f"Выберите сумму пополнения или введите вручную сумму "
|
||||
f"от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"🏦 <b>Оплата через СБП</b>\n\n"
|
||||
f"Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:"
|
||||
)
|
||||
|
||||
# Создаем клавиатуру
|
||||
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="yookassa_sbp")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_yookassa_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_yookassa_enabled():
|
||||
await message.answer("❌ Оплата через YooKassa временно недоступна")
|
||||
return
|
||||
|
||||
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
|
||||
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f"❌ Минимальная сумма для оплаты картой: {min_rubles:.0f} ₽")
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
|
||||
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f"❌ Максимальная сумма для оплаты картой: {max_rubles:,.0f} ₽".replace(',', ' '))
|
||||
return
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(message.bot)
|
||||
|
||||
payment_result = await payment_service.create_yookassa_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
receipt_email=None,
|
||||
receipt_phone=None,
|
||||
metadata={
|
||||
"user_telegram_id": str(db_user.telegram_id),
|
||||
"user_username": db_user.username or "",
|
||||
"purpose": "balance_topup"
|
||||
}
|
||||
)
|
||||
|
||||
if not payment_result:
|
||||
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
confirmation_url = payment_result.get("confirmation_url")
|
||||
if not confirmation_url:
|
||||
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")]
|
||||
])
|
||||
|
||||
await message.answer(
|
||||
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"✅ Принимаем карты: Visa, MasterCard, МИР\n\n"
|
||||
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
logger.info(f"Создан платеж YooKassa для пользователя {db_user.telegram_id}: "
|
||||
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания YooKassa платежа: {e}")
|
||||
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
|
||||
await state.clear()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_yookassa_sbp_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_yookassa_enabled() or not settings.YOOKASSA_SBP_ENABLED:
|
||||
await message.answer("❌ Оплата через СБП временно недоступна")
|
||||
return
|
||||
|
||||
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
|
||||
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f"❌ Минимальная сумма для оплаты через СБП: {min_rubles:.0f} ₽")
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
|
||||
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f"❌ Максимальная сумма для оплаты через СБП: {max_rubles:,.0f} ₽".replace(',', ' '))
|
||||
return
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(message.bot)
|
||||
|
||||
payment_result = await payment_service.create_yookassa_sbp_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
receipt_email=None,
|
||||
receipt_phone=None,
|
||||
metadata={
|
||||
"user_telegram_id": str(db_user.telegram_id),
|
||||
"user_username": db_user.username or "",
|
||||
"purpose": "balance_topup_sbp"
|
||||
}
|
||||
)
|
||||
|
||||
if not payment_result:
|
||||
await message.answer("❌ Ошибка создания платежа через СБП. Попробуйте позже или обратитесь в поддержку.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
confirmation_url = payment_result.get("confirmation_url")
|
||||
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
|
||||
|
||||
# Подготовим 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}")
|
||||
|
||||
# Если нет 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"
|
||||
)
|
||||
|
||||
# Добавляем инструкции в зависимости от доступных способов оплаты
|
||||
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']}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания YooKassa СБП платежа: {e}")
|
||||
await message.answer("❌ Ошибка создания платежа через СБП. Попробуйте позже или обратитесь в поддержку.")
|
||||
await state.clear()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_yookassa_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.split('_')[-1])
|
||||
|
||||
from app.database.crud.yookassa import get_yookassa_payment_by_local_id
|
||||
payment = await get_yookassa_payment_by_local_id(db, local_payment_id)
|
||||
|
||||
if not payment:
|
||||
await callback.answer("❌ Платеж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
status_emoji = {
|
||||
"pending": "⏳",
|
||||
"waiting_for_capture": "⌛",
|
||||
"succeeded": "✅",
|
||||
"canceled": "❌",
|
||||
"failed": "❌"
|
||||
}
|
||||
|
||||
status_text = {
|
||||
"pending": "Ожидает оплаты",
|
||||
"waiting_for_capture": "Ожидает подтверждения",
|
||||
"succeeded": "Оплачен",
|
||||
"canceled": "Отменен",
|
||||
"failed": "Ошибка"
|
||||
}
|
||||
|
||||
emoji = status_emoji.get(payment.status, "❓")
|
||||
status = status_text.get(payment.status, "Неизвестно")
|
||||
|
||||
message_text = (f"💳 Статус платежа:\n\n"
|
||||
f"🆔 ID: {payment.yookassa_payment_id[:8]}...\n"
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
|
||||
f"📊 Статус: {emoji} {status}\n"
|
||||
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n")
|
||||
|
||||
if payment.is_succeeded:
|
||||
message_text += "\n✅ Платеж успешно завершен!\n\nСредства зачислены на баланс."
|
||||
elif payment.is_pending:
|
||||
message_text += "\n⏳ Платеж ожидает оплаты. Нажмите кнопку 'Оплатить' выше."
|
||||
elif payment.is_failed:
|
||||
message_text += (
|
||||
f"\n❌ Платеж не прошел. Обратитесь в {settings.get_support_contact_display()}"
|
||||
)
|
||||
|
||||
await callback.answer(message_text, show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки статуса платежа: {e}")
|
||||
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
|
||||
+59
-30
@@ -30,6 +30,7 @@ from app.services.subscription_checkout_service import (
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.services.main_menu_button_service import MainMenuButtonService
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
build_test_access_hint,
|
||||
@@ -37,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__)
|
||||
@@ -152,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:
|
||||
@@ -162,6 +164,13 @@ async def show_main_menu(
|
||||
|
||||
draft_exists = await has_subscription_checkout_draft(db_user.id)
|
||||
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
|
||||
|
||||
# Проверяем наличие сохраненной корзины в Redis
|
||||
try:
|
||||
has_saved_cart = await user_cart_service.has_user_cart(db_user.id)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки сохраненной корзины для пользователя {db_user.id}: {e}")
|
||||
has_saved_cart = False
|
||||
|
||||
is_admin = settings.is_admin(db_user.telegram_id)
|
||||
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(
|
||||
@@ -190,6 +199,7 @@ async def show_main_menu(
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
has_saved_cart=has_saved_cart, # Добавляем параметр для отображения уведомления о сохраненной корзине
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
@@ -883,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:
|
||||
@@ -893,6 +903,13 @@ async def handle_back_to_menu(
|
||||
|
||||
draft_exists = await has_subscription_checkout_draft(db_user.id)
|
||||
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
|
||||
|
||||
# Проверяем наличие сохраненной корзины в Redis
|
||||
try:
|
||||
has_saved_cart = await user_cart_service.has_user_cart(db_user.id)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки сохраненной корзины для пользователя {db_user.id}: {e}")
|
||||
has_saved_cart = False
|
||||
|
||||
is_admin = settings.is_admin(db_user.telegram_id)
|
||||
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(
|
||||
@@ -921,6 +938,7 @@ async def handle_back_to_menu(
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
has_saved_cart=has_saved_cart, # Добавляем параметр для отображения уведомления о сохраненной корзине
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
@@ -929,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}")
|
||||
@@ -130,6 +130,10 @@ async def handle_successful_payment(
|
||||
"⭐ Потрачено звезд: {stars_spent}\n"
|
||||
"💰 Зачислено на баланс: {amount} ₽\n"
|
||||
"🆔 ID транзакции: {transaction_id}...\n\n"
|
||||
"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
"Обязательно активируйте подписку отдельно!\n\n"
|
||||
"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
"подписка будет приобретена автоматически после пополнения баланса.\n\n"
|
||||
"Спасибо за пополнение! 🚀",
|
||||
).format(
|
||||
stars_spent=payment.total_amount,
|
||||
|
||||
+237
-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
|
||||
|
||||
|
||||
@@ -222,12 +270,32 @@ async def _continue_registration_after_language(
|
||||
|
||||
async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, db_user=None):
|
||||
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
|
||||
start_args = message.text.split()
|
||||
start_parameter = None
|
||||
|
||||
if len(start_args) > 1:
|
||||
start_parameter = start_args[1]
|
||||
elif pending_start_payload:
|
||||
start_parameter = pending_start_payload
|
||||
logger.info(
|
||||
"📦 START: Используем сохраненный payload '%s'",
|
||||
pending_start_payload,
|
||||
)
|
||||
|
||||
if state_needs_update:
|
||||
await state.set_data(data)
|
||||
|
||||
if start_parameter:
|
||||
campaign = await get_campaign_by_start_parameter(
|
||||
db,
|
||||
start_parameter,
|
||||
@@ -250,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(
|
||||
@@ -322,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)
|
||||
|
||||
@@ -662,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(
|
||||
@@ -745,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)
|
||||
|
||||
@@ -927,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)
|
||||
|
||||
@@ -1003,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)
|
||||
|
||||
@@ -1133,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:
|
||||
@@ -1185,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)
|
||||
|
||||
@@ -1242,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",
|
||||
@@ -1276,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",
|
||||
@@ -1409,6 +1529,41 @@ async def required_sub_channel_check(
|
||||
try:
|
||||
state_data = await state.get_data() or {}
|
||||
|
||||
pending_start_payload = state_data.pop("pending_start_payload", None)
|
||||
state_updated = pending_start_payload is not None
|
||||
|
||||
if pending_start_payload:
|
||||
logger.info(
|
||||
"📦 CHANNEL CHECK: Найден сохраненный payload '%s'",
|
||||
pending_start_payload,
|
||||
)
|
||||
|
||||
if "campaign_id" not in state_data and "referral_code" not in state_data:
|
||||
campaign = await get_campaign_by_start_parameter(
|
||||
db,
|
||||
pending_start_payload,
|
||||
only_active=True,
|
||||
)
|
||||
|
||||
if campaign:
|
||||
state_data["campaign_id"] = campaign.id
|
||||
logger.info(
|
||||
"📣 CHANNEL CHECK: Кампания %s восстановлена из payload",
|
||||
campaign.id,
|
||||
)
|
||||
else:
|
||||
state_data["referral_code"] = pending_start_payload
|
||||
logger.info(
|
||||
"🎯 CHANNEL CHECK: Payload интерпретирован как реферальный код",
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"ℹ️ CHANNEL CHECK: Payload уже обработан ранее, пропускаем восстановление",
|
||||
)
|
||||
|
||||
if state_updated:
|
||||
await state.set_data(state_data)
|
||||
|
||||
user = db_user
|
||||
if not user:
|
||||
user = await get_user_by_telegram_id(db, query.from_user.id)
|
||||
@@ -1470,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)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,211 @@
|
||||
# Automatically generated module exports
|
||||
|
||||
from .autopay import (
|
||||
handle_autopay_menu,
|
||||
handle_subscription_cancel,
|
||||
handle_subscription_config_back,
|
||||
set_autopay_days,
|
||||
show_autopay_days,
|
||||
toggle_autopay,
|
||||
)
|
||||
|
||||
from .common import (
|
||||
build_redirect_link,
|
||||
create_deep_link,
|
||||
format_additional_section,
|
||||
format_traffic_display,
|
||||
get_apps_for_device,
|
||||
get_confirm_switch_traffic_keyboard,
|
||||
get_device_name,
|
||||
get_localized_value,
|
||||
get_reset_devices_confirm_keyboard,
|
||||
get_step_description,
|
||||
get_traffic_switch_keyboard,
|
||||
load_app_config,
|
||||
update_traffic_prices,
|
||||
validate_traffic_price,
|
||||
)
|
||||
|
||||
from .countries import (
|
||||
apply_countries_changes,
|
||||
confirm_add_countries_to_subscription,
|
||||
countries_continue,
|
||||
get_countries_price_by_uuids_fallback,
|
||||
handle_add_countries,
|
||||
handle_add_country_to_subscription,
|
||||
handle_manage_country,
|
||||
select_country,
|
||||
)
|
||||
|
||||
from .devices import (
|
||||
confirm_add_devices,
|
||||
confirm_change_devices,
|
||||
confirm_reset_devices,
|
||||
execute_change_devices,
|
||||
get_current_devices_count,
|
||||
get_current_devices_detailed,
|
||||
get_servers_display_names,
|
||||
handle_all_devices_reset_from_management,
|
||||
handle_app_selection,
|
||||
handle_change_devices,
|
||||
handle_device_guide,
|
||||
handle_device_management,
|
||||
handle_devices_page,
|
||||
handle_reset_devices,
|
||||
handle_single_device_reset,
|
||||
handle_specific_app_guide,
|
||||
show_device_connection_help,
|
||||
show_devices_page,
|
||||
)
|
||||
|
||||
from .happ import (
|
||||
handle_happ_download_back,
|
||||
handle_happ_download_close,
|
||||
handle_happ_download_platform_choice,
|
||||
handle_happ_download_request,
|
||||
)
|
||||
|
||||
from .links import (
|
||||
handle_connect_subscription,
|
||||
handle_open_subscription_link,
|
||||
)
|
||||
|
||||
from .notifications import (
|
||||
send_extension_notification,
|
||||
send_purchase_notification,
|
||||
send_trial_notification,
|
||||
)
|
||||
|
||||
from .pricing import (
|
||||
get_subscription_cost,
|
||||
get_subscription_info_text,
|
||||
)
|
||||
|
||||
from .promo import (
|
||||
claim_discount_offer,
|
||||
handle_promo_offer_close,
|
||||
)
|
||||
|
||||
from .purchase import (
|
||||
activate_trial,
|
||||
clear_saved_cart,
|
||||
confirm_extend_subscription,
|
||||
confirm_purchase,
|
||||
create_paid_subscription_with_traffic_mode,
|
||||
devices_continue,
|
||||
handle_extend_subscription,
|
||||
handle_subscription_settings,
|
||||
register_handlers,
|
||||
resume_subscription_checkout,
|
||||
return_to_saved_cart,
|
||||
save_cart_and_redirect_to_topup,
|
||||
select_devices,
|
||||
select_period,
|
||||
show_subscription_info,
|
||||
show_trial_offer,
|
||||
start_subscription_purchase,
|
||||
)
|
||||
|
||||
from .traffic import (
|
||||
add_traffic,
|
||||
confirm_reset_traffic,
|
||||
confirm_switch_traffic,
|
||||
execute_switch_traffic,
|
||||
get_traffic_packages_info,
|
||||
handle_add_traffic,
|
||||
handle_no_traffic_packages,
|
||||
handle_reset_traffic,
|
||||
handle_switch_traffic,
|
||||
refresh_traffic_config,
|
||||
select_traffic,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'activate_trial',
|
||||
'add_traffic',
|
||||
'apply_countries_changes',
|
||||
'build_redirect_link',
|
||||
'claim_discount_offer',
|
||||
'clear_saved_cart',
|
||||
'confirm_add_countries_to_subscription',
|
||||
'confirm_add_devices',
|
||||
'confirm_change_devices',
|
||||
'confirm_extend_subscription',
|
||||
'confirm_purchase',
|
||||
'confirm_reset_devices',
|
||||
'confirm_reset_traffic',
|
||||
'confirm_switch_traffic',
|
||||
'countries_continue',
|
||||
'create_deep_link',
|
||||
'create_paid_subscription_with_traffic_mode',
|
||||
'devices_continue',
|
||||
'execute_change_devices',
|
||||
'execute_switch_traffic',
|
||||
'format_additional_section',
|
||||
'format_traffic_display',
|
||||
'get_apps_for_device',
|
||||
'get_confirm_switch_traffic_keyboard',
|
||||
'get_countries_price_by_uuids_fallback',
|
||||
'get_current_devices_count',
|
||||
'get_current_devices_detailed',
|
||||
'get_device_name',
|
||||
'get_localized_value',
|
||||
'get_reset_devices_confirm_keyboard',
|
||||
'get_servers_display_names',
|
||||
'get_step_description',
|
||||
'get_subscription_cost',
|
||||
'get_subscription_info_text',
|
||||
'get_traffic_packages_info',
|
||||
'get_traffic_switch_keyboard',
|
||||
'handle_add_countries',
|
||||
'handle_add_country_to_subscription',
|
||||
'handle_add_traffic',
|
||||
'handle_all_devices_reset_from_management',
|
||||
'handle_app_selection',
|
||||
'handle_autopay_menu',
|
||||
'handle_change_devices',
|
||||
'handle_connect_subscription',
|
||||
'handle_device_guide',
|
||||
'handle_device_management',
|
||||
'handle_devices_page',
|
||||
'handle_extend_subscription',
|
||||
'handle_happ_download_back',
|
||||
'handle_happ_download_close',
|
||||
'handle_happ_download_platform_choice',
|
||||
'handle_happ_download_request',
|
||||
'handle_manage_country',
|
||||
'handle_no_traffic_packages',
|
||||
'handle_open_subscription_link',
|
||||
'handle_promo_offer_close',
|
||||
'handle_reset_devices',
|
||||
'handle_reset_traffic',
|
||||
'handle_single_device_reset',
|
||||
'handle_specific_app_guide',
|
||||
'handle_subscription_cancel',
|
||||
'handle_subscription_config_back',
|
||||
'handle_subscription_settings',
|
||||
'handle_switch_traffic',
|
||||
'load_app_config',
|
||||
'refresh_traffic_config',
|
||||
'register_handlers',
|
||||
'resume_subscription_checkout',
|
||||
'return_to_saved_cart',
|
||||
'save_cart_and_redirect_to_topup',
|
||||
'select_country',
|
||||
'select_devices',
|
||||
'select_period',
|
||||
'select_traffic',
|
||||
'send_extension_notification',
|
||||
'send_purchase_notification',
|
||||
'send_trial_notification',
|
||||
'set_autopay_days',
|
||||
'show_autopay_days',
|
||||
'show_device_connection_help',
|
||||
'show_devices_page',
|
||||
'show_subscription_info',
|
||||
'show_trial_offer',
|
||||
'start_subscription_purchase',
|
||||
'toggle_autopay',
|
||||
'update_traffic_prices',
|
||||
'validate_traffic_price',
|
||||
]
|
||||
@@ -0,0 +1,284 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Tuple, Optional
|
||||
from urllib.parse import quote
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings, PERIOD_PRICES, get_traffic_prices
|
||||
from app.database.crud.discount_offer import (
|
||||
get_offer_by_id,
|
||||
mark_offer_claimed,
|
||||
)
|
||||
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
|
||||
from app.database.crud.subscription import (
|
||||
create_trial_subscription,
|
||||
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
|
||||
update_subscription_autopay
|
||||
)
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import (
|
||||
User, TransactionType, SubscriptionStatus,
|
||||
Subscription
|
||||
)
|
||||
from app.keyboards.inline import (
|
||||
get_subscription_keyboard, get_trial_keyboard,
|
||||
get_subscription_period_keyboard, get_traffic_packages_keyboard,
|
||||
get_countries_keyboard, get_devices_keyboard,
|
||||
get_subscription_confirm_keyboard, get_autopay_keyboard,
|
||||
get_autopay_days_keyboard, get_back_keyboard,
|
||||
get_add_traffic_keyboard,
|
||||
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
|
||||
get_manage_countries_keyboard,
|
||||
get_device_selection_keyboard, get_connection_guide_keyboard,
|
||||
get_app_selection_keyboard, get_specific_app_keyboard,
|
||||
get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
|
||||
get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
|
||||
get_devices_management_keyboard, get_device_management_help_keyboard,
|
||||
get_happ_cryptolink_keyboard,
|
||||
get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
|
||||
get_happ_download_button_row,
|
||||
get_payment_methods_keyboard_with_cart,
|
||||
get_subscription_confirm_keyboard_with_cart,
|
||||
get_insufficient_balance_keyboard_with_cart
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.subscription_checkout_service import (
|
||||
clear_subscription_checkout_draft,
|
||||
get_subscription_checkout_draft,
|
||||
save_subscription_checkout_draft,
|
||||
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
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.utils.pricing_utils import (
|
||||
calculate_months_from_days,
|
||||
get_remaining_months,
|
||||
calculate_prorated_price,
|
||||
validate_pricing_calculation,
|
||||
format_period_description,
|
||||
apply_percentage_discount,
|
||||
)
|
||||
from app.utils.subscription_utils import (
|
||||
get_display_subscription_link,
|
||||
get_happ_cryptolink_redirect_link,
|
||||
convert_subscription_link_to_happ_scheme,
|
||||
)
|
||||
from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
get_user_active_promo_discount_percent,
|
||||
)
|
||||
|
||||
from .countries import _get_available_countries, _should_show_countries_management
|
||||
from .pricing import _build_subscription_period_prompt
|
||||
|
||||
async def handle_autopay_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
if not subscription:
|
||||
await callback.answer(
|
||||
texts.t("SUBSCRIPTION_ACTIVE_REQUIRED", "⚠️ У вас нет активной подписки!"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
status = (
|
||||
texts.t("AUTOPAY_STATUS_ENABLED", "включен")
|
||||
if subscription.autopay_enabled
|
||||
else texts.t("AUTOPAY_STATUS_DISABLED", "выключен")
|
||||
)
|
||||
days = subscription.autopay_days_before
|
||||
|
||||
text = texts.t(
|
||||
"AUTOPAY_MENU_TEXT",
|
||||
(
|
||||
"💳 <b>Автоплатеж</b>\n\n"
|
||||
"📊 <b>Статус:</b> {status}\n"
|
||||
"⏰ <b>Списание за:</b> {days} дн. до окончания\n\n"
|
||||
"Выберите действие:"
|
||||
),
|
||||
).format(status=status, days=days)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_autopay_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
async def toggle_autopay(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
subscription = db_user.subscription
|
||||
enable = callback.data == "autopay_enable"
|
||||
|
||||
await update_subscription_autopay(db, subscription, enable)
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
status = (
|
||||
texts.t("AUTOPAY_STATUS_ENABLED", "включен")
|
||||
if enable
|
||||
else texts.t("AUTOPAY_STATUS_DISABLED", "выключен")
|
||||
)
|
||||
await callback.answer(
|
||||
texts.t("AUTOPAY_TOGGLE_SUCCESS", "✅ Автоплатеж {status}!").format(status=status)
|
||||
)
|
||||
|
||||
await handle_autopay_menu(callback, db_user, db)
|
||||
|
||||
async def show_autopay_days(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"AUTOPAY_SELECT_DAYS_PROMPT",
|
||||
"⏰ Выберите за сколько дней до окончания списывать средства:",
|
||||
),
|
||||
reply_markup=get_autopay_days_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
async def set_autopay_days(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
days = int(callback.data.split('_')[2])
|
||||
subscription = db_user.subscription
|
||||
|
||||
await update_subscription_autopay(
|
||||
db, subscription, subscription.autopay_enabled, days
|
||||
)
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t("AUTOPAY_DAYS_SET", "✅ Установлено {days} дней!").format(days=days)
|
||||
)
|
||||
|
||||
await handle_autopay_menu(callback, db_user, db)
|
||||
|
||||
async def handle_subscription_config_back(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
current_state = await state.get_state()
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
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, db_user),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_period)
|
||||
|
||||
elif current_state == SubscriptionStates.selecting_countries.state:
|
||||
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)
|
||||
else:
|
||||
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)
|
||||
|
||||
elif current_state == SubscriptionStates.selecting_devices.state:
|
||||
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_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_DEVICES,
|
||||
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_devices)
|
||||
else:
|
||||
await _show_previous_configuration_step(callback, state, db_user, texts, db)
|
||||
|
||||
else:
|
||||
from app.handlers.menu import show_main_menu
|
||||
await show_main_menu(callback, db_user, db)
|
||||
await state.clear()
|
||||
|
||||
await callback.answer()
|
||||
|
||||
async def handle_subscription_cancel(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Tuple, Optional
|
||||
from urllib.parse import quote
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings, PERIOD_PRICES, get_traffic_prices
|
||||
from app.database.crud.discount_offer import (
|
||||
get_offer_by_id,
|
||||
mark_offer_claimed,
|
||||
)
|
||||
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
|
||||
from app.database.crud.subscription import (
|
||||
create_trial_subscription,
|
||||
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
|
||||
update_subscription_autopay
|
||||
)
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import (
|
||||
User, TransactionType, SubscriptionStatus,
|
||||
Subscription
|
||||
)
|
||||
from app.keyboards.inline import (
|
||||
get_subscription_keyboard, get_trial_keyboard,
|
||||
get_subscription_period_keyboard, get_traffic_packages_keyboard,
|
||||
get_countries_keyboard, get_devices_keyboard,
|
||||
get_subscription_confirm_keyboard, get_autopay_keyboard,
|
||||
get_autopay_days_keyboard, get_back_keyboard,
|
||||
get_add_traffic_keyboard,
|
||||
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
|
||||
get_manage_countries_keyboard,
|
||||
get_device_selection_keyboard, get_connection_guide_keyboard,
|
||||
get_app_selection_keyboard, get_specific_app_keyboard,
|
||||
get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
|
||||
get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
|
||||
get_devices_management_keyboard, get_device_management_help_keyboard,
|
||||
get_happ_cryptolink_keyboard,
|
||||
get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
|
||||
get_happ_download_button_row,
|
||||
get_payment_methods_keyboard_with_cart,
|
||||
get_subscription_confirm_keyboard_with_cart,
|
||||
get_insufficient_balance_keyboard_with_cart
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.subscription_checkout_service import (
|
||||
clear_subscription_checkout_draft,
|
||||
get_subscription_checkout_draft,
|
||||
save_subscription_checkout_draft,
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
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
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.utils.pricing_utils import (
|
||||
calculate_months_from_days,
|
||||
get_remaining_months,
|
||||
calculate_prorated_price,
|
||||
validate_pricing_calculation,
|
||||
format_period_description,
|
||||
apply_percentage_discount,
|
||||
)
|
||||
from app.utils.subscription_utils import (
|
||||
get_display_subscription_link,
|
||||
get_happ_cryptolink_redirect_link,
|
||||
convert_subscription_link_to_happ_scheme,
|
||||
)
|
||||
from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
get_user_active_promo_discount_percent,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TRAFFIC_PRICES = get_traffic_prices()
|
||||
|
||||
class _SafeFormatDict(dict):
|
||||
def __missing__(self, key: str) -> str: # pragma: no cover - defensive fallback
|
||||
return "{" + key + "}"
|
||||
|
||||
def _format_text_with_placeholders(template: str, values: Dict[str, Any]) -> str:
|
||||
if not isinstance(template, str):
|
||||
return template
|
||||
|
||||
safe_values = _SafeFormatDict()
|
||||
safe_values.update(values)
|
||||
|
||||
try:
|
||||
return template.format_map(safe_values)
|
||||
except Exception: # pragma: no cover - defensive logging
|
||||
logger.warning("Failed to format template '%s' with values %s", template, values)
|
||||
return template
|
||||
|
||||
def _get_addon_discount_percent_for_user(
|
||||
user: Optional[User],
|
||||
category: str,
|
||||
period_days_hint: Optional[int] = None,
|
||||
) -> int:
|
||||
if user is None:
|
||||
return 0
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
if promo_group is None:
|
||||
return 0
|
||||
|
||||
if not getattr(promo_group, "apply_discounts_to_addons", True):
|
||||
return 0
|
||||
|
||||
try:
|
||||
return user.get_promo_discount(category, period_days_hint)
|
||||
except AttributeError:
|
||||
return 0
|
||||
|
||||
def _apply_addon_discount(
|
||||
user: Optional[User],
|
||||
category: str,
|
||||
amount: int,
|
||||
period_days_hint: Optional[int] = None,
|
||||
) -> Dict[str, int]:
|
||||
percent = _get_addon_discount_percent_for_user(user, category, period_days_hint)
|
||||
discounted_amount, discount_value = apply_percentage_discount(amount, percent)
|
||||
|
||||
return {
|
||||
"discounted": discounted_amount,
|
||||
"discount": discount_value,
|
||||
"percent": percent,
|
||||
}
|
||||
|
||||
def _get_promo_offer_discount_percent(user: Optional[User]) -> int:
|
||||
return get_user_active_promo_discount_percent(user)
|
||||
|
||||
def _apply_promo_offer_discount(user: Optional[User], amount: int) -> Dict[str, int]:
|
||||
percent = _get_promo_offer_discount_percent(user)
|
||||
|
||||
if amount <= 0 or percent <= 0:
|
||||
return {"discounted": amount, "discount": 0, "percent": 0}
|
||||
|
||||
discounted, discount_value = apply_percentage_discount(amount, percent)
|
||||
return {"discounted": discounted, "discount": discount_value, "percent": percent}
|
||||
|
||||
def _get_period_hint_from_subscription(subscription: Optional[Subscription]) -> Optional[int]:
|
||||
if not subscription:
|
||||
return None
|
||||
|
||||
months_remaining = get_remaining_months(subscription.end_date)
|
||||
if months_remaining <= 0:
|
||||
return None
|
||||
|
||||
return months_remaining * 30
|
||||
|
||||
def _apply_discount_to_monthly_component(
|
||||
amount_per_month: int,
|
||||
percent: int,
|
||||
months: int,
|
||||
) -> Dict[str, int]:
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(amount_per_month, percent)
|
||||
|
||||
return {
|
||||
"original_per_month": amount_per_month,
|
||||
"discounted_per_month": discounted_per_month,
|
||||
"discount_percent": max(0, min(100, percent)),
|
||||
"discount_per_month": discount_per_month,
|
||||
"total": discounted_per_month * months,
|
||||
"discount_total": discount_per_month * months,
|
||||
}
|
||||
|
||||
def update_traffic_prices():
|
||||
from app.config import refresh_traffic_prices
|
||||
refresh_traffic_prices()
|
||||
logger.info("🔄 TRAFFIC_PRICES обновлены из конфигурации")
|
||||
|
||||
def format_traffic_display(traffic_gb: int, is_fixed_mode: bool = None) -> str:
|
||||
if is_fixed_mode is None:
|
||||
is_fixed_mode = settings.is_traffic_fixed()
|
||||
|
||||
if traffic_gb == 0:
|
||||
if is_fixed_mode:
|
||||
return "Безлимитный"
|
||||
else:
|
||||
return "Безлимитный"
|
||||
else:
|
||||
if is_fixed_mode:
|
||||
return f"{traffic_gb} ГБ"
|
||||
else:
|
||||
return f"{traffic_gb} ГБ"
|
||||
|
||||
def validate_traffic_price(gb: int) -> bool:
|
||||
from app.config import settings
|
||||
|
||||
price = settings.get_traffic_price(gb)
|
||||
if gb == 0:
|
||||
return True
|
||||
|
||||
return price > 0
|
||||
|
||||
def load_app_config() -> Dict[str, Any]:
|
||||
try:
|
||||
from app.config import settings
|
||||
config_path = settings.get_app_config_path()
|
||||
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
logger.error("Некорректный формат app-config.json: ожидается объект")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка загрузки конфига приложений: {e}")
|
||||
|
||||
return {}
|
||||
|
||||
def get_localized_value(values: Any, language: str, default_language: str = "en") -> str:
|
||||
if not isinstance(values, dict):
|
||||
return ""
|
||||
|
||||
candidates: List[str] = []
|
||||
normalized_language = (language or "").strip().lower()
|
||||
|
||||
if normalized_language:
|
||||
candidates.append(normalized_language)
|
||||
if "-" in normalized_language:
|
||||
candidates.append(normalized_language.split("-")[0])
|
||||
|
||||
default_language = (default_language or "").strip().lower()
|
||||
if default_language and default_language not in candidates:
|
||||
candidates.append(default_language)
|
||||
|
||||
for candidate in candidates:
|
||||
if not candidate:
|
||||
continue
|
||||
value = values.get(candidate)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value
|
||||
|
||||
for value in values.values():
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value
|
||||
|
||||
return ""
|
||||
|
||||
def get_step_description(app: Dict[str, Any], step_key: str, language: str) -> str:
|
||||
if not isinstance(app, dict):
|
||||
return ""
|
||||
|
||||
step = app.get(step_key)
|
||||
if not isinstance(step, dict):
|
||||
return ""
|
||||
|
||||
description = step.get("description")
|
||||
return get_localized_value(description, language)
|
||||
|
||||
def format_additional_section(additional: Any, texts, language: str) -> str:
|
||||
if not isinstance(additional, dict):
|
||||
return ""
|
||||
|
||||
title = get_localized_value(additional.get("title"), language)
|
||||
description = get_localized_value(additional.get("description"), language)
|
||||
|
||||
parts: List[str] = []
|
||||
|
||||
if title:
|
||||
parts.append(
|
||||
texts.t(
|
||||
"SUBSCRIPTION_ADDITIONAL_STEP_TITLE",
|
||||
"<b>{title}:</b>",
|
||||
).format(title=title)
|
||||
)
|
||||
|
||||
if description:
|
||||
parts.append(description)
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
def build_redirect_link(target_link: Optional[str], template: Optional[str]) -> Optional[str]:
|
||||
if not target_link or not template:
|
||||
return None
|
||||
|
||||
normalized_target = str(target_link).strip()
|
||||
normalized_template = str(template).strip()
|
||||
|
||||
if not normalized_target or not normalized_template:
|
||||
return None
|
||||
|
||||
encoded_target = quote(normalized_target, safe="")
|
||||
result = normalized_template
|
||||
replaced = False
|
||||
|
||||
replacements = [
|
||||
("{subscription_link}", encoded_target),
|
||||
("{link}", encoded_target),
|
||||
("{subscription_link_raw}", normalized_target),
|
||||
("{link_raw}", normalized_target),
|
||||
]
|
||||
|
||||
for placeholder, replacement in replacements:
|
||||
if placeholder in result:
|
||||
result = result.replace(placeholder, replacement)
|
||||
replaced = True
|
||||
|
||||
if not replaced:
|
||||
result = f"{result}{encoded_target}"
|
||||
|
||||
return result
|
||||
|
||||
def get_apps_for_device(device_type: str, language: str = "ru") -> List[Dict[str, Any]]:
|
||||
config = load_app_config()
|
||||
platforms = config.get("platforms", {}) if isinstance(config, dict) else {}
|
||||
|
||||
if not isinstance(platforms, dict):
|
||||
return []
|
||||
|
||||
device_mapping = {
|
||||
'ios': 'ios',
|
||||
'android': 'android',
|
||||
'windows': 'windows',
|
||||
'mac': 'macos',
|
||||
'tv': 'androidTV',
|
||||
'appletv': 'appleTV',
|
||||
'apple_tv': 'appleTV',
|
||||
}
|
||||
|
||||
config_key = device_mapping.get(device_type, device_type)
|
||||
apps = platforms.get(config_key, [])
|
||||
return apps if isinstance(apps, list) else []
|
||||
|
||||
def get_device_name(device_type: str, language: str = "ru") -> str:
|
||||
names = {
|
||||
'ios': 'iPhone/iPad',
|
||||
'android': 'Android',
|
||||
'windows': 'Windows',
|
||||
'mac': 'macOS',
|
||||
'tv': 'Android TV',
|
||||
'appletv': 'Apple TV',
|
||||
'apple_tv': 'Apple TV',
|
||||
}
|
||||
|
||||
return names.get(device_type, device_type)
|
||||
|
||||
def create_deep_link(app: Dict[str, Any], subscription_url: str) -> Optional[str]:
|
||||
if not subscription_url:
|
||||
return None
|
||||
|
||||
if not isinstance(app, dict):
|
||||
return subscription_url
|
||||
|
||||
scheme = str(app.get("urlScheme", "")).strip()
|
||||
payload = subscription_url
|
||||
|
||||
if app.get("isNeedBase64Encoding"):
|
||||
try:
|
||||
payload = base64.b64encode(subscription_url.encode("utf-8")).decode("utf-8")
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Не удалось закодировать ссылку подписки в base64 для приложения %s: %s",
|
||||
app.get("id"),
|
||||
exc,
|
||||
)
|
||||
payload = subscription_url
|
||||
|
||||
scheme_link = f"{scheme}{payload}" if scheme else None
|
||||
|
||||
template = settings.get_happ_cryptolink_redirect_template()
|
||||
redirect_link = build_redirect_link(scheme_link, template) if scheme_link and template else None
|
||||
|
||||
return redirect_link or scheme_link or subscription_url
|
||||
|
||||
def get_reset_devices_confirm_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="✅ Да, сбросить все устройства",
|
||||
callback_data="confirm_reset_devices"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="menu_subscription")
|
||||
]
|
||||
])
|
||||
|
||||
def get_traffic_switch_keyboard(
|
||||
current_traffic_gb: int,
|
||||
language: str = "ru",
|
||||
subscription_end_date: datetime = None,
|
||||
discount_percent: int = 0,
|
||||
) -> InlineKeyboardMarkup:
|
||||
from app.config import settings
|
||||
|
||||
months_multiplier = 1
|
||||
period_text = ""
|
||||
if subscription_end_date:
|
||||
months_multiplier = get_remaining_months(subscription_end_date)
|
||||
if months_multiplier > 1:
|
||||
period_text = f" (за {months_multiplier} мес)"
|
||||
|
||||
packages = settings.get_traffic_packages()
|
||||
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
|
||||
|
||||
current_price_per_month = settings.get_traffic_price(current_traffic_gb)
|
||||
discounted_current_per_month, _ = apply_percentage_discount(
|
||||
current_price_per_month,
|
||||
discount_percent,
|
||||
)
|
||||
|
||||
buttons = []
|
||||
|
||||
for package in enabled_packages:
|
||||
gb = package['gb']
|
||||
price_per_month = package['price']
|
||||
discounted_price_per_month, _ = apply_percentage_discount(
|
||||
price_per_month,
|
||||
discount_percent,
|
||||
)
|
||||
|
||||
price_diff_per_month = discounted_price_per_month - discounted_current_per_month
|
||||
total_price_diff = price_diff_per_month * months_multiplier
|
||||
|
||||
if gb == current_traffic_gb:
|
||||
emoji = "✅"
|
||||
action_text = " (текущий)"
|
||||
price_text = ""
|
||||
elif total_price_diff > 0:
|
||||
emoji = "⬆️"
|
||||
action_text = ""
|
||||
price_text = f" (+{total_price_diff // 100}₽{period_text})"
|
||||
if discount_percent > 0:
|
||||
discount_total = (
|
||||
(price_per_month - current_price_per_month) * months_multiplier
|
||||
- total_price_diff
|
||||
)
|
||||
if discount_total > 0:
|
||||
price_text += f" (скидка {discount_percent}%: -{discount_total // 100}₽)"
|
||||
elif total_price_diff < 0:
|
||||
emoji = "⬇️"
|
||||
action_text = ""
|
||||
price_text = " (без возврата)"
|
||||
else:
|
||||
emoji = "🔄"
|
||||
action_text = ""
|
||||
price_text = " (бесплатно)"
|
||||
|
||||
if gb == 0:
|
||||
traffic_text = "Безлимит"
|
||||
else:
|
||||
traffic_text = f"{gb} ГБ"
|
||||
|
||||
button_text = f"{emoji} {traffic_text}{action_text}{price_text}"
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text=button_text, callback_data=f"switch_traffic_{gb}")
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад" if language == "ru" else "⬅️ Back",
|
||||
callback_data="subscription_settings"
|
||||
)
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def get_confirm_switch_traffic_keyboard(
|
||||
new_traffic_gb: int,
|
||||
price_difference: int,
|
||||
language: str = "ru"
|
||||
) -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="✅ Подтвердить переключение",
|
||||
callback_data=f"confirm_switch_traffic_{new_traffic_gb}_{price_difference}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="❌ Отмена",
|
||||
callback_data="subscription_settings"
|
||||
)
|
||||
]
|
||||
])
|
||||
@@ -0,0 +1,964 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Tuple, Optional
|
||||
from urllib.parse import quote
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings, PERIOD_PRICES, get_traffic_prices
|
||||
from app.database.crud.discount_offer import (
|
||||
get_offer_by_id,
|
||||
mark_offer_claimed,
|
||||
)
|
||||
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
|
||||
from app.database.crud.subscription import (
|
||||
create_trial_subscription,
|
||||
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
|
||||
update_subscription_autopay
|
||||
)
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import (
|
||||
User, TransactionType, SubscriptionStatus,
|
||||
Subscription
|
||||
)
|
||||
from app.keyboards.inline import (
|
||||
get_subscription_keyboard, get_trial_keyboard,
|
||||
get_subscription_period_keyboard, get_traffic_packages_keyboard,
|
||||
get_countries_keyboard, get_devices_keyboard,
|
||||
get_subscription_confirm_keyboard, get_autopay_keyboard,
|
||||
get_autopay_days_keyboard, get_back_keyboard,
|
||||
get_add_traffic_keyboard,
|
||||
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
|
||||
get_manage_countries_keyboard,
|
||||
get_device_selection_keyboard, get_connection_guide_keyboard,
|
||||
get_app_selection_keyboard, get_specific_app_keyboard,
|
||||
get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
|
||||
get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
|
||||
get_devices_management_keyboard, get_device_management_help_keyboard,
|
||||
get_happ_cryptolink_keyboard,
|
||||
get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
|
||||
get_happ_download_button_row,
|
||||
get_payment_methods_keyboard_with_cart,
|
||||
get_subscription_confirm_keyboard_with_cart,
|
||||
get_insufficient_balance_keyboard_with_cart
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.subscription_checkout_service import (
|
||||
clear_subscription_checkout_draft,
|
||||
get_subscription_checkout_draft,
|
||||
save_subscription_checkout_draft,
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
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
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.utils.pricing_utils import (
|
||||
calculate_months_from_days,
|
||||
get_remaining_months,
|
||||
calculate_prorated_price,
|
||||
validate_pricing_calculation,
|
||||
format_period_description,
|
||||
apply_percentage_discount,
|
||||
)
|
||||
from app.utils.subscription_utils import (
|
||||
get_display_subscription_link,
|
||||
get_happ_cryptolink_redirect_link,
|
||||
convert_subscription_link_to_happ_scheme,
|
||||
)
|
||||
from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
get_user_active_promo_discount_percent,
|
||||
)
|
||||
|
||||
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,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
if not await _should_show_countries_management(db_user):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"COUNTRY_MANAGEMENT_UNAVAILABLE",
|
||||
"ℹ️ Управление серверами недоступно - доступен только один сервер",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
if not subscription or subscription.is_trial:
|
||||
await callback.answer(
|
||||
texts.t("PAID_FEATURE_ONLY", "⚠ Эта функция доступна только для платных подписок"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
current_countries = subscription.connected_squads
|
||||
|
||||
period_hint_days = _get_period_hint_from_subscription(subscription)
|
||||
servers_discount_percent = _get_addon_discount_percent_for_user(
|
||||
db_user,
|
||||
"servers",
|
||||
period_hint_days,
|
||||
)
|
||||
|
||||
current_countries_names = []
|
||||
for country in countries:
|
||||
if country['uuid'] in current_countries:
|
||||
current_countries_names.append(country['name'])
|
||||
|
||||
current_list = (
|
||||
"\n".join(f"• {name}" for name in current_countries_names)
|
||||
if current_countries_names
|
||||
else texts.t("COUNTRY_MANAGEMENT_NONE", "Нет подключенных стран")
|
||||
)
|
||||
|
||||
text = texts.t(
|
||||
"COUNTRY_MANAGEMENT_PROMPT",
|
||||
(
|
||||
"🌍 <b>Управление странами подписки</b>\n\n"
|
||||
"📋 <b>Текущие страны ({current_count}):</b>\n"
|
||||
"{current_list}\n\n"
|
||||
"💡 <b>Инструкция:</b>\n"
|
||||
"✅ - страна подключена\n"
|
||||
"➕ - будет добавлена (платно)\n"
|
||||
"➖ - будет отключена (бесплатно)\n"
|
||||
"⚪ - не выбрана\n\n"
|
||||
"⚠️ <b>Важно:</b> Повторное подключение отключенных стран будет платным!"
|
||||
),
|
||||
).format(
|
||||
current_count=len(current_countries),
|
||||
current_list=current_list,
|
||||
)
|
||||
|
||||
await state.update_data(countries=current_countries.copy())
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_manage_countries_keyboard(
|
||||
countries,
|
||||
current_countries.copy(),
|
||||
current_countries,
|
||||
db_user.language,
|
||||
subscription.end_date,
|
||||
servers_discount_percent,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
async def get_countries_price_by_uuids_fallback(
|
||||
country_uuids: List[str],
|
||||
db: AsyncSession,
|
||||
promo_group_id: Optional[int] = None,
|
||||
) -> Tuple[int, List[int]]:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_server_squad_by_uuid
|
||||
|
||||
total_price = 0
|
||||
prices_list = []
|
||||
|
||||
for country_uuid in country_uuids:
|
||||
try:
|
||||
server = await get_server_squad_by_uuid(db, country_uuid)
|
||||
is_allowed = True
|
||||
if promo_group_id is not None and server:
|
||||
allowed_ids = {pg.id for pg in server.allowed_promo_groups}
|
||||
is_allowed = promo_group_id in allowed_ids
|
||||
|
||||
if server and server.is_available and not server.is_full and is_allowed:
|
||||
price = server.price_kopeks
|
||||
total_price += price
|
||||
prices_list.append(price)
|
||||
else:
|
||||
default_price = 0
|
||||
total_price += default_price
|
||||
prices_list.append(default_price)
|
||||
except Exception:
|
||||
default_price = 0
|
||||
total_price += default_price
|
||||
prices_list.append(default_price)
|
||||
|
||||
return total_price, prices_list
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка fallback функции: {e}")
|
||||
default_prices = [0] * len(country_uuids)
|
||||
return sum(default_prices), default_prices
|
||||
|
||||
async def handle_manage_country(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
logger.info(f"🔍 Управление страной: {callback.data}")
|
||||
|
||||
country_uuid = callback.data.split('_')[2]
|
||||
|
||||
subscription = db_user.subscription
|
||||
if not subscription or subscription.is_trial:
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t("PAID_FEATURE_ONLY_SHORT", "⚠ Только для платных подписок"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
current_selected = data.get('countries', subscription.connected_squads.copy())
|
||||
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
allowed_country_ids = {country['uuid'] for country in countries}
|
||||
|
||||
if country_uuid not in allowed_country_ids and country_uuid not in current_selected:
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"COUNTRY_NOT_AVAILABLE_PROMOGROUP",
|
||||
"❌ Сервер недоступен для вашей промогруппы",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if country_uuid in current_selected:
|
||||
current_selected.remove(country_uuid)
|
||||
action = "removed"
|
||||
else:
|
||||
current_selected.append(country_uuid)
|
||||
action = "added"
|
||||
|
||||
logger.info(f"🔍 Страна {country_uuid} {action}")
|
||||
|
||||
await state.update_data(countries=current_selected)
|
||||
|
||||
period_hint_days = _get_period_hint_from_subscription(subscription)
|
||||
servers_discount_percent = _get_addon_discount_percent_for_user(
|
||||
db_user,
|
||||
"servers",
|
||||
period_hint_days,
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_reply_markup(
|
||||
reply_markup=get_manage_countries_keyboard(
|
||||
countries,
|
||||
current_selected,
|
||||
subscription.connected_squads,
|
||||
db_user.language,
|
||||
subscription.end_date,
|
||||
servers_discount_percent,
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Клавиатура обновлена")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"⚠ Ошибка обновления клавиатуры: {e}")
|
||||
|
||||
await callback.answer()
|
||||
|
||||
async def apply_countries_changes(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
logger.info(f"🔧 Применение изменений стран")
|
||||
|
||||
data = await state.get_data()
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await save_subscription_checkout_draft(db_user.id, dict(data))
|
||||
resume_callback = (
|
||||
"subscription_resume_checkout"
|
||||
if should_offer_checkout_resume(db_user, True)
|
||||
else None
|
||||
)
|
||||
subscription = db_user.subscription
|
||||
|
||||
selected_countries = data.get('countries', [])
|
||||
current_countries = subscription.connected_squads
|
||||
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
allowed_country_ids = {country['uuid'] for country in countries}
|
||||
|
||||
selected_countries = [
|
||||
country_uuid
|
||||
for country_uuid in selected_countries
|
||||
if country_uuid in allowed_country_ids or country_uuid in current_countries
|
||||
]
|
||||
|
||||
added = [c for c in selected_countries if c not in current_countries]
|
||||
removed = [c for c in current_countries if c not in selected_countries]
|
||||
|
||||
if not added and not removed:
|
||||
await callback.answer(
|
||||
texts.t("COUNTRY_CHANGES_NOT_FOUND", "⚠️ Изменения не обнаружены"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(f"🔧 Добавлено: {added}, Удалено: {removed}")
|
||||
|
||||
months_to_pay = get_remaining_months(subscription.end_date)
|
||||
|
||||
period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None
|
||||
servers_discount_percent = _get_addon_discount_percent_for_user(
|
||||
db_user,
|
||||
"servers",
|
||||
period_hint_days,
|
||||
)
|
||||
|
||||
cost_per_month = 0
|
||||
added_names = []
|
||||
removed_names = []
|
||||
|
||||
added_server_components: List[Dict[str, int]] = []
|
||||
|
||||
for country in countries:
|
||||
if not country.get('is_available', True):
|
||||
continue
|
||||
|
||||
if country['uuid'] in added:
|
||||
server_price_per_month = country['price_kopeks']
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
server_price_per_month,
|
||||
servers_discount_percent,
|
||||
)
|
||||
cost_per_month += discounted_per_month
|
||||
added_names.append(country['name'])
|
||||
added_server_components.append(
|
||||
{
|
||||
"discounted_per_month": discounted_per_month,
|
||||
"discount_per_month": discount_per_month,
|
||||
"original_per_month": server_price_per_month,
|
||||
}
|
||||
)
|
||||
if country['uuid'] in removed:
|
||||
removed_names.append(country['name'])
|
||||
|
||||
total_cost, charged_months = calculate_prorated_price(cost_per_month, subscription.end_date)
|
||||
|
||||
added_server_prices = [
|
||||
component["discounted_per_month"] * charged_months
|
||||
for component in added_server_components
|
||||
]
|
||||
|
||||
total_discount = sum(
|
||||
component["discount_per_month"] * charged_months
|
||||
for component in added_server_components
|
||||
)
|
||||
|
||||
if added_names:
|
||||
logger.info(
|
||||
"Стоимость новых серверов: %.2f₽/мес × %s мес = %.2f₽ (скидка %.2f₽)",
|
||||
cost_per_month / 100,
|
||||
charged_months,
|
||||
total_cost / 100,
|
||||
total_discount / 100,
|
||||
)
|
||||
|
||||
if total_cost > 0 and db_user.balance_kopeks < total_cost:
|
||||
missing_kopeks = total_cost - db_user.balance_kopeks
|
||||
required_text = f"{texts.format_price(total_cost)} (за {charged_months} мес)"
|
||||
message_text = texts.t(
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE",
|
||||
(
|
||||
"⚠️ <b>Недостаточно средств</b>\n\n"
|
||||
"Стоимость услуги: {required}\n"
|
||||
"На балансе: {balance}\n"
|
||||
"Не хватает: {missing}\n\n"
|
||||
"Выберите способ пополнения. Сумма подставится автоматически."
|
||||
),
|
||||
).format(
|
||||
required=required_text,
|
||||
balance=texts.format_price(db_user.balance_kopeks),
|
||||
missing=texts.format_price(missing_kopeks),
|
||||
)
|
||||
|
||||
await callback.message.answer(
|
||||
message_text,
|
||||
reply_markup=get_insufficient_balance_keyboard(
|
||||
db_user.language,
|
||||
resume_callback=resume_callback,
|
||||
amount_kopeks=missing_kopeks,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
try:
|
||||
if added and total_cost > 0:
|
||||
success = await subtract_user_balance(
|
||||
db, db_user, total_cost,
|
||||
f"Добавление стран: {', '.join(added_names)} на {charged_months} мес"
|
||||
)
|
||||
if not success:
|
||||
await callback.answer(
|
||||
texts.t("PAYMENT_CHARGE_ERROR", "⚠️ Ошибка списания средств"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=total_cost,
|
||||
description=f"Добавление стран к подписке: {', '.join(added_names)} на {charged_months} мес"
|
||||
)
|
||||
|
||||
if added:
|
||||
from app.database.crud.server_squad import get_server_ids_by_uuids, add_user_to_servers
|
||||
from app.database.crud.subscription import add_subscription_servers
|
||||
|
||||
added_server_ids = await get_server_ids_by_uuids(db, added)
|
||||
|
||||
if added_server_ids:
|
||||
await add_subscription_servers(db, subscription, added_server_ids, added_server_prices)
|
||||
await add_user_to_servers(db, added_server_ids)
|
||||
|
||||
logger.info(
|
||||
f"📊 Добавлены серверы с ценами за {charged_months} мес: {list(zip(added_server_ids, added_server_prices))}")
|
||||
|
||||
subscription.connected_squads = selected_countries
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
await db.refresh(subscription)
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db, db_user, subscription, "servers", current_countries, selected_countries, total_cost
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об изменении серверов: {e}")
|
||||
|
||||
success_text = texts.t(
|
||||
"COUNTRY_CHANGES_SUCCESS_HEADER",
|
||||
"✅ <b>Страны успешно обновлены!</b>\n\n",
|
||||
)
|
||||
|
||||
if added_names:
|
||||
success_text += texts.t(
|
||||
"COUNTRY_CHANGES_ADDED_HEADER",
|
||||
"➕ <b>Добавлены страны:</b>\n",
|
||||
)
|
||||
success_text += "\n".join(f"• {name}" for name in added_names)
|
||||
if total_cost > 0:
|
||||
success_text += "\n" + texts.t(
|
||||
"COUNTRY_CHANGES_CHARGED",
|
||||
"💰 Списано: {amount} (за {months} мес)",
|
||||
).format(
|
||||
amount=texts.format_price(total_cost),
|
||||
months=charged_months,
|
||||
)
|
||||
if total_discount > 0:
|
||||
success_text += texts.t(
|
||||
"COUNTRY_CHANGES_DISCOUNT_INFO",
|
||||
" (скидка {percent}%: -{amount})",
|
||||
).format(
|
||||
percent=servers_discount_percent,
|
||||
amount=texts.format_price(total_discount),
|
||||
)
|
||||
success_text += "\n"
|
||||
|
||||
if removed_names:
|
||||
success_text += "\n" + texts.t(
|
||||
"COUNTRY_CHANGES_REMOVED_HEADER",
|
||||
"➖ <b>Отключены страны:</b>\n",
|
||||
)
|
||||
success_text += "\n".join(f"• {name}" for name in removed_names)
|
||||
success_text += "\n" + texts.t(
|
||||
"COUNTRY_CHANGES_REMOVED_WARNING",
|
||||
"ℹ️ Повторное подключение будет платным",
|
||||
) + "\n"
|
||||
|
||||
success_text += "\n" + texts.t(
|
||||
"COUNTRY_CHANGES_ACTIVE_COUNT",
|
||||
"🌐 <b>Активных стран:</b> {count}",
|
||||
).format(count=len(selected_countries))
|
||||
|
||||
await callback.message.edit_text(
|
||||
success_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
logger.info(
|
||||
f"✅ Пользователь {db_user.telegram_id} обновил страны. Добавлено: {len(added)}, удалено: {len(removed)}, заплатил: {total_cost / 100}₽")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"⚠️ Ошибка применения изменений: {e}")
|
||||
await callback.message.edit_text(
|
||||
texts.ERROR,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
async def select_country(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
country_uuid = callback.data.split('_')[1]
|
||||
data = await state.get_data()
|
||||
|
||||
selected_countries = data.get('countries', [])
|
||||
if country_uuid in selected_countries:
|
||||
selected_countries.remove(country_uuid)
|
||||
else:
|
||||
selected_countries.append(country_uuid)
|
||||
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
allowed_country_ids = {country['uuid'] for country in countries}
|
||||
|
||||
if country_uuid not in allowed_country_ids and country_uuid not in selected_countries:
|
||||
await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True)
|
||||
return
|
||||
|
||||
period_base_price = PERIOD_PRICES[data['period_days']]
|
||||
|
||||
discounted_base_price, _ = apply_percentage_discount(
|
||||
period_base_price,
|
||||
db_user.get_promo_discount("period", data['period_days']),
|
||||
)
|
||||
|
||||
base_price = discounted_base_price + settings.get_traffic_price(data['traffic_gb'])
|
||||
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
countries_price, _ = await subscription_service.get_countries_price_by_uuids(
|
||||
selected_countries,
|
||||
db,
|
||||
promo_group_id=db_user.promo_group_id,
|
||||
)
|
||||
except AttributeError:
|
||||
logger.warning("Используем fallback функцию для расчета цен стран")
|
||||
countries_price, _ = await get_countries_price_by_uuids_fallback(
|
||||
selected_countries,
|
||||
db,
|
||||
promo_group_id=db_user.promo_group_id,
|
||||
)
|
||||
|
||||
data['countries'] = selected_countries
|
||||
data['total_price'] = base_price + countries_price
|
||||
await state.set_data(data)
|
||||
|
||||
await callback.message.edit_reply_markup(
|
||||
reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
async def countries_continue(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User
|
||||
):
|
||||
data = await state.get_data()
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not data.get('countries'):
|
||||
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(
|
||||
texts.SELECT_DEVICES,
|
||||
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
|
||||
)
|
||||
|
||||
await state.set_state(SubscriptionStates.selecting_devices)
|
||||
await callback.answer()
|
||||
|
||||
async def _get_available_countries(promo_group_id: Optional[int] = None):
|
||||
from app.utils.cache import cache, cache_key
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.crud.server_squad import get_available_server_squads
|
||||
|
||||
cache_key_value = cache_key("available_countries", promo_group_id or "all")
|
||||
cached_countries = await cache.get(cache_key_value)
|
||||
if cached_countries:
|
||||
return cached_countries
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
available_servers = await get_available_server_squads(
|
||||
db, promo_group_id=promo_group_id
|
||||
)
|
||||
|
||||
if promo_group_id is not None and not available_servers:
|
||||
logger.info(
|
||||
"Промогруппа %s не имеет доступных серверов, возврат пустого списка",
|
||||
promo_group_id,
|
||||
)
|
||||
await cache.set(cache_key_value, [], 60)
|
||||
return []
|
||||
|
||||
countries = []
|
||||
for server in available_servers:
|
||||
countries.append({
|
||||
"uuid": server.squad_uuid,
|
||||
"name": server.display_name,
|
||||
"price_kopeks": server.price_kopeks,
|
||||
"country_code": server.country_code,
|
||||
"is_available": server.is_available and not server.is_full
|
||||
})
|
||||
|
||||
if not countries:
|
||||
logger.info("🔄 Серверов в БД нет, получаем из RemnaWave...")
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
squads = await service.get_all_squads()
|
||||
|
||||
for squad in squads:
|
||||
squad_name = squad["name"]
|
||||
|
||||
if not any(flag in squad_name for flag in
|
||||
["🇳🇱", "🇩🇪", "🇺🇸", "🇫🇷", "🇬🇧", "🇮🇹", "🇪🇸", "🇨🇦", "🇯🇵", "🇸🇬", "🇦🇺"]):
|
||||
name_lower = squad_name.lower()
|
||||
if "netherlands" in name_lower or "нидерланды" in name_lower or "nl" in name_lower:
|
||||
squad_name = f"🇳🇱 {squad_name}"
|
||||
elif "germany" in name_lower or "германия" in name_lower or "de" in name_lower:
|
||||
squad_name = f"🇩🇪 {squad_name}"
|
||||
elif "usa" in name_lower or "сша" in name_lower or "america" in name_lower or "us" in name_lower:
|
||||
squad_name = f"🇺🇸 {squad_name}"
|
||||
else:
|
||||
squad_name = f"🌐 {squad_name}"
|
||||
|
||||
countries.append({
|
||||
"uuid": squad["uuid"],
|
||||
"name": squad_name,
|
||||
"price_kopeks": 0,
|
||||
"is_available": True
|
||||
})
|
||||
|
||||
await cache.set(cache_key_value, countries, 300)
|
||||
return countries
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения списка стран: {e}")
|
||||
fallback_countries = [
|
||||
{"uuid": "default-free", "name": "🆓 Бесплатный сервер", "price_kopeks": 0, "is_available": True},
|
||||
]
|
||||
|
||||
await cache.set(cache_key_value, fallback_countries, 60)
|
||||
return fallback_countries
|
||||
|
||||
async def _get_countries_info(squad_uuids):
|
||||
countries = await _get_available_countries()
|
||||
return [c for c in countries if c['uuid'] in squad_uuids]
|
||||
|
||||
async def handle_add_country_to_subscription(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
logger.info(f"🔍 handle_add_country_to_subscription вызван для {db_user.telegram_id}")
|
||||
logger.info(f"🔍 Callback data: {callback.data}")
|
||||
|
||||
current_state = await state.get_state()
|
||||
logger.info(f"🔍 Текущее состояние: {current_state}")
|
||||
|
||||
country_uuid = callback.data.split('_')[1]
|
||||
data = await state.get_data()
|
||||
logger.info(f"🔍 Данные состояния: {data}")
|
||||
|
||||
selected_countries = data.get('countries', [])
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
allowed_country_ids = {country['uuid'] for country in countries}
|
||||
|
||||
if country_uuid not in allowed_country_ids and country_uuid not in selected_countries:
|
||||
await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True)
|
||||
return
|
||||
|
||||
if country_uuid in selected_countries:
|
||||
selected_countries.remove(country_uuid)
|
||||
logger.info(f"🔍 Удалена страна: {country_uuid}")
|
||||
else:
|
||||
selected_countries.append(country_uuid)
|
||||
logger.info(f"🔍 Добавлена страна: {country_uuid}")
|
||||
|
||||
total_price = 0
|
||||
subscription = db_user.subscription
|
||||
period_hint_days = _get_period_hint_from_subscription(subscription)
|
||||
servers_discount_percent = _get_addon_discount_percent_for_user(
|
||||
db_user,
|
||||
"servers",
|
||||
period_hint_days,
|
||||
)
|
||||
|
||||
for country in countries:
|
||||
if not country.get('is_available', True):
|
||||
continue
|
||||
|
||||
if (
|
||||
country['uuid'] in selected_countries
|
||||
and country['uuid'] not in subscription.connected_squads
|
||||
):
|
||||
server_price = country['price_kopeks']
|
||||
if servers_discount_percent > 0 and server_price > 0:
|
||||
discounted_price, _ = apply_percentage_discount(
|
||||
server_price,
|
||||
servers_discount_percent,
|
||||
)
|
||||
else:
|
||||
discounted_price = server_price
|
||||
total_price += discounted_price
|
||||
|
||||
data['countries'] = selected_countries
|
||||
data['total_price'] = total_price
|
||||
await state.set_data(data)
|
||||
|
||||
logger.info(f"🔍 Новые выбранные страны: {selected_countries}")
|
||||
logger.info(f"🔍 Общая стоимость: {total_price}")
|
||||
|
||||
try:
|
||||
from app.keyboards.inline import get_manage_countries_keyboard
|
||||
await callback.message.edit_reply_markup(
|
||||
reply_markup=get_manage_countries_keyboard(
|
||||
countries,
|
||||
selected_countries,
|
||||
subscription.connected_squads,
|
||||
db_user.language,
|
||||
subscription.end_date,
|
||||
servers_discount_percent,
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Клавиатура обновлена")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка обновления клавиатуры: {e}")
|
||||
|
||||
await callback.answer()
|
||||
|
||||
async def _should_show_countries_management(user: Optional[User] = None) -> bool:
|
||||
try:
|
||||
promo_group_id = user.promo_group_id if user else None
|
||||
|
||||
promo_group = getattr(user, "promo_group", None) if user else None
|
||||
if promo_group and getattr(promo_group, "server_squads", None):
|
||||
allowed_servers = [
|
||||
server
|
||||
for server in promo_group.server_squads
|
||||
if server.is_available and not server.is_full
|
||||
]
|
||||
|
||||
if allowed_servers:
|
||||
if len(allowed_servers) > 1:
|
||||
logger.debug(
|
||||
"Промогруппа %s имеет %s доступных серверов, показываем управление странами",
|
||||
promo_group.id,
|
||||
len(allowed_servers),
|
||||
)
|
||||
return True
|
||||
|
||||
logger.debug(
|
||||
"Промогруппа %s имеет всего %s доступный сервер, пропускаем шаг выбора стран",
|
||||
promo_group.id,
|
||||
len(allowed_servers),
|
||||
)
|
||||
return False
|
||||
|
||||
countries = await _get_available_countries(promo_group_id)
|
||||
available_countries = [c for c in countries if c.get('is_available', True)]
|
||||
return len(available_countries) > 1
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки доступных серверов: {e}")
|
||||
return True
|
||||
|
||||
async def confirm_add_countries_to_subscription(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
data = await state.get_data()
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
selected_countries = data.get('countries', [])
|
||||
current_countries = subscription.connected_squads
|
||||
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
allowed_country_ids = {country['uuid'] for country in countries}
|
||||
|
||||
selected_countries = [
|
||||
country_uuid
|
||||
for country_uuid in selected_countries
|
||||
if country_uuid in allowed_country_ids or country_uuid in current_countries
|
||||
]
|
||||
|
||||
new_countries = [c for c in selected_countries if c not in current_countries]
|
||||
removed_countries = [c for c in current_countries if c not in selected_countries]
|
||||
|
||||
if not new_countries and not removed_countries:
|
||||
await callback.answer("⚠️ Изменения не обнаружены", show_alert=True)
|
||||
return
|
||||
|
||||
total_price = 0
|
||||
new_countries_names = []
|
||||
removed_countries_names = []
|
||||
|
||||
period_hint_days = _get_period_hint_from_subscription(subscription)
|
||||
servers_discount_percent = _get_addon_discount_percent_for_user(
|
||||
db_user,
|
||||
"servers",
|
||||
period_hint_days,
|
||||
)
|
||||
total_discount_value = 0
|
||||
|
||||
for country in countries:
|
||||
if not country.get('is_available', True):
|
||||
continue
|
||||
|
||||
if country['uuid'] in new_countries:
|
||||
server_price = country['price_kopeks']
|
||||
if servers_discount_percent > 0 and server_price > 0:
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
server_price,
|
||||
servers_discount_percent,
|
||||
)
|
||||
else:
|
||||
discounted_per_month = server_price
|
||||
discount_per_month = 0
|
||||
|
||||
charged_price, charged_months = calculate_prorated_price(
|
||||
discounted_per_month,
|
||||
subscription.end_date,
|
||||
)
|
||||
|
||||
total_price += charged_price
|
||||
total_discount_value += discount_per_month * charged_months
|
||||
new_countries_names.append(country['name'])
|
||||
if country['uuid'] in removed_countries:
|
||||
removed_countries_names.append(country['name'])
|
||||
|
||||
if new_countries and db_user.balance_kopeks < total_price:
|
||||
missing_kopeks = total_price - db_user.balance_kopeks
|
||||
message_text = texts.t(
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE",
|
||||
(
|
||||
"⚠️ <b>Недостаточно средств</b>\n\n"
|
||||
"Стоимость услуги: {required}\n"
|
||||
"На балансе: {balance}\n"
|
||||
"Не хватает: {missing}\n\n"
|
||||
"Выберите способ пополнения. Сумма подставится автоматически."
|
||||
),
|
||||
).format(
|
||||
required=texts.format_price(total_price),
|
||||
balance=texts.format_price(db_user.balance_kopeks),
|
||||
missing=texts.format_price(missing_kopeks),
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=get_insufficient_balance_keyboard(
|
||||
db_user.language,
|
||||
amount_kopeks=missing_kopeks,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await state.clear()
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
try:
|
||||
if new_countries and total_price > 0:
|
||||
success = await subtract_user_balance(
|
||||
db, db_user, total_price,
|
||||
f"Добавление стран к подписке: {', '.join(new_countries_names)}"
|
||||
)
|
||||
|
||||
if not success:
|
||||
await callback.answer("❌ Ошибка списания средств", show_alert=True)
|
||||
return
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=total_price,
|
||||
description=f"Добавление стран к подписке: {', '.join(new_countries_names)}"
|
||||
)
|
||||
|
||||
subscription.connected_squads = selected_countries
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
await db.refresh(db_user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
success_text = "✅ Страны успешно обновлены!\n\n"
|
||||
|
||||
if new_countries_names:
|
||||
success_text += f"➕ Добавлены страны:\n{chr(10).join(f'• {name}' for name in new_countries_names)}\n"
|
||||
if total_price > 0:
|
||||
success_text += f"💰 Списано: {texts.format_price(total_price)}"
|
||||
if total_discount_value > 0:
|
||||
success_text += (
|
||||
f" (скидка {servers_discount_percent}%:"
|
||||
f" -{texts.format_price(total_discount_value)})"
|
||||
)
|
||||
success_text += "\n"
|
||||
|
||||
if removed_countries_names:
|
||||
success_text += f"\n➖ Отключены страны:\n{chr(10).join(f'• {name}' for name in removed_countries_names)}\n"
|
||||
success_text += "ℹ️ Повторное подключение будет платным\n"
|
||||
|
||||
success_text += f"\n🌍 Активных стран: {len(selected_countries)}"
|
||||
|
||||
await callback.message.edit_text(
|
||||
success_text,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"✅ Пользователь {db_user.telegram_id} обновил страны подписки. Добавлено: {len(new_countries)}, убрано: {len(removed_countries)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления стран подписки: {e}")
|
||||
await callback.message.edit_text(
|
||||
texts.ERROR,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
await callback.answer()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,158 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Tuple, Optional
|
||||
from urllib.parse import quote
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings, PERIOD_PRICES, get_traffic_prices
|
||||
from app.database.crud.discount_offer import (
|
||||
get_offer_by_id,
|
||||
mark_offer_claimed,
|
||||
)
|
||||
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
|
||||
from app.database.crud.subscription import (
|
||||
create_trial_subscription,
|
||||
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
|
||||
update_subscription_autopay
|
||||
)
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import (
|
||||
User, TransactionType, SubscriptionStatus,
|
||||
Subscription
|
||||
)
|
||||
from app.keyboards.inline import (
|
||||
get_subscription_keyboard, get_trial_keyboard,
|
||||
get_subscription_period_keyboard, get_traffic_packages_keyboard,
|
||||
get_countries_keyboard, get_devices_keyboard,
|
||||
get_subscription_confirm_keyboard, get_autopay_keyboard,
|
||||
get_autopay_days_keyboard, get_back_keyboard,
|
||||
get_add_traffic_keyboard,
|
||||
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
|
||||
get_manage_countries_keyboard,
|
||||
get_device_selection_keyboard, get_connection_guide_keyboard,
|
||||
get_app_selection_keyboard, get_specific_app_keyboard,
|
||||
get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
|
||||
get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
|
||||
get_devices_management_keyboard, get_device_management_help_keyboard,
|
||||
get_happ_cryptolink_keyboard,
|
||||
get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
|
||||
get_happ_download_button_row,
|
||||
get_payment_methods_keyboard_with_cart,
|
||||
get_subscription_confirm_keyboard_with_cart,
|
||||
get_insufficient_balance_keyboard_with_cart
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.subscription_checkout_service import (
|
||||
clear_subscription_checkout_draft,
|
||||
get_subscription_checkout_draft,
|
||||
save_subscription_checkout_draft,
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
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
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.utils.pricing_utils import (
|
||||
calculate_months_from_days,
|
||||
get_remaining_months,
|
||||
calculate_prorated_price,
|
||||
validate_pricing_calculation,
|
||||
format_period_description,
|
||||
apply_percentage_discount,
|
||||
)
|
||||
from app.utils.subscription_utils import (
|
||||
get_display_subscription_link,
|
||||
get_happ_cryptolink_redirect_link,
|
||||
convert_subscription_link_to_happ_scheme,
|
||||
)
|
||||
from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
get_user_active_promo_discount_percent,
|
||||
)
|
||||
|
||||
async def handle_happ_download_request(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
prompt_text = texts.t(
|
||||
"HAPP_DOWNLOAD_PROMPT",
|
||||
"📥 <b>Скачать Happ</b>\nВыберите ваше устройство:",
|
||||
)
|
||||
|
||||
keyboard = get_happ_download_platform_keyboard(db_user.language)
|
||||
|
||||
await callback.message.answer(prompt_text, reply_markup=keyboard, parse_mode="HTML")
|
||||
await callback.answer()
|
||||
|
||||
async def handle_happ_download_platform_choice(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
platform = callback.data.split('_')[-1]
|
||||
if platform == "pc":
|
||||
platform = "windows"
|
||||
texts = get_texts(db_user.language)
|
||||
link = settings.get_happ_download_link(platform)
|
||||
|
||||
if not link:
|
||||
await callback.answer(
|
||||
texts.t("HAPP_DOWNLOAD_LINK_NOT_SET", "❌ Ссылка для этого устройства не настроена"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
platform_names = {
|
||||
"ios": texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"),
|
||||
"android": texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"),
|
||||
"macos": texts.t("HAPP_PLATFORM_MACOS", "🖥️ Mac OS"),
|
||||
"windows": texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"),
|
||||
}
|
||||
|
||||
link_text = texts.t(
|
||||
"HAPP_DOWNLOAD_LINK_MESSAGE",
|
||||
"⬇️ Скачайте Happ для {platform}:",
|
||||
).format(platform=platform_names.get(platform, platform.upper()))
|
||||
|
||||
keyboard = get_happ_download_link_keyboard(db_user.language, link)
|
||||
|
||||
await callback.message.edit_text(link_text, reply_markup=keyboard, parse_mode="HTML")
|
||||
await callback.answer()
|
||||
|
||||
async def handle_happ_download_close(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
await callback.message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await callback.answer()
|
||||
|
||||
async def handle_happ_download_back(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
prompt_text = texts.t(
|
||||
"HAPP_DOWNLOAD_PROMPT",
|
||||
"📥 <b>Скачать Happ</b>\nВыберите ваше устройство:",
|
||||
)
|
||||
|
||||
keyboard = get_happ_download_platform_keyboard(db_user.language)
|
||||
|
||||
await callback.message.edit_text(prompt_text, reply_markup=keyboard, parse_mode="HTML")
|
||||
await callback.answer()
|
||||
@@ -0,0 +1,354 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Tuple, Optional
|
||||
from urllib.parse import quote
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings, PERIOD_PRICES, get_traffic_prices
|
||||
from app.database.crud.discount_offer import (
|
||||
get_offer_by_id,
|
||||
mark_offer_claimed,
|
||||
)
|
||||
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
|
||||
from app.database.crud.subscription import (
|
||||
create_trial_subscription,
|
||||
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
|
||||
update_subscription_autopay
|
||||
)
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import (
|
||||
User, TransactionType, SubscriptionStatus,
|
||||
Subscription
|
||||
)
|
||||
from app.keyboards.inline import (
|
||||
get_subscription_keyboard, get_trial_keyboard,
|
||||
get_subscription_period_keyboard, get_traffic_packages_keyboard,
|
||||
get_countries_keyboard, get_devices_keyboard,
|
||||
get_subscription_confirm_keyboard, get_autopay_keyboard,
|
||||
get_autopay_days_keyboard, get_back_keyboard,
|
||||
get_add_traffic_keyboard,
|
||||
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
|
||||
get_manage_countries_keyboard,
|
||||
get_device_selection_keyboard, get_connection_guide_keyboard,
|
||||
get_app_selection_keyboard, get_specific_app_keyboard,
|
||||
get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
|
||||
get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
|
||||
get_devices_management_keyboard, get_device_management_help_keyboard,
|
||||
get_happ_cryptolink_keyboard,
|
||||
get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
|
||||
get_happ_download_button_row,
|
||||
get_payment_methods_keyboard_with_cart,
|
||||
get_subscription_confirm_keyboard_with_cart,
|
||||
get_insufficient_balance_keyboard_with_cart
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.subscription_checkout_service import (
|
||||
clear_subscription_checkout_draft,
|
||||
get_subscription_checkout_draft,
|
||||
save_subscription_checkout_draft,
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
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
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.utils.pricing_utils import (
|
||||
calculate_months_from_days,
|
||||
get_remaining_months,
|
||||
calculate_prorated_price,
|
||||
validate_pricing_calculation,
|
||||
format_period_description,
|
||||
apply_percentage_discount,
|
||||
)
|
||||
from app.utils.subscription_utils import (
|
||||
get_display_subscription_link,
|
||||
get_happ_cryptolink_redirect_link,
|
||||
convert_subscription_link_to_happ_scheme,
|
||||
)
|
||||
from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
get_user_active_promo_discount_percent,
|
||||
)
|
||||
|
||||
async def handle_connect_subscription(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
subscription_link = get_display_subscription_link(subscription)
|
||||
hide_subscription_link = settings.should_hide_subscription_link()
|
||||
|
||||
if not subscription_link:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"SUBSCRIPTION_NO_ACTIVE_LINK",
|
||||
"⚠ У вас нет активной подписки или ссылка еще генерируется",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
connect_mode = settings.CONNECT_BUTTON_MODE
|
||||
|
||||
if connect_mode == "miniapp_subscription":
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
web_app=types.WebAppInfo(url=subscription_link)
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||||
]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE",
|
||||
"""📱 <b>Подключить подписку</b>
|
||||
|
||||
🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:""",
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
elif connect_mode == "miniapp_custom":
|
||||
if not settings.MINIAPP_CUSTOM_URL:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"CUSTOM_MINIAPP_URL_NOT_SET",
|
||||
"⚠ Кастомная ссылка для мини-приложения не настроена",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL)
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||||
]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE",
|
||||
"""🚀 <b>Подключить подписку</b>
|
||||
|
||||
📱 Нажмите кнопку ниже, чтобы открыть приложение:""",
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
elif connect_mode == "link":
|
||||
rows = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
url=subscription_link
|
||||
)
|
||||
]
|
||||
]
|
||||
happ_row = get_happ_download_button_row(texts)
|
||||
if happ_row:
|
||||
rows.append(happ_row)
|
||||
rows.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||||
])
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"SUBSCRIPTION_CONNECT_LINK_MESSAGE",
|
||||
"""🚀 <b>Подключить подписку</b>",
|
||||
|
||||
🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:""",
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
elif connect_mode == "happ_cryptolink":
|
||||
rows = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
callback_data="open_subscription_link",
|
||||
)
|
||||
]
|
||||
]
|
||||
happ_row = get_happ_download_button_row(texts)
|
||||
if happ_row:
|
||||
rows.append(happ_row)
|
||||
rows.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||||
])
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"SUBSCRIPTION_CONNECT_LINK_MESSAGE",
|
||||
"""🚀 <b>Подключить подписку</b>",
|
||||
|
||||
🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:""",
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
if hide_subscription_link:
|
||||
device_text = texts.t(
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN",
|
||||
"""📱 <b>Подключить подписку</b>
|
||||
|
||||
ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе "Моя подписка".
|
||||
|
||||
💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:""",
|
||||
)
|
||||
else:
|
||||
device_text = texts.t(
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE",
|
||||
"""📱 <b>Подключить подписку</b>
|
||||
|
||||
🔗 <b>Ссылка подписки:</b>
|
||||
<code>{subscription_url}</code>
|
||||
|
||||
💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:""",
|
||||
).format(subscription_url=subscription_link)
|
||||
|
||||
await callback.message.edit_text(
|
||||
device_text,
|
||||
reply_markup=get_device_selection_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
async def handle_open_subscription_link(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
subscription_link = get_display_subscription_link(subscription)
|
||||
|
||||
if not subscription_link:
|
||||
await callback.answer(
|
||||
texts.t("SUBSCRIPTION_LINK_UNAVAILABLE", "❌ Ссылка подписки недоступна"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if settings.is_happ_cryptolink_mode():
|
||||
redirect_link = get_happ_cryptolink_redirect_link(subscription_link)
|
||||
happ_scheme_link = convert_subscription_link_to_happ_scheme(subscription_link)
|
||||
happ_message = (
|
||||
texts.t(
|
||||
"SUBSCRIPTION_HAPP_OPEN_TITLE",
|
||||
"🔗 <b>Подключение через Happ</b>",
|
||||
)
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_HAPP_OPEN_LINK",
|
||||
"<a href=\"{subscription_link}\">🔓 Открыть ссылку в Happ</a>",
|
||||
).format(subscription_link=happ_scheme_link)
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_HAPP_OPEN_HINT",
|
||||
"💡 Если ссылка не открывается автоматически, скопируйте её вручную:",
|
||||
)
|
||||
)
|
||||
|
||||
if redirect_link:
|
||||
happ_message += "\n\n" + texts.t(
|
||||
"SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT",
|
||||
"▶️ Нажмите кнопку \"Подключиться\" ниже, чтобы открыть Happ и добавить подписку автоматически.",
|
||||
)
|
||||
|
||||
happ_message += "\n\n" + texts.t(
|
||||
"SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK",
|
||||
"<blockquote expandable><code>{crypto_link}</code></blockquote>",
|
||||
).format(crypto_link=subscription_link)
|
||||
|
||||
keyboard = get_happ_cryptolink_keyboard(
|
||||
subscription_link,
|
||||
db_user.language,
|
||||
redirect_link=redirect_link,
|
||||
)
|
||||
|
||||
await callback.message.answer(
|
||||
happ_message,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
link_text = (
|
||||
texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 <b>Ссылка подписки:</b>")
|
||||
+ "\n\n"
|
||||
+ f"<code>{subscription_link}</code>\n\n"
|
||||
+ texts.t("SUBSCRIPTION_LINK_USAGE_TITLE", "📱 <b>Как использовать:</b>")
|
||||
+ "\n"
|
||||
+ "\n".join(
|
||||
[
|
||||
texts.t(
|
||||
"SUBSCRIPTION_LINK_STEP1",
|
||||
"1. Нажмите на ссылку выше чтобы её скопировать",
|
||||
),
|
||||
texts.t(
|
||||
"SUBSCRIPTION_LINK_STEP2",
|
||||
"2. Откройте ваше VPN приложение",
|
||||
),
|
||||
texts.t(
|
||||
"SUBSCRIPTION_LINK_STEP3",
|
||||
"3. Найдите функцию \"Добавить подписку\" или \"Import\"",
|
||||
),
|
||||
texts.t(
|
||||
"SUBSCRIPTION_LINK_STEP4",
|
||||
"4. Вставьте скопированную ссылку",
|
||||
),
|
||||
]
|
||||
)
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_LINK_HINT",
|
||||
"💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.",
|
||||
)
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
link_text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
callback_data="subscription_connect")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||||
]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
@@ -0,0 +1,131 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Tuple, Optional
|
||||
from urllib.parse import quote
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings, PERIOD_PRICES, get_traffic_prices
|
||||
from app.database.crud.discount_offer import (
|
||||
get_offer_by_id,
|
||||
mark_offer_claimed,
|
||||
)
|
||||
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
|
||||
from app.database.crud.subscription import (
|
||||
create_trial_subscription,
|
||||
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
|
||||
update_subscription_autopay
|
||||
)
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import (
|
||||
User, TransactionType, SubscriptionStatus,
|
||||
Subscription
|
||||
)
|
||||
from app.keyboards.inline import (
|
||||
get_subscription_keyboard, get_trial_keyboard,
|
||||
get_subscription_period_keyboard, get_traffic_packages_keyboard,
|
||||
get_countries_keyboard, get_devices_keyboard,
|
||||
get_subscription_confirm_keyboard, get_autopay_keyboard,
|
||||
get_autopay_days_keyboard, get_back_keyboard,
|
||||
get_add_traffic_keyboard,
|
||||
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
|
||||
get_manage_countries_keyboard,
|
||||
get_device_selection_keyboard, get_connection_guide_keyboard,
|
||||
get_app_selection_keyboard, get_specific_app_keyboard,
|
||||
get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
|
||||
get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
|
||||
get_devices_management_keyboard, get_device_management_help_keyboard,
|
||||
get_happ_cryptolink_keyboard,
|
||||
get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
|
||||
get_happ_download_button_row,
|
||||
get_payment_methods_keyboard_with_cart,
|
||||
get_subscription_confirm_keyboard_with_cart,
|
||||
get_insufficient_balance_keyboard_with_cart
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.subscription_checkout_service import (
|
||||
clear_subscription_checkout_draft,
|
||||
get_subscription_checkout_draft,
|
||||
save_subscription_checkout_draft,
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
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
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.utils.pricing_utils import (
|
||||
calculate_months_from_days,
|
||||
get_remaining_months,
|
||||
calculate_prorated_price,
|
||||
validate_pricing_calculation,
|
||||
format_period_description,
|
||||
apply_percentage_discount,
|
||||
)
|
||||
from app.utils.subscription_utils import (
|
||||
get_display_subscription_link,
|
||||
get_happ_cryptolink_redirect_link,
|
||||
convert_subscription_link_to_happ_scheme,
|
||||
)
|
||||
from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
get_user_active_promo_discount_percent,
|
||||
)
|
||||
|
||||
from .common import logger
|
||||
|
||||
async def send_trial_notification(callback: types.CallbackQuery, db: AsyncSession, db_user: User,
|
||||
subscription: Subscription):
|
||||
try:
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_trial_activation_notification(db, db_user, subscription)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о триале: {e}")
|
||||
|
||||
async def send_purchase_notification(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession,
|
||||
db_user: User,
|
||||
subscription: Subscription,
|
||||
transaction_id: int,
|
||||
period_days: int,
|
||||
was_trial_conversion: bool = False
|
||||
):
|
||||
try:
|
||||
from app.database.crud.transaction import get_transaction_by_id
|
||||
|
||||
transaction = await get_transaction_by_id(db, transaction_id)
|
||||
if transaction:
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_purchase_notification(
|
||||
db, db_user, subscription, transaction, period_days, was_trial_conversion
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о покупке: {e}")
|
||||
|
||||
async def send_extension_notification(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession,
|
||||
db_user: User,
|
||||
subscription: Subscription,
|
||||
transaction_id: int,
|
||||
extended_days: int,
|
||||
old_end_date: datetime
|
||||
):
|
||||
try:
|
||||
from app.database.crud.transaction import get_transaction_by_id
|
||||
|
||||
transaction = await get_transaction_by_id(db, transaction_id)
|
||||
if transaction:
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_extension_notification(
|
||||
db, db_user, subscription, transaction, extended_days, old_end_date
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о продлении: {e}")
|
||||
@@ -0,0 +1,518 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Tuple, Optional
|
||||
from urllib.parse import quote
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings, PERIOD_PRICES, get_traffic_prices
|
||||
from app.database.crud.discount_offer import (
|
||||
get_offer_by_id,
|
||||
mark_offer_claimed,
|
||||
)
|
||||
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
|
||||
from app.database.crud.subscription import (
|
||||
create_trial_subscription,
|
||||
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
|
||||
update_subscription_autopay
|
||||
)
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import (
|
||||
User, TransactionType, SubscriptionStatus,
|
||||
Subscription
|
||||
)
|
||||
from app.keyboards.inline import (
|
||||
get_subscription_keyboard, get_trial_keyboard,
|
||||
get_subscription_period_keyboard, get_traffic_packages_keyboard,
|
||||
get_countries_keyboard, get_devices_keyboard,
|
||||
get_subscription_confirm_keyboard, get_autopay_keyboard,
|
||||
get_autopay_days_keyboard, get_back_keyboard,
|
||||
get_add_traffic_keyboard,
|
||||
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
|
||||
get_manage_countries_keyboard,
|
||||
get_device_selection_keyboard, get_connection_guide_keyboard,
|
||||
get_app_selection_keyboard, get_specific_app_keyboard,
|
||||
get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
|
||||
get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
|
||||
get_devices_management_keyboard, get_device_management_help_keyboard,
|
||||
get_happ_cryptolink_keyboard,
|
||||
get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
|
||||
get_happ_download_button_row,
|
||||
get_payment_methods_keyboard_with_cart,
|
||||
get_subscription_confirm_keyboard_with_cart,
|
||||
get_insufficient_balance_keyboard_with_cart
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.subscription_checkout_service import (
|
||||
clear_subscription_checkout_draft,
|
||||
get_subscription_checkout_draft,
|
||||
save_subscription_checkout_draft,
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
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
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.utils.pricing_utils import (
|
||||
calculate_months_from_days,
|
||||
get_remaining_months,
|
||||
calculate_prorated_price,
|
||||
validate_pricing_calculation,
|
||||
format_period_description,
|
||||
apply_percentage_discount,
|
||||
)
|
||||
from app.utils.subscription_utils import (
|
||||
get_display_subscription_link,
|
||||
get_happ_cryptolink_redirect_link,
|
||||
convert_subscription_link_to_happ_scheme,
|
||||
)
|
||||
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
|
||||
from .devices import get_current_devices_count
|
||||
from .promo import _build_promo_group_discount_text, _get_promo_offer_hint
|
||||
|
||||
async def _prepare_subscription_summary(
|
||||
db_user: User,
|
||||
data: Dict[str, Any],
|
||||
texts,
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
summary_data = dict(data)
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
|
||||
months_in_period = calculate_months_from_days(summary_data['period_days'])
|
||||
period_display = format_period_description(summary_data['period_days'], db_user.language)
|
||||
|
||||
base_price_original = PERIOD_PRICES[summary_data['period_days']]
|
||||
period_discount_percent = db_user.get_promo_discount(
|
||||
"period",
|
||||
summary_data['period_days'],
|
||||
)
|
||||
base_price, base_discount_total = apply_percentage_discount(
|
||||
base_price_original,
|
||||
period_discount_percent,
|
||||
)
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_limit = settings.get_fixed_traffic_limit()
|
||||
traffic_price_per_month = settings.get_traffic_price(traffic_limit)
|
||||
final_traffic_gb = traffic_limit
|
||||
else:
|
||||
traffic_gb = summary_data.get('traffic_gb', 0)
|
||||
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
|
||||
final_traffic_gb = traffic_gb
|
||||
|
||||
traffic_discount_percent = db_user.get_promo_discount(
|
||||
"traffic",
|
||||
summary_data['period_days'],
|
||||
)
|
||||
traffic_component = _apply_discount_to_monthly_component(
|
||||
traffic_price_per_month,
|
||||
traffic_discount_percent,
|
||||
months_in_period,
|
||||
)
|
||||
total_traffic_price = traffic_component["total"]
|
||||
|
||||
countries_price_per_month = 0
|
||||
selected_countries_names: List[str] = []
|
||||
selected_server_prices: List[int] = []
|
||||
server_monthly_prices: List[int] = []
|
||||
|
||||
selected_country_ids = set(summary_data.get('countries', []))
|
||||
for country in countries:
|
||||
if country['uuid'] in selected_country_ids:
|
||||
server_price_per_month = country['price_kopeks']
|
||||
countries_price_per_month += server_price_per_month
|
||||
selected_countries_names.append(country['name'])
|
||||
server_monthly_prices.append(server_price_per_month)
|
||||
|
||||
servers_discount_percent = db_user.get_promo_discount(
|
||||
"servers",
|
||||
summary_data['period_days'],
|
||||
)
|
||||
total_countries_price = 0
|
||||
total_servers_discount = 0
|
||||
discounted_servers_price_per_month = 0
|
||||
|
||||
for server_price_per_month in server_monthly_prices:
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
server_price_per_month,
|
||||
servers_discount_percent,
|
||||
)
|
||||
total_price_for_server = discounted_per_month * months_in_period
|
||||
total_discount_for_server = discount_per_month * months_in_period
|
||||
|
||||
discounted_servers_price_per_month += discounted_per_month
|
||||
total_countries_price += total_price_for_server
|
||||
total_servers_discount += total_discount_for_server
|
||||
selected_server_prices.append(total_price_for_server)
|
||||
|
||||
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(
|
||||
"devices",
|
||||
summary_data['period_days'],
|
||||
)
|
||||
devices_component = _apply_discount_to_monthly_component(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
months_in_period,
|
||||
)
|
||||
total_devices_price = devices_component["total"]
|
||||
|
||||
total_price = base_price + total_traffic_price + total_countries_price + total_devices_price
|
||||
|
||||
discounted_monthly_additions = (
|
||||
traffic_component["discounted_per_month"]
|
||||
+ discounted_servers_price_per_month
|
||||
+ devices_component["discounted_per_month"]
|
||||
)
|
||||
|
||||
is_valid = validate_pricing_calculation(
|
||||
base_price,
|
||||
discounted_monthly_additions,
|
||||
months_in_period,
|
||||
total_price,
|
||||
)
|
||||
|
||||
if not is_valid:
|
||||
raise ValueError("Subscription price calculation validation failed")
|
||||
|
||||
original_total_price = total_price
|
||||
promo_offer_component = _apply_promo_offer_discount(db_user, total_price)
|
||||
if promo_offer_component["discount"] > 0:
|
||||
total_price = promo_offer_component["discounted"]
|
||||
|
||||
summary_data['total_price'] = total_price
|
||||
if promo_offer_component["discount"] > 0:
|
||||
summary_data['promo_offer_discount_percent'] = promo_offer_component["percent"]
|
||||
summary_data['promo_offer_discount_value'] = promo_offer_component["discount"]
|
||||
summary_data['total_price_before_promo_offer'] = original_total_price
|
||||
else:
|
||||
summary_data.pop('promo_offer_discount_percent', None)
|
||||
summary_data.pop('promo_offer_discount_value', None)
|
||||
summary_data.pop('total_price_before_promo_offer', None)
|
||||
summary_data['server_prices_for_period'] = selected_server_prices
|
||||
summary_data['months_in_period'] = months_in_period
|
||||
summary_data['base_price'] = base_price
|
||||
summary_data['base_price_original'] = base_price_original
|
||||
summary_data['base_discount_percent'] = period_discount_percent
|
||||
summary_data['base_discount_total'] = base_discount_total
|
||||
summary_data['final_traffic_gb'] = final_traffic_gb
|
||||
summary_data['traffic_price_per_month'] = traffic_price_per_month
|
||||
summary_data['traffic_discount_percent'] = traffic_component["discount_percent"]
|
||||
summary_data['traffic_discount_total'] = traffic_component["discount_total"]
|
||||
summary_data['traffic_discounted_price_per_month'] = traffic_component["discounted_per_month"]
|
||||
summary_data['total_traffic_price'] = total_traffic_price
|
||||
summary_data['servers_price_per_month'] = countries_price_per_month
|
||||
summary_data['countries_price_per_month'] = countries_price_per_month
|
||||
summary_data['servers_discount_percent'] = servers_discount_percent
|
||||
summary_data['servers_discount_total'] = total_servers_discount
|
||||
summary_data['servers_discounted_price_per_month'] = discounted_servers_price_per_month
|
||||
summary_data['total_servers_price'] = total_countries_price
|
||||
summary_data['total_countries_price'] = total_countries_price
|
||||
summary_data['devices_price_per_month'] = devices_price_per_month
|
||||
summary_data['devices_discount_percent'] = devices_component["discount_percent"]
|
||||
summary_data['devices_discount_total'] = devices_component["discount_total"]
|
||||
summary_data['devices_discounted_price_per_month'] = devices_component["discounted_per_month"]
|
||||
summary_data['total_devices_price'] = total_devices_price
|
||||
summary_data['discounted_monthly_additions'] = discounted_monthly_additions
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
if final_traffic_gb == 0:
|
||||
traffic_display = "Безлимитный"
|
||||
else:
|
||||
traffic_display = f"{final_traffic_gb} ГБ"
|
||||
else:
|
||||
if summary_data.get('traffic_gb', 0) == 0:
|
||||
traffic_display = "Безлимитный"
|
||||
else:
|
||||
traffic_display = f"{summary_data.get('traffic_gb', 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)})"
|
||||
)
|
||||
details_lines.append(base_line)
|
||||
elif base_price_original > 0:
|
||||
base_line = f"- Базовый период: {texts.format_price(base_price_original)}"
|
||||
details_lines.append(base_line)
|
||||
|
||||
if total_traffic_price > 0:
|
||||
traffic_line = (
|
||||
f"- Трафик: {texts.format_price(traffic_price_per_month)}/мес × {months_in_period}"
|
||||
f" = {texts.format_price(total_traffic_price)}"
|
||||
)
|
||||
if traffic_component["discount_total"] > 0:
|
||||
traffic_line += (
|
||||
f" (скидка {traffic_component['discount_percent']}%:"
|
||||
f" -{texts.format_price(traffic_component['discount_total'])})"
|
||||
)
|
||||
details_lines.append(traffic_line)
|
||||
if total_countries_price > 0:
|
||||
servers_line = (
|
||||
f"- Серверы: {texts.format_price(countries_price_per_month)}/мес × {months_in_period}"
|
||||
f" = {texts.format_price(total_countries_price)}"
|
||||
)
|
||||
if total_servers_discount > 0:
|
||||
servers_line += (
|
||||
f" (скидка {servers_discount_percent}%:"
|
||||
f" -{texts.format_price(total_servers_discount)})"
|
||||
)
|
||||
details_lines.append(servers_line)
|
||||
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)}"
|
||||
)
|
||||
if devices_component["discount_total"] > 0:
|
||||
devices_line += (
|
||||
f" (скидка {devices_component['discount_percent']}%:"
|
||||
f" -{texts.format_price(devices_component['discount_total'])})"
|
||||
)
|
||||
details_lines.append(devices_line)
|
||||
|
||||
if promo_offer_component["discount"] > 0:
|
||||
details_lines.append(
|
||||
texts.t(
|
||||
"SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT",
|
||||
"- Промо-предложение: -{amount} ({percent}% дополнительно)",
|
||||
).format(
|
||||
amount=texts.format_price(promo_offer_component["discount"]),
|
||||
percent=promo_offer_component["percent"],
|
||||
)
|
||||
)
|
||||
|
||||
details_text = "\n".join(details_lines)
|
||||
|
||||
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
|
||||
|
||||
async def _build_subscription_period_prompt(
|
||||
db_user: User,
|
||||
texts,
|
||||
db: AsyncSession,
|
||||
) -> str:
|
||||
base_text = texts.BUY_SUBSCRIPTION_START.rstrip()
|
||||
|
||||
lines: List[str] = [base_text]
|
||||
|
||||
promo_offer_hint = await _get_promo_offer_hint(db, db_user, texts)
|
||||
if promo_offer_hint:
|
||||
lines.extend(["", promo_offer_hint])
|
||||
|
||||
promo_text = await _build_promo_group_discount_text(
|
||||
db_user,
|
||||
settings.get_available_subscription_periods(),
|
||||
texts=texts,
|
||||
)
|
||||
|
||||
if promo_text:
|
||||
lines.extend(["", promo_text])
|
||||
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
async def get_subscription_cost(subscription, db: AsyncSession) -> int:
|
||||
try:
|
||||
if subscription.is_trial:
|
||||
return 0
|
||||
|
||||
from app.config import settings
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
base_cost_original = PERIOD_PRICES.get(30, 0)
|
||||
try:
|
||||
owner = subscription.user
|
||||
except AttributeError:
|
||||
owner = None
|
||||
|
||||
promo_group_id = getattr(owner, "promo_group_id", None) if owner else None
|
||||
|
||||
period_discount_percent = 0
|
||||
if owner:
|
||||
try:
|
||||
period_discount_percent = owner.get_promo_discount("period", 30)
|
||||
except AttributeError:
|
||||
period_discount_percent = 0
|
||||
|
||||
base_cost, _ = apply_percentage_discount(
|
||||
base_cost_original,
|
||||
period_discount_percent,
|
||||
)
|
||||
|
||||
try:
|
||||
servers_cost, _ = await subscription_service.get_countries_price_by_uuids(
|
||||
subscription.connected_squads,
|
||||
db,
|
||||
promo_group_id=promo_group_id,
|
||||
)
|
||||
except AttributeError:
|
||||
servers_cost, _ = await get_countries_price_by_uuids_fallback(
|
||||
subscription.connected_squads,
|
||||
db,
|
||||
promo_group_id=promo_group_id,
|
||||
)
|
||||
|
||||
traffic_cost = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
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
|
||||
|
||||
logger.info(f"📊 Месячная стоимость конфигурации подписки {subscription.id}:")
|
||||
base_log = f" 📅 Базовый тариф (30 дней): {base_cost_original / 100}₽"
|
||||
if period_discount_percent > 0:
|
||||
discount_value = base_cost_original * period_discount_percent // 100
|
||||
base_log += (
|
||||
f" → {base_cost / 100}₽"
|
||||
f" (скидка {period_discount_percent}%: -{discount_value / 100}₽)"
|
||||
)
|
||||
logger.info(base_log)
|
||||
if servers_cost > 0:
|
||||
logger.info(f" 🌍 Серверы: {servers_cost / 100}₽")
|
||||
if traffic_cost > 0:
|
||||
logger.info(f" 📊 Трафик: {traffic_cost / 100}₽")
|
||||
if devices_cost > 0:
|
||||
logger.info(f" 📱 Устройства: {devices_cost / 100}₽")
|
||||
logger.info(f" 💎 ИТОГО: {total_cost / 100}₽")
|
||||
|
||||
return total_cost
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"⚠️ Ошибка расчета стоимости подписки: {e}")
|
||||
return 0
|
||||
|
||||
async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSession):
|
||||
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 "Нет"
|
||||
|
||||
subscription_url = getattr(subscription, 'subscription_url', None) or "Генерируется..."
|
||||
|
||||
if subscription.is_trial:
|
||||
status_text = "🎁 Тестовая"
|
||||
type_text = "Триал"
|
||||
else:
|
||||
if subscription.is_active:
|
||||
status_text = "✅ Оплачена"
|
||||
else:
|
||||
status_text = "⌛ Истекла"
|
||||
type_text = "Платная подписка"
|
||||
|
||||
if subscription.traffic_limit_gb == 0:
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_text = "∞ Безлимитный"
|
||||
else:
|
||||
traffic_text = "∞ Безлимитный"
|
||||
else:
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_text = f"{subscription.traffic_limit_gb} ГБ"
|
||||
else:
|
||||
traffic_text = f"{subscription.traffic_limit_gb} ГБ"
|
||||
|
||||
subscription_cost = await get_subscription_cost(subscription, db)
|
||||
|
||||
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=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,
|
||||
countries_count=len(subscription.connected_squads),
|
||||
devices_used=devices_used,
|
||||
devices_limit=subscription.device_limit,
|
||||
autopay_status="✅ Включен" if subscription.autopay_enabled else "⌛ Выключен"
|
||||
)
|
||||
|
||||
if subscription_cost > 0:
|
||||
info_text += f"\n💰 <b>Стоимость подписки в месяц:</b> {texts.format_price(subscription_cost)}"
|
||||
|
||||
if (
|
||||
subscription_url
|
||||
and subscription_url != "Генерируется..."
|
||||
and not settings.should_hide_subscription_link()
|
||||
):
|
||||
info_text += f"\n\n🔗 <b>Ваша ссылка для импорта в VPN приложениe:</b>\n<code>{subscription_url}</code>"
|
||||
|
||||
return info_text
|
||||
@@ -0,0 +1,459 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Tuple, Optional
|
||||
from urllib.parse import quote
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings, PERIOD_PRICES, get_traffic_prices
|
||||
from app.database.crud.discount_offer import (
|
||||
get_offer_by_id,
|
||||
mark_offer_claimed,
|
||||
)
|
||||
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
|
||||
from app.database.crud.subscription import (
|
||||
create_trial_subscription,
|
||||
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
|
||||
update_subscription_autopay
|
||||
)
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import (
|
||||
User, TransactionType, SubscriptionStatus,
|
||||
Subscription
|
||||
)
|
||||
from app.keyboards.inline import (
|
||||
get_subscription_keyboard, get_trial_keyboard,
|
||||
get_subscription_period_keyboard, get_traffic_packages_keyboard,
|
||||
get_countries_keyboard, get_devices_keyboard,
|
||||
get_subscription_confirm_keyboard, get_autopay_keyboard,
|
||||
get_autopay_days_keyboard, get_back_keyboard,
|
||||
get_add_traffic_keyboard,
|
||||
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
|
||||
get_manage_countries_keyboard,
|
||||
get_device_selection_keyboard, get_connection_guide_keyboard,
|
||||
get_app_selection_keyboard, get_specific_app_keyboard,
|
||||
get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
|
||||
get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
|
||||
get_devices_management_keyboard, get_device_management_help_keyboard,
|
||||
get_happ_cryptolink_keyboard,
|
||||
get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
|
||||
get_happ_download_button_row,
|
||||
get_payment_methods_keyboard_with_cart,
|
||||
get_subscription_confirm_keyboard_with_cart,
|
||||
get_insufficient_balance_keyboard_with_cart
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.subscription_checkout_service import (
|
||||
clear_subscription_checkout_draft,
|
||||
get_subscription_checkout_draft,
|
||||
save_subscription_checkout_draft,
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
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
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.utils.pricing_utils import (
|
||||
calculate_months_from_days,
|
||||
get_remaining_months,
|
||||
calculate_prorated_price,
|
||||
validate_pricing_calculation,
|
||||
format_period_description,
|
||||
apply_percentage_discount,
|
||||
)
|
||||
from app.utils.subscription_utils import (
|
||||
get_display_subscription_link,
|
||||
get_happ_cryptolink_redirect_link,
|
||||
convert_subscription_link_to_happ_scheme,
|
||||
)
|
||||
from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
get_user_active_promo_discount_percent,
|
||||
)
|
||||
|
||||
from .common import _format_text_with_placeholders
|
||||
|
||||
async def _get_promo_offer_hint(
|
||||
db: AsyncSession,
|
||||
db_user: User,
|
||||
texts,
|
||||
percent: Optional[int] = None,
|
||||
) -> Optional[str]:
|
||||
return await build_promo_offer_hint(db, db_user, texts, percent)
|
||||
|
||||
async def _build_promo_group_discount_text(
|
||||
db_user: User,
|
||||
periods: Optional[List[int]] = None,
|
||||
texts=None,
|
||||
) -> str:
|
||||
promo_group = db_user.get_primary_promo_group()
|
||||
|
||||
if not promo_group:
|
||||
return ""
|
||||
|
||||
if texts is None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
service_lines: List[str] = []
|
||||
|
||||
if promo_group.server_discount_percent > 0:
|
||||
service_lines.append(
|
||||
texts.PROMO_GROUP_DISCOUNT_SERVERS.format(
|
||||
percent=promo_group.server_discount_percent
|
||||
)
|
||||
)
|
||||
|
||||
if promo_group.traffic_discount_percent > 0:
|
||||
service_lines.append(
|
||||
texts.PROMO_GROUP_DISCOUNT_TRAFFIC.format(
|
||||
percent=promo_group.traffic_discount_percent
|
||||
)
|
||||
)
|
||||
|
||||
if promo_group.device_discount_percent > 0:
|
||||
service_lines.append(
|
||||
texts.PROMO_GROUP_DISCOUNT_DEVICES.format(
|
||||
percent=promo_group.device_discount_percent
|
||||
)
|
||||
)
|
||||
|
||||
period_lines: List[str] = []
|
||||
|
||||
period_candidates: set[int] = set(periods or [])
|
||||
|
||||
raw_period_discounts = getattr(promo_group, "period_discounts", None)
|
||||
if isinstance(raw_period_discounts, dict):
|
||||
for key in raw_period_discounts.keys():
|
||||
try:
|
||||
period_candidates.add(int(key))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
for period_days in sorted(period_candidates):
|
||||
percent = promo_group.get_discount_percent("period", period_days)
|
||||
|
||||
if percent <= 0:
|
||||
continue
|
||||
|
||||
period_display = format_period_description(period_days, db_user.language)
|
||||
period_lines.append(
|
||||
texts.PROMO_GROUP_PERIOD_DISCOUNT_ITEM.format(
|
||||
period=period_display,
|
||||
percent=percent,
|
||||
)
|
||||
)
|
||||
|
||||
if not service_lines and not period_lines:
|
||||
return ""
|
||||
|
||||
lines: List[str] = [texts.PROMO_GROUP_DISCOUNTS_HEADER]
|
||||
|
||||
if service_lines:
|
||||
lines.extend(service_lines)
|
||||
|
||||
if period_lines:
|
||||
if service_lines:
|
||||
lines.append("")
|
||||
|
||||
lines.append(texts.PROMO_GROUP_PERIOD_DISCOUNTS_HEADER)
|
||||
lines.extend(period_lines)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
async def claim_discount_offer(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
try:
|
||||
offer_id = int(callback.data.split("_")[-1])
|
||||
except (ValueError, AttributeError):
|
||||
await callback.answer(
|
||||
texts.get("DISCOUNT_CLAIM_NOT_FOUND", "❌ Предложение не найдено"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
offer = await get_offer_by_id(db, offer_id)
|
||||
if not offer or offer.user_id != db_user.id:
|
||||
await callback.answer(
|
||||
texts.get("DISCOUNT_CLAIM_NOT_FOUND", "❌ Предложение не найдено"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
now = datetime.utcnow()
|
||||
if offer.claimed_at is not None:
|
||||
await callback.answer(
|
||||
texts.get("DISCOUNT_CLAIM_ALREADY", "ℹ️ Скидка уже была активирована"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if not offer.is_active or offer.expires_at <= now:
|
||||
offer.is_active = False
|
||||
await db.commit()
|
||||
await callback.answer(
|
||||
texts.get("DISCOUNT_CLAIM_EXPIRED", "⚠️ Время действия предложения истекло"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
effect_type = (offer.effect_type or "percent_discount").lower()
|
||||
if effect_type == "balance_bonus":
|
||||
effect_type = "percent_discount"
|
||||
|
||||
if effect_type == "test_access":
|
||||
success, newly_added, expires_at, error_code = await promo_offer_service.grant_test_access(
|
||||
db,
|
||||
db_user,
|
||||
offer,
|
||||
)
|
||||
|
||||
if not success:
|
||||
if error_code == "subscription_missing":
|
||||
error_message = texts.get(
|
||||
"TEST_ACCESS_NO_SUBSCRIPTION",
|
||||
"❌ Для активации предложения необходима действующая подписка.",
|
||||
)
|
||||
elif error_code == "squads_missing":
|
||||
error_message = texts.get(
|
||||
"TEST_ACCESS_NO_SQUADS",
|
||||
"❌ Не удалось определить список серверов для теста. Обратитесь к администратору.",
|
||||
)
|
||||
elif error_code == "already_connected":
|
||||
error_message = texts.get(
|
||||
"TEST_ACCESS_ALREADY_CONNECTED",
|
||||
"ℹ️ Этот сервер уже подключен к вашей подписке.",
|
||||
)
|
||||
elif error_code == "remnawave_sync_failed":
|
||||
error_message = texts.get(
|
||||
"TEST_ACCESS_REMNAWAVE_ERROR",
|
||||
"❌ Не удалось подключить серверы. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
else:
|
||||
error_message = texts.get(
|
||||
"TEST_ACCESS_UNKNOWN_ERROR",
|
||||
"❌ Не удалось активировать предложение. Попробуйте позже.",
|
||||
)
|
||||
await callback.answer(error_message, show_alert=True)
|
||||
return
|
||||
|
||||
await mark_offer_claimed(
|
||||
db,
|
||||
offer,
|
||||
details={
|
||||
"context": "test_access_claim",
|
||||
"new_squads": newly_added,
|
||||
"expires_at": expires_at.isoformat() if expires_at else None,
|
||||
},
|
||||
)
|
||||
|
||||
expires_text = expires_at.strftime("%d.%m.%Y %H:%M") if expires_at else ""
|
||||
success_message = texts.get(
|
||||
"TEST_ACCESS_ACTIVATED_MESSAGE",
|
||||
"🎉 Тестовые сервера подключены! Доступ активен до {expires_at}.",
|
||||
).format(expires_at=expires_text)
|
||||
|
||||
popup_text = texts.get("TEST_ACCESS_ACTIVATED_POPUP", "✅ Доступ выдан!")
|
||||
await callback.answer(popup_text, show_alert=True)
|
||||
back_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.get("BACK_TO_MENU", "🏠 В главное меню"),
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
await callback.message.answer(success_message, reply_markup=back_keyboard)
|
||||
return
|
||||
|
||||
discount_percent = int(offer.discount_percent or 0)
|
||||
if discount_percent <= 0:
|
||||
await callback.answer(
|
||||
texts.get("DISCOUNT_CLAIM_ERROR", "❌ Не удалось активировать скидку. Попробуйте позже."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
db_user.promo_offer_discount_percent = discount_percent
|
||||
db_user.promo_offer_discount_source = offer.notification_type
|
||||
db_user.updated_at = now
|
||||
|
||||
extra_data = offer.extra_data or {}
|
||||
raw_duration = extra_data.get("active_discount_hours")
|
||||
template_id = extra_data.get("template_id")
|
||||
|
||||
if raw_duration in (None, "") and template_id:
|
||||
try:
|
||||
template = await get_promo_offer_template_by_id(db, int(template_id))
|
||||
except (ValueError, TypeError):
|
||||
template = None
|
||||
if template and template.active_discount_hours:
|
||||
raw_duration = template.active_discount_hours
|
||||
|
||||
try:
|
||||
duration_hours = int(raw_duration) if raw_duration is not None else None
|
||||
except (TypeError, ValueError):
|
||||
duration_hours = None
|
||||
|
||||
if duration_hours and duration_hours > 0:
|
||||
discount_expires_at = now + timedelta(hours=duration_hours)
|
||||
else:
|
||||
discount_expires_at = None
|
||||
|
||||
db_user.promo_offer_discount_expires_at = discount_expires_at
|
||||
|
||||
await mark_offer_claimed(
|
||||
db,
|
||||
offer,
|
||||
details={
|
||||
"context": "discount_claim",
|
||||
"discount_percent": discount_percent,
|
||||
"discount_expires_at": discount_expires_at.isoformat() if discount_expires_at else None,
|
||||
},
|
||||
)
|
||||
await db.refresh(db_user)
|
||||
|
||||
success_template = texts.get(
|
||||
"DISCOUNT_CLAIM_SUCCESS",
|
||||
"🎉 Скидка {percent}% активирована! Она автоматически применится при следующей оплате.",
|
||||
)
|
||||
|
||||
expires_text = (
|
||||
discount_expires_at.strftime("%d.%m.%Y %H:%M") if discount_expires_at else ""
|
||||
)
|
||||
|
||||
format_values: Dict[str, Any] = {"percent": discount_percent}
|
||||
|
||||
if duration_hours and duration_hours > 0:
|
||||
format_values.setdefault("hours", duration_hours)
|
||||
format_values.setdefault("duration_hours", duration_hours)
|
||||
|
||||
if discount_expires_at:
|
||||
format_values.setdefault("expires_at", expires_text)
|
||||
format_values.setdefault("expires_at_iso", discount_expires_at.isoformat())
|
||||
try:
|
||||
expires_timestamp = int(discount_expires_at.timestamp())
|
||||
except (OverflowError, OSError, ValueError):
|
||||
expires_timestamp = None
|
||||
if expires_timestamp:
|
||||
format_values.setdefault("expires_at_ts", expires_timestamp)
|
||||
remaining_hours = int((discount_expires_at - now).total_seconds() // 3600)
|
||||
if remaining_hours > 0:
|
||||
format_values.setdefault("expires_in_hours", remaining_hours)
|
||||
|
||||
amount_text = ""
|
||||
if isinstance(extra_data, dict):
|
||||
raw_amount_text = (
|
||||
extra_data.get("amount_text")
|
||||
or extra_data.get("discount_amount_text")
|
||||
or extra_data.get("formatted_amount")
|
||||
)
|
||||
if isinstance(raw_amount_text, str) and raw_amount_text.strip():
|
||||
amount_text = raw_amount_text.strip()
|
||||
else:
|
||||
raw_amount = extra_data.get("amount") or extra_data.get("discount_amount")
|
||||
if isinstance(raw_amount, (int, float)):
|
||||
amount_text = settings.format_price(int(raw_amount))
|
||||
elif isinstance(raw_amount, str) and raw_amount.strip():
|
||||
amount_text = raw_amount.strip()
|
||||
|
||||
if not amount_text:
|
||||
for key in ("discount_amount_kopeks", "amount_kopeks", "bonus_amount_kopeks"):
|
||||
maybe_amount = extra_data.get(key)
|
||||
try:
|
||||
amount_value = int(maybe_amount)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if amount_value > 0:
|
||||
amount_text = settings.format_price(amount_value)
|
||||
break
|
||||
|
||||
for key, value in extra_data.items():
|
||||
if (
|
||||
isinstance(key, str)
|
||||
and key.isidentifier()
|
||||
and key not in format_values
|
||||
and isinstance(value, (str, int, float))
|
||||
):
|
||||
format_values[key] = value
|
||||
|
||||
if not amount_text:
|
||||
try:
|
||||
bonus_amount = int(getattr(offer, "bonus_amount_kopeks", 0))
|
||||
except (TypeError, ValueError):
|
||||
bonus_amount = 0
|
||||
if bonus_amount > 0:
|
||||
amount_text = settings.format_price(bonus_amount)
|
||||
|
||||
if amount_text:
|
||||
format_values.setdefault("amount", amount_text)
|
||||
|
||||
success_message = _format_text_with_placeholders(success_template, format_values)
|
||||
|
||||
await callback.answer("✅ Скидка активирована!", show_alert=True)
|
||||
|
||||
offer_type = None
|
||||
if isinstance(extra_data, dict):
|
||||
offer_type = extra_data.get("offer_type")
|
||||
|
||||
subscription = getattr(db_user, "subscription", None)
|
||||
|
||||
if offer_type == "purchase_discount":
|
||||
button_text = texts.get("MENU_BUY_SUBSCRIPTION", "💎 Купить подписку")
|
||||
button_callback = "subscription_upgrade"
|
||||
elif offer_type == "extend_discount":
|
||||
button_text = texts.get("SUBSCRIPTION_EXTEND", "💎 Продлить подписку")
|
||||
button_callback = "subscription_extend"
|
||||
else:
|
||||
has_active_paid_subscription = bool(
|
||||
subscription
|
||||
and getattr(subscription, "is_active", False)
|
||||
and not getattr(subscription, "is_trial", False)
|
||||
)
|
||||
|
||||
if has_active_paid_subscription:
|
||||
button_text = texts.get("SUBSCRIPTION_EXTEND", "💎 Продлить подписку")
|
||||
button_callback = "subscription_extend"
|
||||
else:
|
||||
button_text = texts.get("MENU_BUY_SUBSCRIPTION", "💎 Купить подписку")
|
||||
button_callback = "subscription_upgrade"
|
||||
|
||||
buy_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
build_miniapp_or_callback_button(
|
||||
text=button_text,
|
||||
callback_data=button_callback,
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
await callback.message.answer(success_message, reply_markup=buy_keyboard)
|
||||
|
||||
async def handle_promo_offer_close(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
await callback.message.delete()
|
||||
except Exception:
|
||||
try:
|
||||
await callback.message.edit_reply_markup()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await callback.answer()
|
||||
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
|
||||
@@ -0,0 +1,741 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Tuple, Optional
|
||||
from urllib.parse import quote
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings, PERIOD_PRICES, get_traffic_prices
|
||||
from app.database.crud.discount_offer import (
|
||||
get_offer_by_id,
|
||||
mark_offer_claimed,
|
||||
)
|
||||
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
|
||||
from app.database.crud.subscription import (
|
||||
create_trial_subscription,
|
||||
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
|
||||
update_subscription_autopay
|
||||
)
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import (
|
||||
User, TransactionType, SubscriptionStatus,
|
||||
Subscription
|
||||
)
|
||||
from app.keyboards.inline import (
|
||||
get_subscription_keyboard, get_trial_keyboard,
|
||||
get_subscription_period_keyboard, get_traffic_packages_keyboard,
|
||||
get_countries_keyboard, get_devices_keyboard,
|
||||
get_subscription_confirm_keyboard, get_autopay_keyboard,
|
||||
get_autopay_days_keyboard, get_back_keyboard,
|
||||
get_add_traffic_keyboard,
|
||||
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
|
||||
get_manage_countries_keyboard,
|
||||
get_device_selection_keyboard, get_connection_guide_keyboard,
|
||||
get_app_selection_keyboard, get_specific_app_keyboard,
|
||||
get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
|
||||
get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
|
||||
get_devices_management_keyboard, get_device_management_help_keyboard,
|
||||
get_happ_cryptolink_keyboard,
|
||||
get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
|
||||
get_happ_download_button_row,
|
||||
get_payment_methods_keyboard_with_cart,
|
||||
get_subscription_confirm_keyboard_with_cart,
|
||||
get_insufficient_balance_keyboard_with_cart
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.subscription_checkout_service import (
|
||||
clear_subscription_checkout_draft,
|
||||
get_subscription_checkout_draft,
|
||||
save_subscription_checkout_draft,
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
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
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.utils.pricing_utils import (
|
||||
calculate_months_from_days,
|
||||
get_remaining_months,
|
||||
calculate_prorated_price,
|
||||
validate_pricing_calculation,
|
||||
format_period_description,
|
||||
apply_percentage_discount,
|
||||
)
|
||||
from app.utils.subscription_utils import (
|
||||
get_display_subscription_link,
|
||||
get_happ_cryptolink_redirect_link,
|
||||
convert_subscription_link_to_happ_scheme,
|
||||
)
|
||||
from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
get_user_active_promo_discount_percent,
|
||||
)
|
||||
|
||||
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,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.config import settings
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"TRAFFIC_FIXED_MODE",
|
||||
"⚠️ В текущем режиме трафик фиксированный и не может быть изменен",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
subscription = db_user.subscription
|
||||
|
||||
if not subscription or subscription.is_trial:
|
||||
await callback.answer(
|
||||
texts.t("PAID_FEATURE_ONLY", "⚠ Эта функция доступна только для платных подписок"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if subscription.traffic_limit_gb == 0:
|
||||
await callback.answer(
|
||||
texts.t("TRAFFIC_ALREADY_UNLIMITED", "⚠ У вас уже безлимитный трафик"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
current_traffic = subscription.traffic_limit_gb
|
||||
period_hint_days = _get_period_hint_from_subscription(subscription)
|
||||
traffic_discount_percent = _get_addon_discount_percent_for_user(
|
||||
db_user,
|
||||
"traffic",
|
||||
period_hint_days,
|
||||
)
|
||||
|
||||
prompt_text = texts.t(
|
||||
"ADD_TRAFFIC_PROMPT",
|
||||
(
|
||||
"📈 <b>Добавить трафик к подписке</b>\n\n"
|
||||
"Текущий лимит: {current_traffic}\n"
|
||||
"Выберите дополнительный трафик:"
|
||||
),
|
||||
).format(current_traffic=texts.format_traffic(current_traffic))
|
||||
|
||||
await callback.message.edit_text(
|
||||
prompt_text,
|
||||
reply_markup=get_add_traffic_keyboard(
|
||||
db_user.language,
|
||||
subscription.end_date,
|
||||
traffic_discount_percent,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
async def handle_reset_traffic(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.config import settings
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
await callback.answer("⚠️ В текущем режиме трафик фиксированный и не может быть сброшен", show_alert=True)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
if not subscription or subscription.is_trial:
|
||||
await callback.answer("⌛ Эта функция доступна только для платных подписок", show_alert=True)
|
||||
return
|
||||
|
||||
if subscription.traffic_limit_gb == 0:
|
||||
await callback.answer("⌛ У вас безлимитный трафик", show_alert=True)
|
||||
return
|
||||
|
||||
reset_price = PERIOD_PRICES[30]
|
||||
|
||||
if db_user.balance_kopeks < reset_price:
|
||||
await callback.answer("⌛ Недостаточно средств на балансе", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🔄 <b>Сброс трафика</b>\n\n"
|
||||
f"Использовано: {texts.format_traffic(subscription.traffic_used_gb)}\n"
|
||||
f"Лимит: {texts.format_traffic(subscription.traffic_limit_gb)}\n\n"
|
||||
f"Стоимость сброса: {texts.format_price(reset_price)}\n\n"
|
||||
"После сброса счетчик использованного трафика станет равным 0.",
|
||||
reply_markup=get_reset_traffic_confirm_keyboard(reset_price, db_user.language)
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
async def confirm_reset_traffic(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.config import settings
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
reset_price = PERIOD_PRICES[30]
|
||||
|
||||
if db_user.balance_kopeks < reset_price:
|
||||
missing_kopeks = reset_price - db_user.balance_kopeks
|
||||
message_text = texts.t(
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE",
|
||||
(
|
||||
"⚠️ <b>Недостаточно средств</b>\n\n"
|
||||
"Стоимость услуги: {required}\n"
|
||||
"На балансе: {balance}\n"
|
||||
"Не хватает: {missing}\n\n"
|
||||
"Выберите способ пополнения. Сумма подставится автоматически."
|
||||
),
|
||||
).format(
|
||||
required=texts.format_price(reset_price),
|
||||
balance=texts.format_price(db_user.balance_kopeks),
|
||||
missing=texts.format_price(missing_kopeks),
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=get_insufficient_balance_keyboard(
|
||||
db_user.language,
|
||||
amount_kopeks=missing_kopeks,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
try:
|
||||
success = await subtract_user_balance(
|
||||
db, db_user, reset_price,
|
||||
"Сброс трафика"
|
||||
)
|
||||
|
||||
if not success:
|
||||
await callback.answer("⌛ Ошибка списания средств", show_alert=True)
|
||||
return
|
||||
|
||||
subscription.traffic_used_gb = 0.0
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
remnawave_service = RemnaWaveService()
|
||||
|
||||
user = db_user
|
||||
if user.remnawave_uuid:
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
await api.reset_user_traffic(user.remnawave_uuid)
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=reset_price,
|
||||
description="Сброс трафика"
|
||||
)
|
||||
|
||||
await db.refresh(db_user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Трафик успешно сброшен!\n\n"
|
||||
f"🔄 Использованный трафик обнулен\n"
|
||||
f"📊 Лимит: {texts.format_traffic(subscription.traffic_limit_gb)}",
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
logger.info(f"✅ Пользователь {db_user.telegram_id} сбросил трафик")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка сброса трафика: {e}")
|
||||
await callback.message.edit_text(
|
||||
texts.ERROR,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
async def refresh_traffic_config():
|
||||
try:
|
||||
from app.config import refresh_traffic_prices
|
||||
refresh_traffic_prices()
|
||||
|
||||
packages = settings.get_traffic_packages()
|
||||
enabled_count = sum(1 for pkg in packages if pkg['enabled'])
|
||||
|
||||
logger.info(f"🔄 Конфигурация трафика обновлена: {enabled_count} активных пакетов")
|
||||
for pkg in packages:
|
||||
if pkg['enabled']:
|
||||
gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ"
|
||||
logger.info(f" 📦 {gb_text}: {pkg['price'] / 100}₽")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"⚠️ Ошибка обновления конфигурации трафика: {e}")
|
||||
return False
|
||||
|
||||
async def get_traffic_packages_info() -> str:
|
||||
try:
|
||||
packages = settings.get_traffic_packages()
|
||||
|
||||
info_lines = ["📦 Настроенные пакеты трафика:"]
|
||||
|
||||
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
|
||||
disabled_packages = [pkg for pkg in packages if not pkg['enabled']]
|
||||
|
||||
if enabled_packages:
|
||||
info_lines.append("\n✅ Активные:")
|
||||
for pkg in enabled_packages:
|
||||
gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ"
|
||||
info_lines.append(f" • {gb_text}: {pkg['price'] // 100}₽")
|
||||
|
||||
if disabled_packages:
|
||||
info_lines.append("\n❌ Отключенные:")
|
||||
for pkg in disabled_packages:
|
||||
gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ"
|
||||
info_lines.append(f" • {gb_text}: {pkg['price'] // 100}₽")
|
||||
|
||||
info_lines.append(f"\n📊 Всего пакетов: {len(packages)}")
|
||||
info_lines.append(f"🟢 Активных: {len(enabled_packages)}")
|
||||
info_lines.append(f"🔴 Отключенных: {len(disabled_packages)}")
|
||||
|
||||
return "\n".join(info_lines)
|
||||
|
||||
except Exception as e:
|
||||
return f"⚠️ Ошибка получения информации: {e}"
|
||||
|
||||
async def select_traffic(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User
|
||||
):
|
||||
traffic_gb = int(callback.data.split('_')[1])
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
data = await state.get_data()
|
||||
data['traffic_gb'] = traffic_gb
|
||||
|
||||
traffic_price = settings.get_traffic_price(traffic_gb)
|
||||
data['total_price'] += traffic_price
|
||||
|
||||
await state.set_data(data)
|
||||
|
||||
if await _should_show_countries_management(db_user):
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_COUNTRIES,
|
||||
reply_markup=get_countries_keyboard(countries, [], db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_countries)
|
||||
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(
|
||||
texts.SELECT_DEVICES,
|
||||
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_devices)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
if await present_subscription_summary(callback, state, db_user, texts):
|
||||
await callback.answer()
|
||||
|
||||
async def add_traffic(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
if settings.is_traffic_fixed():
|
||||
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
|
||||
return
|
||||
|
||||
traffic_gb = int(callback.data.split('_')[2])
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
base_price = settings.get_traffic_price(traffic_gb)
|
||||
|
||||
if base_price == 0 and traffic_gb != 0:
|
||||
await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True)
|
||||
return
|
||||
|
||||
period_hint_days = _get_period_hint_from_subscription(subscription)
|
||||
discount_result = _apply_addon_discount(
|
||||
db_user,
|
||||
"traffic",
|
||||
base_price,
|
||||
period_hint_days,
|
||||
)
|
||||
|
||||
discounted_per_month = discount_result["discounted"]
|
||||
discount_per_month = discount_result["discount"]
|
||||
charged_months = 1
|
||||
|
||||
if subscription:
|
||||
price, charged_months = calculate_prorated_price(
|
||||
discounted_per_month,
|
||||
subscription.end_date,
|
||||
)
|
||||
else:
|
||||
price = discounted_per_month
|
||||
|
||||
total_discount_value = discount_per_month * charged_months
|
||||
|
||||
if db_user.balance_kopeks < price:
|
||||
missing_kopeks = price - db_user.balance_kopeks
|
||||
message_text = texts.t(
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE",
|
||||
(
|
||||
"⚠️ <b>Недостаточно средств</b>\n\n"
|
||||
"Стоимость услуги: {required}\n"
|
||||
"На балансе: {balance}\n"
|
||||
"Не хватает: {missing}\n\n"
|
||||
"Выберите способ пополнения. Сумма подставится автоматически."
|
||||
),
|
||||
).format(
|
||||
required=texts.format_price(price),
|
||||
balance=texts.format_price(db_user.balance_kopeks),
|
||||
missing=texts.format_price(missing_kopeks),
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=get_insufficient_balance_keyboard(
|
||||
db_user.language,
|
||||
amount_kopeks=missing_kopeks,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
try:
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
db_user,
|
||||
price,
|
||||
f"Добавление {traffic_gb} ГБ трафика",
|
||||
)
|
||||
|
||||
if not success:
|
||||
await callback.answer("⚠️ Ошибка списания средств", show_alert=True)
|
||||
return
|
||||
|
||||
if traffic_gb == 0:
|
||||
subscription.traffic_limit_gb = 0
|
||||
else:
|
||||
await add_subscription_traffic(db, subscription, traffic_gb)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=price,
|
||||
description=f"Добавление {traffic_gb} ГБ трафика",
|
||||
)
|
||||
|
||||
await db.refresh(db_user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
success_text = f"✅ Трафик успешно добавлен!\n\n"
|
||||
if traffic_gb == 0:
|
||||
success_text += "🎉 Теперь у вас безлимитный трафик!"
|
||||
else:
|
||||
success_text += f"📈 Добавлено: {traffic_gb} ГБ\n"
|
||||
success_text += f"Новый лимит: {texts.format_traffic(subscription.traffic_limit_gb)}"
|
||||
|
||||
if price > 0:
|
||||
success_text += f"\n💰 Списано: {texts.format_price(price)}"
|
||||
if total_discount_value > 0:
|
||||
success_text += (
|
||||
f" (скидка {discount_result['percent']}%:"
|
||||
f" -{texts.format_price(total_discount_value)})"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
success_text,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
logger.info(f"✅ Пользователь {db_user.telegram_id} добавил {traffic_gb} ГБ трафика")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления трафика: {e}")
|
||||
await callback.message.edit_text(
|
||||
texts.ERROR,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
async def handle_no_traffic_packages(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
await callback.answer(
|
||||
"⚠️ В данный момент нет доступных пакетов трафика. "
|
||||
"Обратитесь в техподдержку для получения информации.",
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
async def handle_switch_traffic(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.config import settings
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
if not subscription or subscription.is_trial:
|
||||
await callback.answer("⚠️ Эта функция доступна только для платных подписок", show_alert=True)
|
||||
return
|
||||
|
||||
current_traffic = subscription.traffic_limit_gb
|
||||
period_hint_days = _get_period_hint_from_subscription(subscription)
|
||||
traffic_discount_percent = _get_addon_discount_percent_for_user(
|
||||
db_user,
|
||||
"traffic",
|
||||
period_hint_days,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🔄 <b>Переключение лимита трафика</b>\n\n"
|
||||
f"Текущий лимит: {texts.format_traffic(current_traffic)}\n"
|
||||
f"Выберите новый лимит трафика:\n\n"
|
||||
f"💡 <b>Важно:</b>\n"
|
||||
f"• При увеличении - доплата за разницу\n"
|
||||
f"• При уменьшении - возврат средств не производится",
|
||||
reply_markup=get_traffic_switch_keyboard(
|
||||
current_traffic,
|
||||
db_user.language,
|
||||
subscription.end_date,
|
||||
traffic_discount_percent,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
async def confirm_switch_traffic(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
new_traffic_gb = int(callback.data.split('_')[2])
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
current_traffic = subscription.traffic_limit_gb
|
||||
|
||||
if new_traffic_gb == current_traffic:
|
||||
await callback.answer("ℹ️ Лимит трафика не изменился", show_alert=True)
|
||||
return
|
||||
|
||||
old_price_per_month = settings.get_traffic_price(current_traffic)
|
||||
new_price_per_month = settings.get_traffic_price(new_traffic_gb)
|
||||
|
||||
months_remaining = get_remaining_months(subscription.end_date)
|
||||
period_hint_days = months_remaining * 30 if months_remaining > 0 else None
|
||||
traffic_discount_percent = _get_addon_discount_percent_for_user(
|
||||
db_user,
|
||||
"traffic",
|
||||
period_hint_days,
|
||||
)
|
||||
|
||||
discounted_old_per_month, _ = apply_percentage_discount(
|
||||
old_price_per_month,
|
||||
traffic_discount_percent,
|
||||
)
|
||||
discounted_new_per_month, _ = apply_percentage_discount(
|
||||
new_price_per_month,
|
||||
traffic_discount_percent,
|
||||
)
|
||||
price_difference_per_month = discounted_new_per_month - discounted_old_per_month
|
||||
discount_savings_per_month = (
|
||||
(new_price_per_month - old_price_per_month) - price_difference_per_month
|
||||
)
|
||||
|
||||
if price_difference_per_month > 0:
|
||||
total_price_difference = price_difference_per_month * months_remaining
|
||||
|
||||
if db_user.balance_kopeks < total_price_difference:
|
||||
missing_kopeks = total_price_difference - db_user.balance_kopeks
|
||||
message_text = texts.t(
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE",
|
||||
(
|
||||
"⚠️ <b>Недостаточно средств</b>\n\n"
|
||||
"Стоимость услуги: {required}\n"
|
||||
"На балансе: {balance}\n"
|
||||
"Не хватает: {missing}\n\n"
|
||||
"Выберите способ пополнения. Сумма подставится автоматически."
|
||||
),
|
||||
).format(
|
||||
required=f"{texts.format_price(total_price_difference)} (за {months_remaining} мес)",
|
||||
balance=texts.format_price(db_user.balance_kopeks),
|
||||
missing=texts.format_price(missing_kopeks),
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=get_insufficient_balance_keyboard(
|
||||
db_user.language,
|
||||
amount_kopeks=missing_kopeks,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
action_text = f"увеличить до {texts.format_traffic(new_traffic_gb)}"
|
||||
cost_text = f"Доплата: {texts.format_price(total_price_difference)} (за {months_remaining} мес)"
|
||||
if discount_savings_per_month > 0:
|
||||
total_discount_savings = discount_savings_per_month * months_remaining
|
||||
cost_text += (
|
||||
f" (скидка {traffic_discount_percent}%:"
|
||||
f" -{texts.format_price(total_discount_savings)})"
|
||||
)
|
||||
else:
|
||||
total_price_difference = 0
|
||||
action_text = f"уменьшить до {texts.format_traffic(new_traffic_gb)}"
|
||||
cost_text = "Возврат средств не производится"
|
||||
|
||||
confirm_text = f"🔄 <b>Подтверждение переключения трафика</b>\n\n"
|
||||
confirm_text += f"Текущий лимит: {texts.format_traffic(current_traffic)}\n"
|
||||
confirm_text += f"Новый лимит: {texts.format_traffic(new_traffic_gb)}\n\n"
|
||||
confirm_text += f"Действие: {action_text}\n"
|
||||
confirm_text += f"💰 {cost_text}\n\n"
|
||||
confirm_text += "Подтвердить переключение?"
|
||||
|
||||
await callback.message.edit_text(
|
||||
confirm_text,
|
||||
reply_markup=get_confirm_switch_traffic_keyboard(new_traffic_gb, total_price_difference, db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
async def execute_switch_traffic(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
callback_parts = callback.data.split('_')
|
||||
new_traffic_gb = int(callback_parts[3])
|
||||
price_difference = int(callback_parts[4])
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
current_traffic = subscription.traffic_limit_gb
|
||||
|
||||
try:
|
||||
if price_difference > 0:
|
||||
success = await subtract_user_balance(
|
||||
db, db_user, price_difference,
|
||||
f"Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB"
|
||||
)
|
||||
|
||||
if not success:
|
||||
await callback.answer("⚠️ Ошибка списания средств", show_alert=True)
|
||||
return
|
||||
|
||||
months_remaining = get_remaining_months(subscription.end_date)
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=price_difference,
|
||||
description=f"Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB на {months_remaining} мес"
|
||||
)
|
||||
|
||||
subscription.traffic_limit_gb = new_traffic_gb
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
await db.refresh(db_user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db, db_user, subscription, "traffic", current_traffic, new_traffic_gb, price_difference
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об изменении трафика: {e}")
|
||||
|
||||
if new_traffic_gb > current_traffic:
|
||||
success_text = f"✅ Лимит трафика увеличен!\n\n"
|
||||
success_text += f"📊 Было: {texts.format_traffic(current_traffic)} → "
|
||||
success_text += f"Стало: {texts.format_traffic(new_traffic_gb)}\n"
|
||||
if price_difference > 0:
|
||||
success_text += f"💰 Списано: {texts.format_price(price_difference)}"
|
||||
elif new_traffic_gb < current_traffic:
|
||||
success_text = f"✅ Лимит трафика уменьшен!\n\n"
|
||||
success_text += f"📊 Было: {texts.format_traffic(current_traffic)} → "
|
||||
success_text += f"Стало: {texts.format_traffic(new_traffic_gb)}\n"
|
||||
success_text += f"ℹ️ Возврат средств не производится"
|
||||
|
||||
await callback.message.edit_text(
|
||||
success_text,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"✅ Пользователь {db_user.telegram_id} переключил трафик с {current_traffic}GB на {new_traffic_gb}GB, доплата: {price_difference / 100}₽")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка переключения трафика: {e}")
|
||||
await callback.message.edit_text(
|
||||
texts.ERROR,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
@@ -114,7 +114,11 @@ async def handle_successful_payment(message: types.Message):
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"✅ Баланс успешно пополнен на {settings.format_price(amount_kopeks)}!"
|
||||
f"✅ Баланс успешно пополнен на {settings.format_price(amount_kopeks)}!\n\n"
|
||||
"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
"Обязательно активируйте подписку отдельно!\n\n"
|
||||
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
f"подписка будет приобретена автоматически после пополнения баланса."
|
||||
)
|
||||
|
||||
logger.info(f"✅ Обработан Stars платеж: {payment.telegram_payment_charge_id}")
|
||||
|
||||
+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"
|
||||
)
|
||||
],
|
||||
[
|
||||
|
||||
+231
-114
@@ -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,
|
||||
@@ -231,6 +235,7 @@ def get_main_menu_keyboard(
|
||||
balance_kopeks: int = 0,
|
||||
subscription=None,
|
||||
show_resume_checkout: bool = False,
|
||||
has_saved_cart: bool = False, # Новый параметр для отображения уведомления о сохраненной корзине
|
||||
*,
|
||||
is_moderator: bool = False,
|
||||
custom_buttons: Optional[list[InlineKeyboardButton]] = None,
|
||||
@@ -256,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
|
||||
@@ -311,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")
|
||||
@@ -338,30 +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:
|
||||
keyboard.append([
|
||||
if show_resume_checkout or has_saved_cart:
|
||||
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)
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode"),
|
||||
# Добавляем кнопки промокода и рефералов, учитывая настройки
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode")
|
||||
)
|
||||
|
||||
# Добавляем кнопку рефералов только если программа включена
|
||||
if settings.is_referral_program_enabled():
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_REFERRALS, callback_data="menu_referrals")
|
||||
]
|
||||
])
|
||||
)
|
||||
|
||||
# Support button is configurable (runtime via service)
|
||||
try:
|
||||
@@ -370,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}")
|
||||
|
||||
@@ -624,6 +649,7 @@ def get_insufficient_balance_keyboard(
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
resume_callback: str | None = None,
|
||||
amount_kopeks: int | None = None,
|
||||
has_saved_cart: bool = False, # Новый параметр для указания наличия сохраненной корзины
|
||||
) -> InlineKeyboardMarkup:
|
||||
|
||||
texts = get_texts(language)
|
||||
@@ -644,14 +670,23 @@ def get_insufficient_balance_keyboard(
|
||||
)
|
||||
back_row_index = len(keyboard.inline_keyboard) - 1
|
||||
|
||||
if resume_callback:
|
||||
# Если есть сохраненная корзина, добавляем кнопку возврата к оформлению
|
||||
if has_saved_cart:
|
||||
return_row = [
|
||||
InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="return_to_saved_cart",
|
||||
)
|
||||
]
|
||||
insert_index = back_row_index if back_row_index is not None else len(keyboard.inline_keyboard)
|
||||
keyboard.inline_keyboard.insert(insert_index, return_row)
|
||||
elif resume_callback:
|
||||
return_row = [
|
||||
InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data=resume_callback,
|
||||
)
|
||||
]
|
||||
|
||||
insert_index = back_row_index if back_row_index is not None else len(keyboard.inline_keyboard)
|
||||
keyboard.inline_keyboard.insert(insert_index, return_row)
|
||||
|
||||
@@ -757,6 +792,7 @@ def get_payment_methods_keyboard_with_cart(
|
||||
language: str = "ru",
|
||||
amount_kopeks: int = 0,
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = get_payment_methods_keyboard(amount_kopeks, language)
|
||||
|
||||
# Добавляем кнопку "Очистить корзину"
|
||||
@@ -767,9 +803,18 @@ def get_payment_methods_keyboard_with_cart(
|
||||
)
|
||||
])
|
||||
|
||||
# Добавляем кнопку возврата к оформлению подписки
|
||||
keyboard.inline_keyboard.insert(-1, [ # Вставляем перед кнопкой "назад"
|
||||
InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="return_to_saved_cart"
|
||||
)
|
||||
])
|
||||
|
||||
return keyboard
|
||||
|
||||
def get_subscription_confirm_keyboard_with_cart(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text="✅ Подтвердить покупку",
|
||||
@@ -780,8 +825,8 @@ def get_subscription_confirm_keyboard_with_cart(language: str = "ru") -> InlineK
|
||||
callback_data="clear_saved_cart"
|
||||
)],
|
||||
[InlineKeyboardButton(
|
||||
text="🔙 Назад",
|
||||
callback_data="back_to_menu"
|
||||
text=texts.BACK,
|
||||
callback_data="subscription_config_back" # Изменили на возврат к настройке
|
||||
)]
|
||||
])
|
||||
|
||||
@@ -789,11 +834,14 @@ def get_insufficient_balance_keyboard_with_cart(
|
||||
language: str = "ru",
|
||||
amount_kopeks: int = 0,
|
||||
) -> InlineKeyboardMarkup:
|
||||
# Используем обновленную версию с флагом has_saved_cart=True
|
||||
keyboard = get_insufficient_balance_keyboard(
|
||||
language,
|
||||
amount_kopeks=amount_kopeks,
|
||||
has_saved_cart=True,
|
||||
)
|
||||
|
||||
# Добавляем кнопку очистки корзины в начало
|
||||
keyboard.inline_keyboard.insert(
|
||||
0,
|
||||
[
|
||||
@@ -816,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)
|
||||
|
||||
|
||||
@@ -1035,21 +1108,21 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
])
|
||||
|
||||
if settings.is_yookassa_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_CARD_YOOKASSA", "💳 Банковская карта (YooKassa)"),
|
||||
callback_data=_build_callback("yookassa")
|
||||
)
|
||||
])
|
||||
|
||||
if settings.YOOKASSA_SBP_ENABLED:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_SBP_YOOKASSA", "🏦 Оплатить по СБП (YooKassa)"),
|
||||
callback_data=_build_callback("yookassa_sbp")
|
||||
callback_data=_build_callback("yookassa_sbp"),
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_CARD_YOOKASSA", "💳 Банковская карта (YooKassa)"),
|
||||
callback_data=_build_callback("yookassa"),
|
||||
)
|
||||
])
|
||||
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
@@ -1059,13 +1132,25 @@ 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")
|
||||
)
|
||||
])
|
||||
|
||||
if settings.is_wata_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_CARD_WATA", "💳 Банковская карта (WATA)"),
|
||||
callback_data=_build_callback("wata")
|
||||
)
|
||||
])
|
||||
|
||||
if settings.is_pal24_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
@@ -1074,6 +1159,14 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
)
|
||||
])
|
||||
|
||||
if settings.is_platega_enabled() and settings.get_platega_active_methods():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_PLATEGA", "💳 Platega"),
|
||||
callback_data=_build_callback("platega"),
|
||||
)
|
||||
])
|
||||
|
||||
if settings.is_cryptobot_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
@@ -1082,6 +1175,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", "🛠️ Через поддержку"),
|
||||
@@ -1318,7 +1419,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}"
|
||||
)
|
||||
])
|
||||
@@ -1330,37 +1431,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(
|
||||
@@ -1897,15 +1978,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}"
|
||||
)
|
||||
])
|
||||
@@ -2020,33 +2125,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)
|
||||
|
||||
|
||||
@@ -2262,6 +2373,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"):
|
||||
|
||||
+23
-14
@@ -2,26 +2,35 @@ from aiogram.types import ReplyKeyboardMarkup, KeyboardButton, ReplyKeyboardRemo
|
||||
from typing import List
|
||||
|
||||
from app.localization.texts import get_texts
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def get_main_reply_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
KeyboardButton(text=texts.MENU_BALANCE),
|
||||
KeyboardButton(text=texts.MENU_SUBSCRIPTION)
|
||||
]
|
||||
]
|
||||
|
||||
# Добавляем кнопки промокода и рефералов, учитывая настройки
|
||||
second_row = [KeyboardButton(text=texts.MENU_PROMOCODE)]
|
||||
|
||||
# Добавляем кнопку рефералов только если программа включена
|
||||
if settings.is_referral_program_enabled():
|
||||
second_row.append(KeyboardButton(text=texts.MENU_REFERRALS))
|
||||
|
||||
keyboard.append(second_row)
|
||||
|
||||
keyboard.append([
|
||||
KeyboardButton(text=texts.MENU_SUPPORT),
|
||||
KeyboardButton(text=texts.MENU_RULES)
|
||||
])
|
||||
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[
|
||||
KeyboardButton(text=texts.MENU_BALANCE),
|
||||
KeyboardButton(text=texts.MENU_SUBSCRIPTION)
|
||||
],
|
||||
[
|
||||
KeyboardButton(text=texts.MENU_PROMOCODE),
|
||||
KeyboardButton(text=texts.MENU_REFERRALS)
|
||||
],
|
||||
[
|
||||
KeyboardButton(text=texts.MENU_SUPPORT),
|
||||
KeyboardButton(text=texts.MENU_RULES)
|
||||
]
|
||||
],
|
||||
keyboard=keyboard,
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=False
|
||||
)
|
||||
|
||||
+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"
|
||||
|
||||
+1372
-651
File diff suppressed because it is too large
Load Diff
+1393
-686
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
|
||||
from aiogram import BaseMiddleware, Bot
|
||||
from typing import Callable, Dict, Any, Awaitable, Optional
|
||||
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__)
|
||||
|
||||
@@ -86,16 +88,16 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
return await handler(event, data)
|
||||
|
||||
is_required = settings.CHANNEL_IS_REQUIRED_SUB
|
||||
|
||||
|
||||
if not is_required:
|
||||
logger.debug("⚠️ Обязательная подписка отключена, пропускаем проверку")
|
||||
return await handler(event, data)
|
||||
|
||||
channel_link = settings.CHANNEL_LINK
|
||||
|
||||
|
||||
try:
|
||||
member = await bot.get_chat_member(chat_id=channel_id, user_id=telegram_id)
|
||||
|
||||
|
||||
if member.status in self.GOOD_MEMBER_STATUS:
|
||||
return await handler(event, data)
|
||||
elif member.status in self.BAD_MEMBER_STATUS:
|
||||
@@ -104,6 +106,8 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
if telegram_id:
|
||||
await self._deactivate_trial_subscription(telegram_id)
|
||||
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
|
||||
if isinstance(event, CallbackQuery) and event.data == "sub_channel_check":
|
||||
await event.answer("❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.", show_alert=True)
|
||||
return
|
||||
@@ -111,10 +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, 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, bot)
|
||||
return await self._deny_message(event, bot, channel_link)
|
||||
except TelegramBadRequest as e:
|
||||
if "chat not found" in str(e).lower():
|
||||
@@ -123,11 +129,107 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
logger.error(f"❌ Пользователь {telegram_id} не найден: {e}")
|
||||
else:
|
||||
logger.error(f"❌ Ошибка запроса к каналу {channel_id}: {e}")
|
||||
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,
|
||||
bot: Optional[Bot] = None,
|
||||
) -> None:
|
||||
if not state:
|
||||
return
|
||||
|
||||
message: Optional[Message] = None
|
||||
if isinstance(event, Message):
|
||||
message = event
|
||||
elif isinstance(event, CallbackQuery):
|
||||
message = event.message
|
||||
elif isinstance(event, Update):
|
||||
message = event.message
|
||||
|
||||
if not message or not message.text:
|
||||
return
|
||||
|
||||
text = message.text.strip()
|
||||
if not text.startswith("/start"):
|
||||
return
|
||||
|
||||
parts = text.split(maxsplit=1)
|
||||
if len(parts) < 2 or not parts[1]:
|
||||
return
|
||||
|
||||
payload = parts[1]
|
||||
|
||||
data = await state.get_data() or {}
|
||||
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
|
||||
|
||||
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():
|
||||
try:
|
||||
|
||||
@@ -4,10 +4,12 @@ from datetime import datetime
|
||||
from aiogram import Bot, types
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.exc import MissingGreenlet
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.promo_group import get_promo_group_by_id
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.crud.transaction import get_transaction_by_id
|
||||
from app.database.models import (
|
||||
AdvertisingCampaign,
|
||||
PromoCodeType,
|
||||
@@ -17,6 +19,7 @@ from app.database.models import (
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -75,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] = []
|
||||
|
||||
@@ -173,7 +190,9 @@ class AdminNotificationService:
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription: Subscription
|
||||
subscription: Subscription,
|
||||
*,
|
||||
charged_amount_kopeks: Optional[int] = None,
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
@@ -183,26 +202,42 @@ 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
|
||||
|
||||
payment_block = ""
|
||||
if charged_amount_kopeks and charged_amount_kopeks > 0:
|
||||
payment_block = (
|
||||
f"\n💳 <b>Оплата за активацию:</b> {settings.format_price(charged_amount_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 'отсутствует'}
|
||||
👥 <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 'По умолчанию'}
|
||||
{payment_block}
|
||||
|
||||
📆 <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)
|
||||
|
||||
@@ -215,42 +250,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} дней
|
||||
@@ -258,11 +298,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)
|
||||
|
||||
@@ -309,7 +349,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)
|
||||
|
||||
@@ -334,7 +374,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)
|
||||
|
||||
@@ -342,7 +382,7 @@ class AdminNotificationService:
|
||||
logger.error(f"Ошибка отправки уведомления об ошибке проверки версий: {e}")
|
||||
return False
|
||||
|
||||
async def send_balance_topup_notification(
|
||||
def _build_balance_topup_message(
|
||||
self,
|
||||
user: User,
|
||||
transaction: Transaction,
|
||||
@@ -352,21 +392,19 @@ class AdminNotificationService:
|
||||
referrer_info: str,
|
||||
subscription: Subscription | None,
|
||||
promo_group: PromoGroup | None,
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
) -> str:
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||
balance_change = user.balance_kopeks - old_balance
|
||||
subscription_status = self._get_subscription_status(subscription)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
timestamp = format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
try:
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||
balance_change = user.balance_kopeks - old_balance
|
||||
subscription_status = self._get_subscription_status(subscription)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
return f"""💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>
|
||||
|
||||
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> {topup_status}
|
||||
|
||||
{promo_block}
|
||||
@@ -384,12 +422,137 @@ class AdminNotificationService:
|
||||
🔗 <b>Реферер:</b> {referrer_info}
|
||||
📱 <b>Подписка:</b> {subscription_status}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
⏰ <i>{timestamp}</i>"""
|
||||
|
||||
async def _reload_topup_notification_entities(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
transaction: Transaction,
|
||||
) -> tuple[User, Transaction, Subscription | None, PromoGroup | None]:
|
||||
refreshed_user = await get_user_by_id(db, user.id)
|
||||
if not refreshed_user:
|
||||
raise ValueError(
|
||||
f"Не удалось повторно загрузить пользователя {user.id} для уведомления о пополнении"
|
||||
)
|
||||
|
||||
refreshed_transaction = await get_transaction_by_id(db, transaction.id)
|
||||
if not refreshed_transaction:
|
||||
raise ValueError(
|
||||
f"Не удалось повторно загрузить транзакцию {transaction.id} для уведомления о пополнении"
|
||||
)
|
||||
|
||||
subscription = getattr(refreshed_user, "subscription", None)
|
||||
promo_group = await self._get_user_promo_group(db, refreshed_user)
|
||||
|
||||
return refreshed_user, refreshed_transaction, subscription, promo_group
|
||||
|
||||
def _is_lazy_loading_error(self, error: Exception) -> bool:
|
||||
message = str(error).lower()
|
||||
return (
|
||||
isinstance(error, MissingGreenlet)
|
||||
or "greenlet_spawn" in message
|
||||
or "await_only" in message
|
||||
or "missinggreenlet" in message
|
||||
)
|
||||
|
||||
|
||||
async def send_balance_topup_notification(
|
||||
self,
|
||||
user: User,
|
||||
transaction: Transaction,
|
||||
old_balance: int,
|
||||
*,
|
||||
topup_status: str,
|
||||
referrer_info: str,
|
||||
subscription: Subscription | None,
|
||||
promo_group: PromoGroup | None,
|
||||
db: AsyncSession | None = None,
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
logger.info("Начинаем отправку уведомления о пополнении баланса")
|
||||
|
||||
try:
|
||||
logger.info("Пытаемся создать сообщение уведомления")
|
||||
message = self._build_balance_topup_message(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
)
|
||||
logger.info("Сообщение уведомления создано успешно")
|
||||
except Exception as error:
|
||||
logger.info(f"Перехвачена ошибка при создании сообщения уведомления: {type(error).__name__}: {error}")
|
||||
if not self._is_lazy_loading_error(error):
|
||||
logger.error(
|
||||
"Ошибка подготовки уведомления о пополнении: %s",
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
if db is None:
|
||||
logger.error(
|
||||
"Недостаточно данных для уведомления о пополнении и отсутствует доступ к БД: %s",
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
logger.warning(
|
||||
"Повторная загрузка данных для уведомления о пополнении после ошибки ленивой загрузки: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info("Пытаемся перезагрузить данные для уведомления")
|
||||
(
|
||||
user,
|
||||
transaction,
|
||||
subscription,
|
||||
promo_group,
|
||||
) = await self._reload_topup_notification_entities(db, user, transaction)
|
||||
logger.info("Данные успешно перезагружены")
|
||||
except Exception as reload_error:
|
||||
logger.error(
|
||||
"Ошибка повторной загрузки данных для уведомления о пополнении: %s",
|
||||
reload_error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
logger.info("Пытаемся создать сообщение после перезагрузки данных")
|
||||
message = self._build_balance_topup_message(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
)
|
||||
logger.info("Сообщение успешно создано после перезагрузки данных")
|
||||
except Exception as rebuild_error:
|
||||
logger.error(
|
||||
"Ошибка повторной подготовки уведомления о пополнении после повторной загрузки: %s",
|
||||
rebuild_error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
return await self._send_message(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении: {e}")
|
||||
logger.error(
|
||||
f"Ошибка отправки уведомления о пополнении: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
async def send_subscription_extension_notification(
|
||||
@@ -412,15 +575,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}
|
||||
|
||||
@@ -431,8 +595,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)}
|
||||
@@ -441,7 +605,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)
|
||||
|
||||
@@ -464,13 +628,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,
|
||||
"",
|
||||
@@ -493,7 +658,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}"
|
||||
)
|
||||
@@ -504,7 +669,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>",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -558,7 +723,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>",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -591,13 +756,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="🏆"),
|
||||
]
|
||||
@@ -622,7 +788,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>",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -674,11 +840,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': '💰 С баланса'
|
||||
@@ -699,9 +866,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 "❌ Неактивна"
|
||||
|
||||
@@ -772,7 +939,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("")
|
||||
@@ -784,7 +953,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"]
|
||||
@@ -843,9 +1014,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)
|
||||
|
||||
@@ -891,7 +1063,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"):
|
||||
@@ -937,9 +1111,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)
|
||||
|
||||
@@ -966,6 +1141,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": ("📊 ИЗМЕНЕНИЕ ТРАФИКА", "трафик"),
|
||||
@@ -978,9 +1154,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,
|
||||
"",
|
||||
@@ -1013,11 +1189,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>",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1074,4 +1250,3 @@ class AdminNotificationService:
|
||||
if not (self._is_enabled() and runtime_enabled):
|
||||
return False
|
||||
return await self._send_message(text, reply_markup=keyboard, ticket_event=True)
|
||||
|
||||
|
||||
+794
-259
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user