Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df7e397745 | |||
| 52868eac5b | |||
| 4c600b8557 | |||
| 56b0b1fb5f | |||
| 10519bf68e | |||
| 7bff56070c | |||
| 14e24a546e | |||
| f60231e104 | |||
| 920bab5b4f | |||
| bcf5519880 | |||
| 6fbd0bff28 | |||
| dff3eb14a1 | |||
| fab3d54f24 | |||
| 80e953a07b | |||
| aebf58068f | |||
| a491fe34bd | |||
| 830e64afe0 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.49.0"
|
||||
".": "3.53.0"
|
||||
}
|
||||
|
||||
+145
@@ -1,5 +1,150 @@
|
||||
# Changelog
|
||||
|
||||
## [3.53.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.52.1...v3.53.0) (2026-04-29)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add bulk_actions, info_pages, news to PERMISSION_REGISTRY ([06db393](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/06db3934881bc55851e1ff171fca89abc7deebe5))
|
||||
* per-category enable/disable for admin notifications ([fb857d7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fb857d792b7dd6658df7c081ef46b4cc729cd2fa))
|
||||
* protect active paid subscriptions from bulk delete ([51dfc3a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/51dfc3a1a2a31706b5c307f6394f2ef9f578cc51))
|
||||
* subscription request history API + RemnaWave panel method ([e22beb7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e22beb722934779a6d10cb9a7c9a1853f68f7787))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add 5 missing payment providers to pending-payments model_map ([a506c6b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a506c6be004054998354286fc9dc3990aa867ccc))
|
||||
* add 6 missing payment providers to payment_utils availability checks ([83efc21](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/83efc214fef7cdec438cf39e4301e6d22bceec7e))
|
||||
* add RollyPay, PayPear, Overpay, AuraPay to REAL_PAYMENT_METHODS ([ff7b190](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ff7b1905271e92625c10fa22d809f27c22a496b6))
|
||||
* autopay renewing trial subscriptions at classic-mode pricing ([2fbdbf5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2fbdbf5ab0ec0c13100e330f60a2249ed866c12e))
|
||||
* backup completeness — add 15 missing tables, accept .tar.gz uploads ([eafb243](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eafb243882a2f325771e397cdcc3b258f8ec8f7a))
|
||||
* backup import crash + upload handler hardening ([5cf19c7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5cf19c76e6fa9456cee0157ac4a4c0742d0f7718))
|
||||
* block/unblock endpoints — correct args, response schema, panel sync ([0bcb804](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0bcb804118fa9cb02c580eea849b6cd218d931f8))
|
||||
* cabinet /block endpoint now disables panel user in RemnaWave ([735e16a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/735e16afeba70ef22e690d668e6693cdd2bac140))
|
||||
* cabinet autopay endpoint — same NULL-safe is_trial guard ([29e177d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/29e177d396796827e3970015afb5a84512612035))
|
||||
* cabinet purchase fails after panel user deletion — stale UUID ([f37eb9a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f37eb9a1bd6a149f38458b1a4a1071efd8c03660))
|
||||
* callback.answer() before heavy operations to prevent query timeout ([579e4f2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/579e4f2a695315091db7f014d6a2241852b078ae))
|
||||
* create locales directory with correct permissions in Dockerfile ([74999fe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/74999fe99dd5ef7b10814cb24ff3022aca1d1c0d))
|
||||
* deadlock on user deletion — webhook handler never checked intentional mark ([62e7ecb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/62e7ecba01601ab3d5aa6c913db064d5fa768d9a))
|
||||
* device/traffic addon pricing — use ceil instead of floor for days_left ([cf60ae2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cf60ae2967b8a5b0c42c51354e3a2513c38e7120))
|
||||
* downgrade Pal24 API validation errors from error to debug ([c905fa6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c905fa60000c238475cdc3b253ae6e67aa670ff4))
|
||||
* false subscription expiry notifications — 4 bugs fixed ([134e7fb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/134e7fb0e1999f6404e2d06f38aebdb6af550ec1))
|
||||
* handle A018 error code as user-not-found fallback to create_user ([c619dbc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c619dbcae2a1470d132cdd35cb9824ac801f117c))
|
||||
* handle A018 error code in admin_users sync endpoints (2 more locations) ([59080f7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/59080f7392412cae848fdd3887cb25304a3a33f9))
|
||||
* media upload leaks staging photo to admin chat ([1110d0c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1110d0c7810d52d4f8f789dc5383cf4a0f5fce96))
|
||||
* panel sync subscription duration — ceil for days_remaining ([527c5b4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/527c5b4498972e2806480af1d0625939eae50bee))
|
||||
* PayPear webhook signature — strip signature field before hashing + IP fallback ([443a826](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/443a826402e63b021f1efc856257bbf54c79fdb4))
|
||||
* PollResponse has no created_at — use sent_at for ordering ([768e0b6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/768e0b6a7363e8b496f1e1b83fe567993dc664da))
|
||||
* promo group discount applied to restricted tariffs in autopay ([4ab5928](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4ab5928b61c35b4af115ced949e373c3373c7145))
|
||||
* referral links now clickable — remove <code> wrapping ([43dd0fd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/43dd0fd92c433498d780ca4e8798d606f5f70dbf))
|
||||
* remaining pricing-critical .days floor calculations → math.ceil ([bada41e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bada41ecd67e81020627dc50e90d357c088471ab))
|
||||
* replace all late callback.answer() with edit_text for error feedback ([c743fc8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c743fc81a5cb56153dbfe49e4f293277d773e948))
|
||||
* retry queue action uses _should_create instead of stale subscription UUID ([422844d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/422844d78df2865926c18d888edfb95e72c077c7))
|
||||
* send admin notification on promo code activation from cabinet ([1c38b31](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1c38b31e60f2491c2366f43b792400af71cba70d))
|
||||
* subscription-request-history — correct API client usage, add ownership check ([1fc04d8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1fc04d842fe589df1ec6208d6709b5bd55c85da7))
|
||||
* traffic addon discount also bypassed tariff-promo-group check ([47c7d45](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/47c7d4579336833213203f3e127d4155ed25b555))
|
||||
* traffic addon price mismatch — keyboard showed prorated, handler charged full month ([a88e3c8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a88e3c80ade1a1178270efe5e4100810da6f79b5))
|
||||
* update cabinet_last_login on every request (throttled, 5 min) ([91de6d0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/91de6d03fce4d6082359e7a1ba7e7f5ec02756b6))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* remove unused EXTERNAL_ADMIN_TOKEN functionality ([b9b6957](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b9b695799cb15f0f3be0116063cbf72482096b5d))
|
||||
|
||||
## [3.52.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.52.0...v3.52.1) (2026-04-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* broadcast preview count — add .correlate(User) to EXISTS subqueries ([63e1127](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/63e112735302cc5fc961b2da87fb1250f922fe3d))
|
||||
* ignore bot's own messages in unknown message handlers ([52bf2a9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/52bf2a9589e3e14abda78a62e45e86e022da4994))
|
||||
* unpin messages in Telegram BEFORE deactivating in DB ([ab4661b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ab4661b5c643d6dad787cc499c5cff128ec21be5))
|
||||
* use fresh DB session for deactivate after long unpin loop ([5ed9a0d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5ed9a0d4fbef5c92bfb4e7eb0daed493805c5e44))
|
||||
|
||||
## [3.52.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.51.0...v3.52.0) (2026-04-24)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* admin bulk actions API — mass operations on users ([fb2773f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fb2773fee4f36fc517624d96243b731c94a3508d))
|
||||
* bulk actions — campaign/partner filters, delete_user action ([d77fd81](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d77fd81e161a65ba848133ba4798a6a335e35c1c))
|
||||
* bulk actions — SSE streaming progress, grant subscription, multi-tariff info ([c0e0756](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c0e0756b9ac15cd926708be3f2f9c93e571ad411))
|
||||
* bulk delete_subscription action — removes from bot DB + RemnaWave ([605f202](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/605f202191d0203fdc7f5ea3bb0695ece47c9cd6))
|
||||
* bulk set_devices action + device info in subscription list ([be787a8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/be787a85bfd981b70bd347c943f5cef8ec0610fc))
|
||||
* FAQ support in info pages — page_type field + migration ([d394565](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d394565fe940549d1211a1ddab37f4d002d43dd5))
|
||||
* info page tab replacement — replaces_tab field + API ([bdb8cab](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bdb8cab1c97328ff05218a9a68612418c69ccacf))
|
||||
* information pages — CRUD model, admin API, public API ([e4b4a54](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e4b4a54797875b697146f8b804b3bbdfff4eb78b))
|
||||
* multi-tariff bulk actions — subscription-level targeting ([e78177b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e78177b2fc34c895ab2b74705516af2a3fcbfb70))
|
||||
* support multiple tariff_ids in user list filter ([0d2b1df](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0d2b1dfdc97d2f3058ef793c89beaa8da5709a4a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* /reorder route unreachable — move before /{page_id} path param ([122d12d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/122d12db20537cf0ad55cea5328f03aa3464b540))
|
||||
* add subscription/tariff/promo_group filters to admin user list API ([daa4725](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/daa472570ccf6ef50441e0e173ddd1b87fe31e5e))
|
||||
* always return subscriptions list in user list API ([cfbcc30](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cfbcc3082fca3a6101604aea197e7844974ed15d))
|
||||
* bulk actions review — rollback on error, multi-tariff constraint checks ([5b45d43](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5b45d4354f6e58bd609e69a45ef100acfed3dbf4))
|
||||
* bulk change_tariff not clearing squads when new tariff has none ([db7b673](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/db7b6734fdcae52397a25eb94e1b24e908fcc41c))
|
||||
* bulk delete_user — pass real admin_id, sanitize error messages ([2e45a93](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2e45a93bd72c7e930fad60f7be2292596271c0dc))
|
||||
* gift purchases no longer inflate promo group level ([1522d35](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1522d35f2dec0e35153c452818380f9b41b35f1d))
|
||||
* info pages review — deduplicate slug index, type reorder items ([2071a68](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2071a680d3d569f67c447921370463f13c928c55))
|
||||
* integrate Yandex Metrika offline conv + S2S postback hooks ([7d512d2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7d512d214a1ca184ba6601bf1ee5a39727ed41e5))
|
||||
* MissingGreenlet in subscription-ids bulk actions ([2ad893b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2ad893badfec6f782ed89746b1cf2d3d7b07aa3a))
|
||||
* privacy policy and offer text display HTML links as plain text ([59c54c9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/59c54c9b39d9cb26ed518cc74c93e1da43ce2020))
|
||||
* restore HTTP Referer fallback for landing purchases ([24dc8d2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/24dc8d2a5e1be5239036acf5888acfe1e8e96444))
|
||||
* restore referrer field in admin landing purchases response ([2cde38c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2cde38c63b35e52a552bf58c003a8281525a0be7))
|
||||
* sanitize error messages in all bulk action catch-all handlers ([9217f41](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9217f4116f74d15133531a711cb2bc083f30dd49))
|
||||
* server squad sync fails on fresh DB without default promo group ([ae7feeb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ae7feeb726ab0a9b3074c9ed11fd45e2cc412ea0))
|
||||
* stop printing tracebacks for warning-level logs inside except blocks ([68d2350](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/68d2350dfdf2f1f6964dd744a447b558a1d06267))
|
||||
* suppress 'User already enabled' traceback in bulk add_traffic ([ff41ea9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ff41ea9abbdbeb6746e23cde48a5f962b289fc48))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add Overpay to README with partner block ([bcf5519](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bcf5519880471d113833a5031cae54594e658a7b))
|
||||
* add Overpay to README with partner block ([70568f8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/70568f82c5c6c2c1041865c7109907fa9e2b8c6a))
|
||||
|
||||
## [3.51.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.50.0...v3.51.0) (2026-04-23)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* integrate Overpay payment provider (pay.overpay.io) ([2c3ffc8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2c3ffc8c8a9e6591d2356320ed0a71ab5db6cbec))
|
||||
* respond to unknown media messages (photos, videos, documents) ([29ae708](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/29ae7089aa0a8a5c7ddd73622a492df6b5e67e03))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* inactive user cleanup deletes users with paid subscriptions ([7005052](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/70050521566ab86b75fb4f64ed3b61bced5d1612))
|
||||
* pad short RemnaWave usernames to meet 3-char minimum ([6f87563](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6f875637899544d95daf8d87a30042b5347f6b41))
|
||||
|
||||
## [3.50.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.49.0...v3.50.0) (2026-04-22)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add WEBHOOK_IP to allow Telegram bypass DNS lookup for webhook ([#2894](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/issues/2894)) ([7093d36](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7093d368d3356c692bff3eddda68996579bfd036))
|
||||
* landing page analytics goals and sticky pay button ([3272b4b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3272b4bb053c76c3995cd3904b085a28f741e815))
|
||||
* tariff switch direction control, fix device pricing within tariff limit ([9ed4f08](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ed4f086b0102c20d7861e03f4d212ff57e28245))
|
||||
* **tickets:** multi-media message gallery (media_items JSONB) ([36571c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/36571c4275b76fa0bdb490ffccacc0a0e32e9bd6))
|
||||
* v3.50.0 release ([a491fe3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a491fe34bda6da86d72891fe4d05b6abd169cf2f))
|
||||
* Yandex Metrika offline conversions + S2S postbacks ([1068c13](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1068c1387a03bf7c94c7b29f9bb61acc3e3e782a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* classic mode renewal resets device_limit to 1 via cart key mismatch ([9ca3320](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ca3320a0204346aac2c76601f221b0bc70226a7))
|
||||
* do not reset subscription_crypto_link when cryptoLink absent in webhook ([#2891](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/issues/2891)) ([b71e58c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b71e58c8d25a5c94c94a401a59359cab4870e3c0))
|
||||
* do not update first_name/last_name from OIDC claims ([#2892](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/issues/2892)) ([1696e6f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1696e6f8843ceaa1a3e9a6d536e0d99968e579b1))
|
||||
* FSM state loss on balance topup, PayPear confirmation_url, hidden trial tariff in renewal ([7be404b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7be404b918640dcccdcda82802bb02a342b730d6))
|
||||
* grant all available squads for unrestricted trials ([#2897](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/issues/2897)) ([905cea6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/905cea68b48b596412d83c42de0d1dce77dcaee0))
|
||||
* menu layout schema icon limit, traffic_topup_enabled condition, shadowing imports ([66f8577](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/66f8577448712786d851b1802c4a183e7acc1779))
|
||||
* tariff switch pricing showing free for upgrades, admin duplicate subscription guard ([da855a7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/da855a7c8955dcbf3543dbbaedc00e16b9c35193))
|
||||
* ticket media_items review fixes ([dd17710](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dd177101f7314c5d25120a69e04c66d74f37b18f))
|
||||
* validate analytics goal is set when analytics is enabled on landing ([d316325](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d31632534b3e1b2268a8f8b2a441214f377f23eb))
|
||||
* устранить MissingGreenlet в автоплатежах и починить traceback в логах ([db79cc9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/db79cc9eb0d7dc7a4a1ae9190f7a23c6c9e6e317))
|
||||
* устранить root cause MissingGreenlet в автоплатежах через refetch по id ([3b03c25](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3b03c253cc1603fe54fddcbc8bc374ec2c71bdfc))
|
||||
|
||||
## [3.49.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.48.0...v3.49.0) (2026-04-18)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.49.0" # x-release-please-version
|
||||
ARG VERSION="v3.53.0" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ Bedolaga — полнофункциональная платформа для п
|
||||
|
||||
### 💳 Платежи
|
||||
|
||||
- 🏦 **24+ платёжных провайдера** одновременно
|
||||
- 🏦 **18 платёжных провайдеров** одновременно
|
||||
- 💰 Единый баланс: пополнение любым способом → покупка с баланса
|
||||
- ⚡ Автопокупка подписки после пополнения
|
||||
- 💾 Рекуррентные платежи (сохранённые карты)
|
||||
@@ -127,12 +127,6 @@ Bedolaga — полнофункциональная платформа для п
|
||||
| 🤝 | **[RollyPay](https://rollypay.io/?utm_source=bedolaga&utm_medium=community&utm_campaign=integration)** 🔸 | СБП, карты, крипто | RUB → USDT |
|
||||
| 🤝 | **[AuraPay](https://aurapay.tech/)** 🔸 | Карты, СБП | RUB |
|
||||
| 🤝 | **[Overpay](https://overpay.pro/)** 🔸 | Карты, СБП | RUB |
|
||||
| 🦌 | **Antilopay** | Карты, СБП, SberPay (RSA подпись) | RUB |
|
||||
| 💳 | **Etoplatezhi** | Карты, СБП | RUB |
|
||||
| 🪐 | **[Jupiter](https://t.me/k_juppiter)** 🔸 | СБП через QR (FPGate P2P v2.1) | RUB |
|
||||
| 🍩 | **[Donut](https://t.me/donut_payment)** 🔸 | Карты, СБП по телефону, СБП QR (P2P) | RUB |
|
||||
| 🌋 | **Lava Business** | Карты, СБП (gate.lava.ru) | RUB |
|
||||
| 🍎 | **Apple In-App Purchase** | Покупки через iOS App Store | USD |
|
||||
| 📲 | **Tribute** | Telegram-платежи | RUB |
|
||||
|
||||
</div>
|
||||
@@ -216,32 +210,6 @@ Bedolaga — официальный партнёр платёжного шлюз
|
||||
|
||||
📩 Менеджер: [@A_OverPay](https://t.me/A_OverPay) | 🌐 [overpay.pro](https://overpay.pro/)
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
|
||||
**🤝 Официальный партнёр Jupiter (FPGate P2P)**
|
||||
|
||||
Bedolaga — официальный партнёр платёжного шлюза **Jupiter** (FPGate P2P v2.1).<br>
|
||||
Эквайринг СБП через QR-код банковского приложения, HMAC-SHA256 подпись.<br>
|
||||
Высокая проходимость, callback-driven архитектура, защита от replay-атак.<br>
|
||||
Подключение по кодовому слову **`БЕДОЛАГА`** — **спец. условия**
|
||||
|
||||
📩 Менеджер: [@k_juppiter](https://t.me/k_juppiter)
|
||||
|
||||
</td>
|
||||
<td align="center">
|
||||
|
||||
**🤝 Официальный партнёр Donut**
|
||||
|
||||
Bedolaga — официальный партнёр платёжной системы **Donut** (Donut P2P).<br>
|
||||
P2P-оплата картой, СБП по номеру телефона и СБП QR — три метода через единый API.<br>
|
||||
HMAC-SHA256 подпись, sticky terminal-status guard, защита от amount tampering.<br>
|
||||
Подключение по кодовому слову **`БЕДОЛАГА`** — **спец. условия**
|
||||
|
||||
📩 Менеджер: [@donut_payment](https://t.me/donut_payment)
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -307,7 +275,7 @@ docker compose up -d
|
||||
| | Раздел | Описание |
|
||||
|:---:|:---|:---|
|
||||
| 🚀 | [Быстрый старт](https://docs.bedolagam.ru/getting-started/quickstart) | Развёртывание за 5 минут |
|
||||
| 💳 | [Настройка платежей](https://docs.bedolagam.ru/bot/payments) | 24+ провайдера, webhook, фискализация, Apple IAP |
|
||||
| 💳 | [Настройка платежей](https://docs.bedolagam.ru/bot/payments) | 18 провайдеров, webhook, фискализация |
|
||||
| 📦 | [Подписки и тарифы](https://docs.bedolagam.ru/bot/subscriptions) | Конфигурация планов и трафика |
|
||||
| 👥 | [Реферальная программа](https://docs.bedolagam.ru/bot/referral-program) | Партнёрка и вывод средств |
|
||||
| 🖥 | [Cabinet](https://docs.bedolagam.ru/cabinet/overview) | Настройка веб-кабинета |
|
||||
|
||||
@@ -34,7 +34,6 @@ from .admin_servers import router as admin_servers_router
|
||||
from .admin_settings import router as admin_settings_router
|
||||
from .admin_stats import router as admin_stats_router
|
||||
from .admin_tariffs import router as admin_tariffs_router
|
||||
from .admin_tasks import router as admin_tasks_router
|
||||
from .admin_tickets import router as admin_tickets_router
|
||||
from .admin_traffic import router as admin_traffic_router
|
||||
from .admin_updates import router as admin_updates_router
|
||||
@@ -65,19 +64,11 @@ from .ticket_notifications import (
|
||||
router as ticket_notifications_router,
|
||||
)
|
||||
from .tickets import router as tickets_router
|
||||
from .user_tasks import router as user_tasks_router
|
||||
from .websocket import router as websocket_router
|
||||
from .wheel import router as wheel_router
|
||||
from .withdrawal import router as withdrawal_router
|
||||
|
||||
|
||||
# Conditional imports
|
||||
try:
|
||||
from .apple_iap import router as apple_iap_router
|
||||
except ImportError:
|
||||
apple_iap_router = None
|
||||
|
||||
|
||||
# Main cabinet router
|
||||
router = APIRouter(prefix='/cabinet', tags=['Cabinet'], redirect_slashes=False)
|
||||
|
||||
@@ -90,11 +81,6 @@ router.include_router(subscription_router)
|
||||
router.include_router(multi_tariff_subscription_router)
|
||||
router.include_router(balance_router)
|
||||
router.include_router(referral_router)
|
||||
|
||||
# Apple IAP routes
|
||||
if apple_iap_router is not None:
|
||||
router.include_router(apple_iap_router)
|
||||
|
||||
router.include_router(partner_application_router)
|
||||
router.include_router(withdrawal_router)
|
||||
# Notifications router MUST be before tickets router to avoid route conflict
|
||||
@@ -111,7 +97,6 @@ router.include_router(landing_router)
|
||||
router.include_router(media_router)
|
||||
router.include_router(news_router)
|
||||
router.include_router(info_pages_router)
|
||||
router.include_router(user_tasks_router)
|
||||
|
||||
# Wheel routes
|
||||
router.include_router(wheel_router)
|
||||
@@ -161,7 +146,6 @@ router.include_router(admin_news_tags_router)
|
||||
router.include_router(admin_news_media_router)
|
||||
router.include_router(admin_news_router)
|
||||
router.include_router(admin_info_pages_router)
|
||||
router.include_router(admin_tasks_router)
|
||||
|
||||
# WebSocket route
|
||||
router.include_router(websocket_router)
|
||||
|
||||
@@ -273,8 +273,6 @@ async def get_tariff(
|
||||
external_squad_uuid=tariff.external_squad_uuid,
|
||||
# Показывать в подарках
|
||||
show_in_gift=tariff.show_in_gift,
|
||||
# Бонусные дни Tasks
|
||||
bonus_days_per_purchase=getattr(tariff, 'bonus_days_per_purchase', 0) or 0,
|
||||
created_at=tariff.created_at,
|
||||
updated_at=tariff.updated_at,
|
||||
)
|
||||
@@ -333,8 +331,6 @@ async def create_new_tariff(
|
||||
external_squad_uuid=request.external_squad_uuid,
|
||||
# Показывать в подарках
|
||||
show_in_gift=request.show_in_gift,
|
||||
# Бонусные дни Tasks
|
||||
bonus_days_per_purchase=request.bonus_days_per_purchase,
|
||||
)
|
||||
|
||||
logger.info('Admin created tariff', admin_id=admin.id, tariff_id=tariff.id, tariff_name=tariff.name)
|
||||
@@ -434,9 +430,6 @@ async def update_existing_tariff(
|
||||
# Показывать в подарках
|
||||
if request.show_in_gift is not None:
|
||||
updates['show_in_gift'] = request.show_in_gift
|
||||
# Бонусные дни Tasks
|
||||
if request.bonus_days_per_purchase is not None:
|
||||
updates['bonus_days_per_purchase'] = request.bonus_days_per_purchase
|
||||
|
||||
if updates:
|
||||
await update_tariff(db, tariff, **updates)
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
"""Admin endpoints для системы заданий с наградами и партнёрских каналов."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.cabinet.dependencies import get_cabinet_db, require_permission
|
||||
from app.cabinet.schemas.tasks import (
|
||||
TaskCreateRequest,
|
||||
TaskListItem,
|
||||
TaskPartnerChannelCreateRequest,
|
||||
TaskPartnerChannelResponse,
|
||||
TaskPartnerChannelUpdateRequest,
|
||||
TaskResponse,
|
||||
TaskUpdateRequest,
|
||||
)
|
||||
from app.database.crud import tasks as tasks_crud
|
||||
from app.database.models import User
|
||||
|
||||
|
||||
router = APIRouter(prefix='/admin', tags=['Cabinet Admin Tasks'])
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Tasks
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@router.get('/tasks', response_model=list[TaskListItem])
|
||||
async def admin_list_tasks(
|
||||
include_inactive: bool = True,
|
||||
parent_task_id: int | None = None,
|
||||
admin: User = Depends(require_permission('tasks:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
tasks = await tasks_crud.list_tasks(db, include_inactive=include_inactive, parent_task_id=parent_task_id)
|
||||
return [TaskListItem.model_validate(t) for t in tasks]
|
||||
|
||||
|
||||
@router.get('/tasks/{task_id}', response_model=TaskResponse)
|
||||
async def admin_get_task(
|
||||
task_id: int,
|
||||
admin: User = Depends(require_permission('tasks:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
task = await tasks_crud.get_task_by_id(db, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='task_not_found')
|
||||
return TaskResponse.model_validate(task)
|
||||
|
||||
|
||||
@router.post('/tasks', response_model=TaskResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def admin_create_task(
|
||||
request: TaskCreateRequest,
|
||||
admin: User = Depends(require_permission('tasks:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
if request.parent_task_id is not None:
|
||||
parent = await tasks_crud.get_task_by_id(db, request.parent_task_id)
|
||||
if parent is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='parent_task_not_found')
|
||||
|
||||
task = await tasks_crud.create_task(
|
||||
db,
|
||||
title=request.title,
|
||||
description=request.description,
|
||||
task_type=request.task_type,
|
||||
reward_type=request.reward_type,
|
||||
target_value=request.target_value,
|
||||
reward_value=request.reward_value,
|
||||
target_meta=request.target_meta,
|
||||
reward_meta=request.reward_meta,
|
||||
icon=request.icon,
|
||||
is_active=request.is_active,
|
||||
sort_order=request.sort_order,
|
||||
allow_user_choice=request.allow_user_choice,
|
||||
user_audience=request.user_audience,
|
||||
promo_group_id=request.promo_group_id,
|
||||
parent_task_id=request.parent_task_id,
|
||||
level=request.level,
|
||||
starts_at=request.starts_at,
|
||||
ends_at=request.ends_at,
|
||||
)
|
||||
return TaskResponse.model_validate(task)
|
||||
|
||||
|
||||
async def _parent_chain_has_cycle(db: AsyncSession, *, task_id: int, parent_id: int, max_depth: int = 10) -> bool:
|
||||
"""Идёт вверх по цепочке parent — проверяет, не возвращается ли в task_id."""
|
||||
current = parent_id
|
||||
visited: set[int] = set()
|
||||
for _ in range(max_depth):
|
||||
if current == task_id:
|
||||
return True
|
||||
if current in visited:
|
||||
return False
|
||||
visited.add(current)
|
||||
parent = await tasks_crud.get_task_by_id(db, current)
|
||||
if parent is None or parent.parent_task_id is None:
|
||||
return False
|
||||
current = parent.parent_task_id
|
||||
return False # max_depth достигнут — дальше не считаем циклом
|
||||
|
||||
|
||||
@router.put('/tasks/{task_id}', response_model=TaskResponse)
|
||||
async def admin_update_task(
|
||||
task_id: int,
|
||||
request: TaskUpdateRequest,
|
||||
admin: User = Depends(require_permission('tasks:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
task = await tasks_crud.get_task_by_id(db, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='task_not_found')
|
||||
|
||||
if request.parent_task_id is not None and request.parent_task_id == task_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='parent_task_cannot_be_self')
|
||||
|
||||
if request.parent_task_id is not None:
|
||||
if await _parent_chain_has_cycle(db, task_id=task_id, parent_id=request.parent_task_id):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='parent_chain_cycle_detected')
|
||||
|
||||
fields = request.model_dump(exclude_unset=True)
|
||||
if not fields:
|
||||
return TaskResponse.model_validate(task)
|
||||
|
||||
updated = await tasks_crud.update_task(db, task, **fields)
|
||||
return TaskResponse.model_validate(updated)
|
||||
|
||||
|
||||
@router.delete('/tasks/{task_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def admin_delete_task(
|
||||
task_id: int,
|
||||
admin: User = Depends(require_permission('tasks:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
task = await tasks_crud.get_task_by_id(db, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='task_not_found')
|
||||
await tasks_crud.delete_task(db, task)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Partner channels
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@router.get('/task-partner-channels', response_model=list[TaskPartnerChannelResponse])
|
||||
async def admin_list_partner_channels(
|
||||
include_inactive: bool = True,
|
||||
admin: User = Depends(require_permission('tasks:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
channels = await tasks_crud.list_partner_channels(db, include_inactive=include_inactive)
|
||||
return [TaskPartnerChannelResponse.model_validate(c) for c in channels]
|
||||
|
||||
|
||||
@router.post(
|
||||
'/task-partner-channels',
|
||||
response_model=TaskPartnerChannelResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def admin_create_partner_channel(
|
||||
request: TaskPartnerChannelCreateRequest,
|
||||
admin: User = Depends(require_permission('tasks:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
existing = await tasks_crud.get_partner_channel_by_channel_id(db, request.channel_id)
|
||||
if existing is not None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='channel_id_already_exists')
|
||||
channel = await tasks_crud.create_partner_channel(
|
||||
db,
|
||||
channel_id=request.channel_id,
|
||||
title=request.title,
|
||||
channel_link=request.channel_link,
|
||||
description=request.description,
|
||||
is_active=request.is_active,
|
||||
sort_order=request.sort_order,
|
||||
)
|
||||
return TaskPartnerChannelResponse.model_validate(channel)
|
||||
|
||||
|
||||
@router.put('/task-partner-channels/{channel_pk}', response_model=TaskPartnerChannelResponse)
|
||||
async def admin_update_partner_channel(
|
||||
channel_pk: int,
|
||||
request: TaskPartnerChannelUpdateRequest,
|
||||
admin: User = Depends(require_permission('tasks:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
channel = await tasks_crud.get_partner_channel_by_id(db, channel_pk)
|
||||
if channel is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='channel_not_found')
|
||||
fields = request.model_dump(exclude_unset=True)
|
||||
updated = await tasks_crud.update_partner_channel(db, channel, **fields)
|
||||
return TaskPartnerChannelResponse.model_validate(updated)
|
||||
|
||||
|
||||
@router.delete('/task-partner-channels/{channel_pk}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def admin_delete_partner_channel(
|
||||
channel_pk: int,
|
||||
admin: User = Depends(require_permission('tasks:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
channel = await tasks_crud.get_partner_channel_by_id(db, channel_pk)
|
||||
if channel is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='channel_not_found')
|
||||
await tasks_crud.delete_partner_channel(db, channel)
|
||||
@@ -1,267 +0,0 @@
|
||||
"""Apple In-App Purchase cabinet route."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.apple_iap import (
|
||||
create_apple_transaction,
|
||||
)
|
||||
from app.database.crud.transaction import create_transaction as create_trans
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
from app.database.models import PaymentMethod, TransactionType, User
|
||||
from app.external.apple_iap import AppleIAPService
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.apple_iap import ApplePurchaseRequest, ApplePurchaseResponse
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(tags=['Cabinet Apple IAP'])
|
||||
|
||||
|
||||
def get_apple_iap_service() -> AppleIAPService:
|
||||
return AppleIAPService()
|
||||
|
||||
|
||||
@router.post('/apple-purchase', response_model=ApplePurchaseResponse)
|
||||
async def apple_purchase(
|
||||
request: ApplePurchaseRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
apple_iap_service: AppleIAPService = Depends(get_apple_iap_service),
|
||||
):
|
||||
"""Verify an Apple In-App Purchase and credit the user's balance.
|
||||
|
||||
The iOS app calls this endpoint after a successful StoreKit transaction.
|
||||
If the backend returns success=false, the iOS app will NOT finish the
|
||||
transaction and will retry on next launch.
|
||||
"""
|
||||
if not settings.is_apple_iap_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Apple In-App Purchase is not enabled',
|
||||
)
|
||||
|
||||
# Validate product ID
|
||||
products = settings.get_apple_iap_products()
|
||||
if request.product_id not in products:
|
||||
logger.warning(
|
||||
'Unknown Apple product ID',
|
||||
product_id=request.product_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Unknown product ID',
|
||||
)
|
||||
|
||||
amount_kopeks = products[request.product_id]
|
||||
|
||||
# Verify transaction with Apple Server API (no DB lock needed).
|
||||
# verify_transaction automatically falls back Sandbox<->Production.
|
||||
txn_info = await apple_iap_service.verify_transaction(request.transaction_id, settings.APPLE_IAP_ENVIRONMENT)
|
||||
if not txn_info:
|
||||
logger.warning(
|
||||
'Apple transaction verification failed',
|
||||
transaction_id=request.transaction_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
return ApplePurchaseResponse(success=False)
|
||||
|
||||
# Validate transaction fields
|
||||
validation_error = apple_iap_service.validate_transaction_info(txn_info, request.product_id)
|
||||
if validation_error:
|
||||
logger.warning(
|
||||
'Apple transaction validation failed',
|
||||
error=validation_error,
|
||||
transaction_id=request.transaction_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
return ApplePurchaseResponse(success=False)
|
||||
|
||||
# FIX 4: appAccountToken is mandatory -- reject if missing
|
||||
app_account_token = txn_info.get('appAccountToken')
|
||||
if not app_account_token:
|
||||
logger.warning(
|
||||
'Apple appAccountToken missing -- rejecting transaction',
|
||||
transaction_id=request.transaction_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
return ApplePurchaseResponse(success=False)
|
||||
|
||||
if app_account_token != str(user.id):
|
||||
logger.warning(
|
||||
'Apple appAccountToken mismatch -- possible replay',
|
||||
expected=str(user.id),
|
||||
received=app_account_token,
|
||||
transaction_id=request.transaction_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
return ApplePurchaseResponse(success=False)
|
||||
|
||||
# Detect sandbox transactions -- store actual environment from Apple's response
|
||||
actual_environment = txn_info.get('environment', settings.APPLE_IAP_ENVIRONMENT)
|
||||
is_sandbox = actual_environment == 'Sandbox'
|
||||
|
||||
if is_sandbox and settings.APPLE_IAP_ENVIRONMENT == 'Production':
|
||||
# Sandbox transaction on a production server (e.g. App Review).
|
||||
# Record it for audit but do NOT credit real balance.
|
||||
logger.info(
|
||||
'Apple sandbox transaction on production -- storing without balance credit',
|
||||
transaction_id=request.transaction_id,
|
||||
product_id=request.product_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
await create_apple_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
transaction_id=request.transaction_id,
|
||||
original_transaction_id=txn_info.get('originalTransactionId'),
|
||||
product_id=request.product_id,
|
||||
bundle_id=txn_info.get('bundleId', settings.APPLE_IAP_BUNDLE_ID),
|
||||
amount_kopeks=amount_kopeks,
|
||||
environment='Sandbox',
|
||||
)
|
||||
except IntegrityError:
|
||||
pass # already stored
|
||||
await db.commit()
|
||||
return ApplePurchaseResponse(success=True)
|
||||
|
||||
# Atomically insert transaction record -- unique constraint on transaction_id
|
||||
# prevents double-spend even under concurrent requests.
|
||||
apple_txn = None
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
apple_txn = await create_apple_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
transaction_id=request.transaction_id,
|
||||
original_transaction_id=txn_info.get('originalTransactionId'),
|
||||
product_id=request.product_id,
|
||||
bundle_id=txn_info.get('bundleId', settings.APPLE_IAP_BUNDLE_ID),
|
||||
amount_kopeks=amount_kopeks,
|
||||
environment=actual_environment,
|
||||
)
|
||||
except IntegrityError:
|
||||
logger.info(
|
||||
'Apple transaction already processed (idempotent)',
|
||||
transaction_id=request.transaction_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
return ApplePurchaseResponse(success=True)
|
||||
|
||||
# Create financial transaction record
|
||||
transaction = await create_trans(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f'Пополнение через Apple IAP: {request.product_id}',
|
||||
payment_method=PaymentMethod.APPLE_IAP,
|
||||
external_id=request.transaction_id,
|
||||
is_completed=True,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
# FIX 9: Link AppleTransaction to financial Transaction via FK
|
||||
if apple_txn and transaction:
|
||||
apple_txn.transaction_id_fk = transaction.id
|
||||
apple_txn.updated_at = datetime.now(UTC)
|
||||
|
||||
# Lock user row and credit balance
|
||||
user = await lock_user_for_update(db, user)
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += amount_kopeks
|
||||
# FIX 10: Update user.updated_at when modifying balance
|
||||
user.updated_at = datetime.now(UTC)
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = 'Первое пополнение' if was_first_topup else 'Пополнение'
|
||||
|
||||
await db.commit()
|
||||
|
||||
# --- Post-payment side-effects (after atomic commit) ---
|
||||
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
try:
|
||||
await emit_transaction_side_effects(
|
||||
db,
|
||||
transaction,
|
||||
amount_kopeks=amount_kopeks,
|
||||
user_id=user.id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
payment_method=PaymentMethod.APPLE_IAP,
|
||||
external_id=request.transaction_id,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка emit_transaction_side_effects Apple IAP', error=error)
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(db, user.id, amount_kopeks, bot=None)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения Apple IAP', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
# Admin notification + cart auto-purchase
|
||||
try:
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
bot = create_bot()
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки админ уведомления Apple IAP', error=error)
|
||||
|
||||
try:
|
||||
from app.services.payment.common import send_cart_notification_after_topup
|
||||
|
||||
await send_cart_notification_after_topup(user, amount_kopeks, db, bot)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка при работе с сохраненной корзиной Apple IAP', user_id=user.id, error=error)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as error:
|
||||
logger.error('Ошибка создания бота для уведомлений Apple IAP', error=error)
|
||||
|
||||
logger.info(
|
||||
'Apple IAP purchase credited',
|
||||
transaction_id=request.transaction_id,
|
||||
product_id=request.product_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
return ApplePurchaseResponse(success=True)
|
||||
+62
-104
@@ -172,112 +172,74 @@ async def _process_campaign_bonus(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
campaign_slug: str | None,
|
||||
telegram_id: int | None = None,
|
||||
) -> CampaignBonusInfo | None:
|
||||
"""Process campaign bonus for user during auth. Never raises.
|
||||
|
||||
If ``campaign_slug`` is not provided but ``telegram_id`` is given, the
|
||||
function falls back to Redis ``pending_campaign:{telegram_id}`` -- populated
|
||||
by the bot's /start handler when a user opens an advertising campaign link
|
||||
but then completes registration via the cabinet WebApp (Telegram menu
|
||||
button) instead of the bot dialog. The Redis entry is cleared after a
|
||||
successful consumption attempt.
|
||||
"""
|
||||
pending_campaign_consumed = False
|
||||
if not campaign_slug and telegram_id:
|
||||
try:
|
||||
from app.services.referral_service import get_pending_campaign
|
||||
|
||||
pending = await get_pending_campaign(telegram_id)
|
||||
if pending and pending.get('campaign_slug'):
|
||||
campaign_slug = pending['campaign_slug']
|
||||
pending_campaign_consumed = True
|
||||
logger.info(
|
||||
'Resolved campaign from Redis pending_campaign (cabinet)',
|
||||
telegram_id=telegram_id,
|
||||
campaign_slug=campaign_slug,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning('Failed to check pending campaign', error=e)
|
||||
|
||||
"""Process campaign bonus for user during auth. Never raises."""
|
||||
if not campaign_slug:
|
||||
return None
|
||||
try:
|
||||
try:
|
||||
campaign = await get_campaign_by_start_parameter(db, campaign_slug, only_active=True)
|
||||
if not campaign:
|
||||
return None
|
||||
campaign = await get_campaign_by_start_parameter(db, campaign_slug, only_active=True)
|
||||
if not campaign:
|
||||
return None
|
||||
|
||||
# Skip if user IS the campaign partner — prevent self-referral
|
||||
if campaign.partner_user_id and campaign.partner_user_id == user.id:
|
||||
logger.debug(
|
||||
'Skipping campaign attribution: user is the campaign partner',
|
||||
# Skip if user IS the campaign partner — prevent self-referral
|
||||
if campaign.partner_user_id and campaign.partner_user_id == user.id:
|
||||
logger.debug(
|
||||
'Skipping campaign attribution: user is the campaign partner',
|
||||
user_id=user.id,
|
||||
campaign_id=campaign.id,
|
||||
)
|
||||
return None
|
||||
|
||||
# Lock user row to prevent concurrent bonus application (race condition)
|
||||
await db.execute(select(User).where(User.id == user.id).with_for_update())
|
||||
|
||||
existing = await get_campaign_registration_by_user(db, user.id)
|
||||
if existing:
|
||||
logger.debug('User already has campaign registration', user_id=user.id)
|
||||
return None
|
||||
|
||||
# Привязать реферала к партнёру кампании (если партнёр назначен и юзер ещё не привязан)
|
||||
if campaign.partner_user_id and not user.referred_by_id:
|
||||
user.referred_by_id = campaign.partner_user_id
|
||||
await db.flush()
|
||||
try:
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
async with create_bot() as bot:
|
||||
await process_referral_registration(db, user.id, campaign.partner_user_id, bot=bot)
|
||||
logger.info(
|
||||
'Referral set from campaign partner',
|
||||
user_id=user.id,
|
||||
partner_user_id=campaign.partner_user_id,
|
||||
campaign_id=campaign.id,
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error('Failed to process referral from campaign partner', error=e)
|
||||
|
||||
# Lock user row to prevent concurrent bonus application (race condition)
|
||||
await db.execute(select(User).where(User.id == user.id).with_for_update())
|
||||
|
||||
existing = await get_campaign_registration_by_user(db, user.id)
|
||||
if existing:
|
||||
logger.debug('User already has campaign registration', user_id=user.id)
|
||||
return None
|
||||
|
||||
# Привязать реферала к партнёру кампании (если партнёр назначен и юзер ещё не привязан)
|
||||
if campaign.partner_user_id and not user.referred_by_id:
|
||||
user.referred_by_id = campaign.partner_user_id
|
||||
await db.flush()
|
||||
try:
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
async with create_bot() as bot:
|
||||
await process_referral_registration(db, user.id, campaign.partner_user_id, bot=bot)
|
||||
logger.info(
|
||||
'Referral set from campaign partner',
|
||||
user_id=user.id,
|
||||
partner_user_id=campaign.partner_user_id,
|
||||
campaign_id=campaign.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Failed to process referral from campaign partner', error=e)
|
||||
|
||||
service = AdvertisingCampaignService()
|
||||
result = await service.apply_campaign_bonus(db, user, campaign)
|
||||
if not result.success:
|
||||
return None
|
||||
|
||||
# Refresh user to get updated balance after bonus
|
||||
await db.refresh(user)
|
||||
|
||||
return CampaignBonusInfo(
|
||||
campaign_name=campaign.name,
|
||||
bonus_type=result.bonus_type or campaign.bonus_type,
|
||||
balance_kopeks=result.balance_kopeks,
|
||||
subscription_days=result.subscription_days,
|
||||
tariff_name=result.tariff_name,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to process campaign bonus', user_id=user.id, campaign_slug=campaign_slug)
|
||||
try:
|
||||
await db.rollback()
|
||||
# Re-fetch user so session stays usable for the caller
|
||||
await db.refresh(user)
|
||||
except Exception:
|
||||
logger.exception('Failed to rollback after campaign bonus error', user_id=user.id)
|
||||
service = AdvertisingCampaignService()
|
||||
result = await service.apply_campaign_bonus(db, user, campaign)
|
||||
if not result.success:
|
||||
return None
|
||||
finally:
|
||||
# Clear Redis pending_campaign whenever we consumed it. Done regardless
|
||||
# of success — if processing failed (already applied, race, exception),
|
||||
# we don't want to keep retrying on every subsequent login.
|
||||
if pending_campaign_consumed and telegram_id:
|
||||
try:
|
||||
from app.services.referral_service import clear_pending_campaign
|
||||
|
||||
await clear_pending_campaign(telegram_id)
|
||||
except Exception:
|
||||
pass
|
||||
# Refresh user to get updated balance after bonus
|
||||
await db.refresh(user)
|
||||
|
||||
return CampaignBonusInfo(
|
||||
campaign_name=campaign.name,
|
||||
bonus_type=result.bonus_type or campaign.bonus_type,
|
||||
balance_kopeks=result.balance_kopeks,
|
||||
subscription_days=result.subscription_days,
|
||||
tariff_name=result.tariff_name,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to process campaign bonus', user_id=user.id, campaign_slug=campaign_slug)
|
||||
try:
|
||||
await db.rollback()
|
||||
# Re-fetch user so session stays usable for the caller
|
||||
await db.refresh(user)
|
||||
except Exception:
|
||||
logger.exception('Failed to rollback after campaign bonus error', user_id=user.id)
|
||||
return None
|
||||
|
||||
|
||||
async def _process_referral_code(
|
||||
@@ -622,11 +584,8 @@ async def auth_telegram(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Process campaign bonus.
|
||||
# Pass telegram_id so the function can fall back to Redis pending_campaign
|
||||
# if the user came via /start <campaign> in the bot but completed
|
||||
# registration in the WebApp without an explicit campaign_slug.
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug, telegram_id=telegram_id)
|
||||
# Process campaign bonus
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
if response.campaign_bonus:
|
||||
response.user = _user_to_response(user)
|
||||
|
||||
@@ -732,8 +691,8 @@ async def auth_telegram_widget(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Process campaign bonus (pending_campaign Redis fallback for Telegram Login Widget)
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug, telegram_id=request.id)
|
||||
# Process campaign bonus
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
if response.campaign_bonus:
|
||||
response.user = _user_to_response(user)
|
||||
|
||||
@@ -878,8 +837,7 @@ async def auth_telegram_oidc(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Process campaign bonus (pending_campaign Redis fallback for Telegram OIDC)
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug, telegram_id=telegram_id)
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
if response.campaign_bonus:
|
||||
response.user = _user_to_response(user)
|
||||
|
||||
|
||||
@@ -914,99 +914,6 @@ async def create_topup(
|
||||
detail='Failed to create AuraPay payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'jupiter':
|
||||
if not settings.is_jupiter_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Jupiter payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
payment_method_type = request.payment_option or None
|
||||
result = await payment_service.create_jupiter_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method_type=payment_method_type,
|
||||
return_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create Jupiter payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'donut':
|
||||
if not settings.is_donut_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Donut payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
payment_method_type = request.payment_option or None
|
||||
result = await payment_service.create_donut_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method_type=payment_method_type,
|
||||
return_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create Donut payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'lava':
|
||||
if not settings.is_lava_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Lava payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
payment_method_type = request.payment_option or None
|
||||
result = await payment_service.create_lava_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method_type=payment_method_type,
|
||||
return_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create Lava payment',
|
||||
)
|
||||
|
||||
else:
|
||||
# For other payment methods, redirect to bot
|
||||
raise HTTPException(
|
||||
@@ -1158,45 +1065,6 @@ def _get_status_info(record: PendingPayment) -> tuple[str, str]:
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.JUPITER:
|
||||
mapping = {
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'processing': ('⌛', 'Обрабатывается'),
|
||||
'success': ('✅', 'Оплачено'),
|
||||
'cancelled': ('❌', 'Отменено'),
|
||||
'declined': ('❌', 'Отклонено'),
|
||||
'error': ('❌', 'Ошибка'),
|
||||
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.DONUT:
|
||||
mapping = {
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'created': ('⏳', 'Создано'),
|
||||
'processing': ('⌛', 'Обрабатывается'),
|
||||
'success': ('✅', 'Оплачено'),
|
||||
'cancelled': ('❌', 'Отменено'),
|
||||
'error': ('❌', 'Ошибка'),
|
||||
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.LAVA:
|
||||
mapping = {
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'created': ('⏳', 'Создано'),
|
||||
'processing': ('⌛', 'Обрабатывается'),
|
||||
'success': ('✅', 'Оплачено'),
|
||||
'cancel': ('❌', 'Отменено'),
|
||||
'cancelled': ('❌', 'Отменено'),
|
||||
'expired': ('⌛', 'Истёк'),
|
||||
'failed': ('❌', 'Ошибка'),
|
||||
'error': ('❌', 'Ошибка'),
|
||||
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
return '❓', 'Неизвестно'
|
||||
|
||||
|
||||
|
||||
@@ -517,36 +517,6 @@ async def create_gift_purchase(
|
||||
description=tx_description,
|
||||
)
|
||||
|
||||
# Tasks: триггерим прогресс по подаркам
|
||||
try:
|
||||
from app.database.models import TaskType as _TaskType
|
||||
from app.services.tasks_service import record_event as _record_event
|
||||
|
||||
await _record_event(
|
||||
db,
|
||||
user_id=user.id,
|
||||
event_type=_TaskType.GIFT_PURCHASED,
|
||||
payload={'purchase_id': purchase.id},
|
||||
)
|
||||
await _record_event(
|
||||
db,
|
||||
user_id=user.id,
|
||||
event_type=_TaskType.GIFTS_COUNT,
|
||||
payload={'purchase_id': purchase.id},
|
||||
)
|
||||
# record_event делает только flush(); коммитим явно. Для has_recipient=True далее
|
||||
# fulfill_purchase сделает свой commit, для has_recipient=False — это единственный
|
||||
# шанс закоммитить task-прогресс перед return.
|
||||
await db.commit()
|
||||
except Exception as task_err:
|
||||
# Сессия может быть в poisoned state — откатываем, чтобы fulfill_purchase ниже
|
||||
# мог продолжить работу с сессией.
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning('Tasks: ошибка GIFT триггеров', user_id=user.id, error=task_err)
|
||||
|
||||
# Capture token before fulfill_purchase — session state may change after rollback inside fulfill
|
||||
purchase_token = purchase.token
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ from .subscription_modules import (
|
||||
devices_router,
|
||||
purchase_router,
|
||||
renewal_router,
|
||||
revoke_router,
|
||||
servers_router,
|
||||
status_router,
|
||||
tariff_switch_router,
|
||||
@@ -51,4 +50,3 @@ router.include_router(servers_router)
|
||||
router.include_router(autopay_router)
|
||||
router.include_router(daily_router)
|
||||
router.include_router(tariff_switch_router)
|
||||
router.include_router(revoke_router)
|
||||
|
||||
@@ -10,7 +10,6 @@ from .devices import router as devices_router
|
||||
from .multi_tariff import router as multi_tariff_router
|
||||
from .purchase import router as purchase_router
|
||||
from .renewal import router as renewal_router
|
||||
from .revoke import router as revoke_router
|
||||
from .servers import router as servers_router
|
||||
from .status import router as status_router
|
||||
from .tariff_switch import router as tariff_switch_router
|
||||
@@ -24,7 +23,6 @@ __all__ = [
|
||||
'multi_tariff_router',
|
||||
'purchase_router',
|
||||
'renewal_router',
|
||||
'revoke_router',
|
||||
'servers_router',
|
||||
'status_router',
|
||||
'tariff_switch_router',
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
"""Cabinet API endpoint for subscription reissue.
|
||||
|
||||
POST /subscription/revoke
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
from ...dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from .helpers import resolve_subscription
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post('/revoke')
|
||||
async def revoke_subscription(
|
||||
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict:
|
||||
"""Revoke and reissue subscription (generate new connection link)."""
|
||||
if not settings.is_subscription_revoke_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Subscription reissue is not available',
|
||||
)
|
||||
|
||||
# Reload user from current session
|
||||
from app.database.crud.user import get_user_by_id
|
||||
|
||||
fresh_user = await get_user_by_id(db, user.id)
|
||||
if not fresh_user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found')
|
||||
|
||||
subscription = await resolve_subscription(db, fresh_user, subscription_id)
|
||||
if not subscription:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Subscription not found')
|
||||
|
||||
if not subscription.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Only active subscriptions can be reissued',
|
||||
)
|
||||
|
||||
# Check cooldown
|
||||
if subscription.last_revoke_at:
|
||||
elapsed = (datetime.now(UTC) - subscription.last_revoke_at).total_seconds()
|
||||
cooldown = settings.SUBSCRIPTION_REVOKE_COOLDOWN_SECONDS
|
||||
if elapsed < cooldown:
|
||||
remaining = int(cooldown - elapsed)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f'Cooldown active. Try again in {remaining} seconds.',
|
||||
headers={'Retry-After': str(remaining)},
|
||||
)
|
||||
|
||||
# Execute revoke
|
||||
sub_service = SubscriptionService()
|
||||
new_url = await sub_service.revoke_subscription(db, subscription)
|
||||
|
||||
if not new_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to reissue subscription',
|
||||
)
|
||||
|
||||
# Update cooldown timestamp
|
||||
subscription.last_revoke_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Subscription revoked via cabinet API',
|
||||
user_id=user.id,
|
||||
subscription_id=subscription.id,
|
||||
)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'cooldown_seconds': settings.SUBSCRIPTION_REVOKE_COOLDOWN_SECONDS,
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
"""User-side endpoints для системы заданий с наградами."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.cabinet.dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from app.cabinet.schemas.tasks import (
|
||||
ClaimRewardRequest,
|
||||
ClaimRewardResponse,
|
||||
UserTaskProgressResponse,
|
||||
UserTasksAvailabilityResponse,
|
||||
UserTasksListResponse,
|
||||
)
|
||||
from app.database.models import User
|
||||
from app.services import tasks_service
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/tasks', tags=['Cabinet Tasks'])
|
||||
|
||||
|
||||
@router.get('/availability', response_model=UserTasksAvailabilityResponse)
|
||||
async def get_tasks_availability(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Краткая информация для условного показа вкладки «Задания» в меню."""
|
||||
visible = await tasks_service.get_available_tasks_for_user(db, user)
|
||||
has_available = len(visible) > 0
|
||||
unclaimed = await tasks_service.count_completed_unclaimed(db, user_id=user.id)
|
||||
return UserTasksAvailabilityResponse(
|
||||
has_available_tasks=has_available,
|
||||
unclaimed_count=unclaimed,
|
||||
)
|
||||
|
||||
|
||||
@router.get('', response_model=UserTasksListResponse)
|
||||
async def list_my_tasks(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Список доступных заданий пользователя с их прогрессом."""
|
||||
visible = await tasks_service.get_available_tasks_for_user(db, user)
|
||||
|
||||
items: list[UserTaskProgressResponse] = []
|
||||
unclaimed_count = 0
|
||||
|
||||
for task, progress in visible:
|
||||
current_value = progress.current_value if progress else 0
|
||||
is_completed = progress.completed_at is not None if progress else False
|
||||
is_claimed = progress.claimed_at is not None if progress else False
|
||||
if is_completed and not is_claimed:
|
||||
unclaimed_count += 1
|
||||
percent = (
|
||||
int(min(current_value, task.target_value) / max(task.target_value, 1) * 100)
|
||||
if task.target_value
|
||||
else 0
|
||||
)
|
||||
items.append(
|
||||
UserTaskProgressResponse(
|
||||
task_id=task.id,
|
||||
title=task.title or {},
|
||||
description=task.description or {},
|
||||
icon=task.icon,
|
||||
task_type=task.task_type,
|
||||
target_value=task.target_value,
|
||||
target_meta=task.target_meta or {},
|
||||
reward_type=task.reward_type,
|
||||
reward_value=task.reward_value,
|
||||
reward_meta=task.reward_meta or {},
|
||||
allow_user_choice=task.allow_user_choice,
|
||||
level=task.level,
|
||||
parent_task_id=task.parent_task_id,
|
||||
current_value=current_value,
|
||||
percent=percent,
|
||||
is_completed=is_completed,
|
||||
is_claimed=is_claimed,
|
||||
completed_at=progress.completed_at if progress else None,
|
||||
claimed_at=progress.claimed_at if progress else None,
|
||||
reward_granted_meta=progress.reward_granted_meta if progress else None,
|
||||
)
|
||||
)
|
||||
|
||||
return UserTasksListResponse(
|
||||
items=items,
|
||||
has_unclaimed=unclaimed_count > 0,
|
||||
unclaimed_count=unclaimed_count,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{task_id}/claim', response_model=ClaimRewardResponse)
|
||||
async def claim_task_reward(
|
||||
task_id: int,
|
||||
request: ClaimRewardRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Получить награду за выполненное задание."""
|
||||
try:
|
||||
granted = await tasks_service.claim_reward(
|
||||
db,
|
||||
user_id=user.id,
|
||||
task_id=task_id,
|
||||
chosen_subscription_id=request.chosen_subscription_id,
|
||||
chosen_reward_type=request.chosen_reward_type,
|
||||
)
|
||||
except ValueError as exc:
|
||||
msg = str(exc)
|
||||
# Маппим внутренние коды на HTTP-статусы
|
||||
not_found = {'progress_not_found', 'task_not_found', 'user_not_found'}
|
||||
bad_request = {
|
||||
'not_completed',
|
||||
'already_claimed',
|
||||
'user_not_eligible',
|
||||
'user_choice_not_allowed',
|
||||
'no_paid_subscription',
|
||||
'no_subscription_with_target_tariff',
|
||||
'chosen_subscription_invalid',
|
||||
'need_choose_subscription',
|
||||
'invalid_reward_amount',
|
||||
'invalid_reward_days',
|
||||
}
|
||||
if msg in not_found:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=msg) from exc
|
||||
if msg in bad_request or msg.startswith('unknown_reward_type'):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=msg) from exc
|
||||
logger.exception('claim_reward unexpected error', error=msg)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='internal_error'
|
||||
) from exc
|
||||
|
||||
return ClaimRewardResponse(success=True, reward=granted)
|
||||
@@ -1,23 +0,0 @@
|
||||
"""Apple In-App Purchase schemas for cabinet."""
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class ApplePurchaseRequest(BaseModel):
|
||||
"""Request to verify and credit an Apple IAP transaction."""
|
||||
|
||||
product_id: str = Field(..., description='Apple product ID (e.g. com.bitnet.vpnclient.topup.100)')
|
||||
transaction_id: str = Field(..., min_length=1, max_length=64, description='Apple StoreKit transaction ID')
|
||||
|
||||
@field_validator('transaction_id')
|
||||
@classmethod
|
||||
def transaction_id_must_be_numeric(cls, v: str) -> str:
|
||||
if not v.isdigit():
|
||||
raise ValueError('transaction_id must contain only digits')
|
||||
return v
|
||||
|
||||
|
||||
class ApplePurchaseResponse(BaseModel):
|
||||
"""Response indicating whether the purchase was successfully credited."""
|
||||
|
||||
success: bool
|
||||
@@ -117,8 +117,6 @@ class TariffDetailResponse(BaseModel):
|
||||
external_squad_uuid: str | None = None
|
||||
# Показывать в подарках
|
||||
show_in_gift: bool = True
|
||||
# Бонусные дни для системы Tasks (при награде subscription_days)
|
||||
bonus_days_per_purchase: int = 0
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
@@ -177,8 +175,6 @@ class TariffCreateRequest(BaseModel):
|
||||
external_squad_uuid: str | None = Field(None, pattern=UUID_PATTERN)
|
||||
# Показывать в подарках
|
||||
show_in_gift: bool = True
|
||||
# Бонусные дни для Tasks (subscription_days reward)
|
||||
bonus_days_per_purchase: int = Field(0, ge=0)
|
||||
|
||||
|
||||
class TariffUpdateRequest(BaseModel):
|
||||
@@ -220,8 +216,6 @@ class TariffUpdateRequest(BaseModel):
|
||||
external_squad_uuid: str | None = Field(None, pattern=UUID_PATTERN)
|
||||
# Показывать в подарках
|
||||
show_in_gift: bool | None = None
|
||||
# Бонусные дни для Tasks (subscription_days reward)
|
||||
bonus_days_per_purchase: int | None = Field(None, ge=0)
|
||||
|
||||
|
||||
class TariffSortOrderRequest(BaseModel):
|
||||
|
||||
@@ -1,316 +0,0 @@
|
||||
"""Pydantic schemas для системы заданий с наградами."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task partner channels (admin)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TaskPartnerChannelBase(BaseModel):
|
||||
channel_id: str = Field(min_length=1, max_length=100)
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
channel_link: str | None = Field(default=None, max_length=500)
|
||||
description: str | None = None
|
||||
is_active: bool = True
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class TaskPartnerChannelCreateRequest(TaskPartnerChannelBase):
|
||||
pass
|
||||
|
||||
|
||||
class TaskPartnerChannelUpdateRequest(BaseModel):
|
||||
title: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
channel_link: str | None = Field(default=None, max_length=500)
|
||||
description: str | None = None
|
||||
is_active: bool | None = None
|
||||
sort_order: int | None = None
|
||||
|
||||
|
||||
class TaskPartnerChannelResponse(TaskPartnerChannelBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tasks (admin)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
TASK_TYPES: tuple[str, ...] = (
|
||||
'purchase_tariff',
|
||||
'subscribe_channel',
|
||||
'traffic_used',
|
||||
'referrals_invited',
|
||||
'purchase_period',
|
||||
'spend_amount',
|
||||
'multi_tariff',
|
||||
'gift_purchased',
|
||||
'gifts_count',
|
||||
)
|
||||
|
||||
REWARD_TYPES: tuple[str, ...] = ('balance', 'subscription_days')
|
||||
|
||||
USER_AUDIENCES: tuple[str, ...] = ('telegram', 'email', 'both')
|
||||
|
||||
|
||||
class TaskCreateRequest(BaseModel):
|
||||
"""Создание шаблона задания."""
|
||||
|
||||
title: dict[str, str] = Field(..., description='i18n: { "ru": "...", "en": "..." }')
|
||||
description: dict[str, str] = Field(default_factory=dict)
|
||||
icon: str | None = None
|
||||
is_active: bool = True
|
||||
sort_order: int = 0
|
||||
|
||||
task_type: str = Field(...)
|
||||
target_value: int = Field(default=1, ge=1)
|
||||
target_meta: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
reward_type: str = Field(...)
|
||||
reward_value: int = Field(default=0, ge=0)
|
||||
reward_meta: dict[str, Any] = Field(default_factory=dict)
|
||||
allow_user_choice: bool = False
|
||||
|
||||
user_audience: str = Field(default='both')
|
||||
promo_group_id: int | None = None
|
||||
|
||||
parent_task_id: int | None = None
|
||||
level: int = Field(default=1, ge=1)
|
||||
|
||||
starts_at: datetime | None = None
|
||||
ends_at: datetime | None = None
|
||||
|
||||
@field_validator('task_type')
|
||||
@classmethod
|
||||
def _validate_task_type(cls, v: str) -> str:
|
||||
if v not in TASK_TYPES:
|
||||
raise ValueError(f'invalid task_type: {v}')
|
||||
return v
|
||||
|
||||
@field_validator('reward_type')
|
||||
@classmethod
|
||||
def _validate_reward_type(cls, v: str) -> str:
|
||||
if v not in REWARD_TYPES:
|
||||
raise ValueError(f'invalid reward_type: {v}')
|
||||
return v
|
||||
|
||||
@field_validator('user_audience')
|
||||
@classmethod
|
||||
def _validate_user_audience(cls, v: str) -> str:
|
||||
if v not in USER_AUDIENCES:
|
||||
raise ValueError(f'invalid user_audience: {v}')
|
||||
return v
|
||||
|
||||
@field_validator('title')
|
||||
@classmethod
|
||||
def _validate_title(cls, v: dict[str, str]) -> dict[str, str]:
|
||||
if not v or not any(value.strip() for value in v.values() if isinstance(value, str)):
|
||||
raise ValueError('title must contain at least one non-empty translation')
|
||||
return v
|
||||
|
||||
@model_validator(mode='after')
|
||||
def _validate_meta_per_type(self) -> TaskCreateRequest:
|
||||
"""Per-type validation: target_meta required keys, reward_value sanity."""
|
||||
# PURCHASE_TARIFF требует tariff_id
|
||||
if self.task_type == 'purchase_tariff' and 'tariff_id' not in (self.target_meta or {}):
|
||||
raise ValueError('PURCHASE_TARIFF requires target_meta.tariff_id')
|
||||
# SUBSCRIBE_CHANNEL требует channel_id (строкой, как в TaskPartnerChannel.channel_id)
|
||||
if self.task_type == 'subscribe_channel':
|
||||
channel_id = (self.target_meta or {}).get('channel_id')
|
||||
if channel_id is None:
|
||||
raise ValueError('SUBSCRIBE_CHANNEL requires target_meta.channel_id')
|
||||
if not isinstance(channel_id, str) or not channel_id.strip():
|
||||
raise ValueError('SUBSCRIBE_CHANNEL target_meta.channel_id must be a non-empty string')
|
||||
# PURCHASE_PERIOD требует period_days
|
||||
if self.task_type == 'purchase_period' and 'period_days' not in (self.target_meta or {}):
|
||||
raise ValueError('PURCHASE_PERIOD requires target_meta.period_days')
|
||||
# BALANCE reward требует reward_value > 0
|
||||
if self.reward_type == 'balance' and self.reward_value <= 0:
|
||||
raise ValueError('BALANCE reward requires reward_value > 0')
|
||||
# SUBSCRIPTION_DAYS reward: либо reward_value > 0, либо tariff_id указан
|
||||
if self.reward_type == 'subscription_days':
|
||||
tariff_id = (self.reward_meta or {}).get('tariff_id')
|
||||
if self.reward_value <= 0 and tariff_id is None:
|
||||
raise ValueError(
|
||||
'SUBSCRIPTION_DAYS reward requires reward_value > 0 or '
|
||||
'reward_meta.tariff_id (to use Tariff.bonus_days_per_purchase)'
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class TaskUpdateRequest(BaseModel):
|
||||
"""Частичное обновление задания (все поля опциональны)."""
|
||||
|
||||
title: dict[str, str] | None = None
|
||||
description: dict[str, str] | None = None
|
||||
icon: str | None = None
|
||||
is_active: bool | None = None
|
||||
sort_order: int | None = None
|
||||
|
||||
task_type: str | None = None
|
||||
target_value: int | None = Field(default=None, ge=1)
|
||||
target_meta: dict[str, Any] | None = None
|
||||
|
||||
reward_type: str | None = None
|
||||
reward_value: int | None = Field(default=None, ge=0)
|
||||
reward_meta: dict[str, Any] | None = None
|
||||
allow_user_choice: bool | None = None
|
||||
|
||||
user_audience: str | None = None
|
||||
promo_group_id: int | None = None
|
||||
|
||||
parent_task_id: int | None = None
|
||||
level: int | None = Field(default=None, ge=1)
|
||||
|
||||
starts_at: datetime | None = None
|
||||
ends_at: datetime | None = None
|
||||
|
||||
@field_validator('task_type')
|
||||
@classmethod
|
||||
def _validate_task_type(cls, v: str | None) -> str | None:
|
||||
if v is not None and v not in TASK_TYPES:
|
||||
raise ValueError(f'invalid task_type: {v}')
|
||||
return v
|
||||
|
||||
@field_validator('reward_type')
|
||||
@classmethod
|
||||
def _validate_reward_type(cls, v: str | None) -> str | None:
|
||||
if v is not None and v not in REWARD_TYPES:
|
||||
raise ValueError(f'invalid reward_type: {v}')
|
||||
return v
|
||||
|
||||
@field_validator('user_audience')
|
||||
@classmethod
|
||||
def _validate_user_audience(cls, v: str | None) -> str | None:
|
||||
if v is not None and v not in USER_AUDIENCES:
|
||||
raise ValueError(f'invalid user_audience: {v}')
|
||||
return v
|
||||
|
||||
|
||||
class TaskResponse(BaseModel):
|
||||
"""Полное представление задания (для админа)."""
|
||||
|
||||
id: int
|
||||
title: dict[str, str]
|
||||
description: dict[str, str]
|
||||
icon: str | None = None
|
||||
is_active: bool
|
||||
sort_order: int
|
||||
task_type: str
|
||||
target_value: int
|
||||
target_meta: dict[str, Any]
|
||||
reward_type: str
|
||||
reward_value: int
|
||||
reward_meta: dict[str, Any]
|
||||
allow_user_choice: bool
|
||||
user_audience: str
|
||||
promo_group_id: int | None = None
|
||||
parent_task_id: int | None = None
|
||||
level: int
|
||||
starts_at: datetime | None = None
|
||||
ends_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class TaskListItem(BaseModel):
|
||||
"""Компактное представление для списка."""
|
||||
|
||||
id: int
|
||||
title: dict[str, str]
|
||||
icon: str | None = None
|
||||
is_active: bool
|
||||
sort_order: int
|
||||
task_type: str
|
||||
target_value: int
|
||||
target_meta: dict[str, Any] = Field(default_factory=dict)
|
||||
reward_type: str
|
||||
reward_value: int
|
||||
reward_meta: dict[str, Any] = Field(default_factory=dict)
|
||||
user_audience: str
|
||||
promo_group_id: int | None = None
|
||||
parent_task_id: int | None = None
|
||||
level: int
|
||||
updated_at: datetime | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User-side schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class UserTaskProgressResponse(BaseModel):
|
||||
"""Прогресс пользователя по конкретному заданию."""
|
||||
|
||||
task_id: int
|
||||
title: dict[str, str]
|
||||
description: dict[str, str]
|
||||
icon: str | None = None
|
||||
task_type: str
|
||||
target_value: int
|
||||
target_meta: dict[str, Any] = Field(default_factory=dict)
|
||||
reward_type: str
|
||||
reward_value: int
|
||||
reward_meta: dict[str, Any] = Field(default_factory=dict)
|
||||
allow_user_choice: bool
|
||||
|
||||
level: int
|
||||
parent_task_id: int | None = None
|
||||
|
||||
current_value: int
|
||||
percent: int
|
||||
is_completed: bool
|
||||
is_claimed: bool
|
||||
completed_at: datetime | None = None
|
||||
claimed_at: datetime | None = None
|
||||
reward_granted_meta: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class UserTasksListResponse(BaseModel):
|
||||
"""Список заданий пользователя."""
|
||||
|
||||
items: list[UserTaskProgressResponse]
|
||||
has_unclaimed: bool
|
||||
unclaimed_count: int
|
||||
|
||||
|
||||
class UserTasksAvailabilityResponse(BaseModel):
|
||||
"""Краткая инфа для условного показа вкладки."""
|
||||
|
||||
has_available_tasks: bool
|
||||
unclaimed_count: int
|
||||
|
||||
|
||||
class ClaimRewardRequest(BaseModel):
|
||||
"""Запрос на получение награды."""
|
||||
|
||||
chosen_subscription_id: int | None = Field(
|
||||
default=None,
|
||||
description='Для multi-tariff / subscription_days reward — какой подписке начислить дни',
|
||||
)
|
||||
chosen_reward_type: Literal['balance', 'subscription_days'] | None = Field(
|
||||
default=None,
|
||||
description='Если allow_user_choice=true, юзер может выбрать тип награды',
|
||||
)
|
||||
|
||||
|
||||
class ClaimRewardResponse(BaseModel):
|
||||
"""Результат claim награды."""
|
||||
|
||||
success: bool
|
||||
reward: dict[str, Any]
|
||||
-346
@@ -286,10 +286,6 @@ class Settings(BaseSettings):
|
||||
|
||||
DISPOSABLE_EMAIL_CHECK_ENABLED: bool = True
|
||||
|
||||
# Настройки перевыпуска подписки (revoke + regenerate link)
|
||||
SUBSCRIPTION_REVOKE_ENABLED: bool = True
|
||||
SUBSCRIPTION_REVOKE_COOLDOWN_SECONDS: int = 900 # 15 minutes
|
||||
|
||||
# Настройки простой покупки
|
||||
SIMPLE_SUBSCRIPTION_ENABLED: bool = False
|
||||
SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30
|
||||
@@ -620,19 +616,6 @@ class Settings(BaseSettings):
|
||||
SEVERPAY_RETURN_URL: str | None = None
|
||||
SEVERPAY_LIFETIME: int = 1440 # minutes, 30-4320
|
||||
|
||||
# Apple In-App Purchase
|
||||
APPLE_IAP_ENABLED: bool = False
|
||||
APPLE_IAP_KEY_ID: str | None = None
|
||||
APPLE_IAP_ISSUER_ID: str | None = None
|
||||
APPLE_IAP_BUNDLE_ID: str = 'com.app.client'
|
||||
APPLE_IAP_PRIVATE_KEY: str | None = None # .p8 key contents (PEM)
|
||||
APPLE_IAP_PRIVATE_KEY_PATH: str | None = None # Alternative: path to .p8 file
|
||||
APPLE_IAP_ENVIRONMENT: str = 'Production' # 'Sandbox' or 'Production'
|
||||
APPLE_IAP_WEBHOOK_PATH: str = '/apple-iap-webhook'
|
||||
APPLE_IAP_PRODUCTS: str = (
|
||||
'{"com.app.client.topup.100":10000,"com.app.client.topup.300":30000,"com.app.client.topup.500":50000}'
|
||||
)
|
||||
|
||||
# PayPear (paypear.ru)
|
||||
PAYPEAR_ENABLED: bool = False
|
||||
PAYPEAR_SHOP_ID: str | None = None
|
||||
@@ -685,108 +668,6 @@ class Settings(BaseSettings):
|
||||
AURAPAY_WEBHOOK_PATH: str = '/aurapay-webhook'
|
||||
AURAPAY_RETURN_URL: str | None = None
|
||||
AURAPAY_PAYMENT_LIFETIME_MINUTES: int = 60
|
||||
AURAPAY_SBP_ENABLED: bool = False
|
||||
AURAPAY_SBP_DISPLAY_NAME: str = 'СБП (AuraPay)'
|
||||
AURAPAY_CARD_ENABLED: bool = False
|
||||
AURAPAY_CARD_DISPLAY_NAME: str = 'Карта (AuraPay)'
|
||||
|
||||
# Antilopay (lk.antilopay.com)
|
||||
ANTILOPAY_ENABLED: bool = False
|
||||
ANTILOPAY_SECRET_ID: str | None = None
|
||||
ANTILOPAY_PRIVATE_KEY: str | None = None
|
||||
ANTILOPAY_PUBLIC_KEY: str | None = None
|
||||
ANTILOPAY_PROJECT_ID: str | None = None
|
||||
ANTILOPAY_DISPLAY_NAME: str = 'Antilopay'
|
||||
ANTILOPAY_PRODUCT_NAME: str = 'VPN подписка'
|
||||
ANTILOPAY_PRODUCT_TYPE: str = 'services'
|
||||
ANTILOPAY_CURRENCY: str = 'RUB'
|
||||
ANTILOPAY_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
|
||||
ANTILOPAY_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
|
||||
ANTILOPAY_WEBHOOK_PATH: str = '/antilopay-webhook'
|
||||
ANTILOPAY_RETURN_URL: str | None = None
|
||||
ANTILOPAY_PAYMENT_LIFETIME_MINUTES: int = 60
|
||||
ANTILOPAY_SBP_ENABLED: bool = False
|
||||
ANTILOPAY_SBP_DISPLAY_NAME: str = 'СБП (Antilopay)'
|
||||
ANTILOPAY_CARD_ENABLED: bool = False
|
||||
ANTILOPAY_CARD_DISPLAY_NAME: str = 'Карта (Antilopay)'
|
||||
ANTILOPAY_SBERPAY_ENABLED: bool = False
|
||||
ANTILOPAY_SBERPAY_DISPLAY_NAME: str = 'SberPay (Antilopay)'
|
||||
|
||||
# Jupiter (FPGate P2P v2.1, app.juppiter.tech)
|
||||
JUPITER_ENABLED: bool = False
|
||||
JUPITER_TOKEN: str | None = None
|
||||
JUPITER_SECRET: str | None = None
|
||||
JUPITER_BASE_URL: str = 'https://app.juppiter.tech'
|
||||
JUPITER_METHOD_ID: str | None = None
|
||||
JUPITER_METHOD_DESCRIPTION: str = 'SBP'
|
||||
JUPITER_DISPLAY_NAME: str = 'Jupiter'
|
||||
JUPITER_CURRENCY: str = 'RUB'
|
||||
JUPITER_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
|
||||
JUPITER_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
|
||||
JUPITER_WEBHOOK_PATH: str = '/jupiter-webhook'
|
||||
JUPITER_RETURN_URL: str | None = None
|
||||
JUPITER_PAYMENT_LIFETIME_MINUTES: int = 60
|
||||
JUPITER_FALLBACK_EMAIL: str = 'user@vpn.bot'
|
||||
JUPITER_FALLBACK_PHONE: str = '0000000000'
|
||||
JUPITER_FALLBACK_NAME: str = 'User'
|
||||
JUPITER_SBP_ENABLED: bool = False
|
||||
JUPITER_SBP_DISPLAY_NAME: str = 'СБП (Jupiter)'
|
||||
|
||||
# Donut (Donut P2P, gw.donut.business)
|
||||
DONUT_ENABLED: bool = False
|
||||
DONUT_TOKEN: str | None = None
|
||||
DONUT_SECRET: str | None = None
|
||||
DONUT_BASE_URL: str = 'https://gw.donut.business'
|
||||
DONUT_METHOD_ID: str | None = None
|
||||
DONUT_DISPLAY_NAME: str = 'Donut'
|
||||
DONUT_CURRENCY: str = 'RUB'
|
||||
DONUT_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
|
||||
DONUT_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
|
||||
DONUT_WEBHOOK_PATH: str = '/donut-webhook'
|
||||
DONUT_RETURN_URL: str | None = None
|
||||
DONUT_PAYMENT_LIFETIME_MINUTES: int = 60
|
||||
# Sub-методы Donut (description в PayIn запросе)
|
||||
DONUT_CARD_ENABLED: bool = False
|
||||
DONUT_CARD_DISPLAY_NAME: str = 'Карта (Donut)'
|
||||
DONUT_SBP_ENABLED: bool = False
|
||||
DONUT_SBP_DISPLAY_NAME: str = 'СБП (Donut)'
|
||||
DONUT_SBP_QR_ENABLED: bool = False
|
||||
DONUT_SBP_QR_DISPLAY_NAME: str = 'СБП QR (Donut)'
|
||||
|
||||
# Lava (Lava Business API, gate.lava.ru)
|
||||
LAVA_ENABLED: bool = False
|
||||
LAVA_BASE_URL: str = 'https://gate.lava.ru'
|
||||
LAVA_SHOP_ID: str | None = None # UUID проекта
|
||||
LAVA_SECRET_KEY: str | None = None # secret_key — для подписи запросов
|
||||
LAVA_WEBHOOK_SECRET: str | None = None # secret_key_2 — для проверки подписи webhook
|
||||
LAVA_DISPLAY_NAME: str = 'Lava'
|
||||
LAVA_CURRENCY: str = 'RUB'
|
||||
LAVA_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
|
||||
LAVA_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
|
||||
LAVA_WEBHOOK_PATH: str = '/lava-webhook'
|
||||
LAVA_RETURN_URL: str | None = None
|
||||
LAVA_PAYMENT_LIFETIME_MINUTES: int = 60 # макс 7200 минут (5 дней)
|
||||
# Sub-методы Lava (фильтр через includeService/excludeService на стороне Lava)
|
||||
LAVA_CARD_ENABLED: bool = False
|
||||
LAVA_CARD_DISPLAY_NAME: str = 'Карта (Lava)'
|
||||
LAVA_SBP_ENABLED: bool = False
|
||||
LAVA_SBP_DISPLAY_NAME: str = 'СБП (Lava)'
|
||||
|
||||
# Etoplatezhi (paymentpage.etoplatezhi.ru)
|
||||
ETOPLATEZHI_ENABLED: bool = False
|
||||
ETOPLATEZHI_PROJECT_ID: int | None = None
|
||||
ETOPLATEZHI_SECRET_KEY: str | None = None
|
||||
ETOPLATEZHI_DISPLAY_NAME: str = 'Etoplatezhi'
|
||||
ETOPLATEZHI_CURRENCY: str = 'RUB'
|
||||
ETOPLATEZHI_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
|
||||
ETOPLATEZHI_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
|
||||
ETOPLATEZHI_WEBHOOK_PATH: str = '/etoplatezhi-webhook'
|
||||
ETOPLATEZHI_RETURN_URL: str | None = None
|
||||
ETOPLATEZHI_PAYMENT_LIFETIME_MINUTES: int = 60
|
||||
ETOPLATEZHI_SBP_ENABLED: bool = False
|
||||
ETOPLATEZHI_SBP_DISPLAY_NAME: str = 'СБП (Etoplatezhi)'
|
||||
ETOPLATEZHI_CARD_ENABLED: bool = False
|
||||
ETOPLATEZHI_CARD_DISPLAY_NAME: str = 'Карта (Etoplatezhi)'
|
||||
|
||||
MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet'
|
||||
# Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции)
|
||||
@@ -1867,10 +1748,6 @@ class Settings(BaseSettings):
|
||||
def get_disabled_mode_device_limit(self) -> int | None:
|
||||
return self.get_devices_selection_disabled_amount()
|
||||
|
||||
def is_subscription_revoke_enabled(self) -> bool:
|
||||
"""Проверяет, включен ли перевыпуск подписки."""
|
||||
return self.SUBSCRIPTION_REVOKE_ENABLED
|
||||
|
||||
def is_multi_tariff_enabled(self) -> bool:
|
||||
"""Проверяет, включен ли мультитарифный режим."""
|
||||
return self.MULTI_TARIFF_ENABLED and self.SALES_MODE == 'tariffs'
|
||||
@@ -2150,34 +2027,6 @@ class Settings(BaseSettings):
|
||||
def get_severpay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_severpay_display_name())
|
||||
|
||||
def is_apple_iap_enabled(self) -> bool:
|
||||
return (
|
||||
self.APPLE_IAP_ENABLED
|
||||
and self.APPLE_IAP_KEY_ID is not None
|
||||
and self.APPLE_IAP_ISSUER_ID is not None
|
||||
and (self.APPLE_IAP_PRIVATE_KEY is not None or self.APPLE_IAP_PRIVATE_KEY_PATH is not None)
|
||||
)
|
||||
|
||||
def get_apple_iap_products(self) -> dict[str, int]:
|
||||
"""Return mapping of Apple product ID -> kopeks amount."""
|
||||
import json as _json
|
||||
|
||||
try:
|
||||
return _json.loads(self.APPLE_IAP_PRODUCTS)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def get_apple_iap_private_key(self) -> str | None:
|
||||
"""Return the .p8 private key contents."""
|
||||
if self.APPLE_IAP_PRIVATE_KEY:
|
||||
return self.APPLE_IAP_PRIVATE_KEY
|
||||
if self.APPLE_IAP_PRIVATE_KEY_PATH:
|
||||
try:
|
||||
return Path(self.APPLE_IAP_PRIVATE_KEY_PATH).read_text().strip()
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def is_paypear_enabled(self) -> bool:
|
||||
return self.PAYPEAR_ENABLED and self.PAYPEAR_SHOP_ID is not None and self.PAYPEAR_SECRET_KEY is not None
|
||||
|
||||
@@ -2228,201 +2077,6 @@ class Settings(BaseSettings):
|
||||
def get_aurapay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_aurapay_display_name())
|
||||
|
||||
def is_aurapay_sbp_enabled(self) -> bool:
|
||||
return self.AURAPAY_SBP_ENABLED and self.is_aurapay_enabled()
|
||||
|
||||
def get_aurapay_sbp_display_name(self) -> str:
|
||||
name = (self.AURAPAY_SBP_DISPLAY_NAME or '').strip()
|
||||
return name or 'СБП (AuraPay)'
|
||||
|
||||
def get_aurapay_sbp_display_name_html(self) -> str:
|
||||
return html.escape(self.get_aurapay_sbp_display_name())
|
||||
|
||||
def is_aurapay_card_enabled(self) -> bool:
|
||||
return self.AURAPAY_CARD_ENABLED and self.is_aurapay_enabled()
|
||||
|
||||
def get_aurapay_card_display_name(self) -> str:
|
||||
name = (self.AURAPAY_CARD_DISPLAY_NAME or '').strip()
|
||||
return name or 'Карта (AuraPay)'
|
||||
|
||||
def get_aurapay_card_display_name_html(self) -> str:
|
||||
return html.escape(self.get_aurapay_card_display_name())
|
||||
|
||||
def is_antilopay_enabled(self) -> bool:
|
||||
return (
|
||||
self.ANTILOPAY_ENABLED
|
||||
and self.ANTILOPAY_SECRET_ID is not None
|
||||
and self.ANTILOPAY_PRIVATE_KEY is not None
|
||||
and self.ANTILOPAY_PUBLIC_KEY is not None
|
||||
and self.ANTILOPAY_PROJECT_ID is not None
|
||||
)
|
||||
|
||||
def get_antilopay_display_name(self) -> str:
|
||||
name = (self.ANTILOPAY_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Antilopay'
|
||||
|
||||
def get_antilopay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_antilopay_display_name())
|
||||
|
||||
def is_antilopay_sbp_enabled(self) -> bool:
|
||||
return self.ANTILOPAY_SBP_ENABLED and self.is_antilopay_enabled()
|
||||
|
||||
def get_antilopay_sbp_display_name(self) -> str:
|
||||
name = (self.ANTILOPAY_SBP_DISPLAY_NAME or '').strip()
|
||||
return name or 'СБП (Antilopay)'
|
||||
|
||||
def get_antilopay_sbp_display_name_html(self) -> str:
|
||||
return html.escape(self.get_antilopay_sbp_display_name())
|
||||
|
||||
def is_antilopay_card_enabled(self) -> bool:
|
||||
return self.ANTILOPAY_CARD_ENABLED and self.is_antilopay_enabled()
|
||||
|
||||
def get_antilopay_card_display_name(self) -> str:
|
||||
name = (self.ANTILOPAY_CARD_DISPLAY_NAME or '').strip()
|
||||
return name or 'Карта (Antilopay)'
|
||||
|
||||
def get_antilopay_card_display_name_html(self) -> str:
|
||||
return html.escape(self.get_antilopay_card_display_name())
|
||||
|
||||
def is_antilopay_sberpay_enabled(self) -> bool:
|
||||
return self.ANTILOPAY_SBERPAY_ENABLED and self.is_antilopay_enabled()
|
||||
|
||||
def get_antilopay_sberpay_display_name(self) -> str:
|
||||
name = (self.ANTILOPAY_SBERPAY_DISPLAY_NAME or '').strip()
|
||||
return name or 'SberPay (Antilopay)'
|
||||
|
||||
def get_antilopay_sberpay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_antilopay_sberpay_display_name())
|
||||
|
||||
def is_jupiter_enabled(self) -> bool:
|
||||
return self.JUPITER_ENABLED and self.JUPITER_TOKEN is not None and self.JUPITER_SECRET is not None
|
||||
|
||||
def get_jupiter_display_name(self) -> str:
|
||||
name = (self.JUPITER_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Jupiter'
|
||||
|
||||
def get_jupiter_display_name_html(self) -> str:
|
||||
return html.escape(self.get_jupiter_display_name())
|
||||
|
||||
def is_jupiter_sbp_enabled(self) -> bool:
|
||||
return self.JUPITER_SBP_ENABLED and self.is_jupiter_enabled()
|
||||
|
||||
def get_jupiter_sbp_display_name(self) -> str:
|
||||
name = (self.JUPITER_SBP_DISPLAY_NAME or '').strip()
|
||||
return name or 'СБП (Jupiter)'
|
||||
|
||||
def get_jupiter_sbp_display_name_html(self) -> str:
|
||||
return html.escape(self.get_jupiter_sbp_display_name())
|
||||
|
||||
def is_donut_enabled(self) -> bool:
|
||||
return self.DONUT_ENABLED and self.DONUT_TOKEN is not None and self.DONUT_SECRET is not None
|
||||
|
||||
def get_donut_display_name(self) -> str:
|
||||
name = (self.DONUT_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Donut'
|
||||
|
||||
def get_donut_display_name_html(self) -> str:
|
||||
return html.escape(self.get_donut_display_name())
|
||||
|
||||
def is_donut_card_enabled(self) -> bool:
|
||||
return self.DONUT_CARD_ENABLED and self.is_donut_enabled()
|
||||
|
||||
def get_donut_card_display_name(self) -> str:
|
||||
name = (self.DONUT_CARD_DISPLAY_NAME or '').strip()
|
||||
return name or 'Карта (Donut)'
|
||||
|
||||
def get_donut_card_display_name_html(self) -> str:
|
||||
return html.escape(self.get_donut_card_display_name())
|
||||
|
||||
def is_donut_sbp_enabled(self) -> bool:
|
||||
return self.DONUT_SBP_ENABLED and self.is_donut_enabled()
|
||||
|
||||
def get_donut_sbp_display_name(self) -> str:
|
||||
name = (self.DONUT_SBP_DISPLAY_NAME or '').strip()
|
||||
return name or 'СБП (Donut)'
|
||||
|
||||
def get_donut_sbp_display_name_html(self) -> str:
|
||||
return html.escape(self.get_donut_sbp_display_name())
|
||||
|
||||
def is_donut_sbp_qr_enabled(self) -> bool:
|
||||
return self.DONUT_SBP_QR_ENABLED and self.is_donut_enabled()
|
||||
|
||||
def get_donut_sbp_qr_display_name(self) -> str:
|
||||
name = (self.DONUT_SBP_QR_DISPLAY_NAME or '').strip()
|
||||
return name or 'СБП QR (Donut)'
|
||||
|
||||
def get_donut_sbp_qr_display_name_html(self) -> str:
|
||||
return html.escape(self.get_donut_sbp_qr_display_name())
|
||||
|
||||
def is_lava_enabled(self) -> bool:
|
||||
return (
|
||||
self.LAVA_ENABLED
|
||||
and self.LAVA_SHOP_ID is not None
|
||||
and self.LAVA_SECRET_KEY is not None
|
||||
and self.LAVA_WEBHOOK_SECRET is not None
|
||||
)
|
||||
|
||||
def get_lava_display_name(self) -> str:
|
||||
name = (self.LAVA_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Lava'
|
||||
|
||||
def get_lava_display_name_html(self) -> str:
|
||||
return html.escape(self.get_lava_display_name())
|
||||
|
||||
def is_lava_card_enabled(self) -> bool:
|
||||
return self.LAVA_CARD_ENABLED and self.is_lava_enabled()
|
||||
|
||||
def get_lava_card_display_name(self) -> str:
|
||||
name = (self.LAVA_CARD_DISPLAY_NAME or '').strip()
|
||||
return name or 'Карта (Lava)'
|
||||
|
||||
def get_lava_card_display_name_html(self) -> str:
|
||||
return html.escape(self.get_lava_card_display_name())
|
||||
|
||||
def is_lava_sbp_enabled(self) -> bool:
|
||||
return self.LAVA_SBP_ENABLED and self.is_lava_enabled()
|
||||
|
||||
def get_lava_sbp_display_name(self) -> str:
|
||||
name = (self.LAVA_SBP_DISPLAY_NAME or '').strip()
|
||||
return name or 'СБП (Lava)'
|
||||
|
||||
def get_lava_sbp_display_name_html(self) -> str:
|
||||
return html.escape(self.get_lava_sbp_display_name())
|
||||
|
||||
def is_etoplatezhi_enabled(self) -> bool:
|
||||
return (
|
||||
self.ETOPLATEZHI_ENABLED
|
||||
and self.ETOPLATEZHI_PROJECT_ID is not None
|
||||
and self.ETOPLATEZHI_SECRET_KEY is not None
|
||||
)
|
||||
|
||||
def get_etoplatezhi_display_name(self) -> str:
|
||||
name = (self.ETOPLATEZHI_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Etoplatezhi'
|
||||
|
||||
def get_etoplatezhi_display_name_html(self) -> str:
|
||||
return html.escape(self.get_etoplatezhi_display_name())
|
||||
|
||||
def is_etoplatezhi_sbp_enabled(self) -> bool:
|
||||
return self.ETOPLATEZHI_SBP_ENABLED and self.is_etoplatezhi_enabled()
|
||||
|
||||
def get_etoplatezhi_sbp_display_name(self) -> str:
|
||||
name = (self.ETOPLATEZHI_SBP_DISPLAY_NAME or '').strip()
|
||||
return name or 'СБП (Etoplatezhi)'
|
||||
|
||||
def get_etoplatezhi_sbp_display_name_html(self) -> str:
|
||||
return html.escape(self.get_etoplatezhi_sbp_display_name())
|
||||
|
||||
def is_etoplatezhi_card_enabled(self) -> bool:
|
||||
return self.ETOPLATEZHI_CARD_ENABLED and self.is_etoplatezhi_enabled()
|
||||
|
||||
def get_etoplatezhi_card_display_name(self) -> str:
|
||||
name = (self.ETOPLATEZHI_CARD_DISPLAY_NAME or '').strip()
|
||||
return name or 'Карта (Etoplatezhi)'
|
||||
|
||||
def get_etoplatezhi_card_display_name_html(self) -> str:
|
||||
return html.escape(self.get_etoplatezhi_card_display_name())
|
||||
|
||||
def is_kassa_ai_sbp_enabled(self) -> bool:
|
||||
return self.KASSA_AI_SBP_ENABLED and self.is_kassa_ai_enabled()
|
||||
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
"""CRUD операции для платежей Antilopay."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import AntilopayPayment
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_antilopay_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
order_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str = 'RUB',
|
||||
description: str | None = None,
|
||||
payment_url: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
antilopay_payment_id: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata_json: dict | None = None,
|
||||
) -> AntilopayPayment:
|
||||
"""Создает запись о платеже Antilopay."""
|
||||
payment = AntilopayPayment(
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method,
|
||||
antilopay_payment_id=antilopay_payment_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata_json,
|
||||
status='pending',
|
||||
is_paid=False,
|
||||
)
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info('Создан платеж Antilopay', order_id=order_id, user_id=user_id)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_antilopay_payment_by_order_id(db: AsyncSession, order_id: str) -> AntilopayPayment | None:
|
||||
"""Получает платеж по order_id (internal)."""
|
||||
result = await db.execute(select(AntilopayPayment).where(AntilopayPayment.order_id == order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_antilopay_payment_by_invoice_id(db: AsyncSession, antilopay_payment_id: str) -> AntilopayPayment | None:
|
||||
"""Получает платеж по ID от Antilopay."""
|
||||
result = await db.execute(
|
||||
select(AntilopayPayment).where(AntilopayPayment.antilopay_payment_id == antilopay_payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_antilopay_payment_by_id(db: AsyncSession, payment_id: int) -> AntilopayPayment | None:
|
||||
"""Получает платеж по ID."""
|
||||
result = await db.execute(select(AntilopayPayment).where(AntilopayPayment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_antilopay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> AntilopayPayment | None:
|
||||
"""Получает платеж по ID с блокировкой FOR UPDATE."""
|
||||
result = await db.execute(
|
||||
select(AntilopayPayment)
|
||||
.where(AntilopayPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_antilopay_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: AntilopayPayment,
|
||||
*,
|
||||
status: str,
|
||||
is_paid: bool | None = None,
|
||||
antilopay_payment_id: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
callback_payload: dict | None = None,
|
||||
transaction_id: int | None = None,
|
||||
) -> AntilopayPayment:
|
||||
"""Обновляет статус платежа."""
|
||||
payment.status = status
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
if is_paid is not None:
|
||||
payment.is_paid = is_paid
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
if antilopay_payment_id is not None:
|
||||
payment.antilopay_payment_id = antilopay_payment_id
|
||||
if payment_method is not None:
|
||||
payment.payment_method = payment_method
|
||||
if callback_payload is not None:
|
||||
payment.callback_payload = callback_payload
|
||||
if transaction_id is not None:
|
||||
payment.transaction_id = transaction_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(
|
||||
'Обновлен статус платежа Antilopay',
|
||||
order_id=payment.order_id,
|
||||
status=status,
|
||||
is_paid=payment.is_paid,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pending_antilopay_payments(db: AsyncSession, user_id: int) -> list[AntilopayPayment]:
|
||||
"""Получает незавершенные платежи пользователя."""
|
||||
result = await db.execute(
|
||||
select(AntilopayPayment).where(
|
||||
AntilopayPayment.user_id == user_id,
|
||||
AntilopayPayment.status == 'pending',
|
||||
AntilopayPayment.is_paid == False,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_expired_pending_antilopay_payments(
|
||||
db: AsyncSession,
|
||||
) -> list[AntilopayPayment]:
|
||||
"""Получает просроченные платежи в статусе pending."""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(AntilopayPayment).where(
|
||||
AntilopayPayment.status == 'pending',
|
||||
AntilopayPayment.is_paid == False,
|
||||
AntilopayPayment.expires_at < now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def link_antilopay_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: AntilopayPayment,
|
||||
transaction_id: int,
|
||||
) -> AntilopayPayment:
|
||||
"""Связывает платеж с транзакцией."""
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -1,83 +0,0 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import AppleTransaction
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_apple_transaction(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
transaction_id: str,
|
||||
product_id: str,
|
||||
bundle_id: str,
|
||||
amount_kopeks: int,
|
||||
environment: str,
|
||||
original_transaction_id: str | None = None,
|
||||
transaction_id_fk: int | None = None,
|
||||
) -> AppleTransaction:
|
||||
apple_txn = AppleTransaction(
|
||||
user_id=user_id,
|
||||
transaction_id=transaction_id,
|
||||
original_transaction_id=original_transaction_id,
|
||||
product_id=product_id,
|
||||
bundle_id=bundle_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
environment=environment,
|
||||
status='verified',
|
||||
is_paid=True,
|
||||
paid_at=datetime.now(UTC),
|
||||
transaction_id_fk=transaction_id_fk,
|
||||
)
|
||||
|
||||
db.add(apple_txn)
|
||||
await db.flush()
|
||||
await db.refresh(apple_txn)
|
||||
|
||||
logger.info(
|
||||
'Создана Apple транзакция',
|
||||
transaction_id=transaction_id,
|
||||
product_id=product_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
user_id=user_id,
|
||||
)
|
||||
return apple_txn
|
||||
|
||||
|
||||
async def get_apple_transaction_by_transaction_id(db: AsyncSession, transaction_id: str) -> AppleTransaction | None:
|
||||
result = await db.execute(select(AppleTransaction).where(AppleTransaction.transaction_id == transaction_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_apple_transaction_by_transaction_id_for_update(
|
||||
db: AsyncSession, transaction_id: str
|
||||
) -> AppleTransaction | None:
|
||||
"""Get apple transaction with FOR UPDATE lock for safe concurrent access."""
|
||||
result = await db.execute(
|
||||
select(AppleTransaction).where(AppleTransaction.transaction_id == transaction_id).with_for_update()
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def mark_apple_transaction_refunded(db: AsyncSession, transaction_id: str) -> AppleTransaction | None:
|
||||
"""Mark an Apple transaction as refunded. Returns the transaction or None if not found."""
|
||||
apple_txn = await get_apple_transaction_by_transaction_id(db, transaction_id)
|
||||
if not apple_txn:
|
||||
return None
|
||||
|
||||
apple_txn.status = 'refunded'
|
||||
apple_txn.refunded_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(apple_txn)
|
||||
|
||||
logger.info(
|
||||
'Apple транзакция помечена как возврат',
|
||||
transaction_id=transaction_id,
|
||||
user_id=apple_txn.user_id,
|
||||
)
|
||||
return apple_txn
|
||||
@@ -1,155 +0,0 @@
|
||||
"""CRUD операции для платежей Donut (Donut P2P)."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import DonutPayment
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_donut_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
order_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str = 'RUB',
|
||||
description: str | None = None,
|
||||
payment_url: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
donut_transaction_id: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata_json: dict | None = None,
|
||||
) -> DonutPayment:
|
||||
"""Создаёт запись о платеже Donut."""
|
||||
payment = DonutPayment(
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method,
|
||||
donut_transaction_id=donut_transaction_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata_json,
|
||||
status='pending',
|
||||
is_paid=False,
|
||||
)
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info('Создан платеж Donut', order_id=order_id, user_id=user_id)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_donut_payment_by_order_id(db: AsyncSession, order_id: str) -> DonutPayment | None:
|
||||
"""Получает платеж по order_id (internal)."""
|
||||
result = await db.execute(select(DonutPayment).where(DonutPayment.order_id == order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_donut_payment_by_invoice_id(db: AsyncSession, donut_transaction_id: str) -> DonutPayment | None:
|
||||
"""Получает платёж по transaction_id, выданному Donut."""
|
||||
result = await db.execute(select(DonutPayment).where(DonutPayment.donut_transaction_id == donut_transaction_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_donut_payment_by_id(db: AsyncSession, payment_id: int) -> DonutPayment | None:
|
||||
"""Получает платеж по локальному ID."""
|
||||
result = await db.execute(select(DonutPayment).where(DonutPayment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_donut_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> DonutPayment | None:
|
||||
"""Получает платёж с блокировкой FOR UPDATE."""
|
||||
result = await db.execute(
|
||||
select(DonutPayment)
|
||||
.where(DonutPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_donut_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: DonutPayment,
|
||||
*,
|
||||
status: str,
|
||||
is_paid: bool | None = None,
|
||||
donut_transaction_id: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
callback_payload: dict | None = None,
|
||||
transaction_id: int | None = None,
|
||||
) -> DonutPayment:
|
||||
"""Обновляет статус платежа."""
|
||||
payment.status = status
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
if is_paid is not None:
|
||||
payment.is_paid = is_paid
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
if donut_transaction_id is not None:
|
||||
payment.donut_transaction_id = donut_transaction_id
|
||||
if payment_method is not None:
|
||||
payment.payment_method = payment_method
|
||||
if callback_payload is not None:
|
||||
payment.callback_payload = callback_payload
|
||||
if transaction_id is not None:
|
||||
payment.transaction_id = transaction_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(
|
||||
'Обновлён статус платежа Donut',
|
||||
order_id=payment.order_id,
|
||||
status=status,
|
||||
is_paid=payment.is_paid,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pending_donut_payments(db: AsyncSession, user_id: int) -> list[DonutPayment]:
|
||||
"""Возвращает незавершённые платежи пользователя."""
|
||||
result = await db.execute(
|
||||
select(DonutPayment).where(
|
||||
DonutPayment.user_id == user_id,
|
||||
DonutPayment.status == 'pending',
|
||||
DonutPayment.is_paid == False,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_expired_pending_donut_payments(db: AsyncSession) -> list[DonutPayment]:
|
||||
"""Возвращает просроченные платежи в статусе pending."""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(DonutPayment).where(
|
||||
DonutPayment.status == 'pending',
|
||||
DonutPayment.is_paid == False,
|
||||
DonutPayment.expires_at < now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def link_donut_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: DonutPayment,
|
||||
transaction_id: int,
|
||||
) -> DonutPayment:
|
||||
"""Связывает платёж с транзакцией."""
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -1,161 +0,0 @@
|
||||
"""CRUD операции для платежей Etoplatezhi."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import EtoplatezhiPayment
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_etoplatezhi_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
order_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str = 'RUB',
|
||||
description: str | None = None,
|
||||
payment_url: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
etoplatezhi_payment_id: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata_json: dict | None = None,
|
||||
) -> EtoplatezhiPayment:
|
||||
"""Создает запись о платеже Etoplatezhi."""
|
||||
payment = EtoplatezhiPayment(
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method,
|
||||
etoplatezhi_payment_id=etoplatezhi_payment_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata_json,
|
||||
status='pending',
|
||||
is_paid=False,
|
||||
)
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info('Создан платеж Etoplatezhi', order_id=order_id, user_id=user_id)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_etoplatezhi_payment_by_order_id(db: AsyncSession, order_id: str) -> EtoplatezhiPayment | None:
|
||||
"""Получает платеж по order_id (internal)."""
|
||||
result = await db.execute(select(EtoplatezhiPayment).where(EtoplatezhiPayment.order_id == order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_etoplatezhi_payment_by_invoice_id(
|
||||
db: AsyncSession, etoplatezhi_payment_id: str
|
||||
) -> EtoplatezhiPayment | None:
|
||||
"""Получает платеж по ID от Etoplatezhi."""
|
||||
result = await db.execute(
|
||||
select(EtoplatezhiPayment).where(EtoplatezhiPayment.etoplatezhi_payment_id == etoplatezhi_payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_etoplatezhi_payment_by_id(db: AsyncSession, payment_id: int) -> EtoplatezhiPayment | None:
|
||||
"""Получает платеж по ID."""
|
||||
result = await db.execute(select(EtoplatezhiPayment).where(EtoplatezhiPayment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_etoplatezhi_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> EtoplatezhiPayment | None:
|
||||
"""Получает платеж по ID с блокировкой FOR UPDATE."""
|
||||
result = await db.execute(
|
||||
select(EtoplatezhiPayment)
|
||||
.where(EtoplatezhiPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_etoplatezhi_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: EtoplatezhiPayment,
|
||||
*,
|
||||
status: str,
|
||||
is_paid: bool | None = None,
|
||||
etoplatezhi_payment_id: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
callback_payload: dict | None = None,
|
||||
transaction_id: int | None = None,
|
||||
) -> EtoplatezhiPayment:
|
||||
"""Обновляет статус платежа."""
|
||||
payment.status = status
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
if is_paid is not None:
|
||||
payment.is_paid = is_paid
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
if etoplatezhi_payment_id is not None:
|
||||
payment.etoplatezhi_payment_id = etoplatezhi_payment_id
|
||||
if payment_method is not None:
|
||||
payment.payment_method = payment_method
|
||||
if callback_payload is not None:
|
||||
payment.callback_payload = callback_payload
|
||||
if transaction_id is not None:
|
||||
payment.transaction_id = transaction_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(
|
||||
'Обновлен статус платежа Etoplatezhi',
|
||||
order_id=payment.order_id,
|
||||
status=status,
|
||||
is_paid=payment.is_paid,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pending_etoplatezhi_payments(db: AsyncSession, user_id: int) -> list[EtoplatezhiPayment]:
|
||||
"""Получает незавершенные платежи пользователя."""
|
||||
result = await db.execute(
|
||||
select(EtoplatezhiPayment).where(
|
||||
EtoplatezhiPayment.user_id == user_id,
|
||||
EtoplatezhiPayment.status == 'pending',
|
||||
EtoplatezhiPayment.is_paid == False,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_expired_pending_etoplatezhi_payments(
|
||||
db: AsyncSession,
|
||||
) -> list[EtoplatezhiPayment]:
|
||||
"""Получает просроченные платежи в статусе pending."""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(EtoplatezhiPayment).where(
|
||||
EtoplatezhiPayment.status == 'pending',
|
||||
EtoplatezhiPayment.is_paid == False,
|
||||
EtoplatezhiPayment.expires_at < now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def link_etoplatezhi_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: EtoplatezhiPayment,
|
||||
transaction_id: int,
|
||||
) -> EtoplatezhiPayment:
|
||||
"""Связывает платеж с транзакцией."""
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -1,157 +0,0 @@
|
||||
"""CRUD операции для платежей Jupiter (FPGate P2P v2.1)."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import JupiterPayment
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_jupiter_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
order_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str = 'RUB',
|
||||
description: str | None = None,
|
||||
payment_url: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
jupiter_transaction_id: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata_json: dict | None = None,
|
||||
) -> JupiterPayment:
|
||||
"""Создаёт запись о платеже Jupiter."""
|
||||
payment = JupiterPayment(
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method,
|
||||
jupiter_transaction_id=jupiter_transaction_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata_json,
|
||||
status='pending',
|
||||
is_paid=False,
|
||||
)
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info('Создан платеж Jupiter', order_id=order_id, user_id=user_id)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_jupiter_payment_by_order_id(db: AsyncSession, order_id: str) -> JupiterPayment | None:
|
||||
"""Получает платеж по order_id (internal)."""
|
||||
result = await db.execute(select(JupiterPayment).where(JupiterPayment.order_id == order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_jupiter_payment_by_invoice_id(db: AsyncSession, jupiter_transaction_id: str) -> JupiterPayment | None:
|
||||
"""Получает платёж по transaction_id, выданному Jupiter."""
|
||||
result = await db.execute(
|
||||
select(JupiterPayment).where(JupiterPayment.jupiter_transaction_id == jupiter_transaction_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_jupiter_payment_by_id(db: AsyncSession, payment_id: int) -> JupiterPayment | None:
|
||||
"""Получает платеж по локальному ID."""
|
||||
result = await db.execute(select(JupiterPayment).where(JupiterPayment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_jupiter_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> JupiterPayment | None:
|
||||
"""Получает платёж с блокировкой FOR UPDATE."""
|
||||
result = await db.execute(
|
||||
select(JupiterPayment)
|
||||
.where(JupiterPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_jupiter_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: JupiterPayment,
|
||||
*,
|
||||
status: str,
|
||||
is_paid: bool | None = None,
|
||||
jupiter_transaction_id: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
callback_payload: dict | None = None,
|
||||
transaction_id: int | None = None,
|
||||
) -> JupiterPayment:
|
||||
"""Обновляет статус платежа."""
|
||||
payment.status = status
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
if is_paid is not None:
|
||||
payment.is_paid = is_paid
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
if jupiter_transaction_id is not None:
|
||||
payment.jupiter_transaction_id = jupiter_transaction_id
|
||||
if payment_method is not None:
|
||||
payment.payment_method = payment_method
|
||||
if callback_payload is not None:
|
||||
payment.callback_payload = callback_payload
|
||||
if transaction_id is not None:
|
||||
payment.transaction_id = transaction_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(
|
||||
'Обновлён статус платежа Jupiter',
|
||||
order_id=payment.order_id,
|
||||
status=status,
|
||||
is_paid=payment.is_paid,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pending_jupiter_payments(db: AsyncSession, user_id: int) -> list[JupiterPayment]:
|
||||
"""Возвращает незавершённые платежи пользователя."""
|
||||
result = await db.execute(
|
||||
select(JupiterPayment).where(
|
||||
JupiterPayment.user_id == user_id,
|
||||
JupiterPayment.status == 'pending',
|
||||
JupiterPayment.is_paid == False,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_expired_pending_jupiter_payments(db: AsyncSession) -> list[JupiterPayment]:
|
||||
"""Возвращает просроченные платежи в статусе pending."""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(JupiterPayment).where(
|
||||
JupiterPayment.status == 'pending',
|
||||
JupiterPayment.is_paid == False,
|
||||
JupiterPayment.expires_at < now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def link_jupiter_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: JupiterPayment,
|
||||
transaction_id: int,
|
||||
) -> JupiterPayment:
|
||||
"""Связывает платёж с транзакцией."""
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -1,155 +0,0 @@
|
||||
"""CRUD операции для платежей Lava (Lava Business)."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import LavaPayment
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_lava_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
order_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str = 'RUB',
|
||||
description: str | None = None,
|
||||
payment_url: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
lava_invoice_id: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata_json: dict | None = None,
|
||||
) -> LavaPayment:
|
||||
"""Создаёт запись о платеже Lava."""
|
||||
payment = LavaPayment(
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method,
|
||||
lava_invoice_id=lava_invoice_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata_json,
|
||||
status='pending',
|
||||
is_paid=False,
|
||||
)
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info('Создан платеж Lava', order_id=order_id, user_id=user_id)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_lava_payment_by_order_id(db: AsyncSession, order_id: str) -> LavaPayment | None:
|
||||
"""Получает платёж по нашему orderId."""
|
||||
result = await db.execute(select(LavaPayment).where(LavaPayment.order_id == order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_lava_payment_by_invoice_id(db: AsyncSession, lava_invoice_id: str) -> LavaPayment | None:
|
||||
"""Получает платёж по invoice_id, выданному Lava."""
|
||||
result = await db.execute(select(LavaPayment).where(LavaPayment.lava_invoice_id == lava_invoice_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_lava_payment_by_id(db: AsyncSession, payment_id: int) -> LavaPayment | None:
|
||||
"""Получает платёж по локальному ID."""
|
||||
result = await db.execute(select(LavaPayment).where(LavaPayment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_lava_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> LavaPayment | None:
|
||||
"""Получает платёж с FOR UPDATE-блокировкой."""
|
||||
result = await db.execute(
|
||||
select(LavaPayment)
|
||||
.where(LavaPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_lava_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: LavaPayment,
|
||||
*,
|
||||
status: str,
|
||||
is_paid: bool | None = None,
|
||||
lava_invoice_id: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
callback_payload: dict | None = None,
|
||||
transaction_id: int | None = None,
|
||||
) -> LavaPayment:
|
||||
"""Обновляет статус платежа."""
|
||||
payment.status = status
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
if is_paid is not None:
|
||||
payment.is_paid = is_paid
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
if lava_invoice_id is not None:
|
||||
payment.lava_invoice_id = lava_invoice_id
|
||||
if payment_method is not None:
|
||||
payment.payment_method = payment_method
|
||||
if callback_payload is not None:
|
||||
payment.callback_payload = callback_payload
|
||||
if transaction_id is not None:
|
||||
payment.transaction_id = transaction_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(
|
||||
'Обновлён статус платежа Lava',
|
||||
order_id=payment.order_id,
|
||||
status=status,
|
||||
is_paid=payment.is_paid,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pending_lava_payments(db: AsyncSession, user_id: int) -> list[LavaPayment]:
|
||||
"""Возвращает незавершённые платежи пользователя."""
|
||||
result = await db.execute(
|
||||
select(LavaPayment).where(
|
||||
LavaPayment.user_id == user_id,
|
||||
LavaPayment.status == 'pending',
|
||||
LavaPayment.is_paid == False,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_expired_pending_lava_payments(db: AsyncSession) -> list[LavaPayment]:
|
||||
"""Возвращает просроченные платежи в статусе pending."""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(LavaPayment).where(
|
||||
LavaPayment.status == 'pending',
|
||||
LavaPayment.is_paid == False,
|
||||
LavaPayment.expires_at < now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def link_lava_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: LavaPayment,
|
||||
transaction_id: int,
|
||||
) -> LavaPayment:
|
||||
"""Связывает платёж с транзакцией."""
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -200,8 +200,6 @@ async def create_tariff(
|
||||
traffic_reset_mode: str | None = None, # DAY, WEEK, MONTH, MONTH_ROLLING, NO_RESET, None = глобальная настройка
|
||||
# Внешний сквад RemnaWave
|
||||
external_squad_uuid: str | None = None,
|
||||
# Бонусные дни для Tasks (subscription_days reward)
|
||||
bonus_days_per_purchase: int = 0,
|
||||
) -> Tariff:
|
||||
"""Создает новый тариф."""
|
||||
normalized_prices = _normalize_period_prices(period_prices)
|
||||
@@ -242,8 +240,6 @@ async def create_tariff(
|
||||
traffic_reset_mode=traffic_reset_mode,
|
||||
# Внешний сквад
|
||||
external_squad_uuid=external_squad_uuid,
|
||||
# Бонусные дни Tasks
|
||||
bonus_days_per_purchase=max(0, bonus_days_per_purchase),
|
||||
)
|
||||
|
||||
db.add(tariff)
|
||||
@@ -313,8 +309,6 @@ async def update_tariff(
|
||||
traffic_reset_mode: str | None = ..., # ... = не передан, None = сбросить к глобальной настройке
|
||||
# Внешний сквад RemnaWave
|
||||
external_squad_uuid: str | None = ..., # ... = не передан, None = убрать внешний сквад
|
||||
# Бонусные дни для Tasks
|
||||
bonus_days_per_purchase: int | None = None,
|
||||
) -> Tariff:
|
||||
"""Обновляет существующий тариф."""
|
||||
if name is not None:
|
||||
@@ -384,9 +378,6 @@ async def update_tariff(
|
||||
# Внешний сквад
|
||||
if external_squad_uuid is not ...:
|
||||
tariff.external_squad_uuid = external_squad_uuid
|
||||
# Бонусные дни Tasks
|
||||
if bonus_days_per_purchase is not None:
|
||||
tariff.bonus_days_per_purchase = max(0, bonus_days_per_purchase)
|
||||
|
||||
# Обновляем промогруппы если указаны
|
||||
if promo_group_ids is not None:
|
||||
|
||||
@@ -1,388 +0,0 @@
|
||||
"""CRUD операции для системы заданий с наградами."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
Task,
|
||||
TaskPartnerChannel,
|
||||
TaskRewardType,
|
||||
TaskType,
|
||||
TaskUserAudience,
|
||||
UserTaskProgress,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Task CRUD
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
async def create_task(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
title: dict[str, str],
|
||||
description: dict[str, str],
|
||||
task_type: TaskType | str,
|
||||
reward_type: TaskRewardType | str,
|
||||
target_value: int = 1,
|
||||
reward_value: int = 0,
|
||||
target_meta: dict[str, Any] | None = None,
|
||||
reward_meta: dict[str, Any] | None = None,
|
||||
icon: str | None = None,
|
||||
is_active: bool = True,
|
||||
sort_order: int = 0,
|
||||
allow_user_choice: bool = False,
|
||||
user_audience: TaskUserAudience | str = TaskUserAudience.BOTH,
|
||||
promo_group_id: int | None = None,
|
||||
parent_task_id: int | None = None,
|
||||
level: int = 1,
|
||||
starts_at: datetime | None = None,
|
||||
ends_at: datetime | None = None,
|
||||
) -> Task:
|
||||
"""Создаёт новый шаблон задания."""
|
||||
task = Task(
|
||||
title=title,
|
||||
description=description,
|
||||
icon=icon,
|
||||
is_active=is_active,
|
||||
sort_order=sort_order,
|
||||
task_type=task_type.value if isinstance(task_type, TaskType) else task_type,
|
||||
target_value=target_value,
|
||||
target_meta=target_meta or {},
|
||||
reward_type=reward_type.value if isinstance(reward_type, TaskRewardType) else reward_type,
|
||||
reward_value=reward_value,
|
||||
reward_meta=reward_meta or {},
|
||||
allow_user_choice=allow_user_choice,
|
||||
user_audience=user_audience.value if isinstance(user_audience, TaskUserAudience) else user_audience,
|
||||
promo_group_id=promo_group_id,
|
||||
parent_task_id=parent_task_id,
|
||||
level=level,
|
||||
starts_at=starts_at,
|
||||
ends_at=ends_at,
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
logger.info('Создано задание', task_id=task.id, type=task.task_type, level=task.level)
|
||||
return task
|
||||
|
||||
|
||||
async def get_task_by_id(db: AsyncSession, task_id: int) -> Task | None:
|
||||
result = await db.execute(
|
||||
select(Task).options(selectinload(Task.promo_group)).where(Task.id == task_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_tasks(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
include_inactive: bool = False,
|
||||
parent_task_id: int | None = None,
|
||||
) -> list[Task]:
|
||||
"""Список заданий (для админа). По умолчанию исключает неактивные."""
|
||||
stmt = (
|
||||
select(Task)
|
||||
.options(selectinload(Task.promo_group))
|
||||
.order_by(Task.sort_order, Task.id)
|
||||
)
|
||||
if not include_inactive:
|
||||
stmt = stmt.where(Task.is_active == True)
|
||||
if parent_task_id is not None:
|
||||
stmt = stmt.where(Task.parent_task_id == parent_task_id)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def update_task(db: AsyncSession, task: Task, **fields: Any) -> Task:
|
||||
"""Обновляет поля задания. Enum-поля принимаются как enum или строка."""
|
||||
for key, value in fields.items():
|
||||
if key == 'task_type' and isinstance(value, TaskType):
|
||||
value = value.value
|
||||
if key == 'reward_type' and isinstance(value, TaskRewardType):
|
||||
value = value.value
|
||||
if key == 'user_audience' and isinstance(value, TaskUserAudience):
|
||||
value = value.value
|
||||
setattr(task, key, value)
|
||||
task.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
logger.info('Обновлено задание', task_id=task.id)
|
||||
return task
|
||||
|
||||
|
||||
async def delete_task(db: AsyncSession, task: Task) -> None:
|
||||
await db.delete(task)
|
||||
await db.commit()
|
||||
logger.info('Удалено задание', task_id=task.id)
|
||||
|
||||
|
||||
async def list_active_tasks_for_user(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_audience: TaskUserAudience | str,
|
||||
promo_group_id: int | None,
|
||||
now: datetime | None = None,
|
||||
) -> list[Task]:
|
||||
"""Возвращает активные задания, доступные конкретному пользователю.
|
||||
|
||||
Учитывает:
|
||||
- is_active = True
|
||||
- starts_at <= now <= ends_at (если заданы)
|
||||
- user_audience: задание для 'both' или совпадающего типа аудитории
|
||||
- promo_group_id: задание без промогруппы либо совпадающей с user
|
||||
"""
|
||||
audience_value = (
|
||||
user_audience.value if isinstance(user_audience, TaskUserAudience) else user_audience
|
||||
)
|
||||
now = now or datetime.now(UTC)
|
||||
|
||||
stmt = (
|
||||
select(Task)
|
||||
.where(Task.is_active == True)
|
||||
.where((Task.starts_at == None) | (Task.starts_at <= now))
|
||||
.where((Task.ends_at == None) | (Task.ends_at >= now))
|
||||
.where(Task.user_audience.in_(['both', audience_value]))
|
||||
.order_by(Task.level, Task.sort_order, Task.id)
|
||||
)
|
||||
if promo_group_id is not None:
|
||||
# Задание без promo_group_id — для всех; либо ровно та же группа
|
||||
stmt = stmt.where((Task.promo_group_id == None) | (Task.promo_group_id == promo_group_id))
|
||||
else:
|
||||
stmt = stmt.where(Task.promo_group_id == None)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# UserTaskProgress CRUD
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
async def get_progress(db: AsyncSession, *, user_id: int, task_id: int) -> UserTaskProgress | None:
|
||||
result = await db.execute(
|
||||
select(UserTaskProgress)
|
||||
.options(selectinload(UserTaskProgress.task))
|
||||
.where(UserTaskProgress.user_id == user_id, UserTaskProgress.task_id == task_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_progress_for_update(
|
||||
db: AsyncSession, *, user_id: int, task_id: int
|
||||
) -> UserTaskProgress | None:
|
||||
"""FOR UPDATE lock — для атомарного claim."""
|
||||
result = await db.execute(
|
||||
select(UserTaskProgress)
|
||||
.where(UserTaskProgress.user_id == user_id, UserTaskProgress.task_id == task_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_progress_by_id_for_update(db: AsyncSession, progress_id: int) -> UserTaskProgress | None:
|
||||
result = await db.execute(
|
||||
select(UserTaskProgress)
|
||||
.where(UserTaskProgress.id == progress_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_or_create_progress(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
task_id: int,
|
||||
period_started_at: datetime | None = None,
|
||||
baseline_value: int = 0,
|
||||
) -> tuple[UserTaskProgress, bool]:
|
||||
"""Получает или создаёт запись прогресса. Возвращает (progress, created).
|
||||
|
||||
На PostgreSQL использует ``INSERT ... ON CONFLICT DO NOTHING`` (атомарный upsert),
|
||||
защищая от race на uq_user_task при параллельных record_event.
|
||||
|
||||
На SQLite (dev/test mode) использует savepoint + try/IntegrityError — atomic upsert
|
||||
тоже доступен в SQLite dialect, но проще и надёжнее savepoint pattern.
|
||||
"""
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.database.database import IS_SQLITE
|
||||
|
||||
if IS_SQLITE:
|
||||
existing = await get_progress(db, user_id=user_id, task_id=task_id)
|
||||
if existing is not None:
|
||||
return existing, False
|
||||
|
||||
# ВАЖНО: ``db.add(progress)`` ДОЛЖЕН быть внутри ``begin_nested()``, иначе
|
||||
# SQLAlchemy в ``_take_snapshot`` сделает flush до открытия savepoint, и
|
||||
# IntegrityError повредит outer transaction вместо savepoint. См. эталонный
|
||||
# паттерн в ``app/database/crud/promocode.py``.
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
progress = UserTaskProgress(
|
||||
user_id=user_id,
|
||||
task_id=task_id,
|
||||
current_value=0,
|
||||
baseline_value=baseline_value,
|
||||
period_started_at=period_started_at,
|
||||
)
|
||||
db.add(progress)
|
||||
await db.flush()
|
||||
return progress, True
|
||||
except IntegrityError:
|
||||
# Параллельный insert проскочил впереди — fetch'нем существующую запись.
|
||||
# Savepoint уже откатился, _new очищен через _restore_snapshot.
|
||||
existing = await get_progress(db, user_id=user_id, task_id=task_id)
|
||||
if existing is None:
|
||||
raise RuntimeError('UserTaskProgress race: row disappeared after IntegrityError')
|
||||
return existing, False
|
||||
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
stmt = (
|
||||
pg_insert(UserTaskProgress)
|
||||
.values(
|
||||
user_id=user_id,
|
||||
task_id=task_id,
|
||||
current_value=0,
|
||||
baseline_value=baseline_value,
|
||||
period_started_at=period_started_at,
|
||||
)
|
||||
.on_conflict_do_nothing(index_elements=['user_id', 'task_id'])
|
||||
.returning(UserTaskProgress.id)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
row = result.first()
|
||||
created = row is not None
|
||||
if created:
|
||||
await db.flush()
|
||||
|
||||
progress = await get_progress(db, user_id=user_id, task_id=task_id)
|
||||
if progress is None:
|
||||
# Не должно случаться: либо мы только что вставили, либо запись уже была.
|
||||
raise RuntimeError('UserTaskProgress disappeared after upsert')
|
||||
return progress, created
|
||||
|
||||
|
||||
async def list_user_progress(
|
||||
db: AsyncSession, *, user_id: int, task_ids: list[int] | None = None
|
||||
) -> list[UserTaskProgress]:
|
||||
stmt = (
|
||||
select(UserTaskProgress)
|
||||
.options(selectinload(UserTaskProgress.task))
|
||||
.where(UserTaskProgress.user_id == user_id)
|
||||
)
|
||||
if task_ids is not None:
|
||||
stmt = stmt.where(UserTaskProgress.task_id.in_(task_ids))
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def mark_progress_completed(
|
||||
db: AsyncSession, progress: UserTaskProgress
|
||||
) -> UserTaskProgress:
|
||||
if progress.completed_at is None:
|
||||
progress.completed_at = datetime.now(UTC)
|
||||
progress.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return progress
|
||||
|
||||
|
||||
async def mark_progress_claimed(
|
||||
db: AsyncSession,
|
||||
progress: UserTaskProgress,
|
||||
*,
|
||||
reward_granted_meta: dict[str, Any] | None = None,
|
||||
) -> UserTaskProgress:
|
||||
if progress.claimed_at is None:
|
||||
progress.claimed_at = datetime.now(UTC)
|
||||
if reward_granted_meta is not None:
|
||||
progress.reward_granted_meta = reward_granted_meta
|
||||
progress.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return progress
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TaskPartnerChannel CRUD
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
async def list_partner_channels(db: AsyncSession, *, include_inactive: bool = False) -> list[TaskPartnerChannel]:
|
||||
stmt = select(TaskPartnerChannel).order_by(TaskPartnerChannel.sort_order, TaskPartnerChannel.id)
|
||||
if not include_inactive:
|
||||
stmt = stmt.where(TaskPartnerChannel.is_active == True)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_partner_channel_by_id(db: AsyncSession, channel_pk: int) -> TaskPartnerChannel | None:
|
||||
result = await db.execute(
|
||||
select(TaskPartnerChannel).where(TaskPartnerChannel.id == channel_pk)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_partner_channel_by_channel_id(
|
||||
db: AsyncSession, channel_id: str
|
||||
) -> TaskPartnerChannel | None:
|
||||
result = await db.execute(
|
||||
select(TaskPartnerChannel).where(TaskPartnerChannel.channel_id == channel_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_partner_channel(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
channel_id: str,
|
||||
title: str,
|
||||
channel_link: str | None = None,
|
||||
description: str | None = None,
|
||||
is_active: bool = True,
|
||||
sort_order: int = 0,
|
||||
) -> TaskPartnerChannel:
|
||||
channel = TaskPartnerChannel(
|
||||
channel_id=channel_id,
|
||||
title=title,
|
||||
channel_link=channel_link,
|
||||
description=description,
|
||||
is_active=is_active,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
db.add(channel)
|
||||
await db.commit()
|
||||
await db.refresh(channel)
|
||||
logger.info('Создан партнёрский канал', channel_id=channel_id)
|
||||
return channel
|
||||
|
||||
|
||||
async def update_partner_channel(
|
||||
db: AsyncSession, channel: TaskPartnerChannel, **fields: Any
|
||||
) -> TaskPartnerChannel:
|
||||
for key, value in fields.items():
|
||||
setattr(channel, key, value)
|
||||
channel.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(channel)
|
||||
return channel
|
||||
|
||||
|
||||
async def delete_partner_channel(db: AsyncSession, channel: TaskPartnerChannel) -> None:
|
||||
await db.delete(channel)
|
||||
await db.commit()
|
||||
@@ -31,8 +31,6 @@ REAL_PAYMENT_METHODS = [
|
||||
PaymentMethod.PAYPEAR.value,
|
||||
PaymentMethod.OVERPAY.value,
|
||||
PaymentMethod.AURAPAY.value,
|
||||
PaymentMethod.ETOPLATEZHI.value,
|
||||
PaymentMethod.ANTILOPAY.value,
|
||||
]
|
||||
|
||||
|
||||
@@ -132,28 +130,6 @@ async def create_transaction(
|
||||
except Exception as exc:
|
||||
logger.debug('Не удалось записать событие конкурса для пользователя', user_id=user_id, exc=exc)
|
||||
|
||||
# Tasks: SPEND_AMOUNT — учитываем потраченное на подписку. Триггер также есть
|
||||
# в emit_transaction_side_effects (для commit=False callers); тут — для commit=True.
|
||||
try:
|
||||
from app.database.models import TaskType
|
||||
from app.services.tasks_service import record_event
|
||||
|
||||
await record_event(
|
||||
db,
|
||||
user_id=user_id,
|
||||
event_type=TaskType.SPEND_AMOUNT,
|
||||
payload={'amount_kopeks': abs(amount_kopeks), 'is_trial': False},
|
||||
)
|
||||
# commit=True путь: фиксируем task-прогресс в той же транзакции
|
||||
await db.commit()
|
||||
except Exception as exc:
|
||||
# Откатываем poisoned state, чтобы caller не получил PendingRollbackError.
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning('Tasks: SPEND_AMOUNT прогресс не обновлён', user_id=user_id, exc=exc)
|
||||
|
||||
return transaction
|
||||
|
||||
|
||||
@@ -215,20 +191,6 @@ async def emit_transaction_side_effects(
|
||||
except Exception as exc:
|
||||
logger.debug('Не удалось записать событие конкурса для пользователя', user_id=user_id, exc=exc)
|
||||
|
||||
# Tasks: SPEND_AMOUNT — учитываем потраченное на подписку
|
||||
try:
|
||||
from app.database.models import TaskType
|
||||
from app.services.tasks_service import record_event
|
||||
|
||||
await record_event(
|
||||
db,
|
||||
user_id=user_id,
|
||||
event_type=TaskType.SPEND_AMOUNT,
|
||||
payload={'amount_kopeks': abs(amount_kopeks), 'is_trial': False},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug('Tasks: не удалось обновить SPEND_AMOUNT прогресс', user_id=user_id, exc=exc)
|
||||
|
||||
|
||||
async def get_transaction_by_id(db: AsyncSession, transaction_id: int) -> Transaction | None:
|
||||
result = await db.execute(
|
||||
|
||||
@@ -511,9 +511,6 @@ async def add_user_balance(
|
||||
if create_transaction:
|
||||
from app.database.crud.transaction import create_transaction as create_trans
|
||||
|
||||
# Пропагируем commit=False вниз, иначе вложенный create_trans выпустит
|
||||
# преждевременный db.commit() и сбросит FOR UPDATE-локи (включая progress lock
|
||||
# из claim_reward).
|
||||
await create_trans(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
@@ -521,7 +518,6 @@ async def add_user_balance(
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
payment_method=payment_method,
|
||||
commit=commit,
|
||||
)
|
||||
|
||||
if commit:
|
||||
|
||||
@@ -162,16 +162,10 @@ class PaymentMethod(Enum):
|
||||
KASSA_AI = 'kassa_ai'
|
||||
RIOPAY = 'riopay'
|
||||
SEVERPAY = 'severpay'
|
||||
APPLE_IAP = 'apple_iap'
|
||||
PAYPEAR = 'paypear'
|
||||
ROLLYPAY = 'rollypay'
|
||||
OVERPAY = 'overpay'
|
||||
AURAPAY = 'aurapay'
|
||||
ETOPLATEZHI = 'etoplatezhi'
|
||||
ANTILOPAY = 'antilopay'
|
||||
JUPITER = 'jupiter'
|
||||
DONUT = 'donut'
|
||||
LAVA = 'lava'
|
||||
MANUAL = 'manual'
|
||||
BALANCE = 'balance'
|
||||
|
||||
@@ -335,41 +329,6 @@ class CryptoBotPayment(Base):
|
||||
return f'<CryptoBotPayment(id={self.id}, invoice_id={self.invoice_id}, amount={self.amount} {self.asset}, status={self.status})>'
|
||||
|
||||
|
||||
class AppleTransaction(Base):
|
||||
__tablename__ = 'apple_transactions'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||||
|
||||
transaction_id = Column(String(64), unique=True, nullable=False, index=True)
|
||||
original_transaction_id = Column(String(64), nullable=True, index=True)
|
||||
product_id = Column(String(128), nullable=False)
|
||||
bundle_id = Column(String(255), nullable=False)
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
environment = Column(String(16), nullable=False)
|
||||
|
||||
status = Column(String(50), default='verified')
|
||||
is_paid = Column(Boolean, default=True)
|
||||
paid_at = Column(AwareDateTime(), nullable=True)
|
||||
refunded_at = Column(AwareDateTime(), nullable=True)
|
||||
|
||||
transaction_id_fk = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
|
||||
created_at = Column(AwareDateTime(), default=func.now())
|
||||
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
|
||||
|
||||
user = relationship('User', backref='apple_transactions')
|
||||
transaction = relationship('Transaction', backref='apple_transaction')
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
def __repr__(self):
|
||||
return f'<AppleTransaction(id={self.id}, txn={self.transaction_id}, product={self.product_id}, status={self.status})>'
|
||||
|
||||
|
||||
class HeleketPayment(Base):
|
||||
__tablename__ = 'heleket_payments'
|
||||
|
||||
@@ -1171,318 +1130,6 @@ class AuraPayPayment(Base):
|
||||
return f'<AuraPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class EtoplatezhiPayment(Base):
|
||||
"""Платежи через Etoplatezhi (paymentpage.etoplatezhi.ru)."""
|
||||
|
||||
__tablename__ = 'etoplatezhi_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
|
||||
|
||||
# Идентификаторы
|
||||
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
|
||||
etoplatezhi_payment_id = Column(String(128), unique=True, nullable=True, index=True) # ID от Etoplatezhi
|
||||
|
||||
# Суммы
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default='RUB')
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Статусы
|
||||
status = Column(String(32), nullable=False, default='pending')
|
||||
is_paid = Column(Boolean, default=False)
|
||||
|
||||
# Данные платежа
|
||||
payment_url = Column(Text, nullable=True)
|
||||
payment_method = Column(String(32), nullable=True)
|
||||
|
||||
# Метаданные
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
# Временные метки
|
||||
paid_at = Column(AwareDateTime(), nullable=True)
|
||||
expires_at = Column(AwareDateTime(), nullable=True)
|
||||
created_at = Column(AwareDateTime(), default=func.now())
|
||||
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
|
||||
|
||||
# Связь с транзакцией
|
||||
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', backref='etoplatezhi_payments')
|
||||
transaction = relationship('Transaction', backref='etoplatezhi_payment')
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status == 'pending'
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
return self.status == 'success' and self.is_paid
|
||||
|
||||
@property
|
||||
def is_failed(self) -> bool:
|
||||
return self.status in ['failed', 'expired', 'canceled', 'amount_mismatch']
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return f'<EtoplatezhiPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class AntilopayPayment(Base):
|
||||
"""Платежи через Antilopay (lk.antilopay.com)."""
|
||||
|
||||
__tablename__ = 'antilopay_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
|
||||
|
||||
# Идентификаторы
|
||||
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
|
||||
antilopay_payment_id = Column(String(128), unique=True, nullable=True, index=True) # ID от Antilopay (APAY...)
|
||||
|
||||
# Суммы
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default='RUB')
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Статусы
|
||||
status = Column(String(32), nullable=False, default='pending')
|
||||
is_paid = Column(Boolean, default=False)
|
||||
|
||||
# Данные платежа
|
||||
payment_url = Column(Text, nullable=True)
|
||||
payment_method = Column(String(32), nullable=True)
|
||||
|
||||
# Метаданные
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
# Временные метки
|
||||
paid_at = Column(AwareDateTime(), nullable=True)
|
||||
expires_at = Column(AwareDateTime(), nullable=True)
|
||||
created_at = Column(AwareDateTime(), default=func.now())
|
||||
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
|
||||
|
||||
# Связь с транзакцией
|
||||
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', backref='antilopay_payments')
|
||||
transaction = relationship('Transaction', backref='antilopay_payment')
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status == 'pending'
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
return self.status == 'success' and self.is_paid
|
||||
|
||||
@property
|
||||
def is_failed(self) -> bool:
|
||||
return self.status in ['failed', 'expired', 'canceled', 'amount_mismatch']
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return f'<AntilopayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class JupiterPayment(Base):
|
||||
"""Платежи через Jupiter (FPGate P2P v2.1, app.juppiter.tech)."""
|
||||
|
||||
__tablename__ = 'jupiter_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
|
||||
|
||||
# Идентификаторы
|
||||
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
|
||||
jupiter_transaction_id = Column(String(128), unique=True, nullable=True, index=True) # transaction_id от Jupiter
|
||||
|
||||
# Суммы
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default='RUB')
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Статусы
|
||||
status = Column(String(32), nullable=False, default='pending')
|
||||
is_paid = Column(Boolean, default=False)
|
||||
|
||||
# Данные платежа
|
||||
payment_url = Column(Text, nullable=True) # qrcode_url из details (если есть)
|
||||
payment_method = Column(String(32), nullable=True) # 'sbp' и т.д.
|
||||
|
||||
# Метаданные
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
# Временные метки
|
||||
paid_at = Column(AwareDateTime(), nullable=True)
|
||||
expires_at = Column(AwareDateTime(), nullable=True)
|
||||
created_at = Column(AwareDateTime(), default=func.now())
|
||||
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
|
||||
|
||||
# Связь с транзакцией
|
||||
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', backref='jupiter_payments')
|
||||
transaction = relationship('Transaction', backref='jupiter_payment')
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status == 'pending'
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
return self.status == 'success' and self.is_paid
|
||||
|
||||
@property
|
||||
def is_failed(self) -> bool:
|
||||
return self.status in ['failed', 'expired', 'cancelled', 'amount_mismatch', 'declined', 'error']
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return f'<JupiterPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class DonutPayment(Base):
|
||||
"""Платежи через Donut P2P (gw.donut.business)."""
|
||||
|
||||
__tablename__ = 'donut_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
|
||||
|
||||
# Идентификаторы
|
||||
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
|
||||
donut_transaction_id = Column(String(128), unique=True, nullable=True, index=True) # transaction_id от Donut
|
||||
|
||||
# Суммы
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default='RUB')
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Статусы
|
||||
status = Column(String(32), nullable=False, default='pending')
|
||||
is_paid = Column(Boolean, default=False)
|
||||
|
||||
# Данные платежа
|
||||
payment_url = Column(Text, nullable=True) # redirect_url или qrcode_url
|
||||
payment_method = Column(String(32), nullable=True) # 'card', 'sbp', 'sbp_qr'
|
||||
|
||||
# Метаданные
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
# Временные метки
|
||||
paid_at = Column(AwareDateTime(), nullable=True)
|
||||
expires_at = Column(AwareDateTime(), nullable=True)
|
||||
created_at = Column(AwareDateTime(), default=func.now())
|
||||
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
|
||||
|
||||
# Связь с транзакцией
|
||||
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', backref='donut_payments')
|
||||
transaction = relationship('Transaction', backref='donut_payment')
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status in ('pending', 'created', 'processing')
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
return self.status == 'success' and self.is_paid
|
||||
|
||||
@property
|
||||
def is_failed(self) -> bool:
|
||||
return self.status in ['failed', 'expired', 'cancelled', 'amount_mismatch', 'error']
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return f'<DonutPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class LavaPayment(Base):
|
||||
"""Платежи через Lava Business (gate.lava.ru)."""
|
||||
|
||||
__tablename__ = 'lava_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
|
||||
|
||||
# Идентификаторы
|
||||
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш orderId
|
||||
lava_invoice_id = Column(String(128), unique=True, nullable=True, index=True) # invoice_id (UUID) от Lava
|
||||
|
||||
# Суммы
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default='RUB')
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Статусы
|
||||
status = Column(String(32), nullable=False, default='pending')
|
||||
is_paid = Column(Boolean, default=False)
|
||||
|
||||
# Данные платежа
|
||||
payment_url = Column(Text, nullable=True)
|
||||
payment_method = Column(String(32), nullable=True) # 'card', 'sbp' и т.д.
|
||||
|
||||
# Метаданные
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
# Временные метки
|
||||
paid_at = Column(AwareDateTime(), nullable=True)
|
||||
expires_at = Column(AwareDateTime(), nullable=True)
|
||||
created_at = Column(AwareDateTime(), default=func.now())
|
||||
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
|
||||
|
||||
# Связь с транзакцией
|
||||
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', backref='lava_payments')
|
||||
transaction = relationship('Transaction', backref='lava_payment')
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status in ('pending', 'created', 'processing')
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
return self.status == 'success' and self.is_paid
|
||||
|
||||
@property
|
||||
def is_failed(self) -> bool:
|
||||
return self.status in ['failed', 'expired', 'cancel', 'cancelled', 'amount_mismatch', 'error']
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return (
|
||||
f'<LavaPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
)
|
||||
|
||||
|
||||
class PromoGroup(Base):
|
||||
__tablename__ = 'promo_groups'
|
||||
|
||||
@@ -1649,10 +1296,6 @@ class Tariff(Base):
|
||||
# Видимость в разделе подарков
|
||||
show_in_gift = Column(Boolean, default=True, server_default='true', nullable=False)
|
||||
|
||||
# Бонусные дни — сколько дней начислять при выдаче награды subscription_days,
|
||||
# если задание ссылается на этот тариф (используется системой Tasks)
|
||||
bonus_days_per_purchase = Column(Integer, default=0, nullable=False, server_default='0')
|
||||
|
||||
# Режим сброса трафика: DAY, WEEK, MONTH, MONTH_ROLLING, NO_RESET (по умолчанию берётся из конфига)
|
||||
traffic_reset_mode = Column(String(20), nullable=True, default=None) # None = использовать глобальную настройку
|
||||
|
||||
@@ -2003,7 +1646,6 @@ class Subscription(Base):
|
||||
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
|
||||
|
||||
last_webhook_update_at = Column(AwareDateTime(), nullable=True)
|
||||
last_revoke_at = Column(AwareDateTime(), nullable=True)
|
||||
|
||||
remnawave_short_uuid = Column(String(255), nullable=True)
|
||||
remnawave_uuid = Column(String(255), nullable=True)
|
||||
@@ -4074,172 +3716,3 @@ class InfoPage(Base):
|
||||
replaces_tab = Column(String(20), nullable=True) # 'faq', 'rules', 'privacy', 'offer', or null
|
||||
created_at = Column(AwareDateTime(), server_default=func.now())
|
||||
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class TaskType(Enum):
|
||||
"""Тип задания (что нужно выполнить пользователю)."""
|
||||
|
||||
PURCHASE_TARIFF = 'purchase_tariff' # купить конкретный тариф
|
||||
SUBSCRIBE_CHANNEL = 'subscribe_channel' # подписаться на партнёрский канал
|
||||
TRAFFIC_USED = 'traffic_used' # использовать N ГБ трафика за месяц на подписке
|
||||
REFERRALS_INVITED = 'referrals_invited' # пригласить N рефералов
|
||||
PURCHASE_PERIOD = 'purchase_period' # купить любой тариф минимум на N дней
|
||||
SPEND_AMOUNT = 'spend_amount' # совокупно потратить N копеек
|
||||
MULTI_TARIFF = 'multi_tariff' # иметь N+ активных тарифов в multi-tariff режиме
|
||||
GIFT_PURCHASED = 'gift_purchased' # купить хотя бы 1 подписку в подарок
|
||||
GIFTS_COUNT = 'gifts_count' # купить N подписок в подарок (накопительно)
|
||||
|
||||
|
||||
class TaskRewardType(Enum):
|
||||
"""Тип награды за задание."""
|
||||
|
||||
BALANCE = 'balance' # деньги на баланс (в копейках)
|
||||
SUBSCRIPTION_DAYS = 'subscription_days' # бонусные дни подписки
|
||||
|
||||
|
||||
class TaskUserAudience(Enum):
|
||||
"""Аудитория задания (фильтр по типу пользователя)."""
|
||||
|
||||
TELEGRAM = 'telegram' # только Telegram-юзеры
|
||||
EMAIL = 'email' # только email-юзеры (cabinet)
|
||||
BOTH = 'both' # все
|
||||
|
||||
|
||||
class TaskPartnerChannel(Base):
|
||||
"""Партнёрский канал, на который можно требовать подписку в задании.
|
||||
|
||||
Отдельный список от ``RequiredChannel`` (обязательная подписка), чтобы каналы
|
||||
для заданий не пересекались с системой обязательной подписки.
|
||||
"""
|
||||
|
||||
__tablename__ = 'task_partner_channels'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
channel_id = Column(String(100), unique=True, nullable=False, index=True) # формат -100xxxxxxxx
|
||||
title = Column(String(255), nullable=False)
|
||||
channel_link = Column(String(500), nullable=True) # https://t.me/...
|
||||
description = Column(Text, nullable=True)
|
||||
is_active = Column(Boolean, nullable=False, default=True, server_default='true')
|
||||
sort_order = Column(Integer, nullable=False, default=0, server_default='0')
|
||||
created_at = Column(AwareDateTime(), server_default=func.now())
|
||||
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class Task(Base):
|
||||
"""Шаблон задания с наградой.
|
||||
|
||||
Создаётся админом, выдаётся пользователям. Прогресс трекается в ``UserTaskProgress``.
|
||||
"""
|
||||
|
||||
__tablename__ = 'tasks'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Идентификация и видимость
|
||||
title = Column(JSONB, nullable=False, server_default='{}') # i18n
|
||||
description = Column(JSONB, nullable=False, server_default='{}') # i18n
|
||||
icon = Column(String(50), nullable=True)
|
||||
is_active = Column(Boolean, nullable=False, default=True, server_default='true')
|
||||
sort_order = Column(Integer, nullable=False, default=0, server_default='0')
|
||||
|
||||
# Тип задания и его параметры
|
||||
task_type = Column(String(32), nullable=False, index=True) # значение TaskType.value
|
||||
target_value = Column(BigInteger, nullable=False, default=1) # цель: 5 рефералов / 100 ГБ / N копеек
|
||||
# Дополнительные параметры в зависимости от типа:
|
||||
# - PURCHASE_TARIFF: {"tariff_id": 12}
|
||||
# - SUBSCRIBE_CHANNEL: {"channel_id": "-1001234"}
|
||||
# - PURCHASE_PERIOD: {"period_days": 30}
|
||||
# - TRAFFIC_USED: {} (target_value = ГБ)
|
||||
# - SPEND_AMOUNT: {} (target_value = копейки)
|
||||
# - REFERRALS_INVITED / MULTI_TARIFF / GIFT_PURCHASED / GIFTS_COUNT: {} (target_value = шт.)
|
||||
target_meta = Column(JSON, nullable=False, default=dict, server_default='{}')
|
||||
|
||||
# Награда
|
||||
reward_type = Column(String(32), nullable=False) # значение TaskRewardType.value
|
||||
reward_value = Column(BigInteger, nullable=False, default=0) # копейки или дни
|
||||
# Для SUBSCRIPTION_DAYS: { "tariff_id": 12 } — если задано, дни начисляются на этот тариф
|
||||
# (если у юзера в multi-tariff несколько подписок — он выберет какую продлевать).
|
||||
reward_meta = Column(JSON, nullable=False, default=dict, server_default='{}')
|
||||
# Может ли user сам выбрать тип награды (если админ задал альтернативу в reward_meta.alt)
|
||||
allow_user_choice = Column(Boolean, nullable=False, default=False, server_default='false')
|
||||
|
||||
# Фильтры аудитории (значение TaskUserAudience.value: 'telegram' / 'email' / 'both')
|
||||
user_audience = Column(String(16), nullable=False, default='both', server_default='both')
|
||||
promo_group_id = Column(Integer, ForeignKey('promo_groups.id', ondelete='SET NULL'), nullable=True, index=True)
|
||||
|
||||
# Цепочка уровней (последовательное открытие)
|
||||
parent_task_id = Column(Integer, ForeignKey('tasks.id', ondelete='SET NULL'), nullable=True, index=True)
|
||||
level = Column(Integer, nullable=False, default=1, server_default='1')
|
||||
|
||||
# Период действия задания (опционально)
|
||||
starts_at = Column(AwareDateTime(), nullable=True)
|
||||
ends_at = Column(AwareDateTime(), nullable=True)
|
||||
|
||||
created_at = Column(AwareDateTime(), server_default=func.now())
|
||||
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
promo_group = relationship('PromoGroup', backref='tasks')
|
||||
parent_task = relationship('Task', remote_side=[id], backref='child_tasks')
|
||||
progress_records = relationship('UserTaskProgress', back_populates='task', cascade='all, delete-orphan')
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f'<Task(id={self.id}, type={self.task_type}, level={self.level})>'
|
||||
|
||||
|
||||
class UserTaskProgress(Base):
|
||||
"""Прогресс пользователя по конкретному заданию."""
|
||||
|
||||
__tablename__ = 'user_task_progress'
|
||||
__table_args__ = (UniqueConstraint('user_id', 'task_id', name='uq_user_task'),)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
task_id = Column(Integer, ForeignKey('tasks.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
|
||||
# Текущий прогресс к target_value (BigInteger чтобы поддерживать SPEND_AMOUNT в копейках за всё время)
|
||||
current_value = Column(BigInteger, nullable=False, default=0, server_default='0')
|
||||
|
||||
# Снапшоты для типов с периодом (TRAFFIC_USED — за месяц на подписке)
|
||||
# period_started_at — начало текущего окна (для traffic_used = первое число месяца)
|
||||
# baseline_value — снапшот значения на начало периода (для traffic_used = traffic_used_gb на старте)
|
||||
period_started_at = Column(AwareDateTime(), nullable=True)
|
||||
baseline_value = Column(BigInteger, nullable=False, default=0, server_default='0')
|
||||
|
||||
# Статусы
|
||||
completed_at = Column(AwareDateTime(), nullable=True) # когда выполнено
|
||||
claimed_at = Column(AwareDateTime(), nullable=True) # когда награда получена
|
||||
|
||||
# Метаданные о выданной награде:
|
||||
# { "type": "balance" | "subscription_days",
|
||||
# "value": 100000,
|
||||
# "subscription_id": 42, # для multi-tariff: какой подписке начислили дни
|
||||
# "transaction_id": 1234, # если создана транзакция
|
||||
# "old_end_date": "...",
|
||||
# "new_end_date": "..." }
|
||||
reward_granted_meta = Column(JSON, nullable=True)
|
||||
|
||||
created_at = Column(AwareDateTime(), server_default=func.now())
|
||||
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', backref='task_progress')
|
||||
task = relationship('Task', back_populates='progress_records')
|
||||
|
||||
@property
|
||||
def is_completed(self) -> bool:
|
||||
return self.completed_at is not None
|
||||
|
||||
@property
|
||||
def is_claimed(self) -> bool:
|
||||
return self.claimed_at is not None
|
||||
|
||||
@property
|
||||
def percent(self) -> int:
|
||||
if not self.task or self.task.target_value <= 0:
|
||||
return 0
|
||||
ratio = max(0, min(self.current_value, self.task.target_value)) / self.task.target_value
|
||||
return int(ratio * 100)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f'<UserTaskProgress(user={self.user_id}, task={self.task_id}, {self.current_value}/{self.task.target_value if self.task else "?"})>'
|
||||
|
||||
Vendored
-411
@@ -1,411 +0,0 @@
|
||||
"""Apple App Store Server API client for In-App Purchase verification and webhook handling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import jwt as pyjwt
|
||||
import structlog
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, utils as asym_utils
|
||||
from cryptography.hazmat.primitives.hashes import SHA256
|
||||
from cryptography.x509 import load_der_x509_certificate
|
||||
from cryptography.x509.oid import ExtensionOID, ObjectIdentifier
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Apple Root CA - G3 SHA-256 fingerprint for chain pinning
|
||||
# https://www.apple.com/certificateauthority/
|
||||
APPLE_ROOT_CA_G3_SHA256 = bytes.fromhex('63343abfb89a6a03ebb57e9b3f5fa7be7c4f5c756f3017b3a8c488c3653e9179')
|
||||
|
||||
# Apple WWDR Intermediate Certificate OID
|
||||
APPLE_WWDR_INTERMEDIATE_OID = ObjectIdentifier('1.2.840.113635.100.6.2.1')
|
||||
|
||||
PRODUCTION_BASE_URL = 'https://api.storekit.itunes.apple.com'
|
||||
SANDBOX_BASE_URL = 'https://api.storekit-sandbox.itunes.apple.com'
|
||||
|
||||
|
||||
class AppleIAPService:
|
||||
"""Service for verifying Apple In-App Purchase transactions and handling notifications."""
|
||||
|
||||
def _get_base_url(self, environment: str | None = None) -> str:
|
||||
env = environment or settings.APPLE_IAP_ENVIRONMENT
|
||||
if env == 'Sandbox':
|
||||
return SANDBOX_BASE_URL
|
||||
return PRODUCTION_BASE_URL
|
||||
|
||||
def _generate_jwt(self) -> str:
|
||||
"""Generate a fresh ES256 JWT for App Store Server API authentication.
|
||||
|
||||
Apple recommends generating a new JWT for each request.
|
||||
"""
|
||||
private_key = settings.get_apple_iap_private_key()
|
||||
if not private_key:
|
||||
raise ValueError('Apple IAP private key is not configured')
|
||||
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
'iss': settings.APPLE_IAP_ISSUER_ID,
|
||||
'iat': now,
|
||||
'exp': now + 3600,
|
||||
'aud': 'appstoreconnect-v1',
|
||||
'bid': settings.APPLE_IAP_BUNDLE_ID,
|
||||
}
|
||||
headers = {
|
||||
'alg': 'ES256',
|
||||
'kid': settings.APPLE_IAP_KEY_ID,
|
||||
'typ': 'JWT',
|
||||
}
|
||||
|
||||
return pyjwt.encode(payload, private_key, algorithm='ES256', headers=headers)
|
||||
|
||||
async def _fetch_transaction(
|
||||
self,
|
||||
transaction_id: str,
|
||||
base_url: str,
|
||||
) -> httpx.Response | None:
|
||||
"""Send a GET request to Apple's transaction lookup endpoint."""
|
||||
url = f'{base_url}/inApps/v1/transactions/{transaction_id}'
|
||||
token = self._generate_jwt()
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
try:
|
||||
return await client.get(
|
||||
url,
|
||||
headers={'Authorization': f'Bearer {token}'},
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.error('Apple API request failed', error=str(e), transaction_id=transaction_id)
|
||||
return None
|
||||
|
||||
async def verify_transaction(
|
||||
self,
|
||||
transaction_id: str,
|
||||
environment: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Verify a transaction with Apple's App Store Server API.
|
||||
|
||||
Follows Apple's recommendation: if the configured environment returns
|
||||
a 4xx error, retries against the opposite environment. This ensures
|
||||
Sandbox purchases made during App Review still verify when the server
|
||||
is configured for Production.
|
||||
"""
|
||||
primary_url = self._get_base_url(environment)
|
||||
# Determine fallback URL (opposite environment)
|
||||
fallback_url = SANDBOX_BASE_URL if primary_url == PRODUCTION_BASE_URL else PRODUCTION_BASE_URL
|
||||
|
||||
for attempt_url in (primary_url, fallback_url):
|
||||
response = await self._fetch_transaction(transaction_id, attempt_url)
|
||||
if response is None:
|
||||
return None # network error -- don't retry
|
||||
|
||||
if response.status_code == 200:
|
||||
return self._parse_transaction_response(response, transaction_id)
|
||||
|
||||
# 4xx on primary -> retry on fallback per Apple docs
|
||||
if 400 <= response.status_code < 500 and attempt_url == primary_url:
|
||||
logger.info(
|
||||
'Apple API returned 4xx on primary env, retrying fallback',
|
||||
status=response.status_code,
|
||||
primary=attempt_url,
|
||||
fallback=fallback_url,
|
||||
transaction_id=transaction_id,
|
||||
)
|
||||
continue
|
||||
|
||||
# Log the final failure
|
||||
self._log_api_error(response, transaction_id)
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
def _parse_transaction_response(
|
||||
self,
|
||||
response: httpx.Response,
|
||||
transaction_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Extract and verify signedTransactionInfo from a 200 response."""
|
||||
data = response.json()
|
||||
signed_transaction_info = data.get('signedTransactionInfo')
|
||||
if signed_transaction_info:
|
||||
decoded = self._verify_and_decode_jws(signed_transaction_info)
|
||||
if decoded:
|
||||
return decoded
|
||||
logger.warning('Failed to verify signedTransactionInfo', transaction_id=transaction_id)
|
||||
return None
|
||||
logger.warning('No signedTransactionInfo in response', transaction_id=transaction_id)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _log_api_error(response: httpx.Response, transaction_id: str) -> None:
|
||||
if response.status_code == 404:
|
||||
logger.warning('Apple transaction not found', transaction_id=transaction_id)
|
||||
elif response.status_code == 401:
|
||||
logger.error('Apple API auth failed -- check key configuration')
|
||||
elif response.status_code == 429:
|
||||
logger.warning('Apple API rate limit exceeded')
|
||||
else:
|
||||
logger.error(
|
||||
'Apple API unexpected status',
|
||||
status=response.status_code,
|
||||
body=response.text[:500],
|
||||
transaction_id=transaction_id,
|
||||
)
|
||||
|
||||
def validate_transaction_info(self, txn_info: dict[str, Any], expected_product_id: str) -> str | None:
|
||||
"""Validate decoded transaction info fields.
|
||||
|
||||
Returns None if valid, or an error message string.
|
||||
"""
|
||||
bundle_id = txn_info.get('bundleId')
|
||||
if bundle_id != settings.APPLE_IAP_BUNDLE_ID:
|
||||
return f'Bundle ID mismatch: {bundle_id}'
|
||||
|
||||
product_id = txn_info.get('productId')
|
||||
if product_id != expected_product_id:
|
||||
return f'Product ID mismatch: {product_id} != {expected_product_id}'
|
||||
|
||||
txn_type = txn_info.get('type')
|
||||
if txn_type != 'Consumable':
|
||||
return f'Unexpected transaction type: {txn_type}'
|
||||
|
||||
if txn_info.get('revocationDate'):
|
||||
return f'Transaction was revoked at {txn_info["revocationDate"]}'
|
||||
|
||||
return None
|
||||
|
||||
def _verify_and_decode_jws(self, jws_token: str) -> dict[str, Any] | None:
|
||||
"""Verify x5c certificate chain and ES256 signature, then decode the JWS payload.
|
||||
|
||||
Returns the decoded payload dict, or None if verification fails.
|
||||
Used for both outer notification payloads and inner signed data
|
||||
(signedTransactionInfo, signedRenewalInfo).
|
||||
"""
|
||||
try:
|
||||
parts = jws_token.split('.')
|
||||
if len(parts) != 3:
|
||||
logger.warning('Invalid JWS format: expected 3 parts')
|
||||
return None
|
||||
|
||||
# Decode header to get x5c chain
|
||||
header_b64 = parts[0]
|
||||
padding = 4 - len(header_b64) % 4
|
||||
if padding != 4:
|
||||
header_b64 += '=' * padding
|
||||
header_json = base64.urlsafe_b64decode(header_b64)
|
||||
header = json.loads(header_json)
|
||||
|
||||
x5c_chain = header.get('x5c', [])
|
||||
if not x5c_chain:
|
||||
logger.warning('No x5c certificate chain in JWS header')
|
||||
return None
|
||||
|
||||
# Verify the certificate chain
|
||||
if not self._verify_x5c_chain(x5c_chain):
|
||||
logger.warning('x5c certificate chain verification failed')
|
||||
return None
|
||||
|
||||
# Verify the signature using the leaf certificate
|
||||
leaf_cert_der = base64.b64decode(x5c_chain[0])
|
||||
leaf_cert = load_der_x509_certificate(leaf_cert_der)
|
||||
public_key = leaf_cert.public_key()
|
||||
|
||||
signing_input = f'{parts[0]}.{parts[1]}'.encode('ascii')
|
||||
signature_b64 = parts[2]
|
||||
sig_padding = 4 - len(signature_b64) % 4
|
||||
if sig_padding != 4:
|
||||
signature_b64 += '=' * sig_padding
|
||||
signature = base64.urlsafe_b64decode(signature_b64)
|
||||
|
||||
# ES256 signatures from JWS are in raw (r||s) format, convert to DER
|
||||
if len(signature) == 64:
|
||||
r = int.from_bytes(signature[:32], 'big')
|
||||
s = int.from_bytes(signature[32:], 'big')
|
||||
signature = asym_utils.encode_dss_signature(r, s)
|
||||
|
||||
public_key.verify(signature, signing_input, ec.ECDSA(SHA256()))
|
||||
|
||||
# Signature valid -- decode payload
|
||||
return self._decode_jws_payload(jws_token)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('JWS verification failed', error=str(e), exc_info=True)
|
||||
return None
|
||||
|
||||
def verify_notification(self, signed_payload: str) -> dict[str, Any] | None:
|
||||
"""Verify and decode an App Store Server Notification V2 payload.
|
||||
|
||||
Verifies the JWS x5c certificate chain, then returns the decoded payload.
|
||||
Returns None if verification fails.
|
||||
"""
|
||||
return self._verify_and_decode_jws(signed_payload)
|
||||
|
||||
def _verify_x5c_chain(self, x5c_chain: list[str]) -> bool:
|
||||
"""Verify the x5c certificate chain ends with an Apple Root CA."""
|
||||
try:
|
||||
if len(x5c_chain) < 2:
|
||||
logger.warning('x5c chain too short', length=len(x5c_chain))
|
||||
return False
|
||||
|
||||
certs = []
|
||||
for cert_b64 in x5c_chain:
|
||||
cert_der = base64.b64decode(cert_b64)
|
||||
cert = load_der_x509_certificate(cert_der)
|
||||
certs.append(cert)
|
||||
|
||||
# Check certificate validity periods
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
for i, cert in enumerate(certs):
|
||||
if now < cert.not_valid_before_utc:
|
||||
logger.warning('x5c cert not yet valid', index=i, not_before=str(cert.not_valid_before_utc))
|
||||
return False
|
||||
if now > cert.not_valid_after_utc:
|
||||
logger.warning('x5c cert expired', index=i, not_after=str(cert.not_valid_after_utc))
|
||||
return False
|
||||
|
||||
# Pin the root (last) certificate by SHA-256 fingerprint
|
||||
root_cert = certs[-1]
|
||||
root_fingerprint = root_cert.fingerprint(SHA256())
|
||||
if root_fingerprint != APPLE_ROOT_CA_G3_SHA256:
|
||||
logger.warning(
|
||||
'Root CA fingerprint mismatch -- not genuine Apple Root CA - G3',
|
||||
got=root_fingerprint.hex(),
|
||||
)
|
||||
return False
|
||||
|
||||
# Verify each certificate is signed by the next one in the chain
|
||||
for i in range(len(certs) - 1):
|
||||
child = certs[i]
|
||||
parent = certs[i + 1]
|
||||
parent_public_key = parent.public_key()
|
||||
parent_public_key.verify(
|
||||
child.signature,
|
||||
child.tbs_certificate_bytes,
|
||||
ec.ECDSA(child.signature_hash_algorithm),
|
||||
)
|
||||
|
||||
# FIX 3: Validate Apple WWDR intermediate OID
|
||||
# The intermediate cert (index 1) must contain the Apple WWDR OID
|
||||
# to ensure it is a genuine Apple WWDR intermediate certificate.
|
||||
if len(certs) >= 2:
|
||||
intermediate_cert = certs[1]
|
||||
try:
|
||||
# Check for the Apple WWDR OID in certificate extensions
|
||||
found_apple_oid = False
|
||||
for ext in intermediate_cert.extensions:
|
||||
if ext.oid == ExtensionOID.CERTIFICATE_POLICIES:
|
||||
for policy in ext.value:
|
||||
if policy.policy_identifier == APPLE_WWDR_INTERMEDIATE_OID:
|
||||
found_apple_oid = True
|
||||
break
|
||||
if found_apple_oid:
|
||||
break
|
||||
if not found_apple_oid:
|
||||
logger.warning(
|
||||
'Intermediate cert missing Apple WWDR OID',
|
||||
oid=str(APPLE_WWDR_INTERMEDIATE_OID),
|
||||
)
|
||||
return False
|
||||
except x509.ExtensionNotFound:
|
||||
logger.warning('Intermediate cert has no certificate policies extension')
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error('x5c chain verification error', error=str(e))
|
||||
return False
|
||||
|
||||
def _decode_jws_payload(self, jws_token: str) -> dict[str, Any] | None:
|
||||
"""Decode the payload from a JWS token without signature verification.
|
||||
|
||||
Use only after the signature has already been verified.
|
||||
"""
|
||||
try:
|
||||
parts = jws_token.split('.')
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
|
||||
payload_b64 = parts[1]
|
||||
# Add base64url padding
|
||||
padding = 4 - len(payload_b64) % 4
|
||||
if padding != 4:
|
||||
payload_b64 += '=' * padding
|
||||
|
||||
payload_json = base64.urlsafe_b64decode(payload_b64)
|
||||
return json.loads(payload_json)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Failed to decode JWS payload', error=str(e))
|
||||
return None
|
||||
|
||||
async def send_consumption_info(
|
||||
self,
|
||||
transaction_id: str,
|
||||
customer_consented: bool,
|
||||
consumption_status: int = 0,
|
||||
delivery_status: int = 0,
|
||||
lifetime_dollars_purchased: int = 0,
|
||||
lifetime_dollars_refunded: int = 0,
|
||||
platform: int = 1,
|
||||
play_time: int = 0,
|
||||
sample_content_provided: bool = False,
|
||||
user_status: int = 0,
|
||||
environment: str | None = None,
|
||||
refund_preference: int | None = None,
|
||||
) -> bool:
|
||||
"""Send consumption information to Apple in response to CONSUMPTION_REQUEST.
|
||||
|
||||
Must be sent within 12 hours of receiving the notification.
|
||||
"""
|
||||
base_url = self._get_base_url(environment)
|
||||
url = f'{base_url}/inApps/v2/transactions/consumption/{transaction_id}'
|
||||
token = self._generate_jwt()
|
||||
|
||||
body: dict[str, Any] = {
|
||||
'customerConsented': customer_consented,
|
||||
'consumptionStatus': consumption_status,
|
||||
'deliveryStatus': delivery_status,
|
||||
'lifetimeDollarsPurchased': lifetime_dollars_purchased,
|
||||
'lifetimeDollarsRefunded': lifetime_dollars_refunded,
|
||||
'platform': platform,
|
||||
'playTime': play_time,
|
||||
'sampleContentProvided': sample_content_provided,
|
||||
'userStatus': user_status,
|
||||
}
|
||||
if refund_preference is not None:
|
||||
body['refundPreference'] = refund_preference
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
try:
|
||||
response = await client.put(
|
||||
url,
|
||||
json=body,
|
||||
headers={
|
||||
'Authorization': f'Bearer {token}',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.error('Apple consumption API request failed', error=str(e))
|
||||
return False
|
||||
|
||||
if response.status_code == 202:
|
||||
logger.info('Consumption info sent to Apple', transaction_id=transaction_id)
|
||||
return True
|
||||
|
||||
logger.error(
|
||||
'Apple consumption API error',
|
||||
status=response.status_code,
|
||||
body=response.text[:500],
|
||||
transaction_id=transaction_id,
|
||||
)
|
||||
return False
|
||||
Vendored
-339
@@ -57,9 +57,6 @@ class WebhookServer:
|
||||
|
||||
self.app.router.add_get('/health', self._health_check)
|
||||
|
||||
if settings.is_apple_iap_enabled():
|
||||
self.app.router.add_post(settings.APPLE_IAP_WEBHOOK_PATH, self._apple_iap_webhook_handler)
|
||||
|
||||
self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler)
|
||||
if settings.is_mulenpay_enabled():
|
||||
self.app.router.add_options(settings.MULENPAY_WEBHOOK_PATH, self._options_handler)
|
||||
@@ -67,8 +64,6 @@ class WebhookServer:
|
||||
self.app.router.add_options(settings.CRYPTOBOT_WEBHOOK_PATH, self._options_handler)
|
||||
if settings.is_freekassa_enabled():
|
||||
self.app.router.add_options(settings.FREEKASSA_WEBHOOK_PATH, self._options_handler)
|
||||
if settings.is_apple_iap_enabled():
|
||||
self.app.router.add_options(settings.APPLE_IAP_WEBHOOK_PATH, self._options_handler)
|
||||
|
||||
logger.info('Webhook сервер настроен:')
|
||||
logger.info('Tribute webhook: POST', TRIBUTE_WEBHOOK_PATH=settings.TRIBUTE_WEBHOOK_PATH)
|
||||
@@ -81,8 +76,6 @@ class WebhookServer:
|
||||
logger.info('CryptoBot webhook: POST', CRYPTOBOT_WEBHOOK_PATH=settings.CRYPTOBOT_WEBHOOK_PATH)
|
||||
if settings.is_freekassa_enabled():
|
||||
logger.info('Freekassa webhook: POST', FREEKASSA_WEBHOOK_PATH=settings.FREEKASSA_WEBHOOK_PATH)
|
||||
if settings.is_apple_iap_enabled():
|
||||
logger.info('Apple IAP webhook: POST', APPLE_IAP_WEBHOOK_PATH=settings.APPLE_IAP_WEBHOOK_PATH)
|
||||
logger.info(' - Health check: GET /health')
|
||||
|
||||
return self.app
|
||||
@@ -498,335 +491,3 @@ class WebhookServer:
|
||||
except Exception as e:
|
||||
logger.error('Критическая ошибка обработки Freekassa webhook', error=e, exc_info=True)
|
||||
return web.Response(text='NO', status=500)
|
||||
|
||||
async def _apple_iap_webhook_handler(self, request: web.Request) -> web.Response:
|
||||
"""Handle Apple App Store Server Notifications V2."""
|
||||
try:
|
||||
logger.info('Получен Apple IAP webhook', method=request.method, path=request.path)
|
||||
|
||||
raw_body = await request.read()
|
||||
if not raw_body:
|
||||
logger.warning('Пустой Apple IAP webhook')
|
||||
return web.Response(status=400)
|
||||
|
||||
try:
|
||||
body = json.loads(raw_body.decode('utf-8'))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
logger.error('Ошибка парсинга Apple IAP webhook', error=e)
|
||||
return web.Response(status=400)
|
||||
|
||||
signed_payload = body.get('signedPayload')
|
||||
if not signed_payload:
|
||||
logger.warning('No signedPayload in Apple webhook')
|
||||
return web.Response(status=400)
|
||||
|
||||
# Verify and decode the notification
|
||||
from app.external.apple_iap import AppleIAPService
|
||||
|
||||
apple_service = AppleIAPService()
|
||||
notification = apple_service.verify_notification(signed_payload)
|
||||
if not notification:
|
||||
logger.warning('Apple webhook signature verification failed')
|
||||
return web.Response(status=403)
|
||||
|
||||
notification_type = notification.get('notificationType', '')
|
||||
subtype = notification.get('subtype', '')
|
||||
|
||||
# Verify notification environment matches our config
|
||||
# FIX 11: removed dead initial assignment of expected_envs
|
||||
notif_env = notification.get('data', {}).get('environment', '')
|
||||
if settings.APPLE_IAP_ENVIRONMENT == 'Production':
|
||||
expected_envs = {'Production', 'Sandbox'} # Sandbox for App Review
|
||||
else:
|
||||
expected_envs = {'Sandbox'}
|
||||
if notif_env and notif_env not in expected_envs:
|
||||
logger.warning(
|
||||
'Apple webhook environment mismatch',
|
||||
expected=settings.APPLE_IAP_ENVIRONMENT,
|
||||
received=notif_env,
|
||||
)
|
||||
return web.Response(status=200) # ACK but ignore
|
||||
|
||||
logger.info(
|
||||
'Apple notification received',
|
||||
notification_type=notification_type,
|
||||
subtype=subtype,
|
||||
environment=notif_env,
|
||||
)
|
||||
|
||||
# Handle notification types
|
||||
if notification_type == 'TEST':
|
||||
logger.info('Apple TEST notification received -- OK')
|
||||
return web.Response(status=200)
|
||||
|
||||
if notification_type == 'REFUND':
|
||||
await self._handle_apple_refund(notification, apple_service)
|
||||
return web.Response(status=200)
|
||||
|
||||
if notification_type == 'REFUND_REVERSED':
|
||||
await self._handle_apple_refund_reversed(notification)
|
||||
return web.Response(status=200)
|
||||
|
||||
if notification_type == 'CONSUMPTION_REQUEST':
|
||||
await self._handle_apple_consumption_request(notification, apple_service)
|
||||
return web.Response(status=200)
|
||||
|
||||
if notification_type in ('ONE_TIME_CHARGE', 'REFUND_DECLINED'):
|
||||
logger.info('Apple notification logged', notification_type=notification_type)
|
||||
return web.Response(status=200)
|
||||
|
||||
logger.info('Unhandled Apple notification type', notification_type=notification_type)
|
||||
return web.Response(status=200)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Критическая ошибка обработки Apple IAP webhook', error=e, exc_info=True)
|
||||
return web.Response(status=500)
|
||||
|
||||
async def _handle_apple_refund(self, notification: dict, apple_service) -> None:
|
||||
"""Handle REFUND notification -- deduct credited balance."""
|
||||
try:
|
||||
data = notification.get('data', {})
|
||||
signed_txn_info = data.get('signedTransactionInfo')
|
||||
if not signed_txn_info:
|
||||
logger.warning('No signedTransactionInfo in REFUND notification')
|
||||
return
|
||||
|
||||
txn_info = apple_service._verify_and_decode_jws(signed_txn_info)
|
||||
if not txn_info:
|
||||
logger.warning('Failed to verify REFUND transaction info')
|
||||
return
|
||||
|
||||
apple_txn_id = str(txn_info.get('transactionId') or '')
|
||||
original_txn_id = str(txn_info.get('originalTransactionId') or '')
|
||||
product_id = txn_info.get('productId', '')
|
||||
|
||||
from app.database.crud.apple_iap import (
|
||||
mark_apple_transaction_refunded,
|
||||
)
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
|
||||
lookup_id = original_txn_id or apple_txn_id
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
from app.database.crud.apple_iap import get_apple_transaction_by_transaction_id_for_update
|
||||
|
||||
apple_txn = await get_apple_transaction_by_transaction_id_for_update(db, lookup_id)
|
||||
if not apple_txn:
|
||||
# Try the other ID
|
||||
apple_txn = await get_apple_transaction_by_transaction_id_for_update(db, apple_txn_id)
|
||||
|
||||
if not apple_txn:
|
||||
logger.warning(
|
||||
'Apple REFUND: transaction not found',
|
||||
transaction_id=apple_txn_id,
|
||||
original_transaction_id=original_txn_id,
|
||||
)
|
||||
return
|
||||
|
||||
if apple_txn.status == 'refunded':
|
||||
logger.info('Apple REFUND: already refunded', transaction_id=lookup_id)
|
||||
return
|
||||
|
||||
if apple_txn.environment == 'Sandbox' and settings.APPLE_IAP_ENVIRONMENT == 'Production':
|
||||
logger.info(
|
||||
'Apple REFUND: ignoring sandbox refund on production',
|
||||
transaction_id=lookup_id,
|
||||
user_id=apple_txn.user_id,
|
||||
)
|
||||
return
|
||||
|
||||
# FIX 6: Lock user row with FOR UPDATE before reading balance
|
||||
# to prevent race condition in min() balance cap calculation
|
||||
user = await lock_user_for_pricing(db, apple_txn.user_id)
|
||||
if not user:
|
||||
logger.error('Apple REFUND: user not found', user_id=apple_txn.user_id)
|
||||
return
|
||||
|
||||
# Cap deduction to current balance to prevent negative balance
|
||||
refund_amount = min(apple_txn.amount_kopeks, user.balance_kopeks)
|
||||
if refund_amount < apple_txn.amount_kopeks:
|
||||
logger.warning(
|
||||
'Apple REFUND: partial balance deduction (user already spent funds)',
|
||||
full_amount=apple_txn.amount_kopeks,
|
||||
deducted=refund_amount,
|
||||
user_balance=user.balance_kopeks,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
# Disable active subscriptions -- funds were spent and refunded
|
||||
from app.database.crud.subscription import (
|
||||
deactivate_subscription,
|
||||
get_active_subscriptions_by_user_id,
|
||||
)
|
||||
|
||||
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
|
||||
for sub in active_subs:
|
||||
await deactivate_subscription(db, sub, commit=False)
|
||||
logger.warning(
|
||||
'Apple REFUND: disabled subscription due to insufficient balance',
|
||||
subscription_id=sub.id,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
if refund_amount > 0:
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
|
||||
await subtract_user_balance(
|
||||
db=db,
|
||||
user=user,
|
||||
amount_kopeks=refund_amount,
|
||||
description=f'Возврат Apple IAP: {product_id}',
|
||||
create_transaction=True,
|
||||
payment_method=PaymentMethod.APPLE_IAP,
|
||||
transaction_type=TransactionType.REFUND,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
await mark_apple_transaction_refunded(db, apple_txn.transaction_id)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Apple REFUND processed',
|
||||
transaction_id=apple_txn.transaction_id,
|
||||
amount_kopeks=apple_txn.amount_kopeks,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error handling Apple REFUND', error=e, exc_info=True)
|
||||
|
||||
async def _handle_apple_refund_reversed(self, notification: dict) -> None:
|
||||
"""Handle REFUND_REVERSED -- re-credit balance that was previously deducted."""
|
||||
try:
|
||||
data = notification.get('data', {})
|
||||
signed_txn_info = data.get('signedTransactionInfo')
|
||||
if not signed_txn_info:
|
||||
logger.warning('No signedTransactionInfo in REFUND_REVERSED notification')
|
||||
return
|
||||
|
||||
from app.external.apple_iap import AppleIAPService
|
||||
|
||||
apple_service = AppleIAPService()
|
||||
txn_info = apple_service._verify_and_decode_jws(signed_txn_info)
|
||||
if not txn_info:
|
||||
logger.warning('Failed to verify REFUND_REVERSED transaction info')
|
||||
return
|
||||
|
||||
apple_txn_id = str(txn_info.get('transactionId') or '')
|
||||
original_txn_id = str(txn_info.get('originalTransactionId') or '')
|
||||
product_id = txn_info.get('productId', '')
|
||||
|
||||
from app.database.crud.apple_iap import (
|
||||
get_apple_transaction_by_transaction_id_for_update,
|
||||
)
|
||||
from app.database.crud.user import add_user_balance, get_user_by_id
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import PaymentMethod
|
||||
|
||||
lookup_id = original_txn_id or apple_txn_id
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
# FIX 7: Use FOR UPDATE lock on apple_transactions row
|
||||
# before checking status to prevent idempotency race
|
||||
apple_txn = await get_apple_transaction_by_transaction_id_for_update(db, lookup_id)
|
||||
if not apple_txn:
|
||||
apple_txn = await get_apple_transaction_by_transaction_id_for_update(db, apple_txn_id)
|
||||
|
||||
if not apple_txn:
|
||||
logger.warning(
|
||||
'Apple REFUND_REVERSED: transaction not found',
|
||||
transaction_id=apple_txn_id,
|
||||
)
|
||||
return
|
||||
|
||||
if apple_txn.status != 'refunded':
|
||||
logger.info(
|
||||
'Apple REFUND_REVERSED: transaction not in refunded state',
|
||||
transaction_id=lookup_id,
|
||||
status=apple_txn.status,
|
||||
)
|
||||
return
|
||||
|
||||
if apple_txn.environment == 'Sandbox' and settings.APPLE_IAP_ENVIRONMENT == 'Production':
|
||||
logger.info(
|
||||
'Apple REFUND_REVERSED: ignoring sandbox on production',
|
||||
transaction_id=lookup_id,
|
||||
)
|
||||
return
|
||||
|
||||
user = await get_user_by_id(db, apple_txn.user_id)
|
||||
if not user:
|
||||
logger.error('Apple REFUND_REVERSED: user not found', user_id=apple_txn.user_id)
|
||||
return
|
||||
|
||||
# Re-credit the balance
|
||||
await add_user_balance(
|
||||
db=db,
|
||||
user=user,
|
||||
amount_kopeks=apple_txn.amount_kopeks,
|
||||
description=f'Отмена возврата Apple IAP: {product_id}',
|
||||
payment_method=PaymentMethod.APPLE_IAP,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
apple_txn.status = 'verified'
|
||||
apple_txn.refunded_at = None
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Apple REFUND_REVERSED processed -- balance re-credited',
|
||||
transaction_id=lookup_id,
|
||||
amount_kopeks=apple_txn.amount_kopeks,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error handling Apple REFUND_REVERSED', error=e, exc_info=True)
|
||||
|
||||
async def _handle_apple_consumption_request(self, notification: dict, apple_service) -> None:
|
||||
"""Handle CONSUMPTION_REQUEST -- send consumption info to Apple."""
|
||||
try:
|
||||
data = notification.get('data', {})
|
||||
signed_txn_info = data.get('signedTransactionInfo')
|
||||
if not signed_txn_info:
|
||||
logger.warning('No signedTransactionInfo in CONSUMPTION_REQUEST')
|
||||
return
|
||||
|
||||
txn_info = apple_service._verify_and_decode_jws(signed_txn_info)
|
||||
if not txn_info:
|
||||
logger.warning('Failed to verify CONSUMPTION_REQUEST transaction info')
|
||||
return
|
||||
|
||||
apple_txn_id = str(txn_info.get('transactionId') or '')
|
||||
environment = txn_info.get('environment', settings.APPLE_IAP_ENVIRONMENT)
|
||||
|
||||
from app.database.crud.apple_iap import get_apple_transaction_by_transaction_id
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
apple_txn = await get_apple_transaction_by_transaction_id(db, apple_txn_id)
|
||||
|
||||
# Determine if balance was consumed (spent on subscriptions)
|
||||
# consumptionStatus: 0 = undeclared, 1 = not consumed, 2 = partially consumed, 3 = fully consumed
|
||||
consumption_status = 0
|
||||
if apple_txn and apple_txn.status == 'verified':
|
||||
consumption_status = 3 # Balance was credited and likely spent
|
||||
|
||||
# customerConsented must be false -- we cannot prompt the user
|
||||
# in a server-to-server webhook. Apple accepts the response
|
||||
# regardless, but the consumption data weight may be lower.
|
||||
await apple_service.send_consumption_info(
|
||||
transaction_id=apple_txn_id,
|
||||
customer_consented=False,
|
||||
consumption_status=consumption_status,
|
||||
delivery_status=0, # 0 = delivered
|
||||
platform=1, # 1 = Apple
|
||||
environment=environment,
|
||||
)
|
||||
|
||||
logger.info('Apple CONSUMPTION_REQUEST handled', transaction_id=apple_txn_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error handling Apple CONSUMPTION_REQUEST', error=e, exc_info=True)
|
||||
|
||||
@@ -175,7 +175,6 @@ async def _build_notification_preview_message(language: str, notification_type:
|
||||
message = template.format(
|
||||
end_date=(now - timedelta(days=1)).strftime('%d.%m.%Y %H:%M'),
|
||||
price=price_30_days,
|
||||
tariff_label='',
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
@@ -214,7 +213,6 @@ async def _build_notification_preview_message(language: str, notification_type:
|
||||
percent=percent,
|
||||
expires_at=(now + timedelta(hours=valid_hours)).strftime('%d.%m.%Y %H:%M'),
|
||||
trigger_days=3,
|
||||
tariff_label='',
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
@@ -260,7 +258,6 @@ async def _build_notification_preview_message(language: str, notification_type:
|
||||
percent=percent,
|
||||
trigger_days=trigger_days,
|
||||
expires_at=(now + timedelta(hours=valid_hours)).strftime('%d.%m.%Y %H:%M'),
|
||||
tariff_label='',
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
"""Handler for Antilopay balance top-up."""
|
||||
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
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 = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
|
||||
"""Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе None."""
|
||||
if not getattr(db_user, 'restriction_topup', False):
|
||||
return None
|
||||
|
||||
keyboard = []
|
||||
support_url = settings.get_support_contact_url()
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
async def _create_antilopay_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
payment_method_type: str | None = None,
|
||||
):
|
||||
"""
|
||||
Common logic for creating Antilopay payment and sending response.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
# Create payment
|
||||
payment_service = PaymentService()
|
||||
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description='Пополнение баланса',
|
||||
)
|
||||
|
||||
result = await payment_service.create_antilopay_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language,
|
||||
payment_method_type=payment_method_type,
|
||||
)
|
||||
|
||||
if not result:
|
||||
error_text = texts.t(
|
||||
'PAYMENT_CREATE_ERROR',
|
||||
'Не удалось создать платёж. Попробуйте позже.',
|
||||
)
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
error_text,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
payment_url = result.get('payment_url')
|
||||
display_name = settings.get_antilopay_display_name()
|
||||
|
||||
# Create keyboard with payment button
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
'PAY_BUTTON',
|
||||
'\U0001f4b3 Оплатить {amount}\u20bd',
|
||||
).format(amount=f'{amount_rub:.0f}'),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
response_text = texts.t(
|
||||
'ANTILOPAY_PAYMENT_CREATED',
|
||||
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
|
||||
'Сумма: <b>{amount}\u20bd</b>\n\n'
|
||||
'Нажмите кнопку ниже для оплаты.\n'
|
||||
'После успешной оплаты баланс будет пополнен автоматически.',
|
||||
).format(name=display_name, amount=f'{amount_rub:.2f}')
|
||||
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
logger.info('Antilopay payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_antilopay_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await message.answer(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.ANTILOPAY_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.ANTILOPAY_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount}\u20bd',
|
||||
).format(min_amount=min_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount}\u20bd',
|
||||
).format(max_amount=max_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
payment_method = data.get('payment_method', 'antilopay')
|
||||
# antilopay_sbp → 'sbp', antilopay_card → 'card', antilopay_sberpay → 'sberpay', antilopay → None
|
||||
payment_method_type = _extract_service_type(payment_method)
|
||||
|
||||
await state.clear()
|
||||
|
||||
await _create_antilopay_payment_and_respond(
|
||||
message_or_callback=message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
payment_method_type=payment_method_type,
|
||||
)
|
||||
|
||||
|
||||
ANTILOPAY_PAYMENT_METHODS = {'antilopay', 'antilopay_sbp', 'antilopay_card', 'antilopay_sberpay'}
|
||||
|
||||
ANTILOPAY_SERVICE_MAP: dict[str, str | None] = {
|
||||
'antilopay': None,
|
||||
'antilopay_sbp': 'sbp',
|
||||
'antilopay_card': 'card',
|
||||
'antilopay_sberpay': 'sberpay',
|
||||
}
|
||||
|
||||
|
||||
def _extract_service_type(payment_method: str) -> str | None:
|
||||
return ANTILOPAY_SERVICE_MAP.get(payment_method)
|
||||
|
||||
|
||||
async def _start_antilopay_topup_impl(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
payment_method: str,
|
||||
):
|
||||
"""Common logic for starting Antilopay top-up (generic / SBP / card / SberPay)."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await callback.message.edit_text(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
return
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method=payment_method)
|
||||
|
||||
min_amount = settings.ANTILOPAY_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.ANTILOPAY_MAX_AMOUNT_KOPEKS // 100
|
||||
|
||||
# Choose display name based on sub-method
|
||||
if payment_method == 'antilopay_sbp':
|
||||
display_name = settings.get_antilopay_sbp_display_name()
|
||||
elif payment_method == 'antilopay_card':
|
||||
display_name = settings.get_antilopay_card_display_name()
|
||||
elif payment_method == 'antilopay_sberpay':
|
||||
display_name = settings.get_antilopay_sberpay_display_name()
|
||||
else:
|
||||
display_name = settings.get_antilopay_display_name()
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'ANTILOPAY_ENTER_AMOUNT',
|
||||
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
|
||||
'Введите сумму пополнения в рублях.\n\n'
|
||||
'Минимум: {min_amount}\u20bd\n'
|
||||
'Максимум: {max_amount}\u20bd',
|
||||
).format(
|
||||
name=display_name,
|
||||
min_amount=min_amount,
|
||||
max_amount=f'{max_amount:,}'.replace(',', ' '),
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_antilopay_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_antilopay_topup_impl(callback, db_user, state, 'antilopay')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_antilopay_sbp_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_antilopay_topup_impl(callback, db_user, state, 'antilopay_sbp')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_antilopay_card_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_antilopay_topup_impl(callback, db_user, state, 'antilopay_card')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_antilopay_sberpay_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_antilopay_topup_impl(callback, db_user, state, 'antilopay_sberpay')
|
||||
@@ -39,7 +39,6 @@ async def _create_aurapay_payment_and_respond(
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
payment_method_type: str | None = None,
|
||||
):
|
||||
"""
|
||||
Common logic for creating AuraPay payment and sending response.
|
||||
@@ -62,7 +61,6 @@ async def _create_aurapay_payment_and_respond(
|
||||
description=description,
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language,
|
||||
payment_method_type=payment_method_type,
|
||||
)
|
||||
|
||||
if not result:
|
||||
@@ -181,11 +179,6 @@ async def process_aurapay_payment_amount(
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
payment_method = data.get('payment_method', 'aurapay')
|
||||
# aurapay_sbp → 'sbp', aurapay_card → 'card', aurapay → None
|
||||
payment_method_type = _extract_service_type(payment_method)
|
||||
|
||||
await state.clear()
|
||||
|
||||
await _create_aurapay_payment_and_respond(
|
||||
@@ -194,30 +187,19 @@ async def process_aurapay_payment_amount(
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
payment_method_type=payment_method_type,
|
||||
)
|
||||
|
||||
|
||||
AURAPAY_PAYMENT_METHODS = {'aurapay', 'aurapay_sbp', 'aurapay_card'}
|
||||
|
||||
AURAPAY_SERVICE_MAP: dict[str, str | None] = {
|
||||
'aurapay': None,
|
||||
'aurapay_sbp': 'sbp',
|
||||
'aurapay_card': 'card',
|
||||
}
|
||||
|
||||
|
||||
def _extract_service_type(payment_method: str) -> str | None:
|
||||
return AURAPAY_SERVICE_MAP.get(payment_method)
|
||||
|
||||
|
||||
async def _start_aurapay_topup_impl(
|
||||
@error_handler
|
||||
async def start_aurapay_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
payment_method: str,
|
||||
):
|
||||
"""Common logic for starting AuraPay top-up (generic / SBP / card)."""
|
||||
"""
|
||||
Start AuraPay top-up process - ask for amount.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
@@ -231,18 +213,11 @@ async def _start_aurapay_topup_impl(
|
||||
return
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method=payment_method)
|
||||
await state.update_data(payment_method='aurapay')
|
||||
|
||||
min_amount = settings.AURAPAY_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.AURAPAY_MAX_AMOUNT_KOPEKS // 100
|
||||
|
||||
# Choose display name based on sub-method
|
||||
if payment_method == 'aurapay_sbp':
|
||||
display_name = settings.get_aurapay_sbp_display_name()
|
||||
elif payment_method == 'aurapay_card':
|
||||
display_name = settings.get_aurapay_card_display_name()
|
||||
else:
|
||||
display_name = settings.get_aurapay_display_name()
|
||||
display_name = settings.get_aurapay_display_name()
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
@@ -270,33 +245,3 @@ async def _start_aurapay_topup_impl(
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_aurapay_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_aurapay_topup_impl(callback, db_user, state, 'aurapay')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_aurapay_sbp_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_aurapay_topup_impl(callback, db_user, state, 'aurapay_sbp')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_aurapay_card_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_aurapay_topup_impl(callback, db_user, state, 'aurapay_card')
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
"""Handler for Donut balance top-up (Donut P2P)."""
|
||||
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
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 = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
DONUT_PAYMENT_METHODS = {'donut', 'donut_card', 'donut_sbp', 'donut_sbp_qr'}
|
||||
|
||||
DONUT_SERVICE_MAP: dict[str, str | None] = {
|
||||
'donut': None,
|
||||
'donut_card': 'card',
|
||||
'donut_sbp': 'sbp',
|
||||
'donut_sbp_qr': 'sbp_qr',
|
||||
}
|
||||
|
||||
|
||||
def _extract_service_type(payment_method: str) -> str | None:
|
||||
return DONUT_SERVICE_MAP.get(payment_method)
|
||||
|
||||
|
||||
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
|
||||
"""Проверяет ограничение на пополнение."""
|
||||
if not getattr(db_user, 'restriction_topup', False):
|
||||
return None
|
||||
|
||||
keyboard = []
|
||||
support_url = settings.get_support_contact_url()
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def _get_display_name(payment_method: str) -> str:
|
||||
if payment_method == 'donut_card':
|
||||
return settings.get_donut_card_display_name()
|
||||
if payment_method == 'donut_sbp':
|
||||
return settings.get_donut_sbp_display_name()
|
||||
if payment_method == 'donut_sbp_qr':
|
||||
return settings.get_donut_sbp_qr_display_name()
|
||||
return settings.get_donut_display_name()
|
||||
|
||||
|
||||
async def _create_donut_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
payment_method_type: str | None = None,
|
||||
display_name: str | None = None,
|
||||
):
|
||||
"""Создаёт платёж Donut и отправляет ссылку пользователю."""
|
||||
texts = get_texts(db_user.language)
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
payment_service = PaymentService()
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description='Пополнение баланса',
|
||||
)
|
||||
|
||||
result = await payment_service.create_donut_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language,
|
||||
payment_method_type=payment_method_type,
|
||||
)
|
||||
|
||||
if not result:
|
||||
error_text = texts.t(
|
||||
'PAYMENT_CREATE_ERROR',
|
||||
'Не удалось создать платёж. Попробуйте позже.',
|
||||
)
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(error_text, parse_mode='HTML')
|
||||
return
|
||||
|
||||
payment_url = result.get('payment_url')
|
||||
name = display_name or settings.get_donut_display_name()
|
||||
|
||||
pay_button_text = texts.t('PAY_BUTTON', '\U0001f4b3 Оплатить {amount}₽').format(
|
||||
amount=f'{amount_rub:.0f}',
|
||||
)
|
||||
|
||||
keyboard_buttons: list[list[InlineKeyboardButton]] = []
|
||||
if payment_url:
|
||||
keyboard_buttons.append([InlineKeyboardButton(text=pay_button_text, url=payment_url)])
|
||||
keyboard_buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '◀️ Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
]
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
|
||||
|
||||
if payment_url:
|
||||
response_text = texts.t(
|
||||
'DONUT_PAYMENT_CREATED',
|
||||
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
|
||||
'Сумма: <b>{amount}₽</b>\n\n'
|
||||
'Нажмите кнопку ниже для перехода к оплате.\n'
|
||||
'После подтверждения платежа баланс будет пополнен автоматически.',
|
||||
).format(name=name, amount=f'{amount_rub:.2f}')
|
||||
else:
|
||||
response_text = texts.t(
|
||||
'DONUT_PAYMENT_PROCESSING',
|
||||
'\U0001f4b3 <b>Платёж создан через {name}</b>\n\n'
|
||||
'Сумма: <b>{amount}₽</b>\n\n'
|
||||
'Платёж в обработке. Реквизиты будут отправлены отдельным сообщением.',
|
||||
).format(name=name, amount=f'{amount_rub:.2f}')
|
||||
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(response_text, reply_markup=keyboard, parse_mode='HTML')
|
||||
else:
|
||||
await message_or_callback.answer(response_text, reply_markup=keyboard, parse_mode='HTML')
|
||||
|
||||
logger.info('Donut payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_donut_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Обрабатывает сумму, введённую пользователем для Donut."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await message.answer(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
min_amount = settings.DONUT_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.DONUT_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount}₽',
|
||||
).format(min_amount=min_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount}₽',
|
||||
).format(max_amount=max_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
payment_method = data.get('payment_method', 'donut')
|
||||
payment_method_type = _extract_service_type(payment_method)
|
||||
display_name = _get_display_name(payment_method)
|
||||
|
||||
await state.clear()
|
||||
|
||||
await _create_donut_payment_and_respond(
|
||||
message_or_callback=message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
payment_method_type=payment_method_type,
|
||||
display_name=display_name,
|
||||
)
|
||||
|
||||
|
||||
async def _start_donut_topup_impl(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
payment_method: str,
|
||||
):
|
||||
"""Стартует FSM ввода суммы для Donut."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await callback.message.edit_text(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
return
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method=payment_method)
|
||||
|
||||
min_amount = settings.DONUT_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.DONUT_MAX_AMOUNT_KOPEKS // 100
|
||||
|
||||
display_name = _get_display_name(payment_method)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '◀️ Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'DONUT_ENTER_AMOUNT',
|
||||
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
|
||||
'Введите сумму пополнения в рублях.\n\n'
|
||||
'Минимум: {min_amount}₽\n'
|
||||
'Максимум: {max_amount}₽',
|
||||
).format(
|
||||
name=display_name,
|
||||
min_amount=min_amount,
|
||||
max_amount=f'{max_amount:,}'.replace(',', ' '),
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_donut_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_donut_topup_impl(callback, db_user, state, 'donut')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_donut_card_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_donut_topup_impl(callback, db_user, state, 'donut_card')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_donut_sbp_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_donut_topup_impl(callback, db_user, state, 'donut_sbp')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_donut_sbp_qr_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_donut_topup_impl(callback, db_user, state, 'donut_sbp_qr')
|
||||
@@ -1,302 +0,0 @@
|
||||
"""Handler for Etoplatezhi balance top-up."""
|
||||
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
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 = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
|
||||
"""Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе None."""
|
||||
if not getattr(db_user, 'restriction_topup', False):
|
||||
return None
|
||||
|
||||
keyboard = []
|
||||
support_url = settings.get_support_contact_url()
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
async def _create_etoplatezhi_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
payment_method_type: str | None = None,
|
||||
):
|
||||
"""
|
||||
Common logic for creating Etoplatezhi payment and sending response.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
# Create payment
|
||||
payment_service = PaymentService()
|
||||
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description='Пополнение баланса',
|
||||
)
|
||||
|
||||
result = await payment_service.create_etoplatezhi_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language,
|
||||
payment_method_type=payment_method_type,
|
||||
)
|
||||
|
||||
if not result:
|
||||
error_text = texts.t(
|
||||
'PAYMENT_CREATE_ERROR',
|
||||
'Не удалось создать платёж. Попробуйте позже.',
|
||||
)
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
error_text,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
payment_url = result.get('payment_url')
|
||||
display_name = settings.get_etoplatezhi_display_name()
|
||||
|
||||
# Create keyboard with payment button
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
'PAY_BUTTON',
|
||||
'\U0001f4b3 Оплатить {amount}\u20bd',
|
||||
).format(amount=f'{amount_rub:.0f}'),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
response_text = texts.t(
|
||||
'ETOPLATEZHI_PAYMENT_CREATED',
|
||||
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
|
||||
'Сумма: <b>{amount}\u20bd</b>\n\n'
|
||||
'Нажмите кнопку ниже для оплаты.\n'
|
||||
'После успешной оплаты баланс будет пополнен автоматически.',
|
||||
).format(name=display_name, amount=f'{amount_rub:.2f}')
|
||||
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
logger.info('Etoplatezhi payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_etoplatezhi_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await message.answer(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.ETOPLATEZHI_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.ETOPLATEZHI_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount}\u20bd',
|
||||
).format(min_amount=min_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount}\u20bd',
|
||||
).format(max_amount=max_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
payment_method = data.get('payment_method', 'etoplatezhi')
|
||||
# etoplatezhi_sbp → 'sbp', etoplatezhi_card → 'card', etoplatezhi → None
|
||||
payment_method_type = _extract_service_type(payment_method)
|
||||
|
||||
await state.clear()
|
||||
|
||||
await _create_etoplatezhi_payment_and_respond(
|
||||
message_or_callback=message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
payment_method_type=payment_method_type,
|
||||
)
|
||||
|
||||
|
||||
ETOPLATEZHI_PAYMENT_METHODS = {'etoplatezhi', 'etoplatezhi_sbp', 'etoplatezhi_card'}
|
||||
|
||||
ETOPLATEZHI_SERVICE_MAP: dict[str, str | None] = {
|
||||
'etoplatezhi': None,
|
||||
'etoplatezhi_sbp': 'sbp',
|
||||
'etoplatezhi_card': 'card',
|
||||
}
|
||||
|
||||
|
||||
def _extract_service_type(payment_method: str) -> str | None:
|
||||
return ETOPLATEZHI_SERVICE_MAP.get(payment_method)
|
||||
|
||||
|
||||
async def _start_etoplatezhi_topup_impl(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
payment_method: str,
|
||||
):
|
||||
"""Common logic for starting Etoplatezhi top-up (generic / SBP / card)."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await callback.message.edit_text(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
return
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method=payment_method)
|
||||
|
||||
min_amount = settings.ETOPLATEZHI_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.ETOPLATEZHI_MAX_AMOUNT_KOPEKS // 100
|
||||
|
||||
# Choose display name based on sub-method
|
||||
if payment_method == 'etoplatezhi_sbp':
|
||||
display_name = settings.get_etoplatezhi_sbp_display_name()
|
||||
elif payment_method == 'etoplatezhi_card':
|
||||
display_name = settings.get_etoplatezhi_card_display_name()
|
||||
else:
|
||||
display_name = settings.get_etoplatezhi_display_name()
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'ETOPLATEZHI_ENTER_AMOUNT',
|
||||
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
|
||||
'Введите сумму пополнения в рублях.\n\n'
|
||||
'Минимум: {min_amount}\u20bd\n'
|
||||
'Максимум: {max_amount}\u20bd',
|
||||
).format(
|
||||
name=display_name,
|
||||
min_amount=min_amount,
|
||||
max_amount=f'{max_amount:,}'.replace(',', ' '),
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_etoplatezhi_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_etoplatezhi_topup_impl(callback, db_user, state, 'etoplatezhi')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_etoplatezhi_sbp_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_etoplatezhi_topup_impl(callback, db_user, state, 'etoplatezhi_sbp')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_etoplatezhi_card_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_etoplatezhi_topup_impl(callback, db_user, state, 'etoplatezhi_card')
|
||||
@@ -1,273 +0,0 @@
|
||||
"""Handler for Jupiter balance top-up (FPGate P2P v2.1)."""
|
||||
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
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 = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
JUPITER_PAYMENT_METHODS = {'jupiter', 'jupiter_sbp'}
|
||||
|
||||
JUPITER_SERVICE_MAP: dict[str, str | None] = {
|
||||
'jupiter': None,
|
||||
'jupiter_sbp': 'sbp',
|
||||
}
|
||||
|
||||
|
||||
def _extract_service_type(payment_method: str) -> str | None:
|
||||
return JUPITER_SERVICE_MAP.get(payment_method)
|
||||
|
||||
|
||||
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
|
||||
"""Проверяет ограничение на пополнение."""
|
||||
if not getattr(db_user, 'restriction_topup', False):
|
||||
return None
|
||||
|
||||
keyboard = []
|
||||
support_url = settings.get_support_contact_url()
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
async def _create_jupiter_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
payment_method_type: str | None = None,
|
||||
):
|
||||
"""Создаёт платёж Jupiter и отправляет ссылку/QR пользователю."""
|
||||
texts = get_texts(db_user.language)
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
payment_service = PaymentService()
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description='Пополнение баланса',
|
||||
)
|
||||
|
||||
result = await payment_service.create_jupiter_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language,
|
||||
payment_method_type=payment_method_type,
|
||||
)
|
||||
|
||||
if not result:
|
||||
error_text = texts.t(
|
||||
'PAYMENT_CREATE_ERROR',
|
||||
'Не удалось создать платёж. Попробуйте позже.',
|
||||
)
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(error_text, parse_mode='HTML')
|
||||
return
|
||||
|
||||
payment_url = result.get('payment_url')
|
||||
display_name = settings.get_jupiter_display_name()
|
||||
|
||||
pay_button_text = texts.t('PAY_BUTTON', '\U0001f4b3 Оплатить {amount}₽').format(
|
||||
amount=f'{amount_rub:.0f}',
|
||||
)
|
||||
|
||||
keyboard_buttons: list[list[InlineKeyboardButton]] = []
|
||||
if payment_url:
|
||||
keyboard_buttons.append([InlineKeyboardButton(text=pay_button_text, url=payment_url)])
|
||||
keyboard_buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '◀️ Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
]
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
|
||||
|
||||
if payment_url:
|
||||
response_text = texts.t(
|
||||
'JUPITER_PAYMENT_CREATED',
|
||||
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
|
||||
'Сумма: <b>{amount}₽</b>\n\n'
|
||||
'Нажмите кнопку ниже, чтобы открыть QR-код СБП и оплатить.\n'
|
||||
'Баланс будет пополнен автоматически после подтверждения платежа.',
|
||||
).format(name=display_name, amount=f'{amount_rub:.2f}')
|
||||
else:
|
||||
response_text = texts.t(
|
||||
'JUPITER_PAYMENT_PROCESSING',
|
||||
'\U0001f4b3 <b>Платёж создан через {name}</b>\n\n'
|
||||
'Сумма: <b>{amount}₽</b>\n\n'
|
||||
'Платёж в обработке. Реквизиты будут отправлены отдельным сообщением.',
|
||||
).format(name=display_name, amount=f'{amount_rub:.2f}')
|
||||
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(response_text, reply_markup=keyboard, parse_mode='HTML')
|
||||
else:
|
||||
await message_or_callback.answer(response_text, reply_markup=keyboard, parse_mode='HTML')
|
||||
|
||||
logger.info('Jupiter payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_jupiter_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Обрабатывает сумму, введённую пользователем для Jupiter."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await message.answer(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
min_amount = settings.JUPITER_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.JUPITER_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount}₽',
|
||||
).format(min_amount=min_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount}₽',
|
||||
).format(max_amount=max_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
payment_method = data.get('payment_method', 'jupiter')
|
||||
payment_method_type = _extract_service_type(payment_method)
|
||||
|
||||
await state.clear()
|
||||
|
||||
await _create_jupiter_payment_and_respond(
|
||||
message_or_callback=message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
payment_method_type=payment_method_type,
|
||||
)
|
||||
|
||||
|
||||
async def _start_jupiter_topup_impl(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
payment_method: str,
|
||||
):
|
||||
"""Стартует FSM ввода суммы для Jupiter."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await callback.message.edit_text(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
return
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method=payment_method)
|
||||
|
||||
min_amount = settings.JUPITER_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.JUPITER_MAX_AMOUNT_KOPEKS // 100
|
||||
|
||||
if payment_method == 'jupiter_sbp':
|
||||
display_name = settings.get_jupiter_sbp_display_name()
|
||||
else:
|
||||
display_name = settings.get_jupiter_display_name()
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '◀️ Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'JUPITER_ENTER_AMOUNT',
|
||||
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
|
||||
'Введите сумму пополнения в рублях.\n\n'
|
||||
'Минимум: {min_amount}₽\n'
|
||||
'Максимум: {max_amount}₽',
|
||||
).format(
|
||||
name=display_name,
|
||||
min_amount=min_amount,
|
||||
max_amount=f'{max_amount:,}'.replace(',', ' '),
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_jupiter_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_jupiter_topup_impl(callback, db_user, state, 'jupiter')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_jupiter_sbp_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_jupiter_topup_impl(callback, db_user, state, 'jupiter_sbp')
|
||||
@@ -1,284 +0,0 @@
|
||||
"""Handler for Lava balance top-up (Lava Business, gate.lava.ru)."""
|
||||
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
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 = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
LAVA_PAYMENT_METHODS = {'lava', 'lava_card', 'lava_sbp'}
|
||||
|
||||
LAVA_SERVICE_MAP: dict[str, str | None] = {
|
||||
'lava': None,
|
||||
'lava_card': 'card',
|
||||
'lava_sbp': 'sbp',
|
||||
}
|
||||
|
||||
|
||||
def _extract_service_type(payment_method: str) -> str | None:
|
||||
return LAVA_SERVICE_MAP.get(payment_method)
|
||||
|
||||
|
||||
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
|
||||
"""Проверяет ограничение на пополнение."""
|
||||
if not getattr(db_user, 'restriction_topup', False):
|
||||
return None
|
||||
|
||||
keyboard = []
|
||||
support_url = settings.get_support_contact_url()
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def _get_display_name(payment_method: str) -> str:
|
||||
if payment_method == 'lava_card':
|
||||
return settings.get_lava_card_display_name()
|
||||
if payment_method == 'lava_sbp':
|
||||
return settings.get_lava_sbp_display_name()
|
||||
return settings.get_lava_display_name()
|
||||
|
||||
|
||||
async def _create_lava_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
payment_method_type: str | None = None,
|
||||
display_name: str | None = None,
|
||||
):
|
||||
"""Создаёт инвойс Lava и отправляет ссылку пользователю."""
|
||||
texts = get_texts(db_user.language)
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
payment_service = PaymentService()
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description='Пополнение баланса',
|
||||
)
|
||||
|
||||
result = await payment_service.create_lava_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language,
|
||||
payment_method_type=payment_method_type,
|
||||
)
|
||||
|
||||
if not result:
|
||||
error_text = texts.t(
|
||||
'PAYMENT_CREATE_ERROR',
|
||||
'Не удалось создать платёж. Попробуйте позже.',
|
||||
)
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(error_text, parse_mode='HTML')
|
||||
return
|
||||
|
||||
payment_url = result.get('payment_url')
|
||||
name = display_name or settings.get_lava_display_name()
|
||||
|
||||
# Без URL mixin вернул бы None ещё до этого блока; здесь URL гарантирован.
|
||||
pay_button_text = texts.t('PAY_BUTTON', '\U0001f4b3 Оплатить {amount}₽').format(
|
||||
amount=f'{amount_rub:.0f}',
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text=pay_button_text, url=payment_url)],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '◀️ Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
response_text = texts.t(
|
||||
'LAVA_PAYMENT_CREATED',
|
||||
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
|
||||
'Сумма: <b>{amount}₽</b>\n\n'
|
||||
'Нажмите кнопку ниже для перехода к оплате.\n'
|
||||
'После подтверждения платежа баланс будет пополнен автоматически.',
|
||||
).format(name=name, amount=f'{amount_rub:.2f}')
|
||||
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(response_text, reply_markup=keyboard, parse_mode='HTML')
|
||||
else:
|
||||
await message_or_callback.answer(response_text, reply_markup=keyboard, parse_mode='HTML')
|
||||
|
||||
logger.info('Lava payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_lava_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Обрабатывает сумму для Lava."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await message.answer(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
min_amount = settings.LAVA_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.LAVA_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount}₽',
|
||||
).format(min_amount=min_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount}₽',
|
||||
).format(max_amount=max_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
payment_method = data.get('payment_method', 'lava')
|
||||
payment_method_type = _extract_service_type(payment_method)
|
||||
display_name = _get_display_name(payment_method)
|
||||
|
||||
await state.clear()
|
||||
|
||||
await _create_lava_payment_and_respond(
|
||||
message_or_callback=message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
payment_method_type=payment_method_type,
|
||||
display_name=display_name,
|
||||
)
|
||||
|
||||
|
||||
async def _start_lava_topup_impl(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
payment_method: str,
|
||||
):
|
||||
"""Стартует FSM ввода суммы для Lava."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await callback.message.edit_text(
|
||||
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
return
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method=payment_method)
|
||||
|
||||
min_amount = settings.LAVA_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.LAVA_MAX_AMOUNT_KOPEKS // 100
|
||||
|
||||
display_name = _get_display_name(payment_method)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '◀️ Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'LAVA_ENTER_AMOUNT',
|
||||
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
|
||||
'Введите сумму пополнения в рублях.\n\n'
|
||||
'Минимум: {min_amount}₽\n'
|
||||
'Максимум: {max_amount}₽',
|
||||
).format(
|
||||
name=display_name,
|
||||
min_amount=min_amount,
|
||||
max_amount=f'{max_amount:,}'.replace(',', ' '),
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_lava_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_lava_topup_impl(callback, db_user, state, 'lava')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_lava_card_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_lava_topup_impl(callback, db_user, state, 'lava_card')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_lava_sbp_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_lava_topup_impl(callback, db_user, state, 'lava_sbp')
|
||||
@@ -170,48 +170,13 @@ async def route_payment_by_method(
|
||||
await process_overpay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method in ('aurapay', 'aurapay_sbp', 'aurapay_card'):
|
||||
if payment_method == 'aurapay':
|
||||
from .aurapay import process_aurapay_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_aurapay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method in ('etoplatezhi', 'etoplatezhi_sbp', 'etoplatezhi_card'):
|
||||
from .etoplatezhi import process_etoplatezhi_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_etoplatezhi_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method in ('antilopay', 'antilopay_sbp', 'antilopay_card', 'antilopay_sberpay'):
|
||||
from .antilopay import process_antilopay_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_antilopay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method in ('jupiter', 'jupiter_sbp'):
|
||||
from .jupiter import process_jupiter_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_jupiter_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method in ('donut', 'donut_card', 'donut_sbp', 'donut_sbp_qr'):
|
||||
from .donut import process_donut_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_donut_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method in ('lava', 'lava_card', 'lava_sbp'):
|
||||
from .lava import process_lava_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_lava_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == 'riopay':
|
||||
from .riopay import process_riopay_payment_amount
|
||||
|
||||
@@ -803,52 +768,9 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(start_overpay_topup, F.data == 'topup_overpay')
|
||||
|
||||
from .aurapay import start_aurapay_card_topup, start_aurapay_sbp_topup, start_aurapay_topup
|
||||
from .aurapay import start_aurapay_topup
|
||||
|
||||
dp.callback_query.register(start_aurapay_topup, F.data == 'topup_aurapay')
|
||||
dp.callback_query.register(start_aurapay_sbp_topup, F.data == 'topup_aurapay_sbp')
|
||||
dp.callback_query.register(start_aurapay_card_topup, F.data == 'topup_aurapay_card')
|
||||
|
||||
from .etoplatezhi import start_etoplatezhi_card_topup, start_etoplatezhi_sbp_topup, start_etoplatezhi_topup
|
||||
|
||||
dp.callback_query.register(start_etoplatezhi_topup, F.data == 'topup_etoplatezhi')
|
||||
dp.callback_query.register(start_etoplatezhi_sbp_topup, F.data == 'topup_etoplatezhi_sbp')
|
||||
dp.callback_query.register(start_etoplatezhi_card_topup, F.data == 'topup_etoplatezhi_card')
|
||||
|
||||
from .antilopay import (
|
||||
start_antilopay_card_topup,
|
||||
start_antilopay_sberpay_topup,
|
||||
start_antilopay_sbp_topup,
|
||||
start_antilopay_topup,
|
||||
)
|
||||
|
||||
dp.callback_query.register(start_antilopay_topup, F.data == 'topup_antilopay')
|
||||
dp.callback_query.register(start_antilopay_sbp_topup, F.data == 'topup_antilopay_sbp')
|
||||
dp.callback_query.register(start_antilopay_card_topup, F.data == 'topup_antilopay_card')
|
||||
dp.callback_query.register(start_antilopay_sberpay_topup, F.data == 'topup_antilopay_sberpay')
|
||||
|
||||
from .jupiter import start_jupiter_sbp_topup, start_jupiter_topup
|
||||
|
||||
dp.callback_query.register(start_jupiter_topup, F.data == 'topup_jupiter')
|
||||
dp.callback_query.register(start_jupiter_sbp_topup, F.data == 'topup_jupiter_sbp')
|
||||
|
||||
from .donut import (
|
||||
start_donut_card_topup,
|
||||
start_donut_sbp_qr_topup,
|
||||
start_donut_sbp_topup,
|
||||
start_donut_topup,
|
||||
)
|
||||
|
||||
dp.callback_query.register(start_donut_topup, F.data == 'topup_donut')
|
||||
dp.callback_query.register(start_donut_card_topup, F.data == 'topup_donut_card')
|
||||
dp.callback_query.register(start_donut_sbp_topup, F.data == 'topup_donut_sbp')
|
||||
dp.callback_query.register(start_donut_sbp_qr_topup, F.data == 'topup_donut_sbp_qr')
|
||||
|
||||
from .lava import start_lava_card_topup, start_lava_sbp_topup, start_lava_topup
|
||||
|
||||
dp.callback_query.register(start_lava_topup, F.data == 'topup_lava')
|
||||
dp.callback_query.register(start_lava_card_topup, F.data == 'topup_lava_card')
|
||||
dp.callback_query.register(start_lava_sbp_topup, F.data == 'topup_lava_sbp')
|
||||
|
||||
from .mulenpay import check_mulenpay_payment_status
|
||||
|
||||
|
||||
@@ -17,16 +17,14 @@ from aiogram.types import ChatMemberUpdated
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.subscription import deactivate_subscription, reactivate_subscription
|
||||
from app.database.crud.tasks import get_partner_channel_by_channel_id
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import SubscriptionStatus, TaskType, UserStatus
|
||||
from app.database.models import SubscriptionStatus, UserStatus
|
||||
from app.keyboards.inline import get_channel_sub_keyboard
|
||||
from app.localization.loader import DEFAULT_LANGUAGE
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.channel_subscription_service import channel_subscription_service
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.tasks_service import record_event
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -40,58 +38,14 @@ async def _is_required_channel(channel_id: str) -> bool:
|
||||
return channel_id in required_ids
|
||||
|
||||
|
||||
async def _is_task_partner_channel(channel_id: str) -> bool:
|
||||
"""Check if the channel_id is a partner channel used for SUBSCRIBE_CHANNEL tasks."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
partner = await get_partner_channel_by_channel_id(db, channel_id)
|
||||
return partner is not None and partner.is_active
|
||||
|
||||
|
||||
async def _trigger_subscribe_channel_task(telegram_id: int, channel_id: str) -> None:
|
||||
"""Записывает событие SUBSCRIBE_CHANNEL для системы заданий.
|
||||
|
||||
Вызывается, когда юзер подписался на канал, у которого есть TaskPartnerChannel.
|
||||
Безопасно — не пробрасывает исключения, чтобы не сломать обработку события.
|
||||
"""
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
db_user = await get_user_by_telegram_id(db, telegram_id)
|
||||
if db_user is None:
|
||||
return
|
||||
await record_event(
|
||||
db,
|
||||
user_id=db_user.id,
|
||||
event_type=TaskType.SUBSCRIBE_CHANNEL,
|
||||
payload={'channel_id': channel_id},
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
'Failed to record SUBSCRIBE_CHANNEL task event',
|
||||
telegram_id=telegram_id,
|
||||
channel_id=channel_id,
|
||||
error=exc,
|
||||
)
|
||||
|
||||
|
||||
@router.chat_member(ChatMemberUpdatedFilter(member_status_changed=IS_NOT_MEMBER >> IS_MEMBER))
|
||||
async def on_user_joined_channel(event: ChatMemberUpdated, bot: Bot) -> None:
|
||||
"""User subscribed to a channel -- update cache and reactivate VPN if applicable."""
|
||||
user = event.new_chat_member.user
|
||||
channel_id = str(event.chat.id) # Normalize int to str (DB stores string)
|
||||
|
||||
is_required = await _is_required_channel(channel_id)
|
||||
is_partner = await _is_task_partner_channel(channel_id)
|
||||
|
||||
# FILTER: Only process events for required or task partner channels
|
||||
if not is_required and not is_partner:
|
||||
return
|
||||
|
||||
# Если канал партнёрский (для заданий) — триггерим прогресс задания
|
||||
if is_partner:
|
||||
await _trigger_subscribe_channel_task(user.id, channel_id)
|
||||
|
||||
if not is_required:
|
||||
# FILTER: Only process events for required channels
|
||||
if not await _is_required_channel(channel_id):
|
||||
return
|
||||
|
||||
await channel_subscription_service.on_user_joined(user.id, channel_id)
|
||||
@@ -171,12 +125,7 @@ async def on_user_joined_channel(event: ChatMemberUpdated, bot: Bot) -> None:
|
||||
|
||||
@router.chat_member(ChatMemberUpdatedFilter(member_status_changed=IS_MEMBER >> IS_NOT_MEMBER))
|
||||
async def on_user_left_channel(event: ChatMemberUpdated, bot: Bot) -> None:
|
||||
"""User unsubscribed from a channel -- update cache and deactivate VPN if applicable.
|
||||
|
||||
Партнёрские каналы (TaskPartnerChannel) тут не учитываем: задания SUBSCRIBE_CHANNEL
|
||||
в режиме absolute completed-once. Отписка не должна откатывать выполненное задание
|
||||
(юзер уже claim'нул награду).
|
||||
"""
|
||||
"""User unsubscribed from a channel -- update cache and deactivate VPN if applicable."""
|
||||
user = event.old_chat_member.user
|
||||
channel_id = str(event.chat.id) # Normalize int to str (DB stores string)
|
||||
|
||||
|
||||
+1
-47
@@ -49,11 +49,7 @@ from app.services.pinned_message_service import (
|
||||
get_active_pinned_message,
|
||||
)
|
||||
from app.services.privacy_policy_service import PrivacyPolicyService
|
||||
from app.services.referral_service import (
|
||||
process_referral_registration,
|
||||
save_pending_campaign,
|
||||
save_pending_referral,
|
||||
)
|
||||
from app.services.referral_service import process_referral_registration, save_pending_referral
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.services.web_auth_service import WEB_AUTH_TOKEN_MIN_LENGTH, link_web_auth_token
|
||||
@@ -390,17 +386,6 @@ async def _apply_campaign_bonus_if_needed(
|
||||
if not result.success:
|
||||
return None
|
||||
|
||||
# Bot-flow successfully applied the campaign — clear the Redis pending entry
|
||||
# (set in cmd_start as a fallback for the cabinet WebApp path) so it isn't
|
||||
# re-evaluated on a subsequent cabinet login.
|
||||
try:
|
||||
from app.services.referral_service import clear_pending_campaign
|
||||
|
||||
if getattr(user, 'telegram_id', None):
|
||||
await clear_pending_campaign(user.telegram_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if result.bonus_type == 'balance':
|
||||
amount_text = texts.format_price(result.balance_kopeks)
|
||||
return texts.CAMPAIGN_BONUS_BALANCE.format(
|
||||
@@ -739,23 +724,6 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
start_parameter=campaign.start_parameter,
|
||||
)
|
||||
await state.update_data(campaign_id=campaign.id)
|
||||
# Persist campaign to Redis immediately so it survives if user opens
|
||||
# miniapp/cabinet (via Telegram menu button) before completing the
|
||||
# bot registration flow. Mirrors the pending_referral mechanism.
|
||||
# Only for new users — existing users already had attribution applied.
|
||||
if not db_user:
|
||||
try:
|
||||
await save_pending_campaign(
|
||||
message.from_user.id,
|
||||
campaign.start_parameter,
|
||||
campaign.id,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
'Failed to persist pending campaign',
|
||||
campaign_id=campaign.id,
|
||||
error=exc,
|
||||
)
|
||||
if campaign.partner_user_id:
|
||||
await state.update_data(referrer_id=campaign.partner_user_id)
|
||||
logger.info(
|
||||
@@ -2448,20 +2416,6 @@ async def required_sub_channel_check(
|
||||
campaign_id=campaign.id,
|
||||
partner_user_id=campaign.partner_user_id,
|
||||
)
|
||||
# Mirror save in Redis so cabinet WebApp auth can pick it up
|
||||
# if user opens miniapp before completing registration.
|
||||
try:
|
||||
await save_pending_campaign(
|
||||
query.from_user.id,
|
||||
campaign.start_parameter,
|
||||
campaign.id,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
'Failed to persist pending campaign after channel check',
|
||||
campaign_id=campaign.id,
|
||||
error=exc,
|
||||
)
|
||||
else:
|
||||
state_data['referral_code'] = pending_start_payload
|
||||
logger.info(
|
||||
|
||||
@@ -406,37 +406,11 @@ async def get_apps_for_platform_async(device_type: str, language: str = 'ru') ->
|
||||
|
||||
def normalize_app(app: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Normalize Remnawave app dict to a unified format with blocks."""
|
||||
|
||||
# Extract urlScheme from blocks if not present at root level
|
||||
url_scheme = app.get('urlScheme', '')
|
||||
|
||||
if not url_scheme:
|
||||
# Try to extract from subscriptionLink button in blocks
|
||||
blocks = app.get('blocks', [])
|
||||
for block in blocks:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
buttons = block.get('buttons', [])
|
||||
for btn in buttons:
|
||||
if not isinstance(btn, dict):
|
||||
continue
|
||||
if btn.get('type') == 'subscriptionLink':
|
||||
link = btn.get('link', '') or btn.get('url', '')
|
||||
if '{{SUBSCRIPTION_LINK}}' in link:
|
||||
url_scheme = link.split('{{SUBSCRIPTION_LINK}}')[0]
|
||||
break
|
||||
if url_scheme:
|
||||
break
|
||||
|
||||
# Validate extracted scheme contains ://
|
||||
if url_scheme and '://' not in url_scheme:
|
||||
url_scheme = ''
|
||||
|
||||
return {
|
||||
'id': app.get('id', app.get('name', 'unknown')),
|
||||
'name': app.get('name', ''),
|
||||
'isFeatured': app.get('featured', app.get('isFeatured', False)),
|
||||
'urlScheme': url_scheme,
|
||||
'urlScheme': app.get('urlScheme', ''),
|
||||
'isNeedBase64Encoding': app.get('isNeedBase64Encoding', False),
|
||||
'blocks': app.get('blocks', []),
|
||||
'_raw': app,
|
||||
|
||||
@@ -132,16 +132,6 @@ def _build_subscription_detail_keyboard(sub_id: int, sub=None) -> types.InlineKe
|
||||
if is_inactive:
|
||||
buttons.append([types.InlineKeyboardButton(text='🗑 Удалить подписку', callback_data=f'sub_del:{sub_id}')])
|
||||
|
||||
if not is_inactive and settings.is_subscription_revoke_enabled():
|
||||
buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='🔄 Перевыпустить',
|
||||
callback_data=f'sr:{sub_id}',
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
buttons.append([types.InlineKeyboardButton(text='◀️ К списку подписок', callback_data='my_subscriptions')])
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
@@ -4141,17 +4141,6 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(handle_change_devices_menu, F.data.startswith('change_devices_menu:'))
|
||||
dp.callback_query.register(handle_device_management_menu, F.data.startswith('device_management:'))
|
||||
|
||||
# Subscription revoke (reissue)
|
||||
from app.handlers.subscription.revoke import (
|
||||
confirm_subscription_revoke,
|
||||
start_multi_revoke,
|
||||
start_subscription_revoke,
|
||||
)
|
||||
|
||||
dp.callback_query.register(start_subscription_revoke, F.data == 'subscription_revoke')
|
||||
dp.callback_query.register(confirm_subscription_revoke, F.data == 'subscription_revoke_confirm')
|
||||
dp.callback_query.register(start_multi_revoke, F.data.startswith('sr:'))
|
||||
|
||||
dp.callback_query.register(show_trial_offer, F.data == 'menu_trial')
|
||||
|
||||
dp.callback_query.register(activate_trial, F.data == 'trial_activate')
|
||||
|
||||
@@ -1,350 +0,0 @@
|
||||
"""Handler for subscription reissue (revoke + regenerate link)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InaccessibleMessage, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.subscription import get_subscription_by_id_for_user
|
||||
from app.database.models import Subscription, User
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _check_revoke_cooldown(subscription: Subscription) -> int | None:
|
||||
"""Returns remaining seconds if on cooldown, None if ready."""
|
||||
if not subscription.last_revoke_at:
|
||||
return None
|
||||
elapsed = (datetime.now(UTC) - subscription.last_revoke_at).total_seconds()
|
||||
cooldown = settings.SUBSCRIPTION_REVOKE_COOLDOWN_SECONDS
|
||||
if elapsed < cooldown:
|
||||
return int(cooldown - elapsed)
|
||||
return None
|
||||
|
||||
|
||||
def _build_revoke_confirm_keyboard(
|
||||
language: str,
|
||||
multi_tariff: bool = False,
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Build confirmation keyboard for revoke action."""
|
||||
texts = get_texts(language)
|
||||
back_callback = 'my_subscriptions' if multi_tariff else 'subscription_settings'
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('SUBSCRIPTION_REVOKE_CONFIRM_BTN', '✅ Подтвердить'),
|
||||
callback_data='subscription_revoke_confirm',
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data=back_callback,
|
||||
),
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _build_revoke_success_keyboard(
|
||||
language: str,
|
||||
multi_tariff: bool = False,
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Build success keyboard with connect and back buttons."""
|
||||
texts = get_texts(language)
|
||||
back_callback = 'my_subscriptions' if multi_tariff else 'menu_subscription'
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('SUBSCRIPTION_REVOKE_CONNECT_BTN', '🔗 Подключиться'),
|
||||
callback_data='subscription_connect',
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data=back_callback,
|
||||
),
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Classic mode (single subscription)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_subscription_revoke(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext | None = None,
|
||||
) -> None:
|
||||
"""Show revoke confirmation for classic single-subscription mode."""
|
||||
if isinstance(callback.message, InaccessibleMessage):
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_subscription_revoke_enabled():
|
||||
await callback.answer(
|
||||
texts.t('SUBSCRIPTION_REVOKE_DISABLED', 'Перевыпуск подписки недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
subscription = db_user.subscription
|
||||
if not subscription or not subscription.is_active:
|
||||
await callback.answer(
|
||||
texts.t('SUBSCRIPTION_NOT_FOUND', 'Подписка не найдена'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Check cooldown
|
||||
remaining = _check_revoke_cooldown(subscription)
|
||||
if remaining is not None:
|
||||
minutes = remaining // 60
|
||||
seconds = remaining % 60
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'SUBSCRIPTION_REVOKE_COOLDOWN',
|
||||
'⏱ Перевыпуск будет доступен через {minutes} мин. {seconds} сек.',
|
||||
).format(minutes=minutes, seconds=seconds),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'SUBSCRIPTION_REVOKE_WARNING',
|
||||
(
|
||||
'⚠️ <b>Перевыпуск подписки</b>\n\n'
|
||||
'Это действие:\n'
|
||||
'• Сгенерирует новую ссылку подключения\n'
|
||||
'• Сбросит все подключённые устройства\n'
|
||||
'• Старая ссылка перестанет работать\n\n'
|
||||
'Продолжить?'
|
||||
),
|
||||
),
|
||||
reply_markup=_build_revoke_confirm_keyboard(db_user.language, multi_tariff=False),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def confirm_subscription_revoke(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext | None = None,
|
||||
) -> None:
|
||||
"""Execute revoke for classic or multi-tariff mode (uses FSM state for multi)."""
|
||||
if isinstance(callback.message, InaccessibleMessage):
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_subscription_revoke_enabled():
|
||||
await callback.answer(
|
||||
texts.t('SUBSCRIPTION_REVOKE_DISABLED', 'Перевыпуск подписки недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Determine subscription: multi-tariff via FSM state or classic via db_user
|
||||
is_multi = False
|
||||
subscription: Subscription | None = None
|
||||
|
||||
if state:
|
||||
data = await state.get_data()
|
||||
revoke_sub_id = data.get('revoke_sub_id')
|
||||
if revoke_sub_id is not None:
|
||||
is_multi = True
|
||||
subscription = await get_subscription_by_id_for_user(db, revoke_sub_id, db_user.id)
|
||||
if not subscription:
|
||||
await callback.answer(
|
||||
texts.t('SUBSCRIPTION_NOT_FOUND', 'Подписка не найдена'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if subscription is None:
|
||||
subscription = db_user.subscription
|
||||
|
||||
if not subscription or not subscription.is_active:
|
||||
await callback.answer(
|
||||
texts.t('SUBSCRIPTION_NOT_FOUND', 'Подписка не найдена'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# TOCTOU protection: re-check cooldown
|
||||
remaining = _check_revoke_cooldown(subscription)
|
||||
if remaining is not None:
|
||||
minutes = remaining // 60
|
||||
seconds = remaining % 60
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'SUBSCRIPTION_REVOKE_COOLDOWN',
|
||||
'⏱ Перевыпуск будет доступен через {minutes} мин. {seconds} сек.',
|
||||
).format(minutes=minutes, seconds=seconds),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Answer callback BEFORE heavy operation
|
||||
await callback.answer()
|
||||
|
||||
# Execute revoke
|
||||
sub_service = SubscriptionService()
|
||||
new_url = await sub_service.revoke_subscription(db, subscription)
|
||||
|
||||
if not new_url:
|
||||
await callback.message.edit_text(
|
||||
texts.t('SUBSCRIPTION_REVOKE_ERROR', '❌ Ошибка при перевыпуске подписки. Попробуйте позже.'),
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')],
|
||||
]
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
# Update cooldown timestamp
|
||||
subscription.last_revoke_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Subscription revoked successfully',
|
||||
user_id=db_user.id,
|
||||
subscription_id=subscription.id,
|
||||
is_multi=is_multi,
|
||||
)
|
||||
|
||||
# Clean up FSM state
|
||||
if state and is_multi:
|
||||
await state.update_data(revoke_sub_id=None)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'SUBSCRIPTION_REVOKE_SUCCESS',
|
||||
(
|
||||
'✅ <b>Подписка перевыпущена!</b>\n\n'
|
||||
'Новая ссылка подключения готова. '
|
||||
'Старая ссылка больше не действительна.\n\n'
|
||||
'Все устройства были отключены.'
|
||||
),
|
||||
),
|
||||
reply_markup=_build_revoke_success_keyboard(db_user.language, multi_tariff=is_multi),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-tariff mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_multi_revoke(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
"""Show revoke confirmation for multi-tariff mode (callback_data = 'sr:{sub_id}')."""
|
||||
if isinstance(callback.message, InaccessibleMessage):
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_subscription_revoke_enabled():
|
||||
await callback.answer(
|
||||
texts.t('SUBSCRIPTION_REVOKE_DISABLED', 'Перевыпуск подписки недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Extract sub_id from callback_data
|
||||
parts = (callback.data or '').split(':')
|
||||
if len(parts) < 2:
|
||||
await callback.answer('Неверный формат', show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
sub_id = int(parts[1])
|
||||
except (ValueError, TypeError):
|
||||
await callback.answer('Неверный формат', show_alert=True)
|
||||
return
|
||||
|
||||
# Validate ownership (IDOR protection)
|
||||
subscription = await get_subscription_by_id_for_user(db, sub_id, db_user.id)
|
||||
if not subscription:
|
||||
await callback.answer(
|
||||
texts.t('SUBSCRIPTION_NOT_FOUND', 'Подписка не найдена'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if not subscription.is_active:
|
||||
await callback.answer(
|
||||
texts.t('SUBSCRIPTION_NOT_FOUND', 'Подписка не найдена'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Check cooldown
|
||||
remaining = _check_revoke_cooldown(subscription)
|
||||
if remaining is not None:
|
||||
minutes = remaining // 60
|
||||
seconds = remaining % 60
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'SUBSCRIPTION_REVOKE_COOLDOWN',
|
||||
'⏱ Перевыпуск будет доступен через {minutes} мин. {seconds} сек.',
|
||||
).format(minutes=minutes, seconds=seconds),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Store sub_id in FSM state for the confirmation handler
|
||||
await state.update_data(revoke_sub_id=sub_id)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'SUBSCRIPTION_REVOKE_WARNING',
|
||||
(
|
||||
'⚠️ <b>Перевыпуск подписки</b>\n\n'
|
||||
'Это действие:\n'
|
||||
'• Сгенерирует новую ссылку подключения\n'
|
||||
'• Сбросит все подключённые устройства\n'
|
||||
'• Старая ссылка перестанет работать\n\n'
|
||||
'Продолжить?'
|
||||
),
|
||||
),
|
||||
reply_markup=_build_revoke_confirm_keyboard(db_user.language, multi_tariff=True),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
@@ -1111,22 +1111,6 @@ async def handle_custom_confirm(
|
||||
description=f'Покупка тарифа {tariff.name} на {custom_days} дней',
|
||||
)
|
||||
|
||||
# Tasks: триггерим прогресс по платным покупкам подписок
|
||||
try:
|
||||
from app.services.tasks_service import trigger_paid_purchase_tasks
|
||||
|
||||
await trigger_paid_purchase_tasks(
|
||||
db,
|
||||
user_id=db_user.id,
|
||||
tariff_id=getattr(tariff, 'id', None),
|
||||
period_days=custom_days,
|
||||
amount_kopeks=total_price,
|
||||
subscription_id=getattr(subscription, 'id', None),
|
||||
is_trial=False,
|
||||
)
|
||||
except Exception as task_err:
|
||||
logger.warning('Tasks: ошибка триггеров tariff_purchase (custom)', error=task_err)
|
||||
|
||||
# Отправляем уведомление админу
|
||||
try:
|
||||
admin_notification_service = AdminNotificationService(callback.bot)
|
||||
@@ -1686,22 +1670,6 @@ async def confirm_tariff_purchase(
|
||||
except Exception as e:
|
||||
logger.error('Ошибка создания транзакции', error=e)
|
||||
|
||||
# Tasks: триггерим прогресс по платным покупкам подписок
|
||||
try:
|
||||
from app.services.tasks_service import trigger_paid_purchase_tasks
|
||||
|
||||
await trigger_paid_purchase_tasks(
|
||||
db,
|
||||
user_id=db_user.id,
|
||||
tariff_id=getattr(tariff, 'id', None),
|
||||
period_days=period,
|
||||
amount_kopeks=final_price,
|
||||
subscription_id=getattr(subscription, 'id', None),
|
||||
is_trial=False,
|
||||
)
|
||||
except Exception as task_err:
|
||||
logger.warning('Tasks: ошибка триггеров tariff_purchase (preset)', error=task_err)
|
||||
|
||||
# Отправляем уведомление админу
|
||||
try:
|
||||
admin_notification_service = AdminNotificationService(callback.bot)
|
||||
|
||||
+8
-274
@@ -8,12 +8,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import PERIOD_PRICES, settings
|
||||
from app.database.models import User
|
||||
from app.handlers.subscription.common import (
|
||||
build_redirect_link,
|
||||
create_deep_link,
|
||||
get_localized_value,
|
||||
resolve_button_url,
|
||||
)
|
||||
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
|
||||
@@ -1850,35 +1844,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_aurapay_sbp_enabled():
|
||||
sbp_name = settings.get_aurapay_sbp_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_AURAPAY_SBP', f'📱 {sbp_name}'),
|
||||
callback_data=_build_callback('aurapay_sbp'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_aurapay_card_enabled():
|
||||
card_name = settings.get_aurapay_card_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_AURAPAY_CARD', f'💳 {card_name}'),
|
||||
callback_data=_build_callback('aurapay_card'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if (
|
||||
settings.is_aurapay_enabled()
|
||||
and not settings.is_aurapay_sbp_enabled()
|
||||
and not settings.is_aurapay_card_enabled()
|
||||
):
|
||||
if settings.is_aurapay_enabled():
|
||||
aurapay_name = settings.get_aurapay_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
@@ -1890,212 +1856,6 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_etoplatezhi_sbp_enabled():
|
||||
sbp_name = settings.get_etoplatezhi_sbp_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_ETOPLATEZHI_SBP', f'📱 {sbp_name}'),
|
||||
callback_data=_build_callback('etoplatezhi_sbp'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_etoplatezhi_card_enabled():
|
||||
card_name = settings.get_etoplatezhi_card_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_ETOPLATEZHI_CARD', f'💳 {card_name}'),
|
||||
callback_data=_build_callback('etoplatezhi_card'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if (
|
||||
settings.is_etoplatezhi_enabled()
|
||||
and not settings.is_etoplatezhi_sbp_enabled()
|
||||
and not settings.is_etoplatezhi_card_enabled()
|
||||
):
|
||||
etoplatezhi_name = settings.get_etoplatezhi_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_ETOPLATEZHI', f'💳 {etoplatezhi_name}'),
|
||||
callback_data=_build_callback('etoplatezhi'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_antilopay_sbp_enabled():
|
||||
sbp_name = settings.get_antilopay_sbp_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_ANTILOPAY_SBP', f'📱 {sbp_name}'),
|
||||
callback_data=_build_callback('antilopay_sbp'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_antilopay_card_enabled():
|
||||
card_name = settings.get_antilopay_card_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_ANTILOPAY_CARD', f'💳 {card_name}'),
|
||||
callback_data=_build_callback('antilopay_card'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_antilopay_sberpay_enabled():
|
||||
sberpay_name = settings.get_antilopay_sberpay_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_ANTILOPAY_SBERPAY', f'💳 {sberpay_name}'),
|
||||
callback_data=_build_callback('antilopay_sberpay'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if (
|
||||
settings.is_antilopay_enabled()
|
||||
and not settings.is_antilopay_sbp_enabled()
|
||||
and not settings.is_antilopay_card_enabled()
|
||||
and not settings.is_antilopay_sberpay_enabled()
|
||||
):
|
||||
antilopay_name = settings.get_antilopay_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_ANTILOPAY', f'💳 {antilopay_name}'),
|
||||
callback_data=_build_callback('antilopay'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_jupiter_sbp_enabled():
|
||||
jupiter_sbp_name = settings.get_jupiter_sbp_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_JUPITER_SBP', f'📱 {jupiter_sbp_name}'),
|
||||
callback_data=_build_callback('jupiter_sbp'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_jupiter_enabled() and not settings.is_jupiter_sbp_enabled():
|
||||
jupiter_name = settings.get_jupiter_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_JUPITER', f'🪐 {jupiter_name}'),
|
||||
callback_data=_build_callback('jupiter'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_donut_card_enabled():
|
||||
donut_card_name = settings.get_donut_card_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_DONUT_CARD', f'💳 {donut_card_name}'),
|
||||
callback_data=_build_callback('donut_card'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_donut_sbp_enabled():
|
||||
donut_sbp_name = settings.get_donut_sbp_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_DONUT_SBP', f'📱 {donut_sbp_name}'),
|
||||
callback_data=_build_callback('donut_sbp'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_donut_sbp_qr_enabled():
|
||||
donut_qr_name = settings.get_donut_sbp_qr_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_DONUT_SBP_QR', f'🏦 {donut_qr_name}'),
|
||||
callback_data=_build_callback('donut_sbp_qr'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if (
|
||||
settings.is_donut_enabled()
|
||||
and not settings.is_donut_card_enabled()
|
||||
and not settings.is_donut_sbp_enabled()
|
||||
and not settings.is_donut_sbp_qr_enabled()
|
||||
):
|
||||
donut_name = settings.get_donut_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_DONUT', f'🍩 {donut_name}'),
|
||||
callback_data=_build_callback('donut'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_lava_card_enabled():
|
||||
lava_card_name = settings.get_lava_card_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_LAVA_CARD', f'💳 {lava_card_name}'),
|
||||
callback_data=_build_callback('lava_card'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_lava_sbp_enabled():
|
||||
lava_sbp_name = settings.get_lava_sbp_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_LAVA_SBP', f'📱 {lava_sbp_name}'),
|
||||
callback_data=_build_callback('lava_sbp'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_lava_enabled() and not settings.is_lava_card_enabled() and not settings.is_lava_sbp_enabled():
|
||||
lava_name = settings.get_lava_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_LAVA', f'🌋 {lava_name}'),
|
||||
callback_data=_build_callback('lava'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_support_topup_enabled():
|
||||
keyboard.append(
|
||||
[
|
||||
@@ -2794,6 +2554,9 @@ def get_device_selection_keyboard(
|
||||
platforms: list[dict] | None = None,
|
||||
sub_id: int | None = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
from app.config import settings
|
||||
from app.handlers.subscription.common import get_localized_value
|
||||
|
||||
texts = get_texts(language)
|
||||
back_cb = f'sm:{sub_id}' if sub_id and settings.is_multi_tariff_enabled() else 'menu_subscription'
|
||||
|
||||
@@ -2846,6 +2609,8 @@ def get_connection_guide_keyboard(
|
||||
has_other_apps: bool = False,
|
||||
sub_id: int | None = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
from app.handlers.subscription.common import create_deep_link, get_localized_value, resolve_button_url
|
||||
|
||||
texts = get_texts(language)
|
||||
back_cb = f'sm:{sub_id}' if sub_id and settings.is_multi_tariff_enabled() else 'menu_subscription'
|
||||
|
||||
@@ -2858,10 +2623,6 @@ def get_connection_guide_keyboard(
|
||||
if not isinstance(btn, dict):
|
||||
continue
|
||||
btn_type = btn.get('type', '')
|
||||
# Support both 'external' and 'externalLink' for backward compatibility
|
||||
if btn_type == 'external':
|
||||
btn_type = 'externalLink'
|
||||
|
||||
btn_text = btn.get('text', {})
|
||||
if isinstance(btn_text, dict):
|
||||
btn_text = get_localized_value(btn_text, language)
|
||||
@@ -2883,26 +2644,9 @@ def get_connection_guide_keyboard(
|
||||
]
|
||||
)
|
||||
elif btn_type == 'subscriptionLink':
|
||||
# First try to resolve the button's URL template
|
||||
url = resolved_url or resolve_button_url(btn_url, subscription_url)
|
||||
|
||||
# If button has no template, try deep link
|
||||
if not btn_url or '{{SUBSCRIPTION_LINK}}' not in btn_url:
|
||||
deep_link = create_deep_link(app.get('_raw', app), subscription_url)
|
||||
final_url = deep_link or url or subscription_url
|
||||
else:
|
||||
final_url = url or subscription_url
|
||||
|
||||
# Telegram doesn't support custom URL schemes — wrap with redirect
|
||||
if final_url and not final_url.startswith(('http://', 'https://')):
|
||||
template = settings.get_happ_cryptolink_redirect_template()
|
||||
if template:
|
||||
wrapped_url = build_redirect_link(final_url, template)
|
||||
if wrapped_url:
|
||||
final_url = wrapped_url
|
||||
else:
|
||||
final_url = subscription_url
|
||||
|
||||
deep_link = create_deep_link(app.get('_raw', app), subscription_url)
|
||||
final_url = deep_link or url or subscription_url
|
||||
if final_url:
|
||||
keyboard.append(
|
||||
[
|
||||
@@ -3232,16 +2976,6 @@ def get_updated_subscription_settings_keyboard(
|
||||
]
|
||||
)
|
||||
|
||||
if settings.is_subscription_revoke_enabled():
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('SUBSCRIPTION_REVOKE_BTN', '🔄 Перевыпустить подписку'),
|
||||
callback_data='subscription_revoke',
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
@@ -1758,15 +1758,5 @@
|
||||
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>Torrent detected</b>\n\nTorrent traffic was detected on your connection{tariff_label}. Using torrents may result in subscription restrictions.",
|
||||
"WEBHOOK_CLOSE_BUTTON": "✖️ Close",
|
||||
"TRAFFIC_WARNING_ALERT": "⚠️ <b>Traffic Warning</b>\n\nUsed: {used:.1f} / {limit} GB ({percent:.0f}%)\n\nYour traffic limit is almost reached.",
|
||||
"LOW_BALANCE_ALERT": "⚠️ <b>Low Balance</b>\n\nYour balance: {balance} ₽\nNotification threshold: {threshold} ₽\n\nTop up your balance to ensure automatic subscription renewal.",
|
||||
|
||||
"SUBSCRIPTION_REVOKE_BTN": "🔄 Reissue Subscription",
|
||||
"SUBSCRIPTION_REVOKE_TITLE": "⚠️ Reissue Subscription",
|
||||
"SUBSCRIPTION_REVOKE_WARNING": "⚠️ <b>Reissue Subscription</b>\n\nThis action will:\n• Generate a new connection link\n• Disconnect all devices\n• The old link will stop working\n\nContinue?",
|
||||
"SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ Confirm",
|
||||
"SUBSCRIPTION_REVOKE_SUCCESS": "✅ <b>Subscription reissued!</b>\n\nYour new connection link is ready. The old link is no longer valid.\n\nAll devices have been disconnected.",
|
||||
"SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ Reissue will be available in {minutes} min {seconds} sec.",
|
||||
"SUBSCRIPTION_REVOKE_DISABLED": "Subscription reissue is not available",
|
||||
"SUBSCRIPTION_REVOKE_ERROR": "❌ Error reissuing subscription. Please try again later.",
|
||||
"SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 Connect"
|
||||
"LOW_BALANCE_ALERT": "⚠️ <b>Low Balance</b>\n\nYour balance: {balance} ₽\nNotification threshold: {threshold} ₽\n\nTop up your balance to ensure automatic subscription renewal."
|
||||
}
|
||||
@@ -1779,15 +1779,5 @@
|
||||
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>تورنت شناسایی شد</b>\n\nترافیک تورنت در اتصال{tariff_label} شما شناسایی شد. استفاده از تورنت ممکن است منجر به محدودیت اشتراک شود.",
|
||||
"WEBHOOK_CLOSE_BUTTON": "✖️ بستن",
|
||||
"TRAFFIC_WARNING_ALERT": "⚠️ <b>هشدار ترافیک</b>\n\nاستفاده شده: {used:.1f} / {limit} گیگابایت ({percent:.0f}%)\n\nحد ترافیک شما تقریباً تمام شده است.",
|
||||
"LOW_BALANCE_ALERT": "⚠️ <b>موجودی کم</b>\n\nموجودی شما: {balance} ₽\nآستانه اطلاعرسانی: {threshold} ₽\n\nموجودی خود را شارژ کنید تا تمدید خودکار اشتراک با موفقیت انجام شود.",
|
||||
|
||||
"SUBSCRIPTION_REVOKE_BTN": "🔄 صدور مجدد اشتراک",
|
||||
"SUBSCRIPTION_REVOKE_TITLE": "⚠️ صدور مجدد اشتراک",
|
||||
"SUBSCRIPTION_REVOKE_WARNING": "⚠️ <b>صدور مجدد اشتراک</b>\n\nاین عمل:\n• لینک اتصال جدیدی تولید میکند\n• تمام دستگاههای متصل را قطع میکند\n• لینک قدیمی دیگر کار نخواهد کرد\n\nادامه میدهید؟",
|
||||
"SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ تأیید",
|
||||
"SUBSCRIPTION_REVOKE_SUCCESS": "✅ <b>اشتراک مجدداً صادر شد!</b>\n\nلینک اتصال جدید آماده است. لینک قدیمی دیگر معتبر نیست.\n\nتمام دستگاهها قطع شدند.",
|
||||
"SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ صدور مجدد {minutes} دقیقه و {seconds} ثانیه دیگر در دسترس خواهد بود.",
|
||||
"SUBSCRIPTION_REVOKE_DISABLED": "صدور مجدد اشتراک در دسترس نیست",
|
||||
"SUBSCRIPTION_REVOKE_ERROR": "❌ خطا در صدور مجدد اشتراک. لطفاً بعداً دوباره امتحان کنید.",
|
||||
"SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 اتصال"
|
||||
"LOW_BALANCE_ALERT": "⚠️ <b>موجودی کم</b>\n\nموجودی شما: {balance} ₽\nآستانه اطلاعرسانی: {threshold} ₽\n\nموجودی خود را شارژ کنید تا تمدید خودکار اشتراک با موفقیت انجام شود."
|
||||
}
|
||||
@@ -1779,15 +1779,5 @@
|
||||
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>Обнаружен торрент</b>\n\nВ вашем подключении{tariff_label} обнаружен торрент-трафик. Использование торрентов может привести к ограничению подписки.",
|
||||
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрыть",
|
||||
"TRAFFIC_WARNING_ALERT": "⚠️ <b>Предупреждение о трафике</b>\n\nИспользовано: {used:.1f} / {limit} ГБ ({percent:.0f}%)\n\nВаш лимит трафика почти исчерпан.",
|
||||
"LOW_BALANCE_ALERT": "⚠️ <b>Низкий баланс</b>\n\nВаш баланс: {balance} ₽\nПорог уведомления: {threshold} ₽\n\nПополните баланс, чтобы автопродление подписки прошло успешно.",
|
||||
|
||||
"SUBSCRIPTION_REVOKE_BTN": "🔄 Перевыпустить подписку",
|
||||
"SUBSCRIPTION_REVOKE_TITLE": "⚠️ Перевыпуск подписки",
|
||||
"SUBSCRIPTION_REVOKE_WARNING": "⚠️ <b>Перевыпуск подписки</b>\n\nЭто действие:\n• Сгенерирует новую ссылку подключения\n• Сбросит все подключённые устройства\n• Старая ссылка перестанет работать\n\nПродолжить?",
|
||||
"SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ Подтвердить",
|
||||
"SUBSCRIPTION_REVOKE_SUCCESS": "✅ <b>Подписка перевыпущена!</b>\n\nНовая ссылка подключения готова. Старая ссылка больше не действительна.\n\nВсе устройства были отключены.",
|
||||
"SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ Перевыпуск будет доступен через {minutes} мин. {seconds} сек.",
|
||||
"SUBSCRIPTION_REVOKE_DISABLED": "Перевыпуск подписки недоступен",
|
||||
"SUBSCRIPTION_REVOKE_ERROR": "❌ Ошибка при перевыпуске подписки. Попробуйте позже.",
|
||||
"SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 Подключиться"
|
||||
"LOW_BALANCE_ALERT": "⚠️ <b>Низкий баланс</b>\n\nВаш баланс: {balance} ₽\nПорог уведомления: {threshold} ₽\n\nПополните баланс, чтобы автопродление подписки прошло успешно."
|
||||
}
|
||||
@@ -1650,15 +1650,5 @@
|
||||
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>Виявлено торент</b>\n\nУ вашому підключенні{tariff_label} виявлено торент-трафік. Використання торентів може призвести до обмеження підписки.",
|
||||
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрити",
|
||||
"TRAFFIC_WARNING_ALERT": "⚠️ <b>Попередження про трафік</b>\n\nВикористано: {used:.1f} / {limit} ГБ ({percent:.0f}%)\n\nВаш ліміт трафіку майже вичерпаний.",
|
||||
"LOW_BALANCE_ALERT": "⚠️ <b>Низький баланс</b>\n\nВаш баланс: {balance} ₽\nПоріг сповіщення: {threshold} ₽\n\nПоповніть баланс, щоб автопродовження підписки пройшло успішно.",
|
||||
|
||||
"SUBSCRIPTION_REVOKE_BTN": "🔄 Перевипустити підписку",
|
||||
"SUBSCRIPTION_REVOKE_TITLE": "⚠️ Перевипуск підписки",
|
||||
"SUBSCRIPTION_REVOKE_WARNING": "⚠️ <b>Перевипуск підписки</b>\n\nЦя дія:\n• Згенерує нове посилання підключення\n• Скине всі підключені пристрої\n• Старе посилання перестане працювати\n\nПродовжити?",
|
||||
"SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ Підтвердити",
|
||||
"SUBSCRIPTION_REVOKE_SUCCESS": "✅ <b>Підписку перевипущено!</b>\n\nНове посилання підключення готове. Старе посилання більше не дійсне.\n\nВсі пристрої були відключені.",
|
||||
"SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ Перевипуск буде доступний через {minutes} хв. {seconds} сек.",
|
||||
"SUBSCRIPTION_REVOKE_DISABLED": "Перевипуск підписки недоступний",
|
||||
"SUBSCRIPTION_REVOKE_ERROR": "❌ Помилка при перевипуску підписки. Спробуйте пізніше.",
|
||||
"SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 Підключитися"
|
||||
"LOW_BALANCE_ALERT": "⚠️ <b>Низький баланс</b>\n\nВаш баланс: {balance} ₽\nПоріг сповіщення: {threshold} ₽\n\nПоповніть баланс, щоб автопродовження підписки пройшло успішно."
|
||||
}
|
||||
@@ -1648,15 +1648,5 @@
|
||||
"BALANCE_TOPPED_UP_CART_SUFFICIENT": "✅ 余额已充值 {amount}!\n\n💰 当前余额:{balance}\n\n🛒 您有一个已保存的购物车,金额为 {cart_total}\n余额足够完成订购。",
|
||||
"BALANCE_TOPPED_UP_CART_INSUFFICIENT": "✅ 余额已充值 {amount}!\n\n💰 当前余额:{balance}\n\n🛒 您有一个已保存的购物车,金额为 {cart_total}\n还差:{missing}",
|
||||
"TRAFFIC_WARNING_ALERT": "⚠️ <b>流量警告</b>\n\n已使用:{used:.1f} / {limit} GB ({percent:.0f}%)\n\n您的流量限制即将用完。",
|
||||
"LOW_BALANCE_ALERT": "⚠️ <b>余额不足</b>\n\n您的余额:{balance} ₽\n通知阈值:{threshold} ₽\n\n请充值以确保订阅自动续费成功。",
|
||||
|
||||
"SUBSCRIPTION_REVOKE_BTN": "🔄 重新签发订阅",
|
||||
"SUBSCRIPTION_REVOKE_TITLE": "⚠️ 重新签发订阅",
|
||||
"SUBSCRIPTION_REVOKE_WARNING": "⚠️ <b>重新签发订阅</b>\n\n此操作将:\n• 生成新的连接链接\n• 断开所有已连接的设备\n• 旧链接将失效\n\n是否继续?",
|
||||
"SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ 确认",
|
||||
"SUBSCRIPTION_REVOKE_SUCCESS": "✅ <b>订阅已重新签发!</b>\n\n新的连接链接已准备就绪。旧链接已失效。\n\n所有设备已断开连接。",
|
||||
"SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ 重新签发将在 {minutes} 分 {seconds} 秒后可用。",
|
||||
"SUBSCRIPTION_REVOKE_DISABLED": "订阅重新签发不可用",
|
||||
"SUBSCRIPTION_REVOKE_ERROR": "❌ 重新签发订阅时出错。请稍后重试。",
|
||||
"SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 连接"
|
||||
"LOW_BALANCE_ALERT": "⚠️ <b>余额不足</b>\n\n您的余额:{balance} ₽\n通知阈值:{threshold} ₽\n\n请充值以确保订阅自动续费成功。"
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
"""Сервис для работы с API Antilopay (lk.antilopay.com/api/v2)."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
from Crypto.Hash import SHA256
|
||||
from Crypto.PublicKey import RSA
|
||||
from Crypto.Signature import pkcs1_15
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
API_BASE_URL = 'https://lk.antilopay.com/api/v2'
|
||||
|
||||
|
||||
class AntilopayAPIError(Exception):
|
||||
"""Ошибка API Antilopay."""
|
||||
|
||||
def __init__(self, status_code: int, message: str, code: int | None = None):
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
self.api_code = code
|
||||
super().__init__(f'Antilopay API error ({status_code}): {message}')
|
||||
|
||||
|
||||
class AntilopayService:
|
||||
"""Сервис для работы с API Antilopay."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
|
||||
@property
|
||||
def secret_id(self) -> str:
|
||||
return settings.ANTILOPAY_SECRET_ID or ''
|
||||
|
||||
@property
|
||||
def private_key(self) -> str:
|
||||
return settings.ANTILOPAY_PRIVATE_KEY or ''
|
||||
|
||||
@property
|
||||
def public_key(self) -> str:
|
||||
return settings.ANTILOPAY_PUBLIC_KEY or ''
|
||||
|
||||
@property
|
||||
def project_id(self) -> str:
|
||||
return settings.ANTILOPAY_PROJECT_ID or ''
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
"""Возвращает переиспользуемую HTTP-сессию."""
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
)
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Закрывает HTTP-сессию."""
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
|
||||
def _sign_request(self, json_body: str) -> str:
|
||||
"""SHA256WithRSA подпись JSON body приватным ключом.
|
||||
|
||||
Результат — base64-encoded строка.
|
||||
"""
|
||||
rsa_key = RSA.import_key(base64.b64decode(self.private_key))
|
||||
h = SHA256.new(json_body.encode('UTF-8'))
|
||||
signature = pkcs1_15.new(rsa_key).sign(h)
|
||||
return base64.b64encode(signature).decode('UTF-8')
|
||||
|
||||
def _build_headers(self, json_body: str) -> dict[str, str]:
|
||||
"""Строит заголовки запроса с подписью."""
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Apay-Secret-Id': self.secret_id,
|
||||
'X-Apay-Sign': self._sign_request(json_body),
|
||||
'X-Apay-Sign-Version': '1',
|
||||
}
|
||||
|
||||
async def create_payment(
|
||||
self,
|
||||
*,
|
||||
amount_rubles: float,
|
||||
order_id: str,
|
||||
product_name: str,
|
||||
product_type: str = 'services',
|
||||
description: str = '',
|
||||
customer_email: str | None = None,
|
||||
customer_phone: str | None = None,
|
||||
prefer_methods: list[str] | None = None,
|
||||
success_url: str | None = None,
|
||||
fail_url: str | None = None,
|
||||
merchant_extra: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Создает платеж через API Antilopay.
|
||||
POST /payment/create
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
'project_identificator': self.project_id,
|
||||
'amount': amount_rubles,
|
||||
'order_id': order_id,
|
||||
'currency': settings.ANTILOPAY_CURRENCY.lower(),
|
||||
'product_name': product_name,
|
||||
'product_type': product_type,
|
||||
'description': description,
|
||||
}
|
||||
|
||||
# customer — обязательное поле, нужен email или phone
|
||||
customer: dict[str, str] = {}
|
||||
if customer_email:
|
||||
customer['email'] = customer_email
|
||||
if customer_phone:
|
||||
customer['phone'] = customer_phone
|
||||
if not customer:
|
||||
# Fallback email, чтобы API не отказал
|
||||
customer['email'] = 'user@vpn.bot'
|
||||
payload['customer'] = customer
|
||||
|
||||
if prefer_methods:
|
||||
payload['prefer_methods'] = prefer_methods
|
||||
if success_url:
|
||||
payload['success_url'] = success_url
|
||||
if fail_url:
|
||||
payload['fail_url'] = fail_url
|
||||
if merchant_extra:
|
||||
payload['merchant_extra'] = merchant_extra[:255]
|
||||
|
||||
json_body = json.dumps(payload, separators=(',', ':'), ensure_ascii=False)
|
||||
|
||||
logger.info(
|
||||
'Antilopay API create_payment',
|
||||
order_id=order_id,
|
||||
amount_rubles=amount_rubles,
|
||||
prefer_methods=prefer_methods,
|
||||
)
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.post(
|
||||
f'{API_BASE_URL}/payment/create',
|
||||
data=json_body,
|
||||
headers=self._build_headers(json_body),
|
||||
) as response:
|
||||
data = await response.json(content_type=None)
|
||||
|
||||
api_code = data.get('code')
|
||||
if response.status == 200 and api_code == 0:
|
||||
logger.info(
|
||||
'Antilopay API payment created',
|
||||
order_id=order_id,
|
||||
payment_id=data.get('payment_id'),
|
||||
payment_url=data.get('payment_url'),
|
||||
)
|
||||
return data
|
||||
|
||||
error_msg = data.get('message') or data.get('error') or str(data)
|
||||
logger.error(
|
||||
'Antilopay create_payment error',
|
||||
status_code=response.status,
|
||||
api_code=api_code,
|
||||
error_msg=error_msg,
|
||||
response_data=data,
|
||||
)
|
||||
raise AntilopayAPIError(response.status, error_msg, api_code)
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception('Antilopay API connection error', error=e)
|
||||
raise
|
||||
|
||||
async def check_payment(
|
||||
self,
|
||||
*,
|
||||
order_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Проверяет статус платежа.
|
||||
POST /payment/check
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
'project_identificator': self.project_id,
|
||||
'order_id': order_id,
|
||||
}
|
||||
|
||||
json_body = json.dumps(payload, separators=(',', ':'), ensure_ascii=False)
|
||||
|
||||
logger.info('Antilopay check_payment', order_id=order_id)
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.post(
|
||||
f'{API_BASE_URL}/payment/check',
|
||||
data=json_body,
|
||||
headers=self._build_headers(json_body),
|
||||
) as response:
|
||||
data = await response.json(content_type=None)
|
||||
|
||||
if response.status == 200:
|
||||
return data
|
||||
|
||||
error_msg = data.get('message') or data.get('error') or str(data)
|
||||
logger.error(
|
||||
'Antilopay check_payment error',
|
||||
status_code=response.status,
|
||||
error_msg=error_msg,
|
||||
)
|
||||
raise AntilopayAPIError(response.status, error_msg)
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception('Antilopay API connection error', error=e)
|
||||
raise
|
||||
|
||||
def verify_callback_signature(self, raw_body: bytes, received_signature: str) -> bool:
|
||||
"""Верификация подписи callback Antilopay через SHA256WithRSA.
|
||||
|
||||
Подпись приходит в заголовке X-Apay-Callback.
|
||||
Проверяется ПУБЛИЧНЫМ ключом.
|
||||
"""
|
||||
try:
|
||||
if not received_signature:
|
||||
logger.warning('Antilopay callback: отсутствует X-Apay-Callback')
|
||||
return False
|
||||
|
||||
rsa_key = RSA.import_key(base64.b64decode(self.public_key))
|
||||
h = SHA256.new(raw_body)
|
||||
signature_bytes = base64.b64decode(received_signature)
|
||||
|
||||
pkcs1_15.new(rsa_key).verify(h, signature_bytes)
|
||||
return True
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning('Antilopay callback: invalid signature', error=str(e))
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error('Antilopay callback verify error', error=e)
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance
|
||||
antilopay_service = AntilopayService()
|
||||
@@ -197,9 +197,8 @@ class AuraPayService:
|
||||
return False
|
||||
|
||||
# Сортируем ключи по алфавиту и конкатенируем значения
|
||||
# None → '' (PHP implode() converts null to empty string, not "None")
|
||||
sorted_keys = sorted(payload.keys())
|
||||
concatenated_values = ''.join(str(payload[key]) if payload[key] is not None else '' for key in sorted_keys)
|
||||
concatenated_values = ''.join(str(payload[key]) for key in sorted_keys)
|
||||
|
||||
expected = hmac.new(
|
||||
self.secret_key.encode('utf-8'),
|
||||
|
||||
@@ -31,7 +31,6 @@ from app.database.models import (
|
||||
AdminRole,
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration,
|
||||
AppleTransaction,
|
||||
AuraPayPayment,
|
||||
BroadcastHistory,
|
||||
ButtonClickLog,
|
||||
@@ -205,7 +204,6 @@ class BackupService:
|
||||
RollyPayPayment,
|
||||
OverpayPayment,
|
||||
AuraPayPayment,
|
||||
AppleTransaction,
|
||||
SavedPaymentMethod,
|
||||
# --- Settings/content ---
|
||||
PaymentMethodConfig,
|
||||
@@ -1517,9 +1515,6 @@ class BackupService:
|
||||
'rollypay_payments',
|
||||
'overpay_payments',
|
||||
'aurapay_payments',
|
||||
'etoplatezhi_payments',
|
||||
'antilopay_payments',
|
||||
'apple_transactions',
|
||||
'saved_payment_methods',
|
||||
# --- Content/config ---
|
||||
'pinned_messages',
|
||||
|
||||
@@ -209,39 +209,6 @@ class DailySubscriptionService:
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
# Tasks: триггерим прогресс по платным purchase-tasks (суточное списание).
|
||||
# SPEND_AMOUNT не сработает через emit_transaction_side_effects (он не вызывается
|
||||
# тут), поэтому делаем это вручную через trigger_paid_purchase_tasks +
|
||||
# отдельный SPEND_AMOUNT.
|
||||
try:
|
||||
from app.database.models import TaskType
|
||||
from app.services.tasks_service import record_event, trigger_paid_purchase_tasks
|
||||
|
||||
await trigger_paid_purchase_tasks(
|
||||
db,
|
||||
user_id=user.id,
|
||||
tariff_id=getattr(tariff, 'id', None),
|
||||
period_days=1,
|
||||
amount_kopeks=daily_price,
|
||||
subscription_id=getattr(subscription, 'id', None),
|
||||
is_trial=False,
|
||||
)
|
||||
# SPEND_AMOUNT отдельно — суточный путь не идёт через стандартный
|
||||
# create_transaction(commit=True), значит SPEND_AMOUNT нужно явно.
|
||||
await record_event(
|
||||
db,
|
||||
user_id=user.id,
|
||||
event_type=TaskType.SPEND_AMOUNT,
|
||||
payload={'amount_kopeks': daily_price, 'is_trial': False},
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as task_err:
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning('Tasks: ошибка триггеров daily charge', error=task_err)
|
||||
|
||||
user_id_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
logger.info(
|
||||
'✅ Суточное списание: подписка сумма коп., пользователь',
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
"""Сервис для работы с API Donut (Donut P2P, gw.donut.business)."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class DonutAPIError(Exception):
|
||||
"""Ошибка API Donut."""
|
||||
|
||||
def __init__(self, status_code: int, message: str, code: str | None = None) -> None:
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
self.api_code = code
|
||||
super().__init__(f'Donut API error ({status_code}): {message}')
|
||||
|
||||
|
||||
class DonutService:
|
||||
"""Клиент для Donut P2P (gw.donut.business)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return (settings.DONUT_BASE_URL or 'https://gw.donut.business').rstrip('/')
|
||||
|
||||
@property
|
||||
def token(self) -> str:
|
||||
return settings.DONUT_TOKEN or ''
|
||||
|
||||
@property
|
||||
def secret(self) -> str:
|
||||
return settings.DONUT_SECRET or ''
|
||||
|
||||
@property
|
||||
def method_id(self) -> str | None:
|
||||
value = (settings.DONUT_METHOD_ID or '').strip()
|
||||
return value or None
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
)
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
|
||||
@staticmethod
|
||||
def _build_signature_string(parts: list[tuple[str, Any]]) -> str:
|
||||
"""Собирает каноническую строку для подписи: имя=значение (без разделителя)."""
|
||||
chunks: list[str] = []
|
||||
for key, value in parts:
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, bool):
|
||||
chunks.append(f'{key}={"true" if value else "false"}')
|
||||
else:
|
||||
value_str = str(value)
|
||||
if value_str == '':
|
||||
continue
|
||||
chunks.append(f'{key}={value_str}')
|
||||
return ''.join(chunks)
|
||||
|
||||
def _hmac_hex(self, message: str) -> str:
|
||||
"""HMAC-SHA256 в hex."""
|
||||
return hmac.new(
|
||||
self.secret.encode('utf-8'),
|
||||
msg=message.encode('utf-8'),
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
def _sign_payin(self, payload: dict[str, Any]) -> str:
|
||||
amount = payload['amount']
|
||||
customer = payload['customer']
|
||||
parts: list[tuple[str, Any]] = [
|
||||
('token', payload['token']),
|
||||
('order_id', payload['order_id']),
|
||||
('amount.value', amount['value']),
|
||||
('amount.currency', amount['currency']),
|
||||
('customer.id', customer['id']),
|
||||
('redirect', payload['redirect']),
|
||||
]
|
||||
return self._hmac_hex(self._build_signature_string(parts))
|
||||
|
||||
def _sign_status(self, payload: dict[str, Any]) -> str:
|
||||
parts: list[tuple[str, Any]] = [
|
||||
('token', payload['token']),
|
||||
('transaction_id', payload['transaction_id']),
|
||||
]
|
||||
return self._hmac_hex(self._build_signature_string(parts))
|
||||
|
||||
def _sign_balance(self, payload: dict[str, Any]) -> str:
|
||||
parts: list[tuple[str, Any]] = [
|
||||
('token', payload['token']),
|
||||
]
|
||||
return self._hmac_hex(self._build_signature_string(parts))
|
||||
|
||||
@staticmethod
|
||||
def _format_amount(amount_rubles: float) -> str:
|
||||
"""Сумма строго '0.00' с точкой (требование Donut P2P)."""
|
||||
return f'{float(amount_rubles):.2f}'
|
||||
|
||||
async def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
url = f'{self.base_url}/{path.lstrip("/")}'
|
||||
body = json.dumps(payload, separators=(',', ':'), ensure_ascii=False)
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.post(
|
||||
url,
|
||||
data=body,
|
||||
headers={'Content-Type': 'application/json'},
|
||||
) as response:
|
||||
data = await response.json(content_type=None)
|
||||
return data if isinstance(data, dict) else {'_raw': data}
|
||||
except aiohttp.ClientError as error:
|
||||
logger.exception('Donut API connection error', url=url, error=error)
|
||||
raise
|
||||
|
||||
async def create_payment(
|
||||
self,
|
||||
*,
|
||||
amount_rubles: float,
|
||||
order_id: str,
|
||||
customer_id: str,
|
||||
method_description: str,
|
||||
customer_email: str | None = None,
|
||||
customer_phone: str | None = None,
|
||||
callback_url: str | None = None,
|
||||
return_url: str | None = None,
|
||||
receipt: str | None = None,
|
||||
redirect: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Создаёт платёж (PayIn) через Donut P2P.
|
||||
|
||||
POST /p2p_payin
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
'token': self.token,
|
||||
'order_id': order_id,
|
||||
'amount': {
|
||||
'value': self._format_amount(amount_rubles),
|
||||
'currency': (settings.DONUT_CURRENCY or 'RUB').upper(),
|
||||
},
|
||||
'customer': {'id': str(customer_id)},
|
||||
'redirect': 'true' if redirect else 'false',
|
||||
'description': method_description,
|
||||
}
|
||||
|
||||
if customer_email:
|
||||
payload['customer']['email'] = customer_email
|
||||
if customer_phone:
|
||||
payload['customer']['phone'] = customer_phone
|
||||
|
||||
if self.method_id:
|
||||
payload['method_id'] = self.method_id
|
||||
if callback_url:
|
||||
payload['callback_url'] = callback_url
|
||||
if return_url:
|
||||
payload['return_url'] = return_url
|
||||
if receipt:
|
||||
payload['receipt'] = receipt[:255]
|
||||
|
||||
payload['signature'] = self._sign_payin(payload)
|
||||
|
||||
logger.info(
|
||||
'Donut API create_payment',
|
||||
order_id=order_id,
|
||||
amount_rubles=amount_rubles,
|
||||
description=method_description,
|
||||
)
|
||||
|
||||
data = await self._post('/p2p_payin', payload)
|
||||
status_obj = (data.get('status') or {}) if isinstance(data, dict) else {}
|
||||
status_type = status_obj.get('type')
|
||||
|
||||
if status_type in ('processing', 'success', 'created'):
|
||||
logger.info(
|
||||
'Donut API payment created',
|
||||
order_id=order_id,
|
||||
transaction_id=data.get('transaction_id'),
|
||||
status_type=status_type,
|
||||
)
|
||||
return data
|
||||
|
||||
error_code = status_obj.get('error_code') or '0'
|
||||
error_msg = status_obj.get('error_description') or status_obj.get('message') or 'Unknown error'
|
||||
logger.error(
|
||||
'Donut create_payment error',
|
||||
error_code=error_code,
|
||||
error_msg=error_msg,
|
||||
response_data=data,
|
||||
)
|
||||
raise DonutAPIError(200, error_msg, error_code)
|
||||
|
||||
async def check_payment(self, *, transaction_id: str) -> dict[str, Any]:
|
||||
"""Получает статус платежа.
|
||||
|
||||
POST /p2p_status
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
'token': self.token,
|
||||
'transaction_id': str(transaction_id),
|
||||
}
|
||||
payload['signature'] = self._sign_status(payload)
|
||||
|
||||
logger.info('Donut check_payment', transaction_id=transaction_id)
|
||||
return await self._post('/p2p_status', payload)
|
||||
|
||||
async def get_balance(self) -> dict[str, Any]:
|
||||
"""Получает баланс продавца.
|
||||
|
||||
POST /p2p_balance
|
||||
"""
|
||||
payload: dict[str, Any] = {'token': self.token}
|
||||
payload['signature'] = self._sign_balance(payload)
|
||||
return await self._post('/p2p_balance', payload)
|
||||
|
||||
def verify_callback_signature(self, payload: dict[str, Any]) -> bool:
|
||||
"""Верификация подписи callback (HMAC-SHA256, hex)."""
|
||||
try:
|
||||
received = (payload.get('signature') or '').strip()
|
||||
if not received:
|
||||
logger.warning('Donut callback: отсутствует signature')
|
||||
return False
|
||||
|
||||
amount = payload.get('amount') or {}
|
||||
status_obj = payload.get('status') or {}
|
||||
parts: list[tuple[str, Any]] = [
|
||||
('token', payload.get('token')),
|
||||
('transaction_id', payload.get('transaction_id')),
|
||||
('order_id', payload.get('order_id')),
|
||||
('amount.value', amount.get('value')),
|
||||
('amount.currency', amount.get('currency')),
|
||||
('recalculated', payload.get('recalculated')),
|
||||
('status.type', status_obj.get('type')),
|
||||
]
|
||||
expected = self._hmac_hex(self._build_signature_string(parts))
|
||||
if not hmac.compare_digest(expected.lower(), received.lower()):
|
||||
logger.warning(
|
||||
'Donut callback: invalid signature',
|
||||
expected_prefix=expected[:8],
|
||||
received_prefix=received[:8],
|
||||
)
|
||||
return False
|
||||
return True
|
||||
except Exception as error:
|
||||
logger.error('Donut callback verify error', error=error)
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance
|
||||
donut_service = DonutService()
|
||||
@@ -1,202 +0,0 @@
|
||||
"""Сервис для работы с Etoplatezhi (paymentpage.etoplatezhi.ru)."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import structlog
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
PAYMENT_PAGE_BASE_URL = 'https://paymentpage.etoplatezhi.ru/payment'
|
||||
|
||||
|
||||
class EtoplatezhiService:
|
||||
"""Сервис для построения URL платежей и верификации callback-ов Etoplatezhi."""
|
||||
|
||||
@property
|
||||
def project_id(self) -> int:
|
||||
return settings.ETOPLATEZHI_PROJECT_ID or 0
|
||||
|
||||
@property
|
||||
def secret_key(self) -> str:
|
||||
return settings.ETOPLATEZHI_SECRET_KEY or ''
|
||||
|
||||
def _flatten_params(
|
||||
self,
|
||||
params: dict[str, Any],
|
||||
prefix: str = '',
|
||||
ignore: set[str] | None = None,
|
||||
) -> list[str]:
|
||||
"""Рекурсивно «сплющивает» вложенные словари в список 'key:value' строк.
|
||||
|
||||
Keys разделяются двоеточием. ``frame_mode`` и ``signature`` игнорируются.
|
||||
Booleans приводятся к '1'/'0'.
|
||||
Empty arrays (lists) are excluded entirely per Etoplatezhi spec.
|
||||
"""
|
||||
if ignore is None:
|
||||
ignore = {'frame_mode', 'signature'}
|
||||
|
||||
entries: list[str] = []
|
||||
for key, value in params.items():
|
||||
full_key = f'{prefix}:{key}' if prefix else key
|
||||
if full_key in ignore or key in ignore:
|
||||
continue
|
||||
|
||||
if isinstance(value, dict):
|
||||
entries.extend(self._flatten_params(value, prefix=full_key, ignore=ignore))
|
||||
elif isinstance(value, list):
|
||||
# Empty arrays are excluded entirely per spec
|
||||
if not value:
|
||||
continue
|
||||
# Non-empty arrays: flatten each element with index as key
|
||||
for idx, item in enumerate(value):
|
||||
item_key = f'{full_key}:{idx}'
|
||||
if isinstance(item, dict):
|
||||
entries.extend(self._flatten_params(item, prefix=item_key, ignore=ignore))
|
||||
elif isinstance(item, bool):
|
||||
entries.append(f'{item_key}:{"1" if item else "0"}')
|
||||
elif item is not None:
|
||||
entries.append(f'{item_key}:{item}')
|
||||
elif isinstance(value, bool):
|
||||
entries.append(f'{full_key}:{"1" if value else "0"}')
|
||||
elif value is not None:
|
||||
entries.append(f'{full_key}:{value}')
|
||||
|
||||
return entries
|
||||
|
||||
def _sign(self, params: dict[str, Any]) -> str:
|
||||
"""HMAC-SHA512 + base64 подпись параметров.
|
||||
|
||||
Algorithm:
|
||||
1. Flatten nested dicts with ':' separator.
|
||||
2. Each leaf → "key:value".
|
||||
3. Sort alphabetically by full key string.
|
||||
4. Join with ';'.
|
||||
5. HMAC-SHA512 with secret_key.
|
||||
6. base64-encode the raw digest.
|
||||
"""
|
||||
entries = self._flatten_params(params)
|
||||
entries.sort()
|
||||
message = ';'.join(entries)
|
||||
|
||||
digest = hmac.new(
|
||||
self.secret_key.encode('utf-8'),
|
||||
message.encode('utf-8'),
|
||||
hashlib.sha512,
|
||||
).digest()
|
||||
|
||||
return base64.b64encode(digest).decode('utf-8')
|
||||
|
||||
def build_payment_url(
|
||||
self,
|
||||
*,
|
||||
project_id: int,
|
||||
payment_id: str,
|
||||
payment_amount: int,
|
||||
payment_currency: str = 'RUB',
|
||||
customer_id: str,
|
||||
description: str | None = None,
|
||||
callback_url: str | None = None,
|
||||
success_url: str | None = None,
|
||||
fail_url: str | None = None,
|
||||
force_payment_method: str | None = None,
|
||||
customer_email: str | None = None,
|
||||
language_code: str | None = None,
|
||||
) -> str:
|
||||
"""Строит URL для редиректа на платёжную страницу Etoplatezhi.
|
||||
|
||||
Args:
|
||||
project_id: ID проекта в Etoplatezhi.
|
||||
payment_id: Наш internal order_id.
|
||||
payment_amount: Сумма в минорных единицах (копейках).
|
||||
payment_currency: ISO 4217 код валюты.
|
||||
customer_id: Telegram ID или guest-идентификатор покупателя.
|
||||
description: Описание платежа.
|
||||
callback_url: URL для callback (POST JSON).
|
||||
success_url: URL редиректа при успехе.
|
||||
fail_url: URL редиректа при ошибке.
|
||||
force_payment_method: 'sbp' или 'card' для принудительного выбора.
|
||||
customer_email: Email покупателя.
|
||||
language_code: Язык интерфейса ('ru', 'en').
|
||||
|
||||
Returns:
|
||||
Полный URL с параметрами и подписью.
|
||||
"""
|
||||
params: dict[str, Any] = {
|
||||
'project_id': project_id,
|
||||
'payment_id': payment_id,
|
||||
'payment_amount': payment_amount,
|
||||
'payment_currency': payment_currency,
|
||||
'customer_id': customer_id,
|
||||
}
|
||||
|
||||
if description:
|
||||
params['payment_description'] = description
|
||||
if callback_url:
|
||||
params['merchant_callback_url'] = callback_url
|
||||
if success_url:
|
||||
params['redirect_success_url'] = success_url
|
||||
if fail_url:
|
||||
params['redirect_fail_url'] = fail_url
|
||||
if force_payment_method:
|
||||
params['force_payment_method'] = force_payment_method
|
||||
if customer_email:
|
||||
params['customer_email'] = customer_email
|
||||
if language_code:
|
||||
params['language_code'] = language_code
|
||||
|
||||
params['signature'] = self._sign(params)
|
||||
|
||||
logger.info(
|
||||
'Etoplatezhi: building payment URL',
|
||||
payment_id=payment_id,
|
||||
payment_amount=payment_amount,
|
||||
customer_id=customer_id,
|
||||
)
|
||||
|
||||
return f'{PAYMENT_PAGE_BASE_URL}?{urlencode(params)}'
|
||||
|
||||
def verify_callback_signature(self, payload: dict[str, Any]) -> bool:
|
||||
"""Верифицирует подпись в callback-е Etoplatezhi.
|
||||
|
||||
Подпись находится внутри JSON body (поле ``signature``).
|
||||
Для проверки: удаляем ``signature`` из всех уровней вложенности,
|
||||
вычисляем подпись по оставшимся данным и сравниваем.
|
||||
"""
|
||||
try:
|
||||
received_signature = payload.get('signature')
|
||||
if not received_signature:
|
||||
logger.warning('Etoplatezhi callback: отсутствует signature в payload')
|
||||
return False
|
||||
|
||||
# Deep-copy payload and strip all 'signature' keys recursively
|
||||
cleaned = self._strip_signature_keys(payload)
|
||||
|
||||
expected = self._sign(cleaned)
|
||||
return hmac.compare_digest(expected, str(received_signature))
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Etoplatezhi callback verify error', error=e)
|
||||
return False
|
||||
|
||||
def _strip_signature_keys(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Рекурсивно удаляет ключ ``signature`` из словаря и вложенных словарей."""
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in data.items():
|
||||
if key == 'signature':
|
||||
continue
|
||||
if isinstance(value, dict):
|
||||
result[key] = self._strip_signature_keys(value)
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
# Singleton instance
|
||||
etoplatezhi_service = EtoplatezhiService()
|
||||
@@ -1,268 +0,0 @@
|
||||
"""Сервис для работы с API Jupiter (FPGate P2P v2.1, app.juppiter.tech)."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class JupiterAPIError(Exception):
|
||||
"""Ошибка API Jupiter."""
|
||||
|
||||
def __init__(self, status_code: int, message: str, code: str | None = None) -> None:
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
self.api_code = code
|
||||
super().__init__(f'Jupiter API error ({status_code}): {message}')
|
||||
|
||||
|
||||
class JupiterService:
|
||||
"""Клиент для FPGate P2P v2.1 (Jupiter / app.juppiter.tech)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return (settings.JUPITER_BASE_URL or 'https://app.juppiter.tech').rstrip('/')
|
||||
|
||||
@property
|
||||
def token(self) -> str:
|
||||
return settings.JUPITER_TOKEN or ''
|
||||
|
||||
@property
|
||||
def secret(self) -> str:
|
||||
return settings.JUPITER_SECRET or ''
|
||||
|
||||
@property
|
||||
def method_id(self) -> str | None:
|
||||
value = (settings.JUPITER_METHOD_ID or '').strip()
|
||||
return value or None
|
||||
|
||||
@property
|
||||
def method_description(self) -> str:
|
||||
return (settings.JUPITER_METHOD_DESCRIPTION or 'SBP').strip() or 'SBP'
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
)
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
|
||||
@staticmethod
|
||||
def _build_signature_string(parts: list[tuple[str, Any]]) -> str:
|
||||
"""Собирает каноническую строку для подписи: имя=значение... в порядке полей.
|
||||
|
||||
По спецификации FPGate P2P v2.1: «Если поле подписываемое, но не обязательное,
|
||||
то оно входит в подпись, если оно присутствует в запросе и имеет непустое значение».
|
||||
"""
|
||||
chunks: list[str] = []
|
||||
for key, value in parts:
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, bool):
|
||||
chunks.append(f'{key}={"true" if value else "false"}')
|
||||
continue
|
||||
value_str = str(value)
|
||||
if value_str == '':
|
||||
continue
|
||||
chunks.append(f'{key}={value_str}')
|
||||
return ''.join(chunks)
|
||||
|
||||
def _hmac_hex(self, message: str) -> str:
|
||||
"""HMAC-SHA256 в hex (регистр не важен по спецификации)."""
|
||||
return hmac.new(
|
||||
self.secret.encode('utf-8'),
|
||||
msg=message.encode('utf-8'),
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
def _sign_payin(self, payload: dict[str, Any]) -> str:
|
||||
amount = payload['amount']
|
||||
customer = payload['customer']
|
||||
parts: list[tuple[str, Any]] = [
|
||||
('token', payload['token']),
|
||||
('order_id', payload['order_id']),
|
||||
('amount.value', amount['value']),
|
||||
('amount.currency', amount['currency']),
|
||||
('customer.id', customer['id']),
|
||||
('redirect', payload['redirect']),
|
||||
]
|
||||
return self._hmac_hex(self._build_signature_string(parts))
|
||||
|
||||
def _sign_status(self, payload: dict[str, Any]) -> str:
|
||||
parts: list[tuple[str, Any]] = [
|
||||
('token', payload['token']),
|
||||
('transaction_id', payload['transaction_id']),
|
||||
]
|
||||
return self._hmac_hex(self._build_signature_string(parts))
|
||||
|
||||
def _sign_balance(self, payload: dict[str, Any]) -> str:
|
||||
parts: list[tuple[str, Any]] = [
|
||||
('token', payload['token']),
|
||||
]
|
||||
return self._hmac_hex(self._build_signature_string(parts))
|
||||
|
||||
@staticmethod
|
||||
def _format_amount(amount_rubles: float) -> str:
|
||||
"""Сумма строго '0.00' с точкой-разделителем (требование P2P v2.1)."""
|
||||
return f'{float(amount_rubles):.2f}'
|
||||
|
||||
async def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
url = f'{self.base_url}/{path.lstrip("/")}'
|
||||
body = json.dumps(payload, separators=(',', ':'), ensure_ascii=False)
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.post(
|
||||
url,
|
||||
data=body,
|
||||
headers={'Content-Type': 'application/json'},
|
||||
) as response:
|
||||
data = await response.json(content_type=None)
|
||||
return data if isinstance(data, dict) else {'_raw': data}
|
||||
except aiohttp.ClientError as error:
|
||||
logger.exception('Jupiter API connection error', url=url, error=error)
|
||||
raise
|
||||
|
||||
async def create_payment(
|
||||
self,
|
||||
*,
|
||||
amount_rubles: float,
|
||||
order_id: str,
|
||||
customer_id: str,
|
||||
customer_email: str | None = None,
|
||||
customer_phone: str | None = None,
|
||||
customer_name: str | None = None,
|
||||
callback_url: str | None = None,
|
||||
receipt: str | None = None,
|
||||
description: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Создаёт платёж (PayIn) согласно FPGate P2P v2.1.
|
||||
|
||||
POST /p2p_payin_v2.1
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
'token': self.token,
|
||||
'order_id': order_id,
|
||||
'amount': {
|
||||
'value': self._format_amount(amount_rubles),
|
||||
'currency': (settings.JUPITER_CURRENCY or 'RUB').upper(),
|
||||
},
|
||||
'customer': {
|
||||
'id': str(customer_id),
|
||||
'email': customer_email or settings.JUPITER_FALLBACK_EMAIL or 'user@vpn.bot',
|
||||
'phone': customer_phone or settings.JUPITER_FALLBACK_PHONE or '0000000000',
|
||||
'name': customer_name or settings.JUPITER_FALLBACK_NAME or 'User',
|
||||
},
|
||||
'redirect': 'false',
|
||||
'description': (description or self.method_description)[:255],
|
||||
}
|
||||
|
||||
if self.method_id:
|
||||
payload['method_id'] = self.method_id
|
||||
if callback_url:
|
||||
payload['callback_url'] = callback_url
|
||||
if receipt:
|
||||
payload['receipt'] = receipt[:255]
|
||||
|
||||
payload['signature'] = self._sign_payin(payload)
|
||||
|
||||
logger.info('Jupiter API create_payment', order_id=order_id, amount_rubles=amount_rubles)
|
||||
|
||||
data = await self._post('/p2p_payin_v2.1', payload)
|
||||
status = (data.get('status') or {}) if isinstance(data, dict) else {}
|
||||
status_type = status.get('type')
|
||||
|
||||
if status_type in ('processing', 'success'):
|
||||
logger.info(
|
||||
'Jupiter API payment created',
|
||||
order_id=order_id,
|
||||
transaction_id=data.get('transaction_id'),
|
||||
status_type=status_type,
|
||||
)
|
||||
return data
|
||||
|
||||
error_code = status.get('error_code') or '0'
|
||||
error_msg = status.get('error_description') or 'Unknown error'
|
||||
logger.error(
|
||||
'Jupiter create_payment error',
|
||||
error_code=error_code,
|
||||
error_msg=error_msg,
|
||||
response_data=data,
|
||||
)
|
||||
raise JupiterAPIError(200, error_msg, error_code)
|
||||
|
||||
async def check_payment(self, *, transaction_id: str) -> dict[str, Any]:
|
||||
"""Получает статус платежа.
|
||||
|
||||
POST /p2p_status_v2.1
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
'token': self.token,
|
||||
'transaction_id': str(transaction_id),
|
||||
}
|
||||
payload['signature'] = self._sign_status(payload)
|
||||
|
||||
logger.info('Jupiter check_payment', transaction_id=transaction_id)
|
||||
data = await self._post('/p2p_status_v2.1', payload)
|
||||
return data
|
||||
|
||||
async def get_balance(self) -> dict[str, Any]:
|
||||
"""Получает баланс продавца.
|
||||
|
||||
POST /p2p_balance_v2.1
|
||||
"""
|
||||
payload: dict[str, Any] = {'token': self.token}
|
||||
payload['signature'] = self._sign_balance(payload)
|
||||
data = await self._post('/p2p_balance_v2.1', payload)
|
||||
return data
|
||||
|
||||
def verify_callback_signature(self, payload: dict[str, Any]) -> bool:
|
||||
"""Верификация подписи callback (HMAC-SHA256, hex)."""
|
||||
try:
|
||||
received = (payload.get('signature') or '').strip()
|
||||
if not received:
|
||||
logger.warning('Jupiter callback: отсутствует signature')
|
||||
return False
|
||||
|
||||
amount = payload.get('amount') or {}
|
||||
status = payload.get('status') or {}
|
||||
parts: list[tuple[str, Any]] = [
|
||||
('token', payload.get('token')),
|
||||
('transaction_id', payload.get('transaction_id')),
|
||||
('order_id', payload.get('order_id')),
|
||||
('amount.value', amount.get('value')),
|
||||
('amount.currency', amount.get('currency')),
|
||||
('recalculated', payload.get('recalculated')),
|
||||
('status.type', status.get('type')),
|
||||
]
|
||||
expected = self._hmac_hex(self._build_signature_string(parts))
|
||||
if not hmac.compare_digest(expected.lower(), received.lower()):
|
||||
logger.warning(
|
||||
'Jupiter callback: invalid signature',
|
||||
expected_prefix=expected[:8],
|
||||
received_prefix=received[:8],
|
||||
)
|
||||
return False
|
||||
return True
|
||||
except Exception as error:
|
||||
logger.error('Jupiter callback verify error', error=error)
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance
|
||||
jupiter_service = JupiterService()
|
||||
@@ -1,234 +0,0 @@
|
||||
"""Сервис для работы с API Lava Business (gate.lava.ru)."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class LavaAPIError(Exception):
|
||||
"""Ошибка API Lava."""
|
||||
|
||||
def __init__(self, status_code: int, message: str, code: str | int | None = None) -> None:
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
self.api_code = code
|
||||
super().__init__(f'Lava API error ({status_code}): {message}')
|
||||
|
||||
|
||||
class LavaService:
|
||||
"""Клиент для Lava Business API (gate.lava.ru).
|
||||
|
||||
Подпись запросов: HMAC-SHA256(json_body, secret_key) → hex.
|
||||
Передаётся в заголовке ``Signature``.
|
||||
Ключи `secret_key` (запросы) и `secret_key_2` (webhook) выдаются мерчанту в личном кабинете.
|
||||
Каноническая строка для подписи — JSON в том же порядке, в котором отправляется в теле.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return (settings.LAVA_BASE_URL or 'https://gate.lava.ru').rstrip('/')
|
||||
|
||||
@property
|
||||
def shop_id(self) -> str:
|
||||
return settings.LAVA_SHOP_ID or ''
|
||||
|
||||
@property
|
||||
def secret_key(self) -> str:
|
||||
return settings.LAVA_SECRET_KEY or ''
|
||||
|
||||
@property
|
||||
def webhook_secret(self) -> str:
|
||||
# secret_key_2 — для проверки подписи webhook'а
|
||||
return settings.LAVA_WEBHOOK_SECRET or ''
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
)
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
|
||||
@staticmethod
|
||||
def _serialize(payload: dict[str, Any]) -> str:
|
||||
"""Сериализация JSON для подписи и тела запроса.
|
||||
|
||||
Lava подписывает байт-в-байт ту же строку, что и отправляется в теле, поэтому
|
||||
порядок ключей определяется порядком вставки в payload (Python ≥3.7 dict сохраняет
|
||||
порядок). Используем компактный сепаратор и UTF-8 без экранирования юникода.
|
||||
"""
|
||||
return json.dumps(payload, separators=(',', ':'), ensure_ascii=False)
|
||||
|
||||
def _hmac_hex(self, message: str | bytes, key: str | None = None) -> str:
|
||||
secret = (key if key is not None else self.secret_key) or ''
|
||||
msg_bytes = message if isinstance(message, (bytes, bytearray)) else message.encode('utf-8')
|
||||
return hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
msg=msg_bytes,
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
def _build_headers(self, body: str) -> dict[str, str]:
|
||||
return {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Signature': self._hmac_hex(body),
|
||||
}
|
||||
|
||||
async def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
url = f'{self.base_url}/{path.lstrip("/")}'
|
||||
body = self._serialize(payload)
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.post(url, data=body, headers=self._build_headers(body)) as response:
|
||||
try:
|
||||
data = await response.json(content_type=None)
|
||||
except Exception:
|
||||
text = await response.text()
|
||||
data = {'_raw': text}
|
||||
if not isinstance(data, dict):
|
||||
data = {'_raw': data}
|
||||
if response.status >= 400:
|
||||
error_msg = (
|
||||
data.get('error')
|
||||
or (data.get('data') or {}).get('error')
|
||||
or data.get('message')
|
||||
or 'Lava API HTTP error'
|
||||
)
|
||||
logger.warning(
|
||||
'Lava API HTTP error',
|
||||
url=url,
|
||||
status=response.status,
|
||||
error_msg=str(error_msg),
|
||||
code=data.get('code'),
|
||||
)
|
||||
raise LavaAPIError(response.status, str(error_msg), data.get('code'))
|
||||
return data
|
||||
except aiohttp.ClientError as error:
|
||||
logger.exception('Lava API connection error', url=url, error=error)
|
||||
raise
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
*,
|
||||
amount_rubles: float,
|
||||
order_id: str,
|
||||
success_url: str | None = None,
|
||||
fail_url: str | None = None,
|
||||
hook_url: str | None = None,
|
||||
expire_minutes: int | None = None,
|
||||
comment: str | None = None,
|
||||
custom_fields: str | None = None,
|
||||
include_service: list[str] | None = None,
|
||||
exclude_service: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Создаёт инвойс через POST /api/v2/invoice/create.
|
||||
|
||||
Сумма передаётся в рублях с двумя знаками после запятой.
|
||||
``orderId`` — наш уникальный идентификатор платежа.
|
||||
"""
|
||||
# Порядок полей важен (этим же порядком сериализуется и подписывается)
|
||||
payload: dict[str, Any] = {
|
||||
'sum': round(float(amount_rubles), 2),
|
||||
'orderId': str(order_id),
|
||||
'shopId': self.shop_id,
|
||||
}
|
||||
if hook_url:
|
||||
payload['hookUrl'] = hook_url[:500]
|
||||
if success_url:
|
||||
payload['successUrl'] = success_url[:500]
|
||||
if fail_url:
|
||||
payload['failUrl'] = fail_url[:500]
|
||||
if expire_minutes is not None:
|
||||
# Lava лимит: 1..7200 минут (5 дней)
|
||||
payload['expire'] = max(1, min(7200, int(expire_minutes)))
|
||||
if comment:
|
||||
payload['comment'] = comment[:255]
|
||||
if custom_fields:
|
||||
payload['customFields'] = custom_fields[:500]
|
||||
if include_service:
|
||||
payload['includeService'] = list(include_service)
|
||||
if exclude_service:
|
||||
payload['excludeService'] = list(exclude_service)
|
||||
|
||||
logger.info('Lava API invoice/create', order_id=order_id, sum=payload['sum'])
|
||||
data = await self._post('/api/v2/invoice/create', payload)
|
||||
|
||||
# Lava возвращает {"status": "success", "data": {...}} или {"status": "error", "error": "..."}
|
||||
if isinstance(data.get('status'), str) and data['status'].lower() == 'error':
|
||||
raise LavaAPIError(200, str(data.get('error') or data.get('message') or 'unknown'))
|
||||
|
||||
return data
|
||||
|
||||
async def get_invoice_status(
|
||||
self,
|
||||
*,
|
||||
order_id: str | None = None,
|
||||
invoice_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""POST /api/v2/invoice/status — статус инвойса по orderId или invoiceId."""
|
||||
if not order_id and not invoice_id:
|
||||
raise ValueError('Lava status: order_id or invoice_id required')
|
||||
|
||||
payload: dict[str, Any] = {'shopId': self.shop_id}
|
||||
if invoice_id:
|
||||
payload['invoiceId'] = str(invoice_id)
|
||||
if order_id:
|
||||
payload['orderId'] = str(order_id)
|
||||
|
||||
logger.info('Lava API invoice/status', order_id=order_id, invoice_id=invoice_id)
|
||||
return await self._post('/api/v2/invoice/status', payload)
|
||||
|
||||
async def get_services(self) -> dict[str, Any]:
|
||||
"""POST /api/v2/invoice/services — доступные методы оплаты для shopId."""
|
||||
payload: dict[str, Any] = {'shopId': self.shop_id}
|
||||
return await self._post('/api/v2/invoice/services', payload)
|
||||
|
||||
def verify_webhook_signature(self, raw_body: bytes, received_signature: str) -> bool:
|
||||
"""Верификация подписи webhook (заголовок ``Authorization``).
|
||||
|
||||
Lava Business webhook подписан HMAC-SHA256 от raw JSON body ключом ``secret_key_2``.
|
||||
"""
|
||||
try:
|
||||
if not received_signature:
|
||||
logger.warning('Lava webhook: отсутствует Authorization header')
|
||||
return False
|
||||
if not self.webhook_secret:
|
||||
logger.error('Lava webhook: LAVA_WEBHOOK_SECRET не настроен')
|
||||
return False
|
||||
|
||||
# HMAC берётся напрямую от raw bytes — без decode/encode round-trip,
|
||||
# чтобы не терять байты при некорректной кодировке payload.
|
||||
expected = self._hmac_hex(raw_body, key=self.webhook_secret)
|
||||
received = received_signature.strip()
|
||||
|
||||
if not hmac.compare_digest(expected.lower(), received.lower()):
|
||||
logger.warning(
|
||||
'Lava webhook: invalid signature',
|
||||
received_prefix=received[:8],
|
||||
)
|
||||
return False
|
||||
return True
|
||||
except Exception as error:
|
||||
logger.error('Lava webhook verify error', error=error)
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance
|
||||
lava_service = LavaService()
|
||||
@@ -1044,7 +1044,7 @@ class MonitoringService:
|
||||
# Day 1 reminder
|
||||
if NotificationSettingsService.is_expired_1d_enabled() and 1 <= days_since < 2:
|
||||
if not await notification_sent(db, user.id, subscription.id, 'expired_1d'):
|
||||
success = await self._send_expired_day1_notification(db, user, subscription)
|
||||
success = await self._send_expired_day1_notification(user, subscription)
|
||||
if success:
|
||||
await record_notification(db, user.id, subscription.id, 'expired_1d')
|
||||
sent_day1 += 1
|
||||
@@ -1811,29 +1811,12 @@ class MonitoringService:
|
||||
)
|
||||
return False
|
||||
|
||||
async def _send_expired_day1_notification(self, db: AsyncSession, user: User, subscription: Subscription) -> bool:
|
||||
async def _send_expired_day1_notification(self, user: User, subscription: Subscription) -> bool:
|
||||
try:
|
||||
texts = get_texts(user.language)
|
||||
tariff = getattr(subscription, 'tariff', None)
|
||||
tariff_label = ''
|
||||
if settings.is_multi_tariff_enabled() and tariff:
|
||||
tariff_label = f' «{tariff.name}»'
|
||||
|
||||
renewal_period = (tariff.get_shortest_period() if tariff else None) or 30
|
||||
try:
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
pricing = await pricing_engine.calculate_renewal_price(db, subscription, renewal_period, user=user)
|
||||
renewal_price_kopeks = pricing.final_total
|
||||
except Exception as price_error:
|
||||
logger.warning(
|
||||
'Не удалось рассчитать цену продления для уведомления expired_1d, используем PRICE_30_DAYS',
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
error=str(price_error),
|
||||
)
|
||||
renewal_price_kopeks = settings.PRICE_30_DAYS
|
||||
|
||||
if settings.is_multi_tariff_enabled() and hasattr(subscription, 'tariff') and subscription.tariff:
|
||||
tariff_label = f' «{subscription.tariff.name}»'
|
||||
template = texts.get(
|
||||
'SUBSCRIPTION_EXPIRED_1D',
|
||||
(
|
||||
@@ -1843,7 +1826,7 @@ class MonitoringService:
|
||||
)
|
||||
message = template.format(
|
||||
end_date=format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M'),
|
||||
price=settings.format_price(renewal_price_kopeks),
|
||||
price=settings.format_price(settings.PRICE_30_DAYS),
|
||||
tariff_label=tariff_label,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,531 +0,0 @@
|
||||
"""Mixin для интеграции с Antilopay (lk.antilopay.com)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.antilopay_service import antilopay_service
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
|
||||
# Маппинг статусов Antilopay -> internal
|
||||
ANTILOPAY_STATUS_MAP: dict[str, tuple[str, bool]] = {
|
||||
'PENDING': ('pending', False),
|
||||
'SUCCESS': ('success', True),
|
||||
'FAIL': ('failed', False),
|
||||
'CANCEL': ('cancelled', False),
|
||||
'EXPIRED': ('expired', False),
|
||||
'CHARGEBACK': ('chargeback', False),
|
||||
'REVERSED': ('reversed', False),
|
||||
}
|
||||
|
||||
|
||||
class AntilopayPaymentMixin:
|
||||
"""Mixin для работы с платежами Antilopay."""
|
||||
|
||||
async def create_antilopay_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
amount_kopeks: int,
|
||||
description: str = 'Пополнение баланса',
|
||||
email: str | None = None,
|
||||
language: str = 'ru',
|
||||
payment_method_type: str | None = None,
|
||||
return_url: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Создает платеж Antilopay.
|
||||
|
||||
Returns:
|
||||
Словарь с данными платежа или None при ошибке
|
||||
"""
|
||||
if not settings.is_antilopay_enabled():
|
||||
logger.error('Antilopay не настроен')
|
||||
return None
|
||||
|
||||
# Валидация лимитов
|
||||
if amount_kopeks < settings.ANTILOPAY_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'Antilopay: сумма меньше минимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
ANTILOPAY_MIN_AMOUNT_KOPEKS=settings.ANTILOPAY_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.ANTILOPAY_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'Antilopay: сумма больше максимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
ANTILOPAY_MAX_AMOUNT_KOPEKS=settings.ANTILOPAY_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
# Получаем telegram_id пользователя для order_id
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
if user_id is not None:
|
||||
user = await payment_module.get_user_by_id(db, user_id)
|
||||
tg_id = user.telegram_id if user else user_id
|
||||
else:
|
||||
user = None
|
||||
tg_id = 'guest'
|
||||
|
||||
# Генерируем уникальный order_id с telegram_id для удобного поиска
|
||||
order_id = f'alp{tg_id}_{uuid.uuid4().hex[:6]}'
|
||||
amount_rubles = amount_kopeks / 100
|
||||
currency = settings.ANTILOPAY_CURRENCY
|
||||
|
||||
# Метаданные
|
||||
metadata = {
|
||||
'user_id': user_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'description': description,
|
||||
'language': language,
|
||||
'type': 'balance_topup',
|
||||
}
|
||||
|
||||
try:
|
||||
# Определяем prefer_methods по типу подметода
|
||||
prefer_methods: list[str] | None = None
|
||||
if payment_method_type == 'sbp':
|
||||
prefer_methods = ['SBP']
|
||||
elif payment_method_type == 'card':
|
||||
prefer_methods = ['CARD_RU']
|
||||
elif payment_method_type == 'sberpay':
|
||||
prefer_methods = ['SBER_PAY']
|
||||
|
||||
# Формируем success/fail URL
|
||||
result_url = return_url or settings.ANTILOPAY_RETURN_URL
|
||||
|
||||
# merchant_extra — строка до 255 символов для callback
|
||||
merchant_extra = order_id
|
||||
|
||||
# Создаем платеж через API
|
||||
api_result = await antilopay_service.create_payment(
|
||||
amount_rubles=amount_rubles,
|
||||
order_id=order_id,
|
||||
product_name=settings.ANTILOPAY_PRODUCT_NAME,
|
||||
product_type=settings.ANTILOPAY_PRODUCT_TYPE,
|
||||
description=description,
|
||||
customer_email=email,
|
||||
prefer_methods=prefer_methods,
|
||||
success_url=result_url,
|
||||
fail_url=result_url,
|
||||
merchant_extra=merchant_extra,
|
||||
)
|
||||
|
||||
payment_id = api_result.get('payment_id')
|
||||
payment_url = api_result.get('payment_url')
|
||||
|
||||
logger.info(
|
||||
'Antilopay: получен ответ API',
|
||||
order_id=order_id,
|
||||
payment_id=payment_id,
|
||||
payment_url=payment_url,
|
||||
)
|
||||
|
||||
lifetime = settings.ANTILOPAY_PAYMENT_LIFETIME_MINUTES
|
||||
expires_at = datetime.now(UTC) + timedelta(minutes=lifetime)
|
||||
|
||||
# Сохраняем в БД
|
||||
antilopay_crud = import_module('app.database.crud.antilopay')
|
||||
local_payment = await antilopay_crud.create_antilopay_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method_type,
|
||||
antilopay_payment_id=payment_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Antilopay: создан платеж',
|
||||
order_id=order_id,
|
||||
user_id=user_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_rubles,
|
||||
'currency': currency,
|
||||
'payment_url': payment_url,
|
||||
'payment_id': payment_id,
|
||||
'expires_at': expires_at.isoformat(),
|
||||
'local_payment_id': local_payment.id,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Antilopay: ошибка создания платежа', error=e)
|
||||
return None
|
||||
|
||||
async def process_antilopay_callback(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: dict[str, Any],
|
||||
) -> bool:
|
||||
"""
|
||||
Обрабатывает callback от Antilopay.
|
||||
|
||||
Подпись проверяется в webserver/payments.py до вызова этого метода.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
payload: JSON тело callback (signature проверена в webserver)
|
||||
|
||||
Returns:
|
||||
True если платеж успешно обработан
|
||||
"""
|
||||
try:
|
||||
callback_type = payload.get('type')
|
||||
if callback_type != 'payment':
|
||||
logger.info('Antilopay callback: неизвестный тип', callback_type=callback_type)
|
||||
return True # Не наш тип — не ошибка
|
||||
|
||||
antilopay_payment_id = payload.get('payment_id')
|
||||
antilopay_status = payload.get('status')
|
||||
our_order_id = payload.get('order_id')
|
||||
|
||||
if not our_order_id or not antilopay_status:
|
||||
logger.warning('Antilopay callback: отсутствуют обязательные поля', payload=payload)
|
||||
return False
|
||||
|
||||
# Определяем is_paid по статусу
|
||||
is_confirmed = antilopay_status == 'SUCCESS'
|
||||
|
||||
# Ищем платеж по order_id
|
||||
antilopay_crud = import_module('app.database.crud.antilopay')
|
||||
payment = await antilopay_crud.get_antilopay_payment_by_order_id(db, our_order_id)
|
||||
|
||||
if not payment:
|
||||
logger.warning(
|
||||
'Antilopay callback: платеж не найден',
|
||||
order_id=our_order_id,
|
||||
)
|
||||
return False
|
||||
|
||||
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
|
||||
locked = await antilopay_crud.get_antilopay_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('Antilopay: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
# Проверка дублирования (re-check from locked row)
|
||||
if payment.is_paid:
|
||||
logger.info('Antilopay callback: платеж уже обработан', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Маппинг статуса
|
||||
status_info = ANTILOPAY_STATUS_MAP.get(antilopay_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
# Если статус SUCCESS, принудительно считаем оплаченным
|
||||
if is_confirmed:
|
||||
is_paid = True
|
||||
internal_status = 'success'
|
||||
|
||||
callback_payload = {
|
||||
'antilopay_payment_id': antilopay_payment_id,
|
||||
'status': antilopay_status,
|
||||
'amount': payload.get('amount'),
|
||||
'original_amount': payload.get('original_amount'),
|
||||
'fee': payload.get('fee'),
|
||||
'currency': payload.get('currency'),
|
||||
'pay_method': payload.get('pay_method'),
|
||||
'pay_data': payload.get('pay_data'),
|
||||
'customer': payload.get('customer'),
|
||||
'merchant_extra': payload.get('merchant_extra'),
|
||||
}
|
||||
|
||||
# Проверка суммы ДО обновления статуса
|
||||
if is_paid:
|
||||
original_amount = payload.get('original_amount')
|
||||
if original_amount is not None:
|
||||
# original_amount в РУБЛЯХ (float), конвертируем в копейки
|
||||
received_kopeks = round(float(original_amount) * 100)
|
||||
if abs(received_kopeks - payment.amount_kopeks) > 1:
|
||||
logger.error(
|
||||
'Antilopay amount mismatch',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await antilopay_crud.update_antilopay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='amount_mismatch',
|
||||
is_paid=False,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
return False
|
||||
|
||||
# Финализируем платеж если оплачен — без промежуточного commit
|
||||
if is_paid:
|
||||
# Inline field assignments to keep FOR UPDATE lock intact
|
||||
payment.status = internal_status
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.antilopay_payment_id = str(antilopay_payment_id) if antilopay_payment_id else None
|
||||
payment.callback_payload = callback_payload
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return await self._finalize_antilopay_payment(db, payment, trigger='webhook')
|
||||
|
||||
# Для не-success статусов можно безопасно коммитить
|
||||
payment = await antilopay_crud.update_antilopay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
is_paid=False,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Antilopay callback: ошибка обработки', error=e)
|
||||
return False
|
||||
|
||||
async def _finalize_antilopay_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
*,
|
||||
trigger: str,
|
||||
) -> bool:
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
|
||||
|
||||
FOR UPDATE lock must be acquired by the caller before invoking this method.
|
||||
"""
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
antilopay_crud = import_module('app.database.crud.antilopay')
|
||||
|
||||
# FOR UPDATE lock already acquired by caller — just check idempotency
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
'Antilopay платеж уже связан с транзакцией',
|
||||
order_id=payment.order_id,
|
||||
transaction_id=payment.transaction_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
return True
|
||||
|
||||
# Read fresh metadata AFTER lock to avoid stale data
|
||||
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
|
||||
# --- Guest purchase flow ---
|
||||
from app.services.payment.common import try_fulfill_guest_purchase
|
||||
|
||||
guest_result = await try_fulfill_guest_purchase(
|
||||
db,
|
||||
metadata=metadata,
|
||||
payment_amount_kopeks=payment.amount_kopeks,
|
||||
provider_payment_id=payment.order_id,
|
||||
provider_name='antilopay',
|
||||
)
|
||||
if guest_result is not None:
|
||||
return True
|
||||
|
||||
# Ensure paid fields are set (idempotent — caller may have already set them)
|
||||
if not payment.is_paid:
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
balance_already_credited = bool(metadata.get('balance_credited'))
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error('Пользователь не найден для Antilopay', user_id=payment.user_id)
|
||||
return False
|
||||
|
||||
# Загружаем промогруппы в асинхронном контексте
|
||||
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
|
||||
for user_promo_group in getattr(user, 'user_promo_groups', []):
|
||||
await db.refresh(user_promo_group, attribute_names=['promo_group'])
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
|
||||
transaction_external_id = payment.order_id
|
||||
|
||||
# Проверяем дупликат транзакции
|
||||
existing_transaction = None
|
||||
if transaction_external_id:
|
||||
existing_transaction = await payment_module.get_transaction_by_external_id(
|
||||
db,
|
||||
transaction_external_id,
|
||||
PaymentMethod.ANTILOPAY,
|
||||
)
|
||||
|
||||
display_name = settings.get_antilopay_display_name()
|
||||
description = f'Пополнение через {display_name}'
|
||||
|
||||
transaction = existing_transaction
|
||||
created_transaction = False
|
||||
|
||||
if not transaction:
|
||||
transaction = await payment_module.create_transaction(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=description,
|
||||
payment_method=PaymentMethod.ANTILOPAY,
|
||||
external_id=transaction_external_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
commit=False,
|
||||
)
|
||||
created_transaction = True
|
||||
|
||||
await antilopay_crud.link_antilopay_payment_to_transaction(db, payment=payment, transaction_id=transaction.id)
|
||||
|
||||
should_credit_balance = created_transaction or not balance_already_credited
|
||||
|
||||
if not should_credit_balance:
|
||||
logger.info('Antilopay платеж уже зачислил баланс ранее', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Lock user row to prevent concurrent balance race conditions
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
# Emit deferred side-effects after atomic commit
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
await emit_transaction_side_effects(
|
||||
db,
|
||||
transaction,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
payment_method=PaymentMethod.ANTILOPAY,
|
||||
external_id=transaction_external_id,
|
||||
)
|
||||
|
||||
topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение'
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
payment.amount_kopeks,
|
||||
getattr(self, 'bot', None),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения Antilopay', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if getattr(self, 'bot', None):
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки админ уведомления Antilopay', error=error)
|
||||
|
||||
if getattr(self, 'bot', None) and user.telegram_id:
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
'\u2705 <b>Пополнение успешно!</b>\n\n'
|
||||
f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
|
||||
f'\U0001f4b3 Способ: {display_name}\n'
|
||||
f'\U0001f194 Транзакция: {transaction.id}\n\n'
|
||||
'Баланс пополнен автоматически!'
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки уведомления пользователю Antilopay', error=error)
|
||||
|
||||
try:
|
||||
from app.services.payment.common import send_cart_notification_after_topup
|
||||
|
||||
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Ошибка при работе с сохраненной корзиной для пользователя',
|
||||
user_id=payment.user_id,
|
||||
error=error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
metadata['balance_change'] = {
|
||||
'old_balance': old_balance,
|
||||
'new_balance': user.balance_kopeks,
|
||||
'credited_at': datetime.now(UTC).isoformat(),
|
||||
}
|
||||
metadata['balance_credited'] = True
|
||||
payment.metadata_json = metadata
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Обработан Antilopay платеж',
|
||||
order_id=payment.order_id,
|
||||
user_id=payment.user_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def check_antilopay_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
order_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Проверяет статус платежа через API Antilopay."""
|
||||
try:
|
||||
result = await antilopay_service.check_payment(order_id=order_id)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error('Antilopay: ошибка проверки статуса', order_id=order_id, error=e)
|
||||
return None
|
||||
@@ -1,500 +0,0 @@
|
||||
"""Mixin для интеграции с Donut P2P (gw.donut.business)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.donut_service import donut_service
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
|
||||
# Маппинг description при PayIn (Donut) <-> наш sub-method id
|
||||
DONUT_METHOD_DESCRIPTIONS: dict[str | None, str] = {
|
||||
None: 'CARD',
|
||||
'card': 'CARD',
|
||||
'sbp': 'SBP',
|
||||
'sbp_qr': 'SBP_QR',
|
||||
}
|
||||
|
||||
|
||||
# Маппинг статусов Donut -> internal
|
||||
DONUT_STATUS_MAP: dict[str, tuple[str, bool]] = {
|
||||
'created': ('pending', False),
|
||||
'processing': ('pending', False),
|
||||
'success': ('success', True),
|
||||
'cancelled': ('cancelled', False),
|
||||
'error': ('error', False),
|
||||
}
|
||||
|
||||
|
||||
class DonutPaymentMixin:
|
||||
"""Mixin для работы с платежами Donut."""
|
||||
|
||||
async def create_donut_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
amount_kopeks: int,
|
||||
description: str = 'Пополнение баланса',
|
||||
email: str | None = None,
|
||||
language: str = 'ru',
|
||||
payment_method_type: str | None = None,
|
||||
return_url: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Создаёт платёж Donut."""
|
||||
if not settings.is_donut_enabled():
|
||||
logger.error('Donut не настроен')
|
||||
return None
|
||||
|
||||
if amount_kopeks < settings.DONUT_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'Donut: сумма меньше минимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
DONUT_MIN_AMOUNT_KOPEKS=settings.DONUT_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.DONUT_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'Donut: сумма больше максимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
DONUT_MAX_AMOUNT_KOPEKS=settings.DONUT_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
if user_id is not None:
|
||||
user = await payment_module.get_user_by_id(db, user_id)
|
||||
tg_id = user.telegram_id if user else user_id
|
||||
else:
|
||||
user = None
|
||||
tg_id = 'guest'
|
||||
|
||||
order_id = f'dnt{tg_id}_{uuid.uuid4().hex[:6]}'
|
||||
amount_rubles = amount_kopeks / 100
|
||||
currency = settings.DONUT_CURRENCY
|
||||
|
||||
method_key = (payment_method_type or '').lower() or None
|
||||
method_description = DONUT_METHOD_DESCRIPTIONS.get(method_key, 'CARD')
|
||||
|
||||
metadata = {
|
||||
'user_id': user_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'description': description,
|
||||
'language': language,
|
||||
'type': 'balance_topup',
|
||||
'payment_method_type': method_key,
|
||||
'donut_description': method_description,
|
||||
}
|
||||
|
||||
try:
|
||||
callback_url = self._build_donut_callback_url()
|
||||
customer_id = str(tg_id) if tg_id != 'guest' else f'guest-{order_id[-6:]}'
|
||||
actual_return_url = return_url or settings.DONUT_RETURN_URL
|
||||
|
||||
api_result = await donut_service.create_payment(
|
||||
amount_rubles=amount_rubles,
|
||||
order_id=order_id,
|
||||
customer_id=customer_id,
|
||||
method_description=method_description,
|
||||
customer_email=email,
|
||||
customer_phone=getattr(user, 'phone', None) if user else None,
|
||||
callback_url=callback_url,
|
||||
return_url=actual_return_url,
|
||||
redirect=True,
|
||||
)
|
||||
|
||||
transaction_id = api_result.get('transaction_id')
|
||||
details = api_result.get('details') or {}
|
||||
payment_url = api_result.get('redirect_url') or details.get('qrcode_url') or actual_return_url
|
||||
|
||||
logger.info(
|
||||
'Donut: получен ответ API',
|
||||
order_id=order_id,
|
||||
transaction_id=transaction_id,
|
||||
payment_url=payment_url,
|
||||
method_description=method_description,
|
||||
)
|
||||
|
||||
lifetime = settings.DONUT_PAYMENT_LIFETIME_MINUTES
|
||||
expires_at = datetime.now(UTC) + timedelta(minutes=lifetime)
|
||||
|
||||
donut_crud = import_module('app.database.crud.donut')
|
||||
local_payment = await donut_crud.create_donut_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=method_key,
|
||||
donut_transaction_id=str(transaction_id) if transaction_id else None,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Donut: создан платеж',
|
||||
order_id=order_id,
|
||||
user_id=user_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_rubles,
|
||||
'currency': currency,
|
||||
'payment_url': payment_url,
|
||||
'payment_id': str(transaction_id) if transaction_id else None,
|
||||
'expires_at': expires_at.isoformat(),
|
||||
'local_payment_id': local_payment.id,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Donut: ошибка создания платежа', error=e)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _build_donut_callback_url() -> str | None:
|
||||
"""Собирает абсолютный callback URL для Donut."""
|
||||
webhook_path = settings.DONUT_WEBHOOK_PATH or '/donut-webhook'
|
||||
base = (
|
||||
getattr(settings, 'WEBHOOK_URL', None)
|
||||
or getattr(settings, 'WEB_API_BASE_URL', None)
|
||||
or getattr(settings, 'CABINET_URL', None)
|
||||
)
|
||||
if not base:
|
||||
return None
|
||||
return f'{base.rstrip("/")}{webhook_path if webhook_path.startswith("/") else "/" + webhook_path}'
|
||||
|
||||
async def process_donut_callback(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Обрабатывает callback от Donut (подпись уже проверена в webserver)."""
|
||||
try:
|
||||
our_order_id = payload.get('order_id')
|
||||
donut_transaction_id = payload.get('transaction_id')
|
||||
status_obj = payload.get('status') or {}
|
||||
donut_status = (status_obj.get('type') or '').strip().lower()
|
||||
|
||||
if not our_order_id or not donut_status:
|
||||
logger.warning('Donut callback: отсутствуют обязательные поля', payload=payload)
|
||||
return False
|
||||
|
||||
donut_crud = import_module('app.database.crud.donut')
|
||||
payment = await donut_crud.get_donut_payment_by_order_id(db, our_order_id)
|
||||
if not payment:
|
||||
logger.warning('Donut callback: платеж не найден', order_id=our_order_id)
|
||||
return False
|
||||
|
||||
locked = await donut_crud.get_donut_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('Donut: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
if payment.is_paid:
|
||||
logger.info('Donut callback: платеж уже обработан', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Терминальные неуспешные статусы стики — провайдер не должен иметь возможность
|
||||
# «починить» отклонённый/несовпавший платёж повторным callback'ом.
|
||||
if payment.status in {'amount_mismatch', 'cancelled', 'error', 'expired'}:
|
||||
logger.warning(
|
||||
'Donut callback: платёж в терминальном неуспешном статусе, игнорируется',
|
||||
order_id=payment.order_id,
|
||||
current_status=payment.status,
|
||||
incoming_status=donut_status,
|
||||
)
|
||||
return True
|
||||
|
||||
internal_status, is_paid = DONUT_STATUS_MAP.get(donut_status, ('pending', False))
|
||||
|
||||
callback_payload = {
|
||||
'donut_transaction_id': donut_transaction_id,
|
||||
'status_type': donut_status,
|
||||
'amount': payload.get('amount'),
|
||||
'recalculated': payload.get('recalculated'),
|
||||
'timestamp': payload.get('timestamp'),
|
||||
}
|
||||
|
||||
if is_paid:
|
||||
amount_obj = payload.get('amount') or {}
|
||||
received_value = amount_obj.get('value')
|
||||
if received_value is not None:
|
||||
try:
|
||||
received_kopeks = round(float(received_value) * 100)
|
||||
except (TypeError, ValueError):
|
||||
received_kopeks = None
|
||||
if received_kopeks is not None and abs(received_kopeks - payment.amount_kopeks) > 1:
|
||||
logger.error(
|
||||
'Donut amount mismatch',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await donut_crud.update_donut_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='amount_mismatch',
|
||||
is_paid=False,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
return False
|
||||
|
||||
if is_paid:
|
||||
payment.status = internal_status
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.donut_transaction_id = (
|
||||
str(donut_transaction_id) if donut_transaction_id else payment.donut_transaction_id
|
||||
)
|
||||
payment.callback_payload = callback_payload
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return await self._finalize_donut_payment(db, payment, trigger='webhook')
|
||||
|
||||
payment = await donut_crud.update_donut_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
is_paid=False,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Donut callback: ошибка обработки', error=e)
|
||||
return False
|
||||
|
||||
async def _finalize_donut_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
*,
|
||||
trigger: str,
|
||||
) -> bool:
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
|
||||
|
||||
FOR UPDATE lock уже взят вызывающим.
|
||||
"""
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
donut_crud = import_module('app.database.crud.donut')
|
||||
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
'Donut платеж уже связан с транзакцией',
|
||||
order_id=payment.order_id,
|
||||
transaction_id=payment.transaction_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
return True
|
||||
|
||||
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
|
||||
from app.services.payment.common import try_fulfill_guest_purchase
|
||||
|
||||
guest_result = await try_fulfill_guest_purchase(
|
||||
db,
|
||||
metadata=metadata,
|
||||
payment_amount_kopeks=payment.amount_kopeks,
|
||||
provider_payment_id=payment.order_id,
|
||||
provider_name='donut',
|
||||
)
|
||||
if guest_result is not None:
|
||||
return True
|
||||
|
||||
if not payment.is_paid:
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
balance_already_credited = bool(metadata.get('balance_credited'))
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error('Пользователь не найден для Donut', user_id=payment.user_id)
|
||||
return False
|
||||
|
||||
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
|
||||
for user_promo_group in getattr(user, 'user_promo_groups', []):
|
||||
await db.refresh(user_promo_group, attribute_names=['promo_group'])
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
|
||||
transaction_external_id = payment.order_id
|
||||
|
||||
existing_transaction = None
|
||||
if transaction_external_id:
|
||||
existing_transaction = await payment_module.get_transaction_by_external_id(
|
||||
db,
|
||||
transaction_external_id,
|
||||
PaymentMethod.DONUT,
|
||||
)
|
||||
|
||||
display_name = settings.get_donut_display_name()
|
||||
description = f'Пополнение через {display_name}'
|
||||
|
||||
transaction = existing_transaction
|
||||
created_transaction = False
|
||||
|
||||
if not transaction:
|
||||
transaction = await payment_module.create_transaction(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=description,
|
||||
payment_method=PaymentMethod.DONUT,
|
||||
external_id=transaction_external_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
commit=False,
|
||||
)
|
||||
created_transaction = True
|
||||
|
||||
await donut_crud.link_donut_payment_to_transaction(db, payment=payment, transaction_id=transaction.id)
|
||||
|
||||
should_credit_balance = created_transaction or not balance_already_credited
|
||||
|
||||
if not should_credit_balance:
|
||||
logger.info('Donut платеж уже зачислил баланс ранее', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
await emit_transaction_side_effects(
|
||||
db,
|
||||
transaction,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
payment_method=PaymentMethod.DONUT,
|
||||
external_id=transaction_external_id,
|
||||
)
|
||||
|
||||
topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение'
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
payment.amount_kopeks,
|
||||
getattr(self, 'bot', None),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения Donut', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if getattr(self, 'bot', None):
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки админ уведомления Donut', error=error)
|
||||
|
||||
if getattr(self, 'bot', None) and user.telegram_id:
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
'✅ <b>Пополнение успешно!</b>\n\n'
|
||||
f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
|
||||
f'\U0001f4b3 Способ: {display_name}\n'
|
||||
f'\U0001f194 Транзакция: {transaction.id}\n\n'
|
||||
'Баланс пополнен автоматически!'
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки уведомления пользователю Donut', error=error)
|
||||
|
||||
try:
|
||||
from app.services.payment.common import send_cart_notification_after_topup
|
||||
|
||||
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Ошибка при работе с сохраненной корзиной для пользователя',
|
||||
user_id=payment.user_id,
|
||||
error=error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
metadata['balance_change'] = {
|
||||
'old_balance': old_balance,
|
||||
'new_balance': user.balance_kopeks,
|
||||
'credited_at': datetime.now(UTC).isoformat(),
|
||||
}
|
||||
metadata['balance_credited'] = True
|
||||
payment.metadata_json = metadata
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Обработан Donut платеж',
|
||||
order_id=payment.order_id,
|
||||
user_id=payment.user_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def check_donut_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
transaction_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Запрос статуса платежа через API Donut."""
|
||||
try:
|
||||
return await donut_service.check_payment(transaction_id=transaction_id)
|
||||
except Exception as e:
|
||||
logger.error('Donut: ошибка проверки статуса', transaction_id=transaction_id, error=e)
|
||||
return None
|
||||
@@ -1,518 +0,0 @@
|
||||
"""Mixin для интеграции с Etoplatezhi (paymentpage.etoplatezhi.ru)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.etoplatezhi_service import etoplatezhi_service
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
|
||||
# Маппинг статусов Etoplatezhi -> internal
|
||||
ETOPLATEZHI_STATUS_MAP: dict[str, tuple[str, bool]] = {
|
||||
'success': ('success', True),
|
||||
'decline': ('declined', False),
|
||||
'error': ('error', False),
|
||||
'processing': ('pending', False),
|
||||
'awaiting 3ds result': ('pending', False),
|
||||
'awaiting redirect result': ('pending', False),
|
||||
'awaiting clarification': ('pending', False),
|
||||
'awaiting customer action': ('pending', False),
|
||||
'cancelled': ('cancelled', False),
|
||||
'refunded': ('refunded', False),
|
||||
'partially refunded': ('partially_refunded', False),
|
||||
'reversed': ('reversed', False),
|
||||
}
|
||||
|
||||
|
||||
class EtoplatezhiPaymentMixin:
|
||||
"""Mixin для работы с платежами Etoplatezhi."""
|
||||
|
||||
async def create_etoplatezhi_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
amount_kopeks: int,
|
||||
description: str = 'Пополнение баланса',
|
||||
email: str | None = None,
|
||||
language: str = 'ru',
|
||||
payment_method_type: str | None = None,
|
||||
return_url: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Создает платеж Etoplatezhi.
|
||||
|
||||
Returns:
|
||||
Словарь с данными платежа или None при ошибке
|
||||
"""
|
||||
if not settings.is_etoplatezhi_enabled():
|
||||
logger.error('Etoplatezhi не настроен')
|
||||
return None
|
||||
|
||||
# Валидация лимитов
|
||||
if amount_kopeks < settings.ETOPLATEZHI_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'Etoplatezhi: сумма меньше минимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
ETOPLATEZHI_MIN_AMOUNT_KOPEKS=settings.ETOPLATEZHI_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.ETOPLATEZHI_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'Etoplatezhi: сумма больше максимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
ETOPLATEZHI_MAX_AMOUNT_KOPEKS=settings.ETOPLATEZHI_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
# Получаем telegram_id пользователя для order_id
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
if user_id is not None:
|
||||
user = await payment_module.get_user_by_id(db, user_id)
|
||||
tg_id = user.telegram_id if user else user_id
|
||||
else:
|
||||
user = None
|
||||
tg_id = 'guest'
|
||||
|
||||
# Генерируем уникальный order_id с telegram_id для удобного поиска
|
||||
order_id = f'etp{tg_id}_{uuid.uuid4().hex[:6]}'
|
||||
amount_rubles = amount_kopeks / 100
|
||||
currency = settings.ETOPLATEZHI_CURRENCY
|
||||
|
||||
# Метаданные
|
||||
metadata = {
|
||||
'user_id': user_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'description': description,
|
||||
'language': language,
|
||||
'type': 'balance_topup',
|
||||
}
|
||||
|
||||
try:
|
||||
# Формируем webhook URL
|
||||
webhook_url = None
|
||||
if settings.WEBHOOK_URL:
|
||||
webhook_url = f'{settings.WEBHOOK_URL.rstrip("/")}{settings.ETOPLATEZHI_WEBHOOK_PATH}'
|
||||
|
||||
lifetime = settings.ETOPLATEZHI_PAYMENT_LIFETIME_MINUTES
|
||||
|
||||
# Определяем force_payment_method по типу подметода
|
||||
force_method = None
|
||||
if payment_method_type == 'sbp':
|
||||
force_method = 'sbp'
|
||||
elif payment_method_type == 'card':
|
||||
force_method = 'card'
|
||||
|
||||
# Строим URL для редиректа на платёжную страницу
|
||||
payment_url = etoplatezhi_service.build_payment_url(
|
||||
project_id=settings.ETOPLATEZHI_PROJECT_ID or 0,
|
||||
payment_id=order_id,
|
||||
payment_amount=amount_kopeks,
|
||||
payment_currency=currency,
|
||||
customer_id=str(tg_id),
|
||||
description=description,
|
||||
callback_url=webhook_url,
|
||||
success_url=return_url or settings.ETOPLATEZHI_RETURN_URL,
|
||||
fail_url=return_url or settings.ETOPLATEZHI_RETURN_URL,
|
||||
force_payment_method=force_method,
|
||||
customer_email=email,
|
||||
language_code=language,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Etoplatezhi: сформирован URL платежа',
|
||||
order_id=order_id,
|
||||
payment_url=payment_url,
|
||||
)
|
||||
|
||||
expires_at = datetime.now(UTC) + timedelta(minutes=lifetime)
|
||||
|
||||
# Сохраняем в БД
|
||||
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||
local_payment = await etoplatezhi_crud.create_etoplatezhi_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method_type,
|
||||
etoplatezhi_payment_id=None,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Etoplatezhi: создан платеж',
|
||||
order_id=order_id,
|
||||
user_id=user_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_rubles,
|
||||
'currency': currency,
|
||||
'payment_url': payment_url,
|
||||
'expires_at': expires_at.isoformat(),
|
||||
'local_payment_id': local_payment.id,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Etoplatezhi: ошибка создания платежа', error=e)
|
||||
return None
|
||||
|
||||
async def process_etoplatezhi_callback(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: dict[str, Any],
|
||||
) -> bool:
|
||||
"""
|
||||
Обрабатывает callback от Etoplatezhi.
|
||||
|
||||
Подпись проверяется в webserver/payments.py до вызова этого метода.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
payload: JSON тело callback (signature проверена в webserver)
|
||||
|
||||
Returns:
|
||||
True если платеж успешно обработан
|
||||
"""
|
||||
try:
|
||||
# Etoplatezhi callback structure:
|
||||
# {project_id, payment: {id, status, sum: {amount, currency}}, customer: {id}, signature}
|
||||
payment_data = payload.get('payment', {})
|
||||
etoplatezhi_payment_id = payment_data.get('id')
|
||||
etoplatezhi_status = payment_data.get('status')
|
||||
|
||||
# payment.id в callback — это наш payment_id (order_id)
|
||||
our_payment_id = str(etoplatezhi_payment_id) if etoplatezhi_payment_id else None
|
||||
|
||||
if not our_payment_id or not etoplatezhi_status:
|
||||
logger.warning('Etoplatezhi callback: отсутствуют обязательные поля', payload=payload)
|
||||
return False
|
||||
|
||||
# Определяем is_paid по статусу
|
||||
is_confirmed = etoplatezhi_status == 'success'
|
||||
|
||||
# Ищем платеж по order_id (наш payment_id = order_id)
|
||||
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||
payment = await etoplatezhi_crud.get_etoplatezhi_payment_by_order_id(db, our_payment_id)
|
||||
|
||||
if not payment:
|
||||
logger.warning(
|
||||
'Etoplatezhi callback: платеж не найден',
|
||||
payment_id=our_payment_id,
|
||||
)
|
||||
return False
|
||||
|
||||
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
|
||||
locked = await etoplatezhi_crud.get_etoplatezhi_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('Etoplatezhi: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
# Проверка дублирования (re-check from locked row)
|
||||
if payment.is_paid:
|
||||
logger.info('Etoplatezhi callback: платеж уже обработан', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Маппинг статуса
|
||||
status_info = ETOPLATEZHI_STATUS_MAP.get(etoplatezhi_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
# Если статус success, принудительно считаем оплаченным
|
||||
if is_confirmed:
|
||||
is_paid = True
|
||||
internal_status = 'success'
|
||||
|
||||
# Извлекаем сумму из callback: payment.sum.amount (в минорных единицах)
|
||||
sum_data = payment_data.get('sum', {})
|
||||
|
||||
callback_payload = {
|
||||
'etoplatezhi_payment_id': etoplatezhi_payment_id,
|
||||
'status': etoplatezhi_status,
|
||||
'sum': sum_data,
|
||||
'customer': payload.get('customer'),
|
||||
'project_id': payload.get('project_id'),
|
||||
}
|
||||
|
||||
# Проверка суммы ДО обновления статуса
|
||||
if is_paid:
|
||||
amount_value = sum_data.get('amount')
|
||||
if amount_value is not None:
|
||||
# amount в минорных единицах (копейках)
|
||||
received_kopeks = int(amount_value)
|
||||
if abs(received_kopeks - payment.amount_kopeks) > 1:
|
||||
logger.error(
|
||||
'Etoplatezhi amount mismatch',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await etoplatezhi_crud.update_etoplatezhi_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='amount_mismatch',
|
||||
is_paid=False,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
return False
|
||||
|
||||
# Финализируем платеж если оплачен — без промежуточного commit
|
||||
if is_paid:
|
||||
# Inline field assignments to keep FOR UPDATE lock intact
|
||||
payment.status = internal_status
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.etoplatezhi_payment_id = str(etoplatezhi_payment_id) if etoplatezhi_payment_id else None
|
||||
payment.callback_payload = callback_payload
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return await self._finalize_etoplatezhi_payment(db, payment, trigger='webhook')
|
||||
|
||||
# Для не-success статусов можно безопасно коммитить
|
||||
payment = await etoplatezhi_crud.update_etoplatezhi_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
is_paid=False,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Etoplatezhi callback: ошибка обработки', error=e)
|
||||
return False
|
||||
|
||||
async def _finalize_etoplatezhi_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
*,
|
||||
trigger: str,
|
||||
) -> bool:
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
|
||||
|
||||
FOR UPDATE lock must be acquired by the caller before invoking this method.
|
||||
"""
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||
|
||||
# FOR UPDATE lock already acquired by caller — just check idempotency
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
'Etoplatezhi платеж уже связан с транзакцией',
|
||||
order_id=payment.order_id,
|
||||
transaction_id=payment.transaction_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
return True
|
||||
|
||||
# Read fresh metadata AFTER lock to avoid stale data
|
||||
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
|
||||
# --- Guest purchase flow ---
|
||||
from app.services.payment.common import try_fulfill_guest_purchase
|
||||
|
||||
guest_result = await try_fulfill_guest_purchase(
|
||||
db,
|
||||
metadata=metadata,
|
||||
payment_amount_kopeks=payment.amount_kopeks,
|
||||
provider_payment_id=payment.order_id,
|
||||
provider_name='etoplatezhi',
|
||||
)
|
||||
if guest_result is not None:
|
||||
return True
|
||||
|
||||
# Ensure paid fields are set (idempotent — caller may have already set them)
|
||||
if not payment.is_paid:
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
balance_already_credited = bool(metadata.get('balance_credited'))
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error('Пользователь не найден для Etoplatezhi', user_id=payment.user_id)
|
||||
return False
|
||||
|
||||
# Загружаем промогруппы в асинхронном контексте
|
||||
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
|
||||
for user_promo_group in getattr(user, 'user_promo_groups', []):
|
||||
await db.refresh(user_promo_group, attribute_names=['promo_group'])
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
|
||||
transaction_external_id = payment.order_id
|
||||
|
||||
# Проверяем дупликат транзакции
|
||||
existing_transaction = None
|
||||
if transaction_external_id:
|
||||
existing_transaction = await payment_module.get_transaction_by_external_id(
|
||||
db,
|
||||
transaction_external_id,
|
||||
PaymentMethod.ETOPLATEZHI,
|
||||
)
|
||||
|
||||
display_name = settings.get_etoplatezhi_display_name()
|
||||
description = f'Пополнение через {display_name}'
|
||||
|
||||
transaction = existing_transaction
|
||||
created_transaction = False
|
||||
|
||||
if not transaction:
|
||||
transaction = await payment_module.create_transaction(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=description,
|
||||
payment_method=PaymentMethod.ETOPLATEZHI,
|
||||
external_id=transaction_external_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
commit=False,
|
||||
)
|
||||
created_transaction = True
|
||||
|
||||
await etoplatezhi_crud.link_etoplatezhi_payment_to_transaction(
|
||||
db, payment=payment, transaction_id=transaction.id
|
||||
)
|
||||
|
||||
should_credit_balance = created_transaction or not balance_already_credited
|
||||
|
||||
if not should_credit_balance:
|
||||
logger.info('Etoplatezhi платеж уже зачислил баланс ранее', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Lock user row to prevent concurrent balance race conditions
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
# Emit deferred side-effects after atomic commit
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
await emit_transaction_side_effects(
|
||||
db,
|
||||
transaction,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
payment_method=PaymentMethod.ETOPLATEZHI,
|
||||
external_id=transaction_external_id,
|
||||
)
|
||||
|
||||
topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение'
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
payment.amount_kopeks,
|
||||
getattr(self, 'bot', None),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения Etoplatezhi', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if getattr(self, 'bot', None):
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки админ уведомления Etoplatezhi', error=error)
|
||||
|
||||
if getattr(self, 'bot', None) and user.telegram_id:
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
'\u2705 <b>Пополнение успешно!</b>\n\n'
|
||||
f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
|
||||
f'\U0001f4b3 Способ: {display_name}\n'
|
||||
f'\U0001f194 Транзакция: {transaction.id}\n\n'
|
||||
'Баланс пополнен автоматически!'
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки уведомления пользователю Etoplatezhi', error=error)
|
||||
|
||||
try:
|
||||
from app.services.payment.common import send_cart_notification_after_topup
|
||||
|
||||
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Ошибка при работе с сохраненной корзиной для пользователя',
|
||||
user_id=payment.user_id,
|
||||
error=error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
metadata['balance_change'] = {
|
||||
'old_balance': old_balance,
|
||||
'new_balance': user.balance_kopeks,
|
||||
'credited_at': datetime.now(UTC).isoformat(),
|
||||
}
|
||||
metadata['balance_credited'] = True
|
||||
payment.metadata_json = metadata
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Обработан Etoplatezhi платеж',
|
||||
order_id=payment.order_id,
|
||||
user_id=payment.user_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
|
||||
return True
|
||||
@@ -1,494 +0,0 @@
|
||||
"""Mixin для интеграции с Jupiter (FPGate P2P v2.1, app.juppiter.tech)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.jupiter_service import jupiter_service
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
|
||||
# Маппинг статусов Jupiter -> internal
|
||||
JUPITER_STATUS_MAP: dict[str, tuple[str, bool]] = {
|
||||
'success': ('success', True),
|
||||
'processing': ('pending', False),
|
||||
'cancelled': ('cancelled', False),
|
||||
'declined': ('declined', False),
|
||||
'error': ('error', False),
|
||||
}
|
||||
|
||||
|
||||
class JupiterPaymentMixin:
|
||||
"""Mixin для работы с платежами Jupiter."""
|
||||
|
||||
async def create_jupiter_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
amount_kopeks: int,
|
||||
description: str = 'Пополнение баланса',
|
||||
email: str | None = None,
|
||||
language: str = 'ru',
|
||||
payment_method_type: str | None = None,
|
||||
return_url: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Создаёт платёж Jupiter.
|
||||
|
||||
Параметр ``return_url`` принимается для совместимости сигнатуры с другими провайдерами
|
||||
(cabinet/routes/balance.py одинаково вызывает все ``create_*_payment``), но фактически
|
||||
не используется: спецификация Jupiter v2.1 имеет поле ``redirect`` зарезервированное
|
||||
для будущего использования и не поддерживает return-URL семантику. Пользователь
|
||||
видит QR-код СБП и подтверждает оплату в банковском приложении.
|
||||
"""
|
||||
if not settings.is_jupiter_enabled():
|
||||
logger.error('Jupiter не настроен')
|
||||
return None
|
||||
|
||||
if amount_kopeks < settings.JUPITER_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'Jupiter: сумма меньше минимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
JUPITER_MIN_AMOUNT_KOPEKS=settings.JUPITER_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.JUPITER_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'Jupiter: сумма больше максимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
JUPITER_MAX_AMOUNT_KOPEKS=settings.JUPITER_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
if user_id is not None:
|
||||
user = await payment_module.get_user_by_id(db, user_id)
|
||||
tg_id = user.telegram_id if user else user_id
|
||||
else:
|
||||
user = None
|
||||
tg_id = 'guest'
|
||||
|
||||
order_id = f'jup{tg_id}_{uuid.uuid4().hex[:6]}'
|
||||
amount_rubles = amount_kopeks / 100
|
||||
currency = settings.JUPITER_CURRENCY
|
||||
|
||||
metadata = {
|
||||
'user_id': user_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'description': description,
|
||||
'language': language,
|
||||
'type': 'balance_topup',
|
||||
'payment_method_type': payment_method_type,
|
||||
}
|
||||
|
||||
try:
|
||||
callback_url = self._build_jupiter_callback_url()
|
||||
customer_id = str(tg_id) if tg_id != 'guest' else f'guest-{order_id[-6:]}'
|
||||
customer_name = (
|
||||
getattr(user, 'first_name', None) or getattr(user, 'username', None) or settings.JUPITER_FALLBACK_NAME
|
||||
)
|
||||
|
||||
api_result = await jupiter_service.create_payment(
|
||||
amount_rubles=amount_rubles,
|
||||
order_id=order_id,
|
||||
customer_id=customer_id,
|
||||
customer_email=email,
|
||||
customer_name=customer_name,
|
||||
callback_url=callback_url,
|
||||
description=description[:255] if description else None,
|
||||
)
|
||||
|
||||
transaction_id = api_result.get('transaction_id')
|
||||
details = api_result.get('details') or {}
|
||||
payment_url = details.get('qrcode_url') or details.get('url')
|
||||
|
||||
logger.info(
|
||||
'Jupiter: получен ответ API',
|
||||
order_id=order_id,
|
||||
transaction_id=transaction_id,
|
||||
payment_url=payment_url,
|
||||
)
|
||||
|
||||
lifetime = settings.JUPITER_PAYMENT_LIFETIME_MINUTES
|
||||
expires_at = datetime.now(UTC) + timedelta(minutes=lifetime)
|
||||
|
||||
jupiter_crud = import_module('app.database.crud.jupiter')
|
||||
local_payment = await jupiter_crud.create_jupiter_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method_type,
|
||||
jupiter_transaction_id=str(transaction_id) if transaction_id else None,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Jupiter: создан платеж',
|
||||
order_id=order_id,
|
||||
user_id=user_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_rubles,
|
||||
'currency': currency,
|
||||
'payment_url': payment_url,
|
||||
'payment_id': str(transaction_id) if transaction_id else None,
|
||||
'expires_at': expires_at.isoformat(),
|
||||
'local_payment_id': local_payment.id,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Jupiter: ошибка создания платежа', error=e)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _build_jupiter_callback_url() -> str | None:
|
||||
"""Собирает абсолютный callback URL для Jupiter."""
|
||||
webhook_path = settings.JUPITER_WEBHOOK_PATH or '/jupiter-webhook'
|
||||
base = (
|
||||
getattr(settings, 'WEBHOOK_URL', None)
|
||||
or getattr(settings, 'WEB_API_BASE_URL', None)
|
||||
or getattr(settings, 'CABINET_URL', None)
|
||||
)
|
||||
if not base:
|
||||
return None
|
||||
return f'{base.rstrip("/")}{webhook_path if webhook_path.startswith("/") else "/" + webhook_path}'
|
||||
|
||||
async def process_jupiter_callback(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Обрабатывает callback от Jupiter (подпись уже проверена в webserver)."""
|
||||
try:
|
||||
our_order_id = payload.get('order_id')
|
||||
jupiter_transaction_id = payload.get('transaction_id')
|
||||
status_obj = payload.get('status') or {}
|
||||
jupiter_status = (status_obj.get('type') or '').strip().lower()
|
||||
|
||||
if not our_order_id or not jupiter_status:
|
||||
logger.warning('Jupiter callback: отсутствуют обязательные поля', payload=payload)
|
||||
return False
|
||||
|
||||
jupiter_crud = import_module('app.database.crud.jupiter')
|
||||
payment = await jupiter_crud.get_jupiter_payment_by_order_id(db, our_order_id)
|
||||
if not payment:
|
||||
logger.warning('Jupiter callback: платеж не найден', order_id=our_order_id)
|
||||
return False
|
||||
|
||||
locked = await jupiter_crud.get_jupiter_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('Jupiter: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
if payment.is_paid:
|
||||
logger.info('Jupiter callback: платеж уже обработан', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Терминальные неуспешные статусы стики — провайдер не должен иметь возможность
|
||||
# «починить» отклонённый/несовпавший платёж повторным callback'ом.
|
||||
if payment.status in {'amount_mismatch', 'cancelled', 'declined', 'error', 'expired'}:
|
||||
logger.warning(
|
||||
'Jupiter callback: платёж в терминальном неуспешном статусе, игнорируется',
|
||||
order_id=payment.order_id,
|
||||
current_status=payment.status,
|
||||
incoming_status=jupiter_status,
|
||||
)
|
||||
return True
|
||||
|
||||
internal_status, is_paid = JUPITER_STATUS_MAP.get(jupiter_status, ('pending', False))
|
||||
|
||||
callback_payload = {
|
||||
'jupiter_transaction_id': jupiter_transaction_id,
|
||||
'status_type': jupiter_status,
|
||||
'amount': payload.get('amount'),
|
||||
'recalculated': payload.get('recalculated'),
|
||||
'timestamp': payload.get('timestamp'),
|
||||
}
|
||||
|
||||
# Сверяем сумму ДО обновления статуса
|
||||
if is_paid:
|
||||
amount_obj = payload.get('amount') or {}
|
||||
received_value = amount_obj.get('value')
|
||||
if received_value is not None:
|
||||
try:
|
||||
received_kopeks = round(float(received_value) * 100)
|
||||
except (TypeError, ValueError):
|
||||
received_kopeks = None
|
||||
if received_kopeks is not None and abs(received_kopeks - payment.amount_kopeks) > 1:
|
||||
logger.error(
|
||||
'Jupiter amount mismatch',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await jupiter_crud.update_jupiter_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='amount_mismatch',
|
||||
is_paid=False,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
return False
|
||||
|
||||
if is_paid:
|
||||
payment.status = internal_status
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.jupiter_transaction_id = (
|
||||
str(jupiter_transaction_id) if jupiter_transaction_id else payment.jupiter_transaction_id
|
||||
)
|
||||
payment.callback_payload = callback_payload
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return await self._finalize_jupiter_payment(db, payment, trigger='webhook')
|
||||
|
||||
payment = await jupiter_crud.update_jupiter_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
is_paid=False,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Jupiter callback: ошибка обработки', error=e)
|
||||
return False
|
||||
|
||||
async def _finalize_jupiter_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
*,
|
||||
trigger: str,
|
||||
) -> bool:
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
|
||||
|
||||
FOR UPDATE lock уже взят вызывающим.
|
||||
"""
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
jupiter_crud = import_module('app.database.crud.jupiter')
|
||||
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
'Jupiter платеж уже связан с транзакцией',
|
||||
order_id=payment.order_id,
|
||||
transaction_id=payment.transaction_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
return True
|
||||
|
||||
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
|
||||
from app.services.payment.common import try_fulfill_guest_purchase
|
||||
|
||||
guest_result = await try_fulfill_guest_purchase(
|
||||
db,
|
||||
metadata=metadata,
|
||||
payment_amount_kopeks=payment.amount_kopeks,
|
||||
provider_payment_id=payment.order_id,
|
||||
provider_name='jupiter',
|
||||
)
|
||||
if guest_result is not None:
|
||||
return True
|
||||
|
||||
if not payment.is_paid:
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
balance_already_credited = bool(metadata.get('balance_credited'))
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error('Пользователь не найден для Jupiter', user_id=payment.user_id)
|
||||
return False
|
||||
|
||||
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
|
||||
for user_promo_group in getattr(user, 'user_promo_groups', []):
|
||||
await db.refresh(user_promo_group, attribute_names=['promo_group'])
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
|
||||
transaction_external_id = payment.order_id
|
||||
|
||||
existing_transaction = None
|
||||
if transaction_external_id:
|
||||
existing_transaction = await payment_module.get_transaction_by_external_id(
|
||||
db,
|
||||
transaction_external_id,
|
||||
PaymentMethod.JUPITER,
|
||||
)
|
||||
|
||||
display_name = settings.get_jupiter_display_name()
|
||||
description = f'Пополнение через {display_name}'
|
||||
|
||||
transaction = existing_transaction
|
||||
created_transaction = False
|
||||
|
||||
if not transaction:
|
||||
transaction = await payment_module.create_transaction(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=description,
|
||||
payment_method=PaymentMethod.JUPITER,
|
||||
external_id=transaction_external_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
commit=False,
|
||||
)
|
||||
created_transaction = True
|
||||
|
||||
await jupiter_crud.link_jupiter_payment_to_transaction(db, payment=payment, transaction_id=transaction.id)
|
||||
|
||||
should_credit_balance = created_transaction or not balance_already_credited
|
||||
|
||||
if not should_credit_balance:
|
||||
logger.info('Jupiter платеж уже зачислил баланс ранее', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
await emit_transaction_side_effects(
|
||||
db,
|
||||
transaction,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
payment_method=PaymentMethod.JUPITER,
|
||||
external_id=transaction_external_id,
|
||||
)
|
||||
|
||||
topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение'
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
payment.amount_kopeks,
|
||||
getattr(self, 'bot', None),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения Jupiter', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if getattr(self, 'bot', None):
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки админ уведомления Jupiter', error=error)
|
||||
|
||||
if getattr(self, 'bot', None) and user.telegram_id:
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
'✅ <b>Пополнение успешно!</b>\n\n'
|
||||
f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
|
||||
f'\U0001f4b3 Способ: {display_name}\n'
|
||||
f'\U0001f194 Транзакция: {transaction.id}\n\n'
|
||||
'Баланс пополнен автоматически!'
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки уведомления пользователю Jupiter', error=error)
|
||||
|
||||
try:
|
||||
from app.services.payment.common import send_cart_notification_after_topup
|
||||
|
||||
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Ошибка при работе с сохраненной корзиной для пользователя',
|
||||
user_id=payment.user_id,
|
||||
error=error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
metadata['balance_change'] = {
|
||||
'old_balance': old_balance,
|
||||
'new_balance': user.balance_kopeks,
|
||||
'credited_at': datetime.now(UTC).isoformat(),
|
||||
}
|
||||
metadata['balance_credited'] = True
|
||||
payment.metadata_json = metadata
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Обработан Jupiter платеж',
|
||||
order_id=payment.order_id,
|
||||
user_id=payment.user_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def check_jupiter_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
transaction_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Запрос статуса платежа через API Jupiter."""
|
||||
try:
|
||||
return await jupiter_service.check_payment(transaction_id=transaction_id)
|
||||
except Exception as e:
|
||||
logger.error('Jupiter: ошибка проверки статуса', transaction_id=transaction_id, error=e)
|
||||
return None
|
||||
@@ -1,590 +0,0 @@
|
||||
"""Mixin для интеграции с Lava Business (gate.lava.ru)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.lava_service import lava_service
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
|
||||
# Маппинг sub-method -> includeService для фильтрации методов на странице оплаты Lava
|
||||
LAVA_INCLUDE_SERVICE_MAP: dict[str | None, list[str] | None] = {
|
||||
None: None,
|
||||
'card': ['card'],
|
||||
'sbp': ['sbp'],
|
||||
}
|
||||
|
||||
|
||||
# Маппинг статусов Lava -> internal
|
||||
LAVA_STATUS_MAP: dict[str, tuple[str, bool]] = {
|
||||
'created': ('pending', False),
|
||||
'pending': ('pending', False),
|
||||
'processing': ('pending', False), # на случай промежуточного статуса
|
||||
'success': ('success', True),
|
||||
'cancel': ('cancelled', False),
|
||||
'cancelled': ('cancelled', False),
|
||||
'expired': ('expired', False),
|
||||
'error': ('error', False),
|
||||
'failed': ('failed', False),
|
||||
}
|
||||
|
||||
|
||||
class LavaPaymentMixin:
|
||||
"""Mixin для работы с платежами Lava Business."""
|
||||
|
||||
async def create_lava_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
amount_kopeks: int,
|
||||
description: str = 'Пополнение баланса',
|
||||
email: str | None = None,
|
||||
language: str = 'ru',
|
||||
payment_method_type: str | None = None,
|
||||
return_url: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Создаёт инвойс Lava."""
|
||||
if not settings.is_lava_enabled():
|
||||
logger.error('Lava не настроен')
|
||||
return None
|
||||
|
||||
if amount_kopeks < settings.LAVA_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'Lava: сумма меньше минимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
LAVA_MIN_AMOUNT_KOPEKS=settings.LAVA_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.LAVA_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'Lava: сумма больше максимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
LAVA_MAX_AMOUNT_KOPEKS=settings.LAVA_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
if user_id is not None:
|
||||
user = await payment_module.get_user_by_id(db, user_id)
|
||||
tg_id = user.telegram_id if user else user_id
|
||||
else:
|
||||
user = None
|
||||
tg_id = 'guest'
|
||||
|
||||
# 32 hex char (128 бит) суффикс — order_id уникален даже при публичном tg_id
|
||||
order_id = f'lava{tg_id}_{uuid.uuid4().hex}'
|
||||
amount_rubles = amount_kopeks / 100
|
||||
currency = settings.LAVA_CURRENCY
|
||||
|
||||
method_key = (payment_method_type or '').lower() or None
|
||||
include_service = LAVA_INCLUDE_SERVICE_MAP.get(method_key)
|
||||
|
||||
metadata = {
|
||||
'user_id': user_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'description': description,
|
||||
'language': language,
|
||||
'type': 'balance_topup',
|
||||
'payment_method_type': method_key,
|
||||
'email': email,
|
||||
}
|
||||
|
||||
try:
|
||||
hook_url = self._build_lava_hook_url()
|
||||
if not hook_url:
|
||||
logger.warning(
|
||||
'Lava: hook_url не сконфигурирован — '
|
||||
'платёж создаётся, но автоматическое подтверждение через webhook невозможно. '
|
||||
'Установите WEBHOOK_URL / WEB_API_BASE_URL / CABINET_URL.'
|
||||
)
|
||||
actual_return_url = return_url or settings.LAVA_RETURN_URL
|
||||
|
||||
api_result = await lava_service.create_invoice(
|
||||
amount_rubles=amount_rubles,
|
||||
order_id=order_id,
|
||||
hook_url=hook_url,
|
||||
success_url=actual_return_url,
|
||||
fail_url=actual_return_url,
|
||||
expire_minutes=settings.LAVA_PAYMENT_LIFETIME_MINUTES,
|
||||
comment=(description or '')[:255] or None,
|
||||
custom_fields=str(user_id) if user_id is not None else None,
|
||||
include_service=include_service,
|
||||
)
|
||||
|
||||
data = (api_result.get('data') or api_result) if isinstance(api_result, dict) else {}
|
||||
lava_invoice_id = data.get('id') or data.get('invoice_id')
|
||||
payment_url = data.get('url') or data.get('payment_url')
|
||||
expired_str = data.get('expired')
|
||||
|
||||
if not payment_url:
|
||||
# Без URL у пользователя нет способа оплатить — это аномалия Lava API.
|
||||
# Row не сохраняем, чтобы не плодить «зависшие» pending-инвойсы без реквизитов.
|
||||
logger.error(
|
||||
'Lava: ответ API без payment URL, инвойс не создан',
|
||||
order_id=order_id,
|
||||
lava_invoice_id=lava_invoice_id,
|
||||
response_keys=list(data.keys()) if isinstance(data, dict) else None,
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
'Lava: получен ответ API',
|
||||
order_id=order_id,
|
||||
lava_invoice_id=lava_invoice_id,
|
||||
payment_url=payment_url,
|
||||
)
|
||||
|
||||
lifetime = settings.LAVA_PAYMENT_LIFETIME_MINUTES
|
||||
expires_at = self._parse_lava_expired(expired_str) or (datetime.now(UTC) + timedelta(minutes=lifetime))
|
||||
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
local_payment = await lava_crud.create_lava_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=method_key,
|
||||
lava_invoice_id=str(lava_invoice_id) if lava_invoice_id else None,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Lava: создан платеж',
|
||||
order_id=order_id,
|
||||
user_id=user_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_rubles,
|
||||
'currency': currency,
|
||||
'payment_url': payment_url,
|
||||
'payment_id': str(lava_invoice_id) if lava_invoice_id else None,
|
||||
'expires_at': expires_at.isoformat(),
|
||||
'local_payment_id': local_payment.id,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Lava: ошибка создания платежа', error=e)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_lava_expired(value: Any) -> datetime | None:
|
||||
"""Парсит поле ``expired`` из ответа Lava.
|
||||
|
||||
Принимаются только TZ-aware строки (ISO с offset/Z) или unix timestamp.
|
||||
Naive-строки игнорируются — TZ Lava в спеке не задокументирована,
|
||||
а угадывание UTC может сместить срок жизни инвойса на несколько часов.
|
||||
Если парсинг не удался — caller использует fallback ``now + lifetime``.
|
||||
"""
|
||||
if value is None or value == '':
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
try:
|
||||
return datetime.fromtimestamp(float(value), tz=UTC)
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
from dateutil.parser import isoparse # type: ignore[import-not-found]
|
||||
|
||||
parsed = isoparse(value)
|
||||
if parsed.tzinfo is None:
|
||||
return None # без TZ доверять не можем
|
||||
return parsed
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _build_lava_hook_url() -> str | None:
|
||||
"""Собирает абсолютный URL вебхука для Lava."""
|
||||
webhook_path = settings.LAVA_WEBHOOK_PATH or '/lava-webhook'
|
||||
base = (
|
||||
getattr(settings, 'WEBHOOK_URL', None)
|
||||
or getattr(settings, 'WEB_API_BASE_URL', None)
|
||||
or getattr(settings, 'CABINET_URL', None)
|
||||
)
|
||||
if not base:
|
||||
return None
|
||||
suffix = webhook_path if webhook_path.startswith('/') else f'/{webhook_path}'
|
||||
return f'{base.rstrip("/")}{suffix}'
|
||||
|
||||
async def process_lava_callback(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Обрабатывает webhook от Lava (подпись уже проверена в webserver)."""
|
||||
try:
|
||||
lava_invoice_id = payload.get('invoice_id')
|
||||
our_order_id = payload.get('order_id')
|
||||
lava_status = (payload.get('status') or '').strip().lower()
|
||||
pay_service = payload.get('pay_service')
|
||||
|
||||
if not our_order_id or not lava_status:
|
||||
logger.warning('Lava webhook: отсутствуют обязательные поля')
|
||||
return False
|
||||
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
payment = await lava_crud.get_lava_payment_by_order_id(db, our_order_id)
|
||||
if not payment:
|
||||
# Fallback по invoice_id, но строго проверяем совпадение order_id
|
||||
if lava_invoice_id:
|
||||
payment = await lava_crud.get_lava_payment_by_invoice_id(db, str(lava_invoice_id))
|
||||
if payment and payment.order_id != our_order_id:
|
||||
logger.error(
|
||||
'Lava webhook: order_id mismatch',
|
||||
webhook_order_id=our_order_id,
|
||||
record_order_id=payment.order_id,
|
||||
invoice_id=lava_invoice_id,
|
||||
)
|
||||
return False
|
||||
if not payment:
|
||||
logger.warning('Lava webhook: платеж не найден', order_id=our_order_id)
|
||||
return False
|
||||
|
||||
locked = await lava_crud.get_lava_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('Lava: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
if payment.is_paid:
|
||||
logger.info('Lava webhook: платеж уже обработан', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Терминальные неуспешные статусы — стики, защита от повторного успеха
|
||||
if payment.status in {'amount_mismatch', 'cancelled', 'cancel', 'error', 'expired', 'failed'}:
|
||||
# Если внезапно пришёл success после терминальной неудачи — это сигнал
|
||||
# подделки или ошибки на стороне Lava, эскалируем.
|
||||
if lava_status == 'success':
|
||||
logger.error(
|
||||
'Lava webhook: success на терминально-неуспешном платеже, игнорируется',
|
||||
order_id=payment.order_id,
|
||||
current_status=payment.status,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
'Lava webhook: платёж в терминальном неуспешном статусе, игнорируется',
|
||||
order_id=payment.order_id,
|
||||
current_status=payment.status,
|
||||
incoming_status=lava_status,
|
||||
)
|
||||
return True
|
||||
|
||||
if lava_status not in LAVA_STATUS_MAP:
|
||||
logger.warning(
|
||||
'Lava webhook: неизвестный статус, обрабатываем как pending',
|
||||
order_id=payment.order_id,
|
||||
incoming_status=lava_status,
|
||||
)
|
||||
internal_status, is_paid = LAVA_STATUS_MAP.get(lava_status, ('pending', False))
|
||||
|
||||
callback_payload = {
|
||||
'lava_invoice_id': lava_invoice_id,
|
||||
'status': lava_status,
|
||||
'amount': payload.get('amount'),
|
||||
'credited': payload.get('credited'),
|
||||
'pay_service': pay_service,
|
||||
'pay_time': payload.get('pay_time'),
|
||||
'payer_details': payload.get('payer_details'),
|
||||
'custom_fields': payload.get('custom_fields'),
|
||||
}
|
||||
|
||||
# Сверяем сумму ДО зачисления
|
||||
if is_paid:
|
||||
# Lava webhook содержит amount (сумма счёта в рублях, float).
|
||||
# Сверяем с тем, что мы отправляли на создание.
|
||||
received_amount = payload.get('amount')
|
||||
if received_amount is not None:
|
||||
try:
|
||||
received_kopeks = round(float(received_amount) * 100)
|
||||
except (TypeError, ValueError):
|
||||
received_kopeks = None
|
||||
if received_kopeks is not None and abs(received_kopeks - payment.amount_kopeks) > 1:
|
||||
logger.error(
|
||||
'Lava amount mismatch',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await lava_crud.update_lava_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='amount_mismatch',
|
||||
is_paid=False,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
return False
|
||||
|
||||
if is_paid:
|
||||
payment.status = internal_status
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
if lava_invoice_id and not payment.lava_invoice_id:
|
||||
payment.lava_invoice_id = str(lava_invoice_id)
|
||||
# Сохраняем pay_service в metadata, не перезаписывая user-выбранный payment_method
|
||||
if pay_service:
|
||||
metadata_now = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
metadata_now['actual_pay_service'] = str(pay_service).lower()
|
||||
payment.metadata_json = metadata_now
|
||||
payment.callback_payload = callback_payload
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return await self._finalize_lava_payment(db, payment, trigger='webhook')
|
||||
|
||||
payment = await lava_crud.update_lava_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
is_paid=False,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Lava webhook: ошибка обработки', error=e)
|
||||
return False
|
||||
|
||||
async def _finalize_lava_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
*,
|
||||
trigger: str,
|
||||
) -> bool:
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
|
||||
|
||||
FOR UPDATE-lock уже взят вызывающим.
|
||||
"""
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
'Lava платеж уже связан с транзакцией',
|
||||
order_id=payment.order_id,
|
||||
transaction_id=payment.transaction_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
return True
|
||||
|
||||
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
|
||||
from app.services.payment.common import try_fulfill_guest_purchase
|
||||
|
||||
guest_result = await try_fulfill_guest_purchase(
|
||||
db,
|
||||
metadata=metadata,
|
||||
payment_amount_kopeks=payment.amount_kopeks,
|
||||
provider_payment_id=payment.order_id,
|
||||
provider_name='lava',
|
||||
)
|
||||
if guest_result is not None:
|
||||
return True
|
||||
|
||||
if not payment.is_paid:
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
balance_already_credited = bool(metadata.get('balance_credited'))
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error('Пользователь не найден для Lava', user_id=payment.user_id)
|
||||
return False
|
||||
|
||||
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
|
||||
for user_promo_group in getattr(user, 'user_promo_groups', []):
|
||||
await db.refresh(user_promo_group, attribute_names=['promo_group'])
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
|
||||
transaction_external_id = payment.order_id
|
||||
|
||||
existing_transaction = None
|
||||
if transaction_external_id:
|
||||
existing_transaction = await payment_module.get_transaction_by_external_id(
|
||||
db,
|
||||
transaction_external_id,
|
||||
PaymentMethod.LAVA,
|
||||
)
|
||||
|
||||
display_name = settings.get_lava_display_name()
|
||||
description = f'Пополнение через {display_name}'
|
||||
|
||||
transaction = existing_transaction
|
||||
created_transaction = False
|
||||
|
||||
if not transaction:
|
||||
transaction = await payment_module.create_transaction(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=description,
|
||||
payment_method=PaymentMethod.LAVA,
|
||||
external_id=transaction_external_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
commit=False,
|
||||
)
|
||||
created_transaction = True
|
||||
|
||||
await lava_crud.link_lava_payment_to_transaction(db, payment=payment, transaction_id=transaction.id)
|
||||
|
||||
should_credit_balance = created_transaction or not balance_already_credited
|
||||
|
||||
if not should_credit_balance:
|
||||
logger.info('Lava платеж уже зачислил баланс ранее', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
await emit_transaction_side_effects(
|
||||
db,
|
||||
transaction,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
payment_method=PaymentMethod.LAVA,
|
||||
external_id=transaction_external_id,
|
||||
)
|
||||
|
||||
topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение'
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
payment.amount_kopeks,
|
||||
getattr(self, 'bot', None),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения Lava', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if getattr(self, 'bot', None):
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки админ уведомления Lava', error=error)
|
||||
|
||||
if getattr(self, 'bot', None) and user.telegram_id:
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
'✅ <b>Пополнение успешно!</b>\n\n'
|
||||
f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
|
||||
f'\U0001f4b3 Способ: {display_name}\n'
|
||||
f'\U0001f194 Транзакция: {transaction.id}\n\n'
|
||||
'Баланс пополнен автоматически!'
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки уведомления пользователю Lava', error=error)
|
||||
|
||||
try:
|
||||
from app.services.payment.common import send_cart_notification_after_topup
|
||||
|
||||
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Ошибка при работе с сохраненной корзиной для пользователя',
|
||||
user_id=payment.user_id,
|
||||
error=error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
metadata['balance_change'] = {
|
||||
'old_balance': old_balance,
|
||||
'new_balance': user.balance_kopeks,
|
||||
'credited_at': datetime.now(UTC).isoformat(),
|
||||
}
|
||||
metadata['balance_credited'] = True
|
||||
payment.metadata_json = metadata
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Обработан Lava платеж',
|
||||
order_id=payment.order_id,
|
||||
user_id=payment.user_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def check_lava_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
order_id: str | None = None,
|
||||
invoice_id: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Запрос статуса инвойса через API Lava."""
|
||||
try:
|
||||
return await lava_service.get_invoice_status(order_id=order_id, invoice_id=invoice_id)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Lava: ошибка проверки статуса',
|
||||
order_id=order_id,
|
||||
invoice_id=invoice_id,
|
||||
error=e,
|
||||
)
|
||||
return None
|
||||
@@ -189,57 +189,6 @@ def _get_method_defaults() -> dict:
|
||||
{'id': 'sbp', 'name': 'СБП'},
|
||||
],
|
||||
},
|
||||
'etoplatezhi': {
|
||||
'default_display_name': settings.get_etoplatezhi_display_name(),
|
||||
'is_configured': settings.is_etoplatezhi_enabled(),
|
||||
'default_min': settings.ETOPLATEZHI_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.ETOPLATEZHI_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': [
|
||||
{'id': 'card', 'name': 'Карта'},
|
||||
{'id': 'sbp', 'name': 'СБП'},
|
||||
],
|
||||
},
|
||||
'antilopay': {
|
||||
'default_display_name': settings.get_antilopay_display_name(),
|
||||
'is_configured': settings.is_antilopay_enabled(),
|
||||
'default_min': settings.ANTILOPAY_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.ANTILOPAY_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': [
|
||||
{'id': 'card', 'name': 'Карта'},
|
||||
{'id': 'sbp', 'name': 'СБП'},
|
||||
{'id': 'sberpay', 'name': 'SberPay'},
|
||||
],
|
||||
},
|
||||
'jupiter': {
|
||||
'default_display_name': settings.get_jupiter_display_name(),
|
||||
'is_configured': settings.is_jupiter_enabled(),
|
||||
'default_min': settings.JUPITER_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.JUPITER_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': [
|
||||
{'id': 'sbp', 'name': 'СБП'},
|
||||
],
|
||||
},
|
||||
'donut': {
|
||||
'default_display_name': settings.get_donut_display_name(),
|
||||
'is_configured': settings.is_donut_enabled(),
|
||||
'default_min': settings.DONUT_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.DONUT_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': [
|
||||
{'id': 'card', 'name': 'Карта'},
|
||||
{'id': 'sbp', 'name': 'СБП'},
|
||||
{'id': 'sbp_qr', 'name': 'СБП QR'},
|
||||
],
|
||||
},
|
||||
'lava': {
|
||||
'default_display_name': settings.get_lava_display_name(),
|
||||
'is_configured': settings.is_lava_enabled(),
|
||||
'default_min': settings.LAVA_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.LAVA_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': [
|
||||
{'id': 'card', 'name': 'Карта'},
|
||||
{'id': 'sbp', 'name': 'СБП'},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -286,11 +235,6 @@ DEFAULT_METHOD_ORDER = [
|
||||
'rollypay',
|
||||
'overpay',
|
||||
'aurapay',
|
||||
'etoplatezhi',
|
||||
'antilopay',
|
||||
'jupiter',
|
||||
'donut',
|
||||
'lava',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -15,25 +15,17 @@ from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.types import String as SAString
|
||||
|
||||
from app.database.models import (
|
||||
AntilopayPayment,
|
||||
AuraPayPayment,
|
||||
CloudPaymentsPayment,
|
||||
CryptoBotPayment,
|
||||
DonutPayment,
|
||||
EtoplatezhiPayment,
|
||||
FreekassaPayment,
|
||||
HeleketPayment,
|
||||
JupiterPayment,
|
||||
KassaAiPayment,
|
||||
LavaPayment,
|
||||
MulenPayPayment,
|
||||
OverpayPayment,
|
||||
Pal24Payment,
|
||||
PaymentMethod,
|
||||
PayPearPayment,
|
||||
PlategaPayment,
|
||||
RioPayPayment,
|
||||
RollyPayPayment,
|
||||
SeverPayPayment,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
@@ -691,280 +683,6 @@ async def _search_overpay(db: AsyncSession, params: SearchParams) -> list[Pendin
|
||||
return records
|
||||
|
||||
|
||||
async def _search_paypear(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = select(PayPearPayment).options(selectinload(PayPearPayment.user)).order_by(desc(PayPearPayment.created_at))
|
||||
stmt = _apply_date_filter(stmt, PayPearPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
PayPearPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
PayPearPayment.paypear_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, PayPearPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.PAYPEAR,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_rollypay(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(RollyPayPayment).options(selectinload(RollyPayPayment.user)).order_by(desc(RollyPayPayment.created_at))
|
||||
)
|
||||
stmt = _apply_date_filter(stmt, RollyPayPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
RollyPayPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
RollyPayPayment.rollypay_payment_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, RollyPayPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.ROLLYPAY,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_aurapay(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = select(AuraPayPayment).options(selectinload(AuraPayPayment.user)).order_by(desc(AuraPayPayment.created_at))
|
||||
stmt = _apply_date_filter(stmt, AuraPayPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
AuraPayPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
AuraPayPayment.aurapay_invoice_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, AuraPayPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.AURAPAY,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_etoplatezhi(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(EtoplatezhiPayment)
|
||||
.options(selectinload(EtoplatezhiPayment.user))
|
||||
.order_by(desc(EtoplatezhiPayment.created_at))
|
||||
)
|
||||
stmt = _apply_date_filter(stmt, EtoplatezhiPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
EtoplatezhiPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
EtoplatezhiPayment.etoplatezhi_payment_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, EtoplatezhiPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.ETOPLATEZHI,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_antilopay(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(AntilopayPayment)
|
||||
.options(selectinload(AntilopayPayment.user))
|
||||
.order_by(desc(AntilopayPayment.created_at))
|
||||
)
|
||||
stmt = _apply_date_filter(stmt, AntilopayPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
AntilopayPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
AntilopayPayment.antilopay_payment_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, AntilopayPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.ANTILOPAY,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_jupiter(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = select(JupiterPayment).options(selectinload(JupiterPayment.user)).order_by(desc(JupiterPayment.created_at))
|
||||
stmt = _apply_date_filter(stmt, JupiterPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
JupiterPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
JupiterPayment.jupiter_transaction_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, JupiterPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.JUPITER,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_donut(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = select(DonutPayment).options(selectinload(DonutPayment.user)).order_by(desc(DonutPayment.created_at))
|
||||
stmt = _apply_date_filter(stmt, DonutPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
DonutPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
DonutPayment.donut_transaction_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, DonutPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.DONUT,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_lava(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = select(LavaPayment).options(selectinload(LavaPayment.user)).order_by(desc(LavaPayment.created_at))
|
||||
stmt = _apply_date_filter(stmt, LavaPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
LavaPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
LavaPayment.lava_invoice_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, LavaPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.LAVA,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_stars(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(Transaction)
|
||||
@@ -1019,14 +737,6 @@ _PROVIDER_SEARCH_MAP: dict[PaymentMethod, Any] = {
|
||||
PaymentMethod.RIOPAY: _search_riopay,
|
||||
PaymentMethod.SEVERPAY: _search_severpay,
|
||||
PaymentMethod.OVERPAY: _search_overpay,
|
||||
PaymentMethod.PAYPEAR: _search_paypear,
|
||||
PaymentMethod.ROLLYPAY: _search_rollypay,
|
||||
PaymentMethod.AURAPAY: _search_aurapay,
|
||||
PaymentMethod.ETOPLATEZHI: _search_etoplatezhi,
|
||||
PaymentMethod.ANTILOPAY: _search_antilopay,
|
||||
PaymentMethod.JUPITER: _search_jupiter,
|
||||
PaymentMethod.DONUT: _search_donut,
|
||||
PaymentMethod.LAVA: _search_lava,
|
||||
PaymentMethod.TELEGRAM_STARS: _search_stars,
|
||||
}
|
||||
|
||||
|
||||
@@ -30,15 +30,10 @@ from app.services.payment import (
|
||||
WataPaymentMixin,
|
||||
YooKassaPaymentMixin,
|
||||
)
|
||||
from app.services.payment.antilopay import AntilopayPaymentMixin
|
||||
from app.services.payment.aurapay import AuraPayPaymentMixin
|
||||
from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin
|
||||
from app.services.payment.donut import DonutPaymentMixin
|
||||
from app.services.payment.etoplatezhi import EtoplatezhiPaymentMixin
|
||||
from app.services.payment.freekassa import FreekassaPaymentMixin
|
||||
from app.services.payment.jupiter import JupiterPaymentMixin
|
||||
from app.services.payment.kassa_ai import KassaAiPaymentMixin
|
||||
from app.services.payment.lava import LavaPaymentMixin
|
||||
from app.services.payment.overpay import OverpayPaymentMixin
|
||||
from app.services.payment.paypear import PayPearPaymentMixin
|
||||
from app.services.payment.riopay import RioPayPaymentMixin
|
||||
@@ -487,181 +482,6 @@ async def link_aurapay_payment_to_transaction(*args, **kwargs):
|
||||
return await aurapay_crud.link_aurapay_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_etoplatezhi_payment(*args, **kwargs):
|
||||
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||
return await etoplatezhi_crud.create_etoplatezhi_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_etoplatezhi_payment_by_order_id(*args, **kwargs):
|
||||
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||
return await etoplatezhi_crud.get_etoplatezhi_payment_by_order_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_etoplatezhi_payment_by_invoice_id(*args, **kwargs):
|
||||
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||
return await etoplatezhi_crud.get_etoplatezhi_payment_by_invoice_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_etoplatezhi_payment_by_id(*args, **kwargs):
|
||||
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||
return await etoplatezhi_crud.get_etoplatezhi_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_etoplatezhi_payment_by_id_for_update(*args, **kwargs):
|
||||
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||
return await etoplatezhi_crud.get_etoplatezhi_payment_by_id_for_update(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_etoplatezhi_payment_status(*args, **kwargs):
|
||||
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||
return await etoplatezhi_crud.update_etoplatezhi_payment_status(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_etoplatezhi_payment_to_transaction(*args, **kwargs):
|
||||
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||
return await etoplatezhi_crud.link_etoplatezhi_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_antilopay_payment(*args, **kwargs):
|
||||
antilopay_crud = import_module('app.database.crud.antilopay')
|
||||
return await antilopay_crud.create_antilopay_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_antilopay_payment_by_order_id(*args, **kwargs):
|
||||
antilopay_crud = import_module('app.database.crud.antilopay')
|
||||
return await antilopay_crud.get_antilopay_payment_by_order_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_antilopay_payment_by_invoice_id(*args, **kwargs):
|
||||
antilopay_crud = import_module('app.database.crud.antilopay')
|
||||
return await antilopay_crud.get_antilopay_payment_by_invoice_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_antilopay_payment_by_id(*args, **kwargs):
|
||||
antilopay_crud = import_module('app.database.crud.antilopay')
|
||||
return await antilopay_crud.get_antilopay_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_antilopay_payment_by_id_for_update(*args, **kwargs):
|
||||
antilopay_crud = import_module('app.database.crud.antilopay')
|
||||
return await antilopay_crud.get_antilopay_payment_by_id_for_update(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_antilopay_payment_status(*args, **kwargs):
|
||||
antilopay_crud = import_module('app.database.crud.antilopay')
|
||||
return await antilopay_crud.update_antilopay_payment_status(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_antilopay_payment_to_transaction(*args, **kwargs):
|
||||
antilopay_crud = import_module('app.database.crud.antilopay')
|
||||
return await antilopay_crud.link_antilopay_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_jupiter_payment(*args, **kwargs):
|
||||
jupiter_crud = import_module('app.database.crud.jupiter')
|
||||
return await jupiter_crud.create_jupiter_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_jupiter_payment_by_order_id(*args, **kwargs):
|
||||
jupiter_crud = import_module('app.database.crud.jupiter')
|
||||
return await jupiter_crud.get_jupiter_payment_by_order_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_jupiter_payment_by_invoice_id(*args, **kwargs):
|
||||
jupiter_crud = import_module('app.database.crud.jupiter')
|
||||
return await jupiter_crud.get_jupiter_payment_by_invoice_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_jupiter_payment_by_id(*args, **kwargs):
|
||||
jupiter_crud = import_module('app.database.crud.jupiter')
|
||||
return await jupiter_crud.get_jupiter_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_jupiter_payment_by_id_for_update(*args, **kwargs):
|
||||
jupiter_crud = import_module('app.database.crud.jupiter')
|
||||
return await jupiter_crud.get_jupiter_payment_by_id_for_update(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_jupiter_payment_status(*args, **kwargs):
|
||||
jupiter_crud = import_module('app.database.crud.jupiter')
|
||||
return await jupiter_crud.update_jupiter_payment_status(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_jupiter_payment_to_transaction(*args, **kwargs):
|
||||
jupiter_crud = import_module('app.database.crud.jupiter')
|
||||
return await jupiter_crud.link_jupiter_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_donut_payment(*args, **kwargs):
|
||||
donut_crud = import_module('app.database.crud.donut')
|
||||
return await donut_crud.create_donut_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_donut_payment_by_order_id(*args, **kwargs):
|
||||
donut_crud = import_module('app.database.crud.donut')
|
||||
return await donut_crud.get_donut_payment_by_order_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_donut_payment_by_invoice_id(*args, **kwargs):
|
||||
donut_crud = import_module('app.database.crud.donut')
|
||||
return await donut_crud.get_donut_payment_by_invoice_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_donut_payment_by_id(*args, **kwargs):
|
||||
donut_crud = import_module('app.database.crud.donut')
|
||||
return await donut_crud.get_donut_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_donut_payment_by_id_for_update(*args, **kwargs):
|
||||
donut_crud = import_module('app.database.crud.donut')
|
||||
return await donut_crud.get_donut_payment_by_id_for_update(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_donut_payment_status(*args, **kwargs):
|
||||
donut_crud = import_module('app.database.crud.donut')
|
||||
return await donut_crud.update_donut_payment_status(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_donut_payment_to_transaction(*args, **kwargs):
|
||||
donut_crud = import_module('app.database.crud.donut')
|
||||
return await donut_crud.link_donut_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_lava_payment(*args, **kwargs):
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
return await lava_crud.create_lava_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_lava_payment_by_order_id(*args, **kwargs):
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
return await lava_crud.get_lava_payment_by_order_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_lava_payment_by_invoice_id(*args, **kwargs):
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
return await lava_crud.get_lava_payment_by_invoice_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_lava_payment_by_id(*args, **kwargs):
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
return await lava_crud.get_lava_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_lava_payment_by_id_for_update(*args, **kwargs):
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
return await lava_crud.get_lava_payment_by_id_for_update(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_lava_payment_status(*args, **kwargs):
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
return await lava_crud.update_lava_payment_status(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_lava_payment_to_transaction(*args, **kwargs):
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
return await lava_crud.link_lava_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
# Mapping from model_name to getter function name for providers
|
||||
# where it differs from the standard get_{model_name}_payment_by_id pattern.
|
||||
_GETTER_OVERRIDES: dict[str, str] = {
|
||||
@@ -689,11 +509,6 @@ class PaymentService(
|
||||
RollyPayPaymentMixin,
|
||||
OverpayPaymentMixin,
|
||||
AuraPayPaymentMixin,
|
||||
EtoplatezhiPaymentMixin,
|
||||
AntilopayPaymentMixin,
|
||||
JupiterPaymentMixin,
|
||||
DonutPaymentMixin,
|
||||
LavaPaymentMixin,
|
||||
):
|
||||
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
|
||||
|
||||
@@ -1201,116 +1016,6 @@ class PaymentService(
|
||||
}
|
||||
return None
|
||||
|
||||
# --- Etoplatezhi ------------------------------------------------------
|
||||
if payment_method == 'etoplatezhi':
|
||||
if not settings.is_etoplatezhi_enabled():
|
||||
logger.warning('Etoplatezhi is not enabled, cannot create guest payment')
|
||||
return None
|
||||
|
||||
result = await self.create_etoplatezhi_payment(
|
||||
db=db,
|
||||
user_id=None,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
return_url=return_url,
|
||||
)
|
||||
if result:
|
||||
await _patch_guest_metadata(result['local_payment_id'], 'etoplatezhi')
|
||||
return {
|
||||
'payment_url': result.get('payment_url'),
|
||||
'payment_id': result.get('order_id'),
|
||||
'provider': 'etoplatezhi',
|
||||
}
|
||||
return None
|
||||
|
||||
# --- Antilopay --------------------------------------------------------
|
||||
if payment_method == 'antilopay':
|
||||
if not settings.is_antilopay_enabled():
|
||||
logger.warning('Antilopay is not enabled, cannot create guest payment')
|
||||
return None
|
||||
|
||||
result = await self.create_antilopay_payment(
|
||||
db=db,
|
||||
user_id=None,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
return_url=return_url,
|
||||
)
|
||||
if result:
|
||||
await _patch_guest_metadata(result['local_payment_id'], 'antilopay')
|
||||
return {
|
||||
'payment_url': result.get('payment_url'),
|
||||
'payment_id': result.get('order_id'),
|
||||
'provider': 'antilopay',
|
||||
}
|
||||
return None
|
||||
|
||||
# --- Jupiter ----------------------------------------------------------
|
||||
if payment_method == 'jupiter':
|
||||
if not settings.is_jupiter_enabled():
|
||||
logger.warning('Jupiter is not enabled, cannot create guest payment')
|
||||
return None
|
||||
|
||||
result = await self.create_jupiter_payment(
|
||||
db=db,
|
||||
user_id=None,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
return_url=return_url,
|
||||
)
|
||||
if result:
|
||||
await _patch_guest_metadata(result['local_payment_id'], 'jupiter')
|
||||
return {
|
||||
'payment_url': result.get('payment_url'),
|
||||
'payment_id': result.get('order_id'),
|
||||
'provider': 'jupiter',
|
||||
}
|
||||
return None
|
||||
|
||||
# --- Donut ------------------------------------------------------------
|
||||
if payment_method == 'donut':
|
||||
if not settings.is_donut_enabled():
|
||||
logger.warning('Donut is not enabled, cannot create guest payment')
|
||||
return None
|
||||
|
||||
result = await self.create_donut_payment(
|
||||
db=db,
|
||||
user_id=None,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
return_url=return_url,
|
||||
)
|
||||
if result:
|
||||
await _patch_guest_metadata(result['local_payment_id'], 'donut')
|
||||
return {
|
||||
'payment_url': result.get('payment_url'),
|
||||
'payment_id': result.get('order_id'),
|
||||
'provider': 'donut',
|
||||
}
|
||||
return None
|
||||
|
||||
# --- Lava -------------------------------------------------------------
|
||||
if payment_method == 'lava':
|
||||
if not settings.is_lava_enabled():
|
||||
logger.warning('Lava is not enabled, cannot create guest payment')
|
||||
return None
|
||||
|
||||
result = await self.create_lava_payment(
|
||||
db=db,
|
||||
user_id=None,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
return_url=return_url,
|
||||
)
|
||||
if result:
|
||||
await _patch_guest_metadata(result['local_payment_id'], 'lava')
|
||||
return {
|
||||
'payment_url': result.get('payment_url'),
|
||||
'payment_id': result.get('order_id'),
|
||||
'provider': 'lava',
|
||||
}
|
||||
return None
|
||||
|
||||
# --- Telegram Stars ---------------------------------------------------
|
||||
if payment_method == 'telegram_stars':
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
|
||||
@@ -18,24 +18,16 @@ from sqlalchemy.orm import selectinload
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import (
|
||||
AntilopayPayment,
|
||||
AuraPayPayment,
|
||||
CloudPaymentsPayment,
|
||||
CryptoBotPayment,
|
||||
DonutPayment,
|
||||
EtoplatezhiPayment,
|
||||
FreekassaPayment,
|
||||
HeleketPayment,
|
||||
JupiterPayment,
|
||||
KassaAiPayment,
|
||||
LavaPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PaymentMethod,
|
||||
PayPearPayment,
|
||||
PlategaPayment,
|
||||
RioPayPayment,
|
||||
RollyPayPayment,
|
||||
SeverPayPayment,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
@@ -85,11 +77,6 @@ SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
|
||||
PaymentMethod.RIOPAY,
|
||||
PaymentMethod.SEVERPAY,
|
||||
PaymentMethod.OVERPAY,
|
||||
PaymentMethod.PAYPEAR,
|
||||
PaymentMethod.ROLLYPAY,
|
||||
PaymentMethod.AURAPAY,
|
||||
# ETOPLATEZHI / ANTILOPAY / JUPITER / DONUT / LAVA — webhook-driven,
|
||||
# без API-метода синхронизации БД, manual check не реализован.
|
||||
}
|
||||
)
|
||||
|
||||
@@ -111,9 +98,6 @@ SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
|
||||
PaymentMethod.RIOPAY,
|
||||
PaymentMethod.SEVERPAY,
|
||||
PaymentMethod.OVERPAY,
|
||||
PaymentMethod.PAYPEAR,
|
||||
PaymentMethod.ROLLYPAY,
|
||||
PaymentMethod.AURAPAY,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -145,22 +129,6 @@ def method_display_name(method: PaymentMethod) -> str:
|
||||
return settings.get_severpay_display_name()
|
||||
if method == PaymentMethod.OVERPAY:
|
||||
return settings.get_overpay_display_name()
|
||||
if method == PaymentMethod.PAYPEAR:
|
||||
return settings.get_paypear_display_name()
|
||||
if method == PaymentMethod.ROLLYPAY:
|
||||
return settings.get_rollypay_display_name()
|
||||
if method == PaymentMethod.AURAPAY:
|
||||
return settings.get_aurapay_display_name()
|
||||
if method == PaymentMethod.ETOPLATEZHI:
|
||||
return settings.get_etoplatezhi_display_name()
|
||||
if method == PaymentMethod.ANTILOPAY:
|
||||
return settings.get_antilopay_display_name()
|
||||
if method == PaymentMethod.JUPITER:
|
||||
return settings.get_jupiter_display_name()
|
||||
if method == PaymentMethod.DONUT:
|
||||
return settings.get_donut_display_name()
|
||||
if method == PaymentMethod.LAVA:
|
||||
return settings.get_lava_display_name()
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
return 'Telegram Stars'
|
||||
return method.value
|
||||
@@ -193,22 +161,6 @@ def _method_is_enabled(method: PaymentMethod) -> bool:
|
||||
return settings.is_severpay_enabled()
|
||||
if method == PaymentMethod.OVERPAY:
|
||||
return settings.is_overpay_enabled()
|
||||
if method == PaymentMethod.PAYPEAR:
|
||||
return settings.is_paypear_enabled()
|
||||
if method == PaymentMethod.ROLLYPAY:
|
||||
return settings.is_rollypay_enabled()
|
||||
if method == PaymentMethod.AURAPAY:
|
||||
return settings.is_aurapay_enabled()
|
||||
if method == PaymentMethod.ETOPLATEZHI:
|
||||
return settings.is_etoplatezhi_enabled()
|
||||
if method == PaymentMethod.ANTILOPAY:
|
||||
return settings.is_antilopay_enabled()
|
||||
if method == PaymentMethod.JUPITER:
|
||||
return settings.is_jupiter_enabled()
|
||||
if method == PaymentMethod.DONUT:
|
||||
return settings.is_donut_enabled()
|
||||
if method == PaymentMethod.LAVA:
|
||||
return settings.is_lava_enabled()
|
||||
return False
|
||||
|
||||
|
||||
@@ -458,62 +410,6 @@ def _is_riopay_pending(payment: RioPayPayment) -> bool:
|
||||
return status in {'pending'}
|
||||
|
||||
|
||||
def _is_paypear_pending(payment: PayPearPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or '').lower()
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
|
||||
|
||||
def _is_rollypay_pending(payment: RollyPayPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or '').lower()
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
|
||||
|
||||
def _is_aurapay_pending(payment: AuraPayPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or '').lower()
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
|
||||
|
||||
def _is_etoplatezhi_pending(payment: EtoplatezhiPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or '').lower()
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
|
||||
|
||||
def _is_antilopay_pending(payment: AntilopayPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or '').lower()
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
|
||||
|
||||
def _is_jupiter_pending(payment: JupiterPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or '').lower()
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
|
||||
|
||||
def _is_donut_pending(payment: DonutPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or '').lower()
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
|
||||
|
||||
def _is_lava_pending(payment: LavaPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or '').lower()
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
|
||||
|
||||
def _parse_cryptobot_amount_kopeks(payment: CryptoBotPayment) -> int:
|
||||
payload = payment.payload or ''
|
||||
match = re.search(r'_(\d+)$', payload)
|
||||
@@ -883,214 +779,6 @@ async def _fetch_severpay_payments(db: AsyncSession, cutoff: datetime) -> list[P
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_paypear_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(PayPearPayment)
|
||||
.options(selectinload(PayPearPayment.user))
|
||||
.where(PayPearPayment.created_at >= cutoff)
|
||||
.order_by(desc(PayPearPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_paypear_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.PAYPEAR,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_rollypay_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(RollyPayPayment)
|
||||
.options(selectinload(RollyPayPayment.user))
|
||||
.where(RollyPayPayment.created_at >= cutoff)
|
||||
.order_by(desc(RollyPayPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_rollypay_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.ROLLYPAY,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_aurapay_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(AuraPayPayment)
|
||||
.options(selectinload(AuraPayPayment.user))
|
||||
.where(AuraPayPayment.created_at >= cutoff)
|
||||
.order_by(desc(AuraPayPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_aurapay_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.AURAPAY,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_etoplatezhi_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(EtoplatezhiPayment)
|
||||
.options(selectinload(EtoplatezhiPayment.user))
|
||||
.where(EtoplatezhiPayment.created_at >= cutoff)
|
||||
.order_by(desc(EtoplatezhiPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_etoplatezhi_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.ETOPLATEZHI,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_antilopay_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(AntilopayPayment)
|
||||
.options(selectinload(AntilopayPayment.user))
|
||||
.where(AntilopayPayment.created_at >= cutoff)
|
||||
.order_by(desc(AntilopayPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_antilopay_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.ANTILOPAY,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_jupiter_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(JupiterPayment)
|
||||
.options(selectinload(JupiterPayment.user))
|
||||
.where(JupiterPayment.created_at >= cutoff)
|
||||
.order_by(desc(JupiterPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_jupiter_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.JUPITER,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_donut_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(DonutPayment)
|
||||
.options(selectinload(DonutPayment.user))
|
||||
.where(DonutPayment.created_at >= cutoff)
|
||||
.order_by(desc(DonutPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_donut_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.DONUT,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_lava_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(LavaPayment)
|
||||
.options(selectinload(LavaPayment.user))
|
||||
.where(LavaPayment.created_at >= cutoff)
|
||||
.order_by(desc(LavaPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_lava_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.LAVA,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_stars_transactions(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(Transaction)
|
||||
@@ -1140,14 +828,6 @@ async def list_recent_pending_payments(
|
||||
await _fetch_kassa_ai_payments(db, cutoff),
|
||||
await _fetch_riopay_payments(db, cutoff),
|
||||
await _fetch_severpay_payments(db, cutoff),
|
||||
await _fetch_paypear_payments(db, cutoff),
|
||||
await _fetch_rollypay_payments(db, cutoff),
|
||||
await _fetch_aurapay_payments(db, cutoff),
|
||||
await _fetch_etoplatezhi_payments(db, cutoff),
|
||||
await _fetch_antilopay_payments(db, cutoff),
|
||||
await _fetch_jupiter_payments(db, cutoff),
|
||||
await _fetch_donut_payments(db, cutoff),
|
||||
await _fetch_lava_payments(db, cutoff),
|
||||
await _fetch_stars_transactions(db, cutoff),
|
||||
)
|
||||
|
||||
@@ -1346,126 +1026,6 @@ async def get_payment_record(
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.PAYPEAR:
|
||||
payment = await db.get(PayPearPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=['user'])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.ROLLYPAY:
|
||||
payment = await db.get(RollyPayPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=['user'])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.AURAPAY:
|
||||
payment = await db.get(AuraPayPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=['user'])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.ETOPLATEZHI:
|
||||
payment = await db.get(EtoplatezhiPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=['user'])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.ANTILOPAY:
|
||||
payment = await db.get(AntilopayPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=['user'])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.JUPITER:
|
||||
payment = await db.get(JupiterPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=['user'])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.DONUT:
|
||||
payment = await db.get(DonutPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=['user'])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.LAVA:
|
||||
payment = await db.get(LavaPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=['user'])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
transaction = await db.get(Transaction, local_payment_id)
|
||||
if not transaction:
|
||||
@@ -1538,27 +1098,6 @@ async def run_manual_check(
|
||||
payment = result.get('payment') if result else None
|
||||
else:
|
||||
payment = None
|
||||
elif method == PaymentMethod.PAYPEAR:
|
||||
paypear_payment = await db.get(PayPearPayment, local_payment_id)
|
||||
if paypear_payment:
|
||||
result = await payment_service.check_paypear_payment_status(db, paypear_payment.order_id)
|
||||
payment = result.get('payment') if result else None
|
||||
else:
|
||||
payment = None
|
||||
elif method == PaymentMethod.ROLLYPAY:
|
||||
rollypay_payment = await db.get(RollyPayPayment, local_payment_id)
|
||||
if rollypay_payment:
|
||||
result = await payment_service.check_rollypay_payment_status(db, rollypay_payment.order_id)
|
||||
payment = result.get('payment') if result else None
|
||||
else:
|
||||
payment = None
|
||||
elif method == PaymentMethod.AURAPAY:
|
||||
aurapay_payment = await db.get(AuraPayPayment, local_payment_id)
|
||||
if aurapay_payment:
|
||||
result = await payment_service.check_aurapay_payment_status(db, aurapay_payment.order_id)
|
||||
payment = result.get('payment') if result else None
|
||||
else:
|
||||
payment = None
|
||||
else:
|
||||
logger.warning('Manual check requested for unsupported method', method=method)
|
||||
return None
|
||||
|
||||
@@ -80,7 +80,6 @@ PERMISSION_REGISTRY: dict[str, list[str]] = {
|
||||
'bulk_actions': ['read', 'execute'],
|
||||
'info_pages': ['read', 'create', 'edit', 'delete'],
|
||||
'news': ['read', 'create', 'edit', 'delete'],
|
||||
'tasks': ['read', 'edit'],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -71,7 +71,6 @@ _PRESET_ROLES: list[dict] = [
|
||||
'landings:create',
|
||||
'landings:edit',
|
||||
'landings:delete',
|
||||
'tasks:*',
|
||||
],
|
||||
'color': '#F59E0B',
|
||||
'icon': 'crown',
|
||||
|
||||
@@ -99,73 +99,6 @@ async def clear_pending_referral(telegram_id: int) -> None:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pending campaign helpers (Redis)
|
||||
#
|
||||
# Mirrors pending_referral: lets us survive the case where /start <campaign>
|
||||
# stored campaign_id only in FSM, but the user opened the cabinet WebApp before
|
||||
# completing bot registration. The cabinet auth route reads this as a fallback
|
||||
# when the HTTP request didn't carry an explicit campaign_slug.
|
||||
# ---------------------------------------------------------------------------
|
||||
_PENDING_CAMPAIGN_TTL = 7 * 24 * 3600 # 7 days
|
||||
|
||||
|
||||
async def save_pending_campaign(telegram_id: int, campaign_slug: str, campaign_id: int) -> bool:
|
||||
"""Save pending campaign attribution to Redis for a not-yet-registered user.
|
||||
|
||||
Called from /start handler immediately after resolving an advertising campaign.
|
||||
Picked up by the cabinet auth route if the user opens the WebApp before
|
||||
completing the bot registration flow.
|
||||
"""
|
||||
client = _get_redis()
|
||||
if client is None:
|
||||
return False
|
||||
try:
|
||||
key = f'pending_campaign:{telegram_id}'
|
||||
data = json.dumps({'campaign_slug': campaign_slug, 'campaign_id': campaign_id})
|
||||
await client.setex(key, _PENDING_CAMPAIGN_TTL, data)
|
||||
logger.info(
|
||||
'Saved pending campaign to Redis',
|
||||
telegram_id=telegram_id,
|
||||
campaign_slug=campaign_slug,
|
||||
campaign_id=campaign_id,
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning('Failed to save pending campaign to Redis', error=exc)
|
||||
return False
|
||||
|
||||
|
||||
async def get_pending_campaign(telegram_id: int) -> dict[str, str | int] | None:
|
||||
"""Get pending campaign from Redis.
|
||||
|
||||
Returns ``{'campaign_slug': ..., 'campaign_id': ...}`` or ``None``.
|
||||
"""
|
||||
client = _get_redis()
|
||||
if client is None:
|
||||
return None
|
||||
try:
|
||||
key = f'pending_campaign:{telegram_id}'
|
||||
data = await client.get(key)
|
||||
if data:
|
||||
return json.loads(data)
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.warning('Failed to get pending campaign from Redis', error=exc)
|
||||
return None
|
||||
|
||||
|
||||
async def clear_pending_campaign(telegram_id: int) -> None:
|
||||
"""Clear pending campaign after successful application."""
|
||||
client = _get_redis()
|
||||
if client is None:
|
||||
return
|
||||
try:
|
||||
await client.delete(f'pending_campaign:{telegram_id}')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _is_commission_limit_reached(db: AsyncSession, referrer_id: int, referral_id: int) -> bool:
|
||||
"""Проверяет, исчерпан ли лимит комиссионных платежей для пары реферер-реферал."""
|
||||
if settings.REFERRAL_MAX_COMMISSION_PAYMENTS <= 0:
|
||||
@@ -262,11 +195,6 @@ async def process_referral_registration(db: AsyncSession, new_user_id: int, refe
|
||||
except Exception as exc:
|
||||
logger.debug('Не удалось записать конкурсную регистрацию', exc=exc)
|
||||
|
||||
# NB: REFERRALS_INVITED task progress больше НЕ триггерится здесь (registration).
|
||||
# Триггер перенесён в process_referral_topup → блок first_topup, чтобы предотвратить
|
||||
# multi-account farming (создал 100 пустых аккаунтов → claimed награду без покупок).
|
||||
# Засчитываем реферала только когда он сделал первое qualifying пополнение.
|
||||
|
||||
if bot:
|
||||
commission_percent = get_effective_referral_commission_percent(referrer)
|
||||
referral_notification = (
|
||||
@@ -415,30 +343,6 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
# Tasks: засчитываем REFERRALS_INVITED у реферера только сейчас — реферал сделал
|
||||
# первое qualifying пополнение, значит это «настоящий» приглашённый юзер.
|
||||
# Триггер при регистрации удалён, чтобы предотвратить multi-account farming.
|
||||
try:
|
||||
from app.database.models import TaskType
|
||||
from app.services.tasks_service import record_event
|
||||
|
||||
await record_event(
|
||||
db,
|
||||
user_id=referrer.id,
|
||||
event_type=TaskType.REFERRALS_INVITED,
|
||||
payload={'referred_user_id': user.id},
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as task_err:
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning(
|
||||
'Tasks: ошибка REFERRALS_INVITED триггера в process_referral_topup',
|
||||
exc=task_err,
|
||||
)
|
||||
|
||||
try:
|
||||
await db.execute(
|
||||
delete(ReferralEarning).where(
|
||||
|
||||
@@ -1935,24 +1935,8 @@ class RemnaWaveService:
|
||||
# Update traffic
|
||||
used_traffic_bytes = panel_user.get('usedTrafficBytes', 0) or 0
|
||||
traffic_used_gb = used_traffic_bytes / (1024**3)
|
||||
traffic_changed = abs(subscription.traffic_used_gb - traffic_used_gb) > 0.01
|
||||
if traffic_changed:
|
||||
if abs(subscription.traffic_used_gb - traffic_used_gb) > 0.01:
|
||||
subscription.traffic_used_gb = traffic_used_gb
|
||||
# Tasks: TRAFFIC_USED обновляем per-user аггрегат (sum по
|
||||
# всем платным подпискам). update_traffic_progress сам
|
||||
# подсчитает суммарный traffic_used_gb после обновления.
|
||||
try:
|
||||
from app.services.tasks_service import update_traffic_progress
|
||||
|
||||
await update_traffic_progress(
|
||||
db, user_id=subscription.user_id
|
||||
)
|
||||
except Exception as task_err:
|
||||
logger.warning(
|
||||
'Tasks: ошибка TRAFFIC_USED триггера',
|
||||
user_id=subscription.user_id,
|
||||
error=task_err,
|
||||
)
|
||||
|
||||
# traffic_limit_gb: bot is source of truth, do not overwrite from panel
|
||||
|
||||
@@ -2189,17 +2173,6 @@ class RemnaWaveService:
|
||||
if abs(subscription.traffic_used_gb - traffic_used_gb) > 0.01:
|
||||
subscription.traffic_used_gb = traffic_used_gb
|
||||
logger.debug('Обновлен использованный трафик', traffic_used_gb=traffic_used_gb)
|
||||
# Tasks: TRAFFIC_USED обновляем per-user аггрегат (sum по платным подпискам)
|
||||
try:
|
||||
from app.services.tasks_service import update_traffic_progress
|
||||
|
||||
await update_traffic_progress(db, user_id=subscription.user_id)
|
||||
except Exception as task_err:
|
||||
logger.warning(
|
||||
'Tasks: ошибка TRAFFIC_USED триггера (single-tariff)',
|
||||
user_id=subscription.user_id,
|
||||
error=task_err,
|
||||
)
|
||||
|
||||
# traffic_limit_gb, device_limit: bot is source of truth, do not overwrite from panel
|
||||
|
||||
|
||||
@@ -564,22 +564,6 @@ async def _auto_extend_subscription(
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Tasks: триггерим прогресс по платным purchase-tasks
|
||||
try:
|
||||
from app.services.tasks_service import trigger_paid_purchase_tasks
|
||||
|
||||
await trigger_paid_purchase_tasks(
|
||||
db,
|
||||
user_id=user.id,
|
||||
tariff_id=getattr(prepared, 'tariff_id', None) or getattr(prepared.subscription, 'tariff_id', None),
|
||||
period_days=prepared.period_days,
|
||||
amount_kopeks=prepared.price_kopeks,
|
||||
subscription_id=getattr(prepared.subscription, 'id', None),
|
||||
is_trial=False,
|
||||
)
|
||||
except Exception as task_err:
|
||||
logger.warning('Tasks: ошибка триггеров auto-purchase (renewal)', error=task_err)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
# Сброс трафика: при смене тарифа — по RESET_TRAFFIC_ON_TARIFF_SWITCH, при оплате — по RESET_TRAFFIC_ON_PAYMENT
|
||||
if is_tariff_change:
|
||||
@@ -962,22 +946,6 @@ async def _auto_purchase_tariff(
|
||||
)
|
||||
transaction = None
|
||||
|
||||
# Tasks: триггерим прогресс по платным purchase-tasks
|
||||
try:
|
||||
from app.services.tasks_service import trigger_paid_purchase_tasks
|
||||
|
||||
await trigger_paid_purchase_tasks(
|
||||
db,
|
||||
user_id=user.id,
|
||||
tariff_id=getattr(tariff, 'id', None),
|
||||
period_days=period_days,
|
||||
amount_kopeks=final_price,
|
||||
subscription_id=getattr(subscription, 'id', None),
|
||||
is_trial=False,
|
||||
)
|
||||
except Exception as task_err:
|
||||
logger.warning('Tasks: ошибка триггеров auto-purchase (tariff)', error=task_err)
|
||||
|
||||
# Обновляем Remnawave
|
||||
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
|
||||
try:
|
||||
@@ -1334,22 +1302,6 @@ async def _auto_purchase_daily_tariff(
|
||||
format_user_id=_format_user_id(user),
|
||||
error=error,
|
||||
)
|
||||
|
||||
# Tasks: триггерим прогресс по платным purchase-tasks (суточная подписка)
|
||||
try:
|
||||
from app.services.tasks_service import trigger_paid_purchase_tasks
|
||||
|
||||
await trigger_paid_purchase_tasks(
|
||||
db,
|
||||
user_id=user.id,
|
||||
tariff_id=getattr(tariff, 'id', None),
|
||||
period_days=1,
|
||||
amount_kopeks=final_price,
|
||||
subscription_id=getattr(subscription, 'id', None),
|
||||
is_trial=False,
|
||||
)
|
||||
except Exception as task_err:
|
||||
logger.warning('Tasks: ошибка триггеров auto-purchase (daily)', error=task_err)
|
||||
transaction = None
|
||||
|
||||
# Обновляем Remnawave
|
||||
@@ -2409,22 +2361,6 @@ async def try_auto_extend_expired_after_topup(
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Tasks: триггерим прогресс по платным purchase-tasks (renewal expired)
|
||||
try:
|
||||
from app.services.tasks_service import trigger_paid_purchase_tasks
|
||||
|
||||
await trigger_paid_purchase_tasks(
|
||||
db,
|
||||
user_id=user.id,
|
||||
tariff_id=getattr(updated_subscription, 'tariff_id', None) or getattr(subscription, 'tariff_id', None),
|
||||
period_days=period_days,
|
||||
amount_kopeks=renewal_cost,
|
||||
subscription_id=getattr(updated_subscription, 'id', None) or getattr(subscription, 'id', None),
|
||||
is_trial=False,
|
||||
)
|
||||
except Exception as task_err:
|
||||
logger.warning('Tasks: ошибка триггеров auto-purchase (expired-renewal)', error=task_err)
|
||||
|
||||
# Update RemnaWave
|
||||
try:
|
||||
await subscription_service.update_remnawave_user(
|
||||
@@ -2756,22 +2692,6 @@ async def try_resume_disabled_daily_after_topup(
|
||||
error=error,
|
||||
)
|
||||
|
||||
# Tasks: триггерим прогресс по платным purchase-tasks (daily resume)
|
||||
try:
|
||||
from app.services.tasks_service import trigger_paid_purchase_tasks
|
||||
|
||||
await trigger_paid_purchase_tasks(
|
||||
db,
|
||||
user_id=user.id,
|
||||
tariff_id=getattr(subscription, 'tariff_id', None),
|
||||
period_days=1,
|
||||
amount_kopeks=daily_price,
|
||||
subscription_id=getattr(subscription, 'id', None),
|
||||
is_trial=False,
|
||||
)
|
||||
except Exception as task_err:
|
||||
logger.warning('Tasks: ошибка триггеров auto-purchase (daily-resume)', error=task_err)
|
||||
|
||||
# Update charge time and end_date (+24h)
|
||||
old_end_date = subscription.end_date
|
||||
try:
|
||||
|
||||
@@ -1226,26 +1226,6 @@ class MiniAppSubscriptionPurchaseService:
|
||||
)
|
||||
message = f'{message}\n\n{note}'
|
||||
|
||||
# Tasks: триггерим прогресс по платным покупкам подписок (PURCHASE_TARIFF /
|
||||
# PURCHASE_PERIOD / MULTI_TARIFF). Триал-конверсия тоже считается как платная.
|
||||
try:
|
||||
from app.services.tasks_service import trigger_paid_purchase_tasks
|
||||
|
||||
tariff_id = getattr(pricing.selection, 'tariff_id', None) or getattr(
|
||||
subscription, 'tariff_id', None
|
||||
)
|
||||
await trigger_paid_purchase_tasks(
|
||||
db,
|
||||
user_id=user.id,
|
||||
tariff_id=tariff_id,
|
||||
period_days=pricing.selection.period.days,
|
||||
amount_kopeks=pricing.final_total,
|
||||
subscription_id=subscription.id,
|
||||
is_trial=False,
|
||||
)
|
||||
except Exception as task_err: # pragma: no cover - defensive
|
||||
logger.warning('Tasks: ошибка триггеров submit_purchase', error=task_err)
|
||||
|
||||
return {
|
||||
'subscription': subscription,
|
||||
'transaction': transaction,
|
||||
|
||||
@@ -566,23 +566,6 @@ class SubscriptionRenewalService:
|
||||
error=error,
|
||||
)
|
||||
|
||||
# Tasks: триггерим прогресс по платным purchase-tasks (renewal — это покупка
|
||||
# того же тарифа на новый период, считается как platная покупка).
|
||||
try:
|
||||
from app.services.tasks_service import trigger_paid_purchase_tasks
|
||||
|
||||
await trigger_paid_purchase_tasks(
|
||||
db,
|
||||
user_id=user.id,
|
||||
tariff_id=getattr(subscription_after, 'tariff_id', None),
|
||||
period_days=period_days,
|
||||
amount_kopeks=final_total,
|
||||
subscription_id=getattr(subscription_after, 'id', None),
|
||||
is_trial=False,
|
||||
)
|
||||
except Exception as task_err:
|
||||
logger.warning('Tasks: ошибка триггеров renewal', error=task_err)
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(subscription_after)
|
||||
|
||||
|
||||
@@ -95,11 +95,6 @@ class BotConfigurationService:
|
||||
'ROLLYPAY': '💳 RollyPay',
|
||||
'OVERPAY': '💳 Overpay',
|
||||
'AURAPAY': '💳 AuraPay',
|
||||
'ANTILOPAY': '🦌 Antilopay',
|
||||
'ETOPLATEZHI': '💳 Etoplatezhi',
|
||||
'JUPITER': '🪐 Jupiter',
|
||||
'DONUT': '🍩 Donut',
|
||||
'LAVA': '🌋 Lava',
|
||||
'YOOKASSA': '🟣 YooKassa',
|
||||
'PLATEGA': '💳 {platega_name}',
|
||||
'TRIBUTE': '🎁 Tribute',
|
||||
@@ -164,11 +159,6 @@ class BotConfigurationService:
|
||||
'ROLLYPAY': 'RollyPay: платёжный шлюз rollypay.io с СБП, картами и криптовалютой.',
|
||||
'OVERPAY': 'Overpay: платёжный шлюз pay.overpay.io с mTLS и поддержкой карт и СБП.',
|
||||
'AURAPAY': 'AuraPay: платёжный шлюз aurapay.tech с поддержкой карт и СБП.',
|
||||
'ANTILOPAY': 'Antilopay: lk.antilopay.com, оплата картой, СБП и SberPay.',
|
||||
'ETOPLATEZHI': 'Etoplatezhi: paymentpage.etoplatezhi.ru, оплата картой и через СБП.',
|
||||
'JUPITER': 'Jupiter (FPGate P2P v2.1): app.juppiter.tech, эквайринг СБП с HMAC-SHA256.',
|
||||
'DONUT': 'Donut P2P: gw.donut.business, P2P-оплата картой, СБП по телефону и QR.',
|
||||
'LAVA': 'Lava Business: gate.lava.ru, оплата картой и СБП с HMAC-SHA256 и подтверждением через webhook.',
|
||||
'PLATEGA': '{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.',
|
||||
'MULENPAY': 'Платежи {mulenpay_name} и параметры магазина.',
|
||||
'PAL24': 'PAL24 / PayPalych подключения и лимиты.',
|
||||
@@ -383,11 +373,6 @@ class BotConfigurationService:
|
||||
'ROLLYPAY_': 'ROLLYPAY',
|
||||
'OVERPAY_': 'OVERPAY',
|
||||
'AURAPAY_': 'AURAPAY',
|
||||
'ANTILOPAY_': 'ANTILOPAY',
|
||||
'ETOPLATEZHI_': 'ETOPLATEZHI',
|
||||
'JUPITER_': 'JUPITER',
|
||||
'DONUT_': 'DONUT',
|
||||
'LAVA_': 'LAVA',
|
||||
'PLATEGA_': 'PLATEGA',
|
||||
'MULENPAY_': 'MULENPAY',
|
||||
'PAL24_': 'PAL24',
|
||||
|
||||
@@ -1,756 +0,0 @@
|
||||
"""Сервис системы заданий с наградами.
|
||||
|
||||
Архитектура:
|
||||
- Внешние действия (покупка тарифа, реферал, трафик и т.д.) триггерят
|
||||
``record_event(...)`` через event_emitter или прямые вызовы.
|
||||
- ``record_event`` для каждого активного задания пользователя считает прогресс
|
||||
под конкретный тип задания (см. ``_apply_event_to_progress``).
|
||||
- При достижении ``target_value`` прогресс помечается ``completed_at``.
|
||||
- Пользователь вызывает ``claim_reward(...)`` чтобы получить награду.
|
||||
|
||||
Многоуровневые задания: ``parent_task_id`` указывает на предыдущий уровень.
|
||||
Уровень N+1 виден пользователю только после успешного ``claim`` уровня N.
|
||||
|
||||
Триал-юзеры (subscription.is_trial=True без платных подписок) не получают
|
||||
прогресс — ``_user_eligible`` возвращает False.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.crud import tasks as tasks_crud
|
||||
from app.database.models import (
|
||||
Subscription,
|
||||
Tariff,
|
||||
Task,
|
||||
TaskRewardType,
|
||||
TaskType,
|
||||
TaskUserAudience,
|
||||
User,
|
||||
UserTaskProgress,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Eligibility helpers
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
async def _user_eligible(db: AsyncSession, user: User) -> bool:
|
||||
"""Eligibility пользователя для системы заданий.
|
||||
|
||||
По требованию: триал-юзеры (есть только триал-подписки) НЕ получают доступ
|
||||
к выполнению заданий (`выполнение заданий триал юзерам недоступно`).
|
||||
|
||||
Юзеры без подписок вообще — допускаем (могут выполнять реферал/гифт/spend задания).
|
||||
Юзеры с хотя бы одной платной подпиской — допускаем.
|
||||
|
||||
Per-task-type фильтр (например, ``purchase_tariff`` payload помечается ``is_trial``)
|
||||
дополнительно блокирует засчитывание trial-конверсий ретроактивно.
|
||||
"""
|
||||
if not user.subscriptions:
|
||||
return True
|
||||
has_paid = any(not getattr(sub, 'is_trial', False) for sub in user.subscriptions)
|
||||
return has_paid
|
||||
|
||||
|
||||
def _user_audience_for(user: User) -> str:
|
||||
"""Определяет аудиторию пользователя для фильтрации заданий."""
|
||||
has_telegram = user.telegram_id is not None
|
||||
has_email = bool(getattr(user, 'email', None))
|
||||
if has_telegram and has_email:
|
||||
return 'both'
|
||||
if has_telegram:
|
||||
return TaskUserAudience.TELEGRAM.value
|
||||
if has_email:
|
||||
return TaskUserAudience.EMAIL.value
|
||||
return 'both'
|
||||
|
||||
|
||||
def _user_promo_group_id(user: User) -> int | None:
|
||||
pg = getattr(user, 'promo_group', None)
|
||||
if pg is None:
|
||||
return None
|
||||
return pg.id
|
||||
|
||||
|
||||
def _is_within_period(task: Task, now: datetime | None = None) -> bool:
|
||||
now = now or datetime.now(UTC)
|
||||
if task.starts_at is not None and task.starts_at > now:
|
||||
return False
|
||||
if task.ends_at is not None and task.ends_at < now:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _audience_matches(task: Task, user_audience: str) -> bool:
|
||||
if task.user_audience == TaskUserAudience.BOTH.value:
|
||||
return True
|
||||
return task.user_audience == user_audience
|
||||
|
||||
|
||||
def _promo_group_matches(task: Task, user_promo_group_id: int | None) -> bool:
|
||||
if task.promo_group_id is None:
|
||||
return True
|
||||
return task.promo_group_id == user_promo_group_id
|
||||
|
||||
|
||||
async def _parent_completed_and_claimed(db: AsyncSession, *, user_id: int, parent_task_id: int) -> bool:
|
||||
"""Проверяет, что предыдущий уровень в цепочке выполнен и награда получена."""
|
||||
progress = await tasks_crud.get_progress(db, user_id=user_id, task_id=parent_task_id)
|
||||
if progress is None:
|
||||
return False
|
||||
return progress.claimed_at is not None
|
||||
|
||||
|
||||
async def get_available_tasks_for_user(db: AsyncSession, user: User) -> list[tuple[Task, UserTaskProgress | None]]:
|
||||
"""Возвращает список доступных заданий для пользователя с текущим прогрессом.
|
||||
|
||||
Отфильтровывает скрытые (по audience/promo_group/period/parent).
|
||||
"""
|
||||
if not await _user_eligible(db, user):
|
||||
return []
|
||||
|
||||
audience = _user_audience_for(user)
|
||||
promo_id = _user_promo_group_id(user)
|
||||
candidates = await tasks_crud.list_active_tasks_for_user(db, user_audience=audience, promo_group_id=promo_id)
|
||||
|
||||
progress_map: dict[int, UserTaskProgress] = {}
|
||||
if candidates:
|
||||
progress_records = await tasks_crud.list_user_progress(db, user_id=user.id, task_ids=[t.id for t in candidates])
|
||||
progress_map = {p.task_id: p for p in progress_records}
|
||||
|
||||
visible: list[tuple[Task, UserTaskProgress | None]] = []
|
||||
for task in candidates:
|
||||
if not _is_within_period(task):
|
||||
continue
|
||||
if not _audience_matches(task, audience):
|
||||
continue
|
||||
if not _promo_group_matches(task, promo_id):
|
||||
continue
|
||||
if task.parent_task_id is not None:
|
||||
parent_done = await _parent_completed_and_claimed(db, user_id=user.id, parent_task_id=task.parent_task_id)
|
||||
if not parent_done:
|
||||
continue
|
||||
visible.append((task, progress_map.get(task.id)))
|
||||
|
||||
return visible
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Event recording (вызывается из бизнес-логики при событиях)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
async def record_event(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
event_type: TaskType | str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> list[UserTaskProgress]:
|
||||
"""Регистрирует событие и обновляет прогресс по соответствующим заданиям.
|
||||
|
||||
Возвращает список прогрессов, которые были изменены (для последующих
|
||||
уведомлений / триггеров).
|
||||
"""
|
||||
payload = payload or {}
|
||||
event_value = event_type.value if isinstance(event_type, TaskType) else event_type
|
||||
|
||||
# Загружаем юзера со связями
|
||||
user_q = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscriptions),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(User.id == user_id)
|
||||
)
|
||||
user = user_q.scalar_one_or_none()
|
||||
if user is None:
|
||||
return []
|
||||
|
||||
if not await _user_eligible(db, user):
|
||||
return []
|
||||
|
||||
audience = _user_audience_for(user)
|
||||
promo_id = _user_promo_group_id(user)
|
||||
|
||||
# Берём только те задания, тип которых совпадает с событием.
|
||||
# Это важно для перфоманса — иначе пришлось бы пробегать все активные.
|
||||
candidates = await tasks_crud.list_active_tasks_for_user(db, user_audience=audience, promo_group_id=promo_id)
|
||||
matching = [t for t in candidates if t.task_type == event_value and _is_within_period(t)]
|
||||
if not matching:
|
||||
return []
|
||||
|
||||
changed: list[UserTaskProgress] = []
|
||||
for task in matching:
|
||||
# Multi-level: пропустить, если родитель не зачищен
|
||||
if task.parent_task_id is not None:
|
||||
parent_done = await _parent_completed_and_claimed(db, user_id=user.id, parent_task_id=task.parent_task_id)
|
||||
if not parent_done:
|
||||
continue
|
||||
progress = await _apply_event_to_progress(db, user, task, payload)
|
||||
if progress is not None:
|
||||
changed.append(progress)
|
||||
|
||||
# Пишем изменения в текущую транзакцию через flush; commit делает caller —
|
||||
# это сохраняет атомарность операции, в рамках которой триггерится событие
|
||||
# (покупка, реферал, etc).
|
||||
if changed:
|
||||
await db.flush()
|
||||
return changed
|
||||
|
||||
|
||||
async def _apply_event_to_progress(
|
||||
db: AsyncSession, user: User, task: Task, payload: dict[str, Any]
|
||||
) -> UserTaskProgress | None:
|
||||
"""Применяет событие к прогрессу конкретного задания.
|
||||
|
||||
Возвращает обновлённый ``UserTaskProgress`` либо None если событие не подошло
|
||||
(например, тариф в задании не совпал с купленным).
|
||||
|
||||
Использует FOR UPDATE-lock на progress row для серилизации параллельных обновлений
|
||||
(защита от lost-update / double-credit на gонке).
|
||||
"""
|
||||
progress, created = await tasks_crud.get_or_create_progress(db, user_id=user.id, task_id=task.id)
|
||||
if not created:
|
||||
# Берём lock на существующую строку, чтобы исключить race condition
|
||||
locked = await tasks_crud.get_progress_by_id_for_update(db, progress.id)
|
||||
if locked is not None:
|
||||
progress = locked
|
||||
|
||||
if progress.completed_at is not None:
|
||||
# Уже выполнено, повторно не обновляем
|
||||
return None
|
||||
|
||||
value, target_meta_match, mode = _compute_increment(task, payload)
|
||||
if not target_meta_match:
|
||||
return None
|
||||
if value == 0:
|
||||
return None
|
||||
|
||||
if mode == 'absolute':
|
||||
# Установить абсолютное значение (но не больше target_value)
|
||||
new_value = min(value, task.target_value)
|
||||
# Не уменьшаем прогресс назад (если admin отозвал тариф — оставим что есть)
|
||||
if new_value <= progress.current_value:
|
||||
return None
|
||||
else:
|
||||
new_value = min(progress.current_value + value, task.target_value)
|
||||
|
||||
if new_value == progress.current_value:
|
||||
return None
|
||||
progress.current_value = new_value
|
||||
progress.updated_at = datetime.now(UTC)
|
||||
|
||||
if progress.current_value >= task.target_value:
|
||||
await tasks_crud.mark_progress_completed(db, progress)
|
||||
|
||||
return progress
|
||||
|
||||
|
||||
def _compute_increment(task: Task, payload: dict[str, Any]) -> tuple[int, bool, str]:
|
||||
"""Возвращает ``(value, target_meta_match, mode)``.
|
||||
|
||||
``mode``:
|
||||
- ``'increment'`` — прибавить ``value`` к ``current_value`` (накопительные счётчики)
|
||||
- ``'absolute'`` — установить ``value`` как абсолютное (например, кол-во активных подписок)
|
||||
|
||||
``target_meta_match`` — соответствует ли событие требованиям задания
|
||||
(например, конкретный ``tariff_id`` для PURCHASE_TARIFF). Если False — событие игнорируется.
|
||||
|
||||
Триал-юзер фильтруется отдельно через ``_user_eligible``; здесь дополнительно блокируем
|
||||
события, помеченные ``payload['is_trial']=True``, чтобы конверсия trial→paid не давала
|
||||
ретроактивно зачитанный прогресс.
|
||||
"""
|
||||
ttype = task.task_type
|
||||
meta = task.target_meta or {}
|
||||
|
||||
if ttype == TaskType.PURCHASE_TARIFF.value:
|
||||
if payload.get('is_trial'):
|
||||
return 0, False, 'increment'
|
||||
target_tariff_id = meta.get('tariff_id')
|
||||
if target_tariff_id is None:
|
||||
return 0, False, 'increment'
|
||||
if int(payload.get('tariff_id') or 0) != int(target_tariff_id):
|
||||
return 0, False, 'increment'
|
||||
return 1, True, 'increment'
|
||||
|
||||
if ttype == TaskType.SUBSCRIBE_CHANNEL.value:
|
||||
target_channel_id = meta.get('channel_id')
|
||||
if target_channel_id is None:
|
||||
return 0, False, 'absolute'
|
||||
if str(payload.get('channel_id') or '') != str(target_channel_id):
|
||||
return 0, False, 'absolute'
|
||||
# Подписался — задание выполнено сразу (1 канал = 1 шаг к цели)
|
||||
return task.target_value, True, 'absolute'
|
||||
|
||||
if ttype == TaskType.TRAFFIC_USED.value:
|
||||
# TRAFFIC_USED обрабатывается ТОЛЬКО через update_traffic_progress (calendar-month
|
||||
# windowing + per-user aggregate). Direct record_event(...TRAFFIC_USED) — no-op.
|
||||
return 0, False, 'absolute'
|
||||
|
||||
if ttype == TaskType.REFERRALS_INVITED.value:
|
||||
return 1, True, 'increment'
|
||||
|
||||
if ttype == TaskType.PURCHASE_PERIOD.value:
|
||||
if payload.get('is_trial'):
|
||||
return 0, False, 'increment'
|
||||
target_period = int(meta.get('period_days') or 0)
|
||||
period = int(payload.get('period_days') or 0)
|
||||
if period < target_period:
|
||||
return 0, False, 'increment'
|
||||
return 1, True, 'increment'
|
||||
|
||||
if ttype == TaskType.SPEND_AMOUNT.value:
|
||||
if payload.get('is_trial'):
|
||||
return 0, False, 'increment'
|
||||
amount_kopeks = int(payload.get('amount_kopeks') or 0)
|
||||
if amount_kopeks <= 0:
|
||||
return 0, True, 'increment'
|
||||
return amount_kopeks, True, 'increment'
|
||||
|
||||
if ttype == TaskType.MULTI_TARIFF.value:
|
||||
# Абсолютное число активных платных подписок пользователя в текущий момент.
|
||||
count = int(payload.get('active_paid_subscriptions') or 0)
|
||||
if count <= 0:
|
||||
return 0, True, 'absolute'
|
||||
return count, True, 'absolute'
|
||||
|
||||
if ttype == TaskType.GIFT_PURCHASED.value:
|
||||
# Достаточно одного подарка — устанавливаем target_value сразу
|
||||
return task.target_value, True, 'absolute'
|
||||
|
||||
if ttype == TaskType.GIFTS_COUNT.value:
|
||||
return 1, True, 'increment'
|
||||
|
||||
return 0, False, 'increment'
|
||||
|
||||
return 0, False
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Claim reward
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
async def claim_reward(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
task_id: int,
|
||||
chosen_subscription_id: int | None = None,
|
||||
chosen_reward_type: TaskRewardType | str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Выдаёт награду пользователю за выполненное задание.
|
||||
|
||||
``chosen_subscription_id`` — для multi-tariff: какой подписке начислить дни.
|
||||
``chosen_reward_type`` — если задание ``allow_user_choice=True``, юзер
|
||||
может выбрать тип награды.
|
||||
|
||||
Возвращает meta-словарь о выданной награде. Бросает ValueError при
|
||||
некорректном вызове (не выполнено, уже выдано, и т.д.).
|
||||
"""
|
||||
progress = await tasks_crud.get_progress_for_update(db, user_id=user_id, task_id=task_id)
|
||||
if progress is None:
|
||||
raise ValueError('progress_not_found')
|
||||
if progress.completed_at is None:
|
||||
raise ValueError('not_completed')
|
||||
if progress.claimed_at is not None:
|
||||
raise ValueError('already_claimed')
|
||||
|
||||
task_q = await db.execute(select(Task).where(Task.id == task_id))
|
||||
task = task_q.scalar_one_or_none()
|
||||
if task is None:
|
||||
raise ValueError('task_not_found')
|
||||
|
||||
user_q = await db.execute(select(User).options(selectinload(User.subscriptions)).where(User.id == user_id))
|
||||
user = user_q.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise ValueError('user_not_found')
|
||||
|
||||
# Триал-юзер не может клеймить
|
||||
if not await _user_eligible(db, user):
|
||||
raise ValueError('user_not_eligible')
|
||||
|
||||
# chosen_reward_type разрешён только если task.allow_user_choice=True. Без этой проверки
|
||||
# юзер мог бы подменить balance-награду на subscription_days (или наоборот) и получить
|
||||
# значение, не предусмотренное админом. См. claim_reward в tasks_service.
|
||||
chosen_value = (
|
||||
chosen_reward_type.value if isinstance(chosen_reward_type, TaskRewardType) else chosen_reward_type
|
||||
)
|
||||
if chosen_value is not None and chosen_value != task.reward_type and not task.allow_user_choice:
|
||||
raise ValueError('user_choice_not_allowed')
|
||||
|
||||
reward_type = chosen_value or task.reward_type
|
||||
|
||||
granted_meta: dict[str, Any] = {'type': reward_type}
|
||||
|
||||
if reward_type == TaskRewardType.BALANCE.value:
|
||||
granted_meta.update(await _grant_balance_reward(db, user=user, task=task))
|
||||
elif reward_type == TaskRewardType.SUBSCRIPTION_DAYS.value:
|
||||
granted_meta.update(
|
||||
await _grant_subscription_days_reward(
|
||||
db,
|
||||
user=user,
|
||||
task=task,
|
||||
chosen_subscription_id=chosen_subscription_id,
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise ValueError(f'unknown_reward_type:{reward_type}')
|
||||
|
||||
await tasks_crud.mark_progress_claimed(db, progress, reward_granted_meta=granted_meta)
|
||||
await db.commit()
|
||||
logger.info(
|
||||
'Награда за задание выдана',
|
||||
user_id=user_id,
|
||||
task_id=task_id,
|
||||
reward_type=reward_type,
|
||||
meta=granted_meta,
|
||||
)
|
||||
return granted_meta
|
||||
|
||||
|
||||
async def _grant_balance_reward(db: AsyncSession, *, user: User, task: Task) -> dict[str, Any]:
|
||||
"""Начисляет деньги на баланс пользователя через ``add_user_balance``.
|
||||
|
||||
Не делает commit — caller (``claim_reward``) сам закрывает транзакцию.
|
||||
"""
|
||||
from app.database.crud.user import add_user_balance
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
|
||||
amount_kopeks = int(task.reward_value or 0)
|
||||
if amount_kopeks <= 0:
|
||||
raise ValueError('invalid_reward_amount')
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
|
||||
# add_user_balance берёт FOR UPDATE на user, создаёт транзакцию.
|
||||
# transaction_type=DEPOSIT (единственный безопасный non-monetary тип в проекте — у
|
||||
# TransactionType нет MANUAL). payment_method=MANUAL исключает транзакцию из revenue
|
||||
# графиков (см. REAL_PAYMENT_METHODS).
|
||||
success = await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
amount_kopeks,
|
||||
description=f'Награда за задание #{task.id}',
|
||||
create_transaction=True,
|
||||
transaction_type=TransactionType.DEPOSIT,
|
||||
payment_method=PaymentMethod.MANUAL,
|
||||
commit=False,
|
||||
)
|
||||
if not success:
|
||||
raise ValueError('balance_grant_failed')
|
||||
|
||||
# После lock_for_update в add_user_balance объект user обновлён в session.
|
||||
await db.refresh(user)
|
||||
|
||||
return {
|
||||
'value': amount_kopeks,
|
||||
'old_balance': old_balance,
|
||||
'new_balance': user.balance_kopeks,
|
||||
}
|
||||
|
||||
|
||||
async def _grant_subscription_days_reward(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user: User,
|
||||
task: Task,
|
||||
chosen_subscription_id: int | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Начисляет бонусные дни на платную подписку.
|
||||
|
||||
Логика выбора подписки:
|
||||
1. Если у задания ``reward_meta['tariff_id']`` задан — ищем платную подписку
|
||||
пользователя с этим тарифом.
|
||||
2. Иначе если у юзера несколько платных подписок и ``chosen_subscription_id``
|
||||
не задан — ошибка ``need_choose_subscription``.
|
||||
3. Иначе — единственная платная подписка.
|
||||
|
||||
Количество дней берётся из ``task.reward_value`` (приоритетно), либо из
|
||||
``Tariff.bonus_days_per_purchase`` если ``reward_value=0`` и задан target tariff.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
# Только активные платные подписки — продлевать expired/disabled нельзя
|
||||
active_paid_subs = [
|
||||
s
|
||||
for s in user.subscriptions
|
||||
if not getattr(s, 'is_trial', False) and getattr(s, 'status', None) == SubscriptionStatus.ACTIVE.value
|
||||
]
|
||||
if not active_paid_subs:
|
||||
raise ValueError('no_paid_subscription')
|
||||
|
||||
target_tariff_id = (task.reward_meta or {}).get('tariff_id')
|
||||
candidate_subs: list[Subscription]
|
||||
if target_tariff_id is not None:
|
||||
candidate_subs = [s for s in active_paid_subs if s.tariff_id == int(target_tariff_id)]
|
||||
if not candidate_subs:
|
||||
raise ValueError('no_subscription_with_target_tariff')
|
||||
else:
|
||||
candidate_subs = active_paid_subs
|
||||
|
||||
if chosen_subscription_id is not None:
|
||||
chosen = next((s for s in candidate_subs if s.id == int(chosen_subscription_id)), None)
|
||||
if chosen is None:
|
||||
raise ValueError('chosen_subscription_invalid')
|
||||
target_sub = chosen
|
||||
elif len(candidate_subs) == 1:
|
||||
target_sub = candidate_subs[0]
|
||||
else:
|
||||
raise ValueError('need_choose_subscription')
|
||||
|
||||
# Берём FOR UPDATE на выбранную подписку — защита от lost-update между
|
||||
# параллельными claim'ами на одной подписке (например, два task с разными reward).
|
||||
locked_sub_q = await db.execute(
|
||||
select(Subscription)
|
||||
.where(Subscription.id == target_sub.id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
locked_sub = locked_sub_q.scalar_one_or_none()
|
||||
if locked_sub is None:
|
||||
raise ValueError('chosen_subscription_invalid')
|
||||
target_sub = locked_sub
|
||||
|
||||
# Количество дней: task.reward_value > 0 имеет приоритет;
|
||||
# иначе fallback на Tariff.bonus_days_per_purchase (target → выбранной подписки).
|
||||
days = int(task.reward_value or 0)
|
||||
if days <= 0 and target_tariff_id is not None:
|
||||
tariff_q = await db.execute(select(Tariff).where(Tariff.id == int(target_tariff_id)))
|
||||
tariff = tariff_q.scalar_one_or_none()
|
||||
if tariff is not None:
|
||||
days = int(getattr(tariff, 'bonus_days_per_purchase', 0) or 0)
|
||||
if days <= 0 and target_sub.tariff_id is not None:
|
||||
tariff_q = await db.execute(select(Tariff).where(Tariff.id == target_sub.tariff_id))
|
||||
tariff = tariff_q.scalar_one_or_none()
|
||||
if tariff is not None:
|
||||
days = int(getattr(tariff, 'bonus_days_per_purchase', 0) or 0)
|
||||
if days <= 0:
|
||||
raise ValueError('invalid_reward_days')
|
||||
|
||||
old_end_date = target_sub.end_date
|
||||
target_sub.end_date = (
|
||||
target_sub.end_date + timedelta(days=days)
|
||||
if target_sub.end_date is not None
|
||||
else datetime.now(UTC) + timedelta(days=days)
|
||||
)
|
||||
target_sub.updated_at = datetime.now(UTC)
|
||||
|
||||
return {
|
||||
'value': days,
|
||||
'subscription_id': target_sub.id,
|
||||
'tariff_id': target_sub.tariff_id,
|
||||
'old_end_date': old_end_date.isoformat() if old_end_date else None,
|
||||
'new_end_date': target_sub.end_date.isoformat() if target_sub.end_date else None,
|
||||
}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Helpers (для интеграции из других сервисов)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def _current_month_start(now: datetime | None = None) -> datetime:
|
||||
now = now or datetime.now(UTC)
|
||||
return now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
|
||||
async def update_traffic_progress(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
traffic_used_gb_total: float | None = None,
|
||||
) -> list[UserTaskProgress]:
|
||||
"""Обновляет прогресс по заданиям типа TRAFFIC_USED (за календарный месяц на пользователя).
|
||||
|
||||
``traffic_used_gb_total`` — суммарный трафик по всем платным подпискам пользователя.
|
||||
Если ``None`` — считаем сами через ``user.subscriptions``.
|
||||
|
||||
Логика per-user (НЕ per-subscription):
|
||||
- При пересечении календарного месяца (UTC): фиксируем ``baseline_value =
|
||||
текущий cumulative`` и ``current_value = 0`` (новый период начался с нуля).
|
||||
- На каждом обновлении: ``current_value = min(target, max(0, current - baseline))``.
|
||||
- Если cumulative уменьшился (panel reset / удаление подписки): сдвигаем
|
||||
``baseline`` вниз, чтобы прогресс не уехал в минус и продолжал считаться корректно.
|
||||
"""
|
||||
user_q = await db.execute(
|
||||
select(User).options(selectinload(User.subscriptions), selectinload(User.promo_group)).where(User.id == user_id)
|
||||
)
|
||||
user = user_q.scalar_one_or_none()
|
||||
if user is None:
|
||||
return []
|
||||
if not await _user_eligible(db, user):
|
||||
return []
|
||||
|
||||
# Считаем суммарный использованный трафик по платным подпискам
|
||||
if traffic_used_gb_total is None:
|
||||
traffic_used_gb_total = sum(
|
||||
float(getattr(sub, 'traffic_used_gb', 0) or 0)
|
||||
for sub in user.subscriptions
|
||||
if not getattr(sub, 'is_trial', False)
|
||||
)
|
||||
|
||||
audience = _user_audience_for(user)
|
||||
promo_id = _user_promo_group_id(user)
|
||||
|
||||
candidates = await tasks_crud.list_active_tasks_for_user(db, user_audience=audience, promo_group_id=promo_id)
|
||||
matching = [t for t in candidates if t.task_type == TaskType.TRAFFIC_USED.value and _is_within_period(t)]
|
||||
if not matching:
|
||||
return []
|
||||
|
||||
month_start = _current_month_start()
|
||||
used_gb_int = int(traffic_used_gb_total) # GB округляем вниз
|
||||
changed: list[UserTaskProgress] = []
|
||||
|
||||
for task in matching:
|
||||
if task.parent_task_id is not None:
|
||||
parent_done = await _parent_completed_and_claimed(db, user_id=user.id, parent_task_id=task.parent_task_id)
|
||||
if not parent_done:
|
||||
continue
|
||||
|
||||
progress, created = await tasks_crud.get_or_create_progress(db, user_id=user.id, task_id=task.id)
|
||||
if not created:
|
||||
locked = await tasks_crud.get_progress_by_id_for_update(db, progress.id)
|
||||
if locked is not None:
|
||||
progress = locked
|
||||
|
||||
if progress.completed_at is not None:
|
||||
continue
|
||||
|
||||
# Сброс при пересечении календарного месяца
|
||||
if progress.period_started_at is None or progress.period_started_at < month_start:
|
||||
progress.period_started_at = month_start
|
||||
progress.baseline_value = used_gb_int
|
||||
progress.current_value = 0
|
||||
progress.updated_at = datetime.now(UTC)
|
||||
|
||||
delta = used_gb_int - progress.baseline_value
|
||||
if delta < 0:
|
||||
# Cumulative ушёл вниз (удалили подписку, panel reset). Сдвигаем baseline,
|
||||
# сохраняя current_value (юзер уже потратил эти GB в этом месяце).
|
||||
progress.baseline_value = used_gb_int
|
||||
progress.updated_at = datetime.now(UTC)
|
||||
continue
|
||||
|
||||
new_value = min(delta, task.target_value)
|
||||
if new_value != progress.current_value:
|
||||
progress.current_value = new_value
|
||||
progress.updated_at = datetime.now(UTC)
|
||||
changed.append(progress)
|
||||
|
||||
if progress.current_value >= task.target_value:
|
||||
await tasks_crud.mark_progress_completed(db, progress)
|
||||
|
||||
if changed:
|
||||
await db.flush()
|
||||
return changed
|
||||
|
||||
|
||||
async def trigger_paid_purchase_tasks(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
tariff_id: int | None,
|
||||
period_days: int,
|
||||
amount_kopeks: int,
|
||||
subscription_id: int | None,
|
||||
is_trial: bool = False,
|
||||
) -> None:
|
||||
"""Триггерит PURCHASE_TARIFF / PURCHASE_PERIOD / MULTI_TARIFF после платной покупки.
|
||||
|
||||
Используется из bot handlers и cabinet submit_purchase. Делает явный db.commit() —
|
||||
record_event сам коммит не делает.
|
||||
|
||||
Безопасно — все исключения логируются как warning, но не пробрасываются.
|
||||
"""
|
||||
try:
|
||||
from app.database.crud.subscription import get_active_subscriptions_by_user_id
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
common_payload = {
|
||||
'is_trial': is_trial,
|
||||
'tariff_id': tariff_id,
|
||||
'period_days': period_days,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'subscription_id': subscription_id,
|
||||
}
|
||||
|
||||
await record_event(
|
||||
db, user_id=user_id, event_type=TaskType.PURCHASE_TARIFF, payload=common_payload
|
||||
)
|
||||
await record_event(
|
||||
db, user_id=user_id, event_type=TaskType.PURCHASE_PERIOD, payload=common_payload
|
||||
)
|
||||
|
||||
try:
|
||||
user_subs = await get_active_subscriptions_by_user_id(db, user_id)
|
||||
active_paid = sum(
|
||||
1
|
||||
for s in user_subs
|
||||
if not getattr(s, 'is_trial', False)
|
||||
and getattr(s, 'status', None) == SubscriptionStatus.ACTIVE.value
|
||||
)
|
||||
except Exception:
|
||||
active_paid = 0
|
||||
|
||||
if active_paid > 0:
|
||||
await record_event(
|
||||
db,
|
||||
user_id=user_id,
|
||||
event_type=TaskType.MULTI_TARIFF,
|
||||
payload={'active_paid_subscriptions': active_paid, **common_payload},
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as exc:
|
||||
# Сессия может быть в poisoned state (например, IntegrityError race на uq_user_task) —
|
||||
# явно откатываем, чтобы caller мог продолжить работу с сессией.
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning(
|
||||
'Tasks: ошибка trigger_paid_purchase_tasks',
|
||||
user_id=user_id,
|
||||
error=exc,
|
||||
)
|
||||
|
||||
|
||||
async def has_available_tasks(db: AsyncSession, user: User) -> bool:
|
||||
"""Быстрый чек: есть ли у пользователя хотя бы одно доступное задание.
|
||||
|
||||
Используется фронтом для условного показа вкладки «Задания».
|
||||
"""
|
||||
visible = await get_available_tasks_for_user(db, user)
|
||||
return len(visible) > 0
|
||||
|
||||
|
||||
async def count_completed_unclaimed(db: AsyncSession, *, user_id: int) -> int:
|
||||
"""Количество выполненных, но не полученных наград (для бейджа на иконке)."""
|
||||
from sqlalchemy import func
|
||||
|
||||
result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(UserTaskProgress)
|
||||
.where(
|
||||
UserTaskProgress.user_id == user_id,
|
||||
UserTaskProgress.completed_at.isnot(None),
|
||||
UserTaskProgress.claimed_at.is_(None),
|
||||
)
|
||||
)
|
||||
return int(result.scalar() or 0)
|
||||
+1
-153
@@ -232,35 +232,7 @@ def get_available_payment_methods() -> list[dict[str, str]]:
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_aurapay_sbp_enabled():
|
||||
sbp_name = settings.get_aurapay_sbp_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'aurapay_sbp',
|
||||
'name': sbp_name,
|
||||
'icon': '📱',
|
||||
'description': f'через {sbp_name}',
|
||||
'callback': 'topup_aurapay_sbp',
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_aurapay_card_enabled():
|
||||
card_name = settings.get_aurapay_card_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'aurapay_card',
|
||||
'name': card_name,
|
||||
'icon': '💳',
|
||||
'description': f'через {card_name}',
|
||||
'callback': 'topup_aurapay_card',
|
||||
}
|
||||
)
|
||||
|
||||
if (
|
||||
settings.is_aurapay_enabled()
|
||||
and not settings.is_aurapay_sbp_enabled()
|
||||
and not settings.is_aurapay_card_enabled()
|
||||
):
|
||||
if settings.is_aurapay_enabled():
|
||||
aurapay_name = settings.get_aurapay_display_name()
|
||||
methods.append(
|
||||
{
|
||||
@@ -272,99 +244,6 @@ def get_available_payment_methods() -> list[dict[str, str]]:
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_etoplatezhi_sbp_enabled():
|
||||
sbp_name = settings.get_etoplatezhi_sbp_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'etoplatezhi_sbp',
|
||||
'name': sbp_name,
|
||||
'icon': '📱',
|
||||
'description': f'через {sbp_name}',
|
||||
'callback': 'topup_etoplatezhi_sbp',
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_etoplatezhi_card_enabled():
|
||||
card_name = settings.get_etoplatezhi_card_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'etoplatezhi_card',
|
||||
'name': card_name,
|
||||
'icon': '💳',
|
||||
'description': f'через {card_name}',
|
||||
'callback': 'topup_etoplatezhi_card',
|
||||
}
|
||||
)
|
||||
|
||||
if (
|
||||
settings.is_etoplatezhi_enabled()
|
||||
and not settings.is_etoplatezhi_sbp_enabled()
|
||||
and not settings.is_etoplatezhi_card_enabled()
|
||||
):
|
||||
etoplatezhi_name = settings.get_etoplatezhi_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'etoplatezhi',
|
||||
'name': etoplatezhi_name,
|
||||
'icon': '💳',
|
||||
'description': f'через {etoplatezhi_name}',
|
||||
'callback': 'topup_etoplatezhi',
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_antilopay_sbp_enabled():
|
||||
sbp_name = settings.get_antilopay_sbp_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'antilopay_sbp',
|
||||
'name': sbp_name,
|
||||
'icon': '📱',
|
||||
'description': f'через {sbp_name}',
|
||||
'callback': 'topup_antilopay_sbp',
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_antilopay_card_enabled():
|
||||
card_name = settings.get_antilopay_card_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'antilopay_card',
|
||||
'name': card_name,
|
||||
'icon': '💳',
|
||||
'description': f'через {card_name}',
|
||||
'callback': 'topup_antilopay_card',
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_antilopay_sberpay_enabled():
|
||||
sberpay_name = settings.get_antilopay_sberpay_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'antilopay_sberpay',
|
||||
'name': sberpay_name,
|
||||
'icon': '💳',
|
||||
'description': f'через {sberpay_name}',
|
||||
'callback': 'topup_antilopay_sberpay',
|
||||
}
|
||||
)
|
||||
|
||||
if (
|
||||
settings.is_antilopay_enabled()
|
||||
and not settings.is_antilopay_sbp_enabled()
|
||||
and not settings.is_antilopay_card_enabled()
|
||||
and not settings.is_antilopay_sberpay_enabled()
|
||||
):
|
||||
antilopay_name = settings.get_antilopay_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'antilopay',
|
||||
'name': antilopay_name,
|
||||
'icon': '💳',
|
||||
'description': f'через {antilopay_name}',
|
||||
'callback': 'topup_antilopay',
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_support_topup_enabled():
|
||||
methods.append(
|
||||
{
|
||||
@@ -502,24 +381,6 @@ def is_payment_method_available(method_id: str) -> bool:
|
||||
return settings.is_overpay_enabled()
|
||||
if method_id == 'aurapay':
|
||||
return settings.is_aurapay_enabled()
|
||||
if method_id == 'aurapay_sbp':
|
||||
return settings.is_aurapay_sbp_enabled()
|
||||
if method_id == 'aurapay_card':
|
||||
return settings.is_aurapay_card_enabled()
|
||||
if method_id == 'etoplatezhi':
|
||||
return settings.is_etoplatezhi_enabled()
|
||||
if method_id == 'etoplatezhi_sbp':
|
||||
return settings.is_etoplatezhi_sbp_enabled()
|
||||
if method_id == 'etoplatezhi_card':
|
||||
return settings.is_etoplatezhi_card_enabled()
|
||||
if method_id == 'antilopay':
|
||||
return settings.is_antilopay_enabled()
|
||||
if method_id == 'antilopay_sbp':
|
||||
return settings.is_antilopay_sbp_enabled()
|
||||
if method_id == 'antilopay_card':
|
||||
return settings.is_antilopay_card_enabled()
|
||||
if method_id == 'antilopay_sberpay':
|
||||
return settings.is_antilopay_sberpay_enabled()
|
||||
if method_id == 'support':
|
||||
return settings.is_support_topup_enabled()
|
||||
return False
|
||||
@@ -548,15 +409,6 @@ def get_payment_method_status() -> dict[str, bool]:
|
||||
'rollypay': settings.is_rollypay_enabled(),
|
||||
'overpay': settings.is_overpay_enabled(),
|
||||
'aurapay': settings.is_aurapay_enabled(),
|
||||
'aurapay_sbp': settings.is_aurapay_sbp_enabled(),
|
||||
'aurapay_card': settings.is_aurapay_card_enabled(),
|
||||
'etoplatezhi': settings.is_etoplatezhi_enabled(),
|
||||
'etoplatezhi_sbp': settings.is_etoplatezhi_sbp_enabled(),
|
||||
'etoplatezhi_card': settings.is_etoplatezhi_card_enabled(),
|
||||
'antilopay': settings.is_antilopay_enabled(),
|
||||
'antilopay_sbp': settings.is_antilopay_sbp_enabled(),
|
||||
'antilopay_card': settings.is_antilopay_card_enabled(),
|
||||
'antilopay_sberpay': settings.is_antilopay_sberpay_enabled(),
|
||||
'support': settings.is_support_topup_enabled(),
|
||||
}
|
||||
|
||||
@@ -602,8 +454,4 @@ def get_enabled_payment_methods_count() -> int:
|
||||
count += 1
|
||||
if settings.is_aurapay_enabled():
|
||||
count += 1
|
||||
if settings.is_etoplatezhi_enabled():
|
||||
count += 1
|
||||
if settings.is_antilopay_enabled():
|
||||
count += 1
|
||||
return count
|
||||
|
||||
@@ -1469,241 +1469,6 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
|
||||
routes_registered = True
|
||||
|
||||
# Etoplatezhi webhook
|
||||
if settings.is_etoplatezhi_enabled():
|
||||
|
||||
@router.get(settings.ETOPLATEZHI_WEBHOOK_PATH)
|
||||
async def etoplatezhi_health() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'etoplatezhi_webhook',
|
||||
'enabled': settings.is_etoplatezhi_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@router.post(settings.ETOPLATEZHI_WEBHOOK_PATH)
|
||||
async def etoplatezhi_webhook(request: Request) -> JSONResponse:
|
||||
try:
|
||||
raw_body = await request.body()
|
||||
payload = json.loads(raw_body)
|
||||
except Exception as parse_error:
|
||||
logger.error('Etoplatezhi webhook: failed to parse JSON', parse_error=parse_error)
|
||||
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Подпись внутри JSON body (поле signature)
|
||||
from app.services.etoplatezhi_service import etoplatezhi_service
|
||||
|
||||
if not etoplatezhi_service.verify_callback_signature(payload):
|
||||
logger.warning('Etoplatezhi webhook: invalid signature')
|
||||
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_etoplatezhi_callback',
|
||||
)
|
||||
if not success:
|
||||
logger.error(
|
||||
'Etoplatezhi webhook processing failed',
|
||||
data=payload.get('payment', {}).get('id'),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('Etoplatezhi webhook processing error', error=e)
|
||||
# Always return 200 — Etoplatezhi expects 200 for valid signature
|
||||
return JSONResponse({'status': True}, status_code=status.HTTP_200_OK)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
# Antilopay webhook
|
||||
if settings.is_antilopay_enabled():
|
||||
|
||||
@router.get(settings.ANTILOPAY_WEBHOOK_PATH)
|
||||
async def antilopay_health() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'antilopay_webhook',
|
||||
'enabled': settings.is_antilopay_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@router.post(settings.ANTILOPAY_WEBHOOK_PATH)
|
||||
async def antilopay_webhook(request: Request) -> JSONResponse:
|
||||
try:
|
||||
raw_body = await request.body()
|
||||
payload = json.loads(raw_body)
|
||||
except Exception as parse_error:
|
||||
logger.error('Antilopay webhook: failed to parse JSON', parse_error=parse_error)
|
||||
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Подпись в заголовке X-Apay-Callback, верифицируется публичным ключом
|
||||
from app.services.antilopay_service import antilopay_service
|
||||
|
||||
callback_signature = request.headers.get('X-Apay-Callback') or ''
|
||||
if not antilopay_service.verify_callback_signature(raw_body, callback_signature):
|
||||
logger.warning('Antilopay webhook: invalid signature')
|
||||
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_antilopay_callback',
|
||||
)
|
||||
if not success:
|
||||
logger.error(
|
||||
'Antilopay webhook processing failed',
|
||||
data=payload.get('payment_id'),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('Antilopay webhook processing error', error=e)
|
||||
# Always return 200 — Antilopay retries every 3min for 1hr on non-200
|
||||
return JSONResponse({'status': True}, status_code=status.HTTP_200_OK)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
# Jupiter webhook (FPGate P2P v2.1)
|
||||
if settings.is_jupiter_enabled():
|
||||
|
||||
@router.get(settings.JUPITER_WEBHOOK_PATH)
|
||||
async def jupiter_health() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'jupiter_webhook',
|
||||
'enabled': settings.is_jupiter_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@router.post(settings.JUPITER_WEBHOOK_PATH)
|
||||
async def jupiter_webhook(request: Request) -> JSONResponse:
|
||||
try:
|
||||
raw_body = await request.body()
|
||||
payload = json.loads(raw_body)
|
||||
except Exception as parse_error:
|
||||
logger.error('Jupiter webhook: failed to parse JSON', parse_error=parse_error)
|
||||
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
from app.services.jupiter_service import jupiter_service
|
||||
|
||||
if not jupiter_service.verify_callback_signature(payload):
|
||||
logger.warning('Jupiter webhook: invalid signature')
|
||||
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_jupiter_callback',
|
||||
)
|
||||
if not success:
|
||||
logger.error(
|
||||
'Jupiter webhook processing failed',
|
||||
transaction_id=payload.get('transaction_id'),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('Jupiter webhook processing error', error=e)
|
||||
# FPGate ожидает HTTP 200 как подтверждение приёма callback
|
||||
return JSONResponse({'status': 'ok'}, status_code=status.HTTP_200_OK)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
# Lava webhook (Lava Business)
|
||||
if settings.is_lava_enabled():
|
||||
|
||||
@router.get(settings.LAVA_WEBHOOK_PATH)
|
||||
async def lava_health() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'lava_webhook',
|
||||
'enabled': settings.is_lava_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@router.post(settings.LAVA_WEBHOOK_PATH)
|
||||
async def lava_webhook(request: Request) -> JSONResponse:
|
||||
try:
|
||||
raw_body = await request.body()
|
||||
payload = json.loads(raw_body)
|
||||
except Exception as parse_error:
|
||||
logger.error('Lava webhook: failed to parse JSON', parse_error=parse_error)
|
||||
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
from app.services.lava_service import lava_service
|
||||
|
||||
received_signature = (request.headers.get('Authorization') or '').strip()
|
||||
if not lava_service.verify_webhook_signature(raw_body, received_signature):
|
||||
logger.warning('Lava webhook: invalid signature')
|
||||
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_lava_callback',
|
||||
)
|
||||
if not success:
|
||||
logger.error(
|
||||
'Lava webhook processing failed',
|
||||
order_id=payload.get('order_id'),
|
||||
invoice_id=payload.get('invoice_id'),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('Lava webhook processing error', error=e)
|
||||
# Lava ожидает HTTP 200 как подтверждение приёма; иначе будет повтор до 5 раз раз в 150с
|
||||
return JSONResponse({'status': 'ok'}, status_code=status.HTTP_200_OK)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
# Donut webhook (Donut P2P)
|
||||
if settings.is_donut_enabled():
|
||||
|
||||
@router.get(settings.DONUT_WEBHOOK_PATH)
|
||||
async def donut_health() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'donut_webhook',
|
||||
'enabled': settings.is_donut_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@router.post(settings.DONUT_WEBHOOK_PATH)
|
||||
async def donut_webhook(request: Request) -> JSONResponse:
|
||||
try:
|
||||
raw_body = await request.body()
|
||||
payload = json.loads(raw_body)
|
||||
except Exception as parse_error:
|
||||
logger.error('Donut webhook: failed to parse JSON', parse_error=parse_error)
|
||||
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
from app.services.donut_service import donut_service
|
||||
|
||||
if not donut_service.verify_callback_signature(payload):
|
||||
logger.warning('Donut webhook: invalid signature')
|
||||
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_donut_callback',
|
||||
)
|
||||
if not success:
|
||||
logger.error(
|
||||
'Donut webhook processing failed',
|
||||
transaction_id=payload.get('transaction_id'),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('Donut webhook processing error', error=e)
|
||||
# Donut ожидает HTTP 200 как подтверждение приёма callback
|
||||
return JSONResponse({'status': 'ok'}, status_code=status.HTTP_200_OK)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
if routes_registered:
|
||||
|
||||
@router.get('/health/payment-webhooks')
|
||||
@@ -1728,11 +1493,6 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
'rollypay_enabled': settings.is_rollypay_enabled(),
|
||||
'overpay_enabled': settings.is_overpay_enabled(),
|
||||
'aurapay_enabled': settings.is_aurapay_enabled(),
|
||||
'etoplatezhi_enabled': settings.is_etoplatezhi_enabled(),
|
||||
'antilopay_enabled': settings.is_antilopay_enabled(),
|
||||
'jupiter_enabled': settings.is_jupiter_enabled(),
|
||||
'donut_enabled': settings.is_donut_enabled(),
|
||||
'lava_enabled': settings.is_lava_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
"""add apple_transactions table
|
||||
|
||||
Revision ID: 0068
|
||||
Revises: 0067
|
||||
Create Date: 2026-04-11
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0068'
|
||||
down_revision: Union[str, None] = '0067'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'apple_transactions',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('transaction_id', sa.String(64), unique=True, nullable=False),
|
||||
sa.Column('original_transaction_id', sa.String(64), nullable=True),
|
||||
sa.Column('product_id', sa.String(128), nullable=False),
|
||||
sa.Column('bundle_id', sa.String(255), nullable=False),
|
||||
sa.Column('amount_kopeks', sa.Integer(), nullable=False),
|
||||
sa.Column('environment', sa.String(16), nullable=False),
|
||||
sa.Column('status', sa.String(50), server_default='verified'),
|
||||
sa.Column('is_paid', sa.Boolean(), server_default=sa.text('true')),
|
||||
sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('refunded_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('transaction_id_fk', sa.Integer(), sa.ForeignKey('transactions.id'), nullable=True),
|
||||
sa.Column('metadata_json', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_index('ix_apple_transactions_transaction_id', 'apple_transactions', ['transaction_id'])
|
||||
op.create_index('ix_apple_transactions_original_transaction_id', 'apple_transactions', ['original_transaction_id'])
|
||||
op.create_index('ix_apple_transactions_user_id', 'apple_transactions', ['user_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_apple_transactions_user_id', table_name='apple_transactions')
|
||||
op.drop_index('ix_apple_transactions_original_transaction_id', table_name='apple_transactions')
|
||||
op.drop_index('ix_apple_transactions_transaction_id', table_name='apple_transactions')
|
||||
op.drop_table('apple_transactions')
|
||||
@@ -1,45 +0,0 @@
|
||||
"""create etoplatezhi_payments table
|
||||
|
||||
Revision ID: 0069
|
||||
Revises: 0068
|
||||
Create Date: 2026-05-04
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0069'
|
||||
down_revision: Union[str, None] = '0068'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'etoplatezhi_payments',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True),
|
||||
sa.Column('order_id', sa.String(64), unique=True, nullable=False, index=True),
|
||||
sa.Column('etoplatezhi_payment_id', sa.String(128), unique=True, nullable=True, index=True),
|
||||
sa.Column('amount_kopeks', sa.Integer(), nullable=False),
|
||||
sa.Column('currency', sa.String(10), nullable=False, server_default='RUB'),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('status', sa.String(32), nullable=False, server_default='pending'),
|
||||
sa.Column('is_paid', sa.Boolean(), server_default=sa.text('false'), nullable=False),
|
||||
sa.Column('payment_url', sa.Text(), nullable=True),
|
||||
sa.Column('payment_method', sa.String(32), nullable=True),
|
||||
sa.Column('metadata_json', sa.JSON(), nullable=True),
|
||||
sa.Column('callback_payload', sa.JSON(), nullable=True),
|
||||
sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('transaction_id', sa.Integer(), sa.ForeignKey('transactions.id'), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('etoplatezhi_payments')
|
||||
@@ -1,45 +0,0 @@
|
||||
"""create antilopay_payments table
|
||||
|
||||
Revision ID: 0070
|
||||
Revises: 0069
|
||||
Create Date: 2026-05-04
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0070'
|
||||
down_revision: Union[str, None] = '0069'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'antilopay_payments',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True),
|
||||
sa.Column('order_id', sa.String(64), unique=True, nullable=False, index=True),
|
||||
sa.Column('antilopay_payment_id', sa.String(128), unique=True, nullable=True, index=True),
|
||||
sa.Column('amount_kopeks', sa.Integer(), nullable=False),
|
||||
sa.Column('currency', sa.String(10), nullable=False, server_default='RUB'),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('status', sa.String(32), nullable=False, server_default='pending'),
|
||||
sa.Column('is_paid', sa.Boolean(), server_default=sa.text('false'), nullable=False),
|
||||
sa.Column('payment_url', sa.Text(), nullable=True),
|
||||
sa.Column('payment_method', sa.String(32), nullable=True),
|
||||
sa.Column('metadata_json', sa.JSON(), nullable=True),
|
||||
sa.Column('callback_payload', sa.JSON(), nullable=True),
|
||||
sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('transaction_id', sa.Integer(), sa.ForeignKey('transactions.id'), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('antilopay_payments')
|
||||
@@ -1,25 +0,0 @@
|
||||
"""add last_revoke_at to subscriptions
|
||||
|
||||
Revision ID: 0071
|
||||
Revises: 0070
|
||||
Create Date: 2026-05-04
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0071'
|
||||
down_revision: Union[str, None] = '0070'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('subscriptions', sa.Column('last_revoke_at', sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('subscriptions', 'last_revoke_at')
|
||||
@@ -1,45 +0,0 @@
|
||||
"""create jupiter_payments table
|
||||
|
||||
Revision ID: 0072
|
||||
Revises: 0071
|
||||
Create Date: 2026-05-04
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0072'
|
||||
down_revision: Union[str, None] = '0071'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'jupiter_payments',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True),
|
||||
sa.Column('order_id', sa.String(64), unique=True, nullable=False, index=True),
|
||||
sa.Column('jupiter_transaction_id', sa.String(128), unique=True, nullable=True, index=True),
|
||||
sa.Column('amount_kopeks', sa.Integer(), nullable=False),
|
||||
sa.Column('currency', sa.String(10), nullable=False, server_default='RUB'),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('status', sa.String(32), nullable=False, server_default='pending'),
|
||||
sa.Column('is_paid', sa.Boolean(), server_default=sa.text('false'), nullable=False),
|
||||
sa.Column('payment_url', sa.Text(), nullable=True),
|
||||
sa.Column('payment_method', sa.String(32), nullable=True),
|
||||
sa.Column('metadata_json', sa.JSON(), nullable=True),
|
||||
sa.Column('callback_payload', sa.JSON(), nullable=True),
|
||||
sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('transaction_id', sa.Integer(), sa.ForeignKey('transactions.id'), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('jupiter_payments')
|
||||
@@ -1,45 +0,0 @@
|
||||
"""create donut_payments table
|
||||
|
||||
Revision ID: 0073
|
||||
Revises: 0072
|
||||
Create Date: 2026-05-04
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0073'
|
||||
down_revision: Union[str, None] = '0072'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'donut_payments',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True),
|
||||
sa.Column('order_id', sa.String(64), unique=True, nullable=False, index=True),
|
||||
sa.Column('donut_transaction_id', sa.String(128), unique=True, nullable=True, index=True),
|
||||
sa.Column('amount_kopeks', sa.Integer(), nullable=False),
|
||||
sa.Column('currency', sa.String(10), nullable=False, server_default='RUB'),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('status', sa.String(32), nullable=False, server_default='pending'),
|
||||
sa.Column('is_paid', sa.Boolean(), server_default=sa.text('false'), nullable=False),
|
||||
sa.Column('payment_url', sa.Text(), nullable=True),
|
||||
sa.Column('payment_method', sa.String(32), nullable=True),
|
||||
sa.Column('metadata_json', sa.JSON(), nullable=True),
|
||||
sa.Column('callback_payload', sa.JSON(), nullable=True),
|
||||
sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('transaction_id', sa.Integer(), sa.ForeignKey('transactions.id'), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('donut_payments')
|
||||
@@ -1,45 +0,0 @@
|
||||
"""create lava_payments table
|
||||
|
||||
Revision ID: 0074
|
||||
Revises: 0073
|
||||
Create Date: 2026-05-04
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0074'
|
||||
down_revision: Union[str, None] = '0073'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'lava_payments',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True),
|
||||
sa.Column('order_id', sa.String(64), unique=True, nullable=False, index=True),
|
||||
sa.Column('lava_invoice_id', sa.String(128), unique=True, nullable=True, index=True),
|
||||
sa.Column('amount_kopeks', sa.Integer(), nullable=False),
|
||||
sa.Column('currency', sa.String(10), nullable=False, server_default='RUB'),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('status', sa.String(32), nullable=False, server_default='pending'),
|
||||
sa.Column('is_paid', sa.Boolean(), server_default=sa.text('false'), nullable=False),
|
||||
sa.Column('payment_url', sa.Text(), nullable=True),
|
||||
sa.Column('payment_method', sa.String(32), nullable=True),
|
||||
sa.Column('metadata_json', sa.JSON(), nullable=True),
|
||||
sa.Column('callback_payload', sa.JSON(), nullable=True),
|
||||
sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('transaction_id', sa.Integer(), sa.ForeignKey('transactions.id'), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('lava_payments')
|
||||
@@ -1,106 +0,0 @@
|
||||
"""create tasks system: tasks, user_task_progress, task_partner_channels + tariffs.bonus_days_per_purchase
|
||||
|
||||
Revision ID: 0075
|
||||
Revises: 0074
|
||||
Create Date: 2026-05-06
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = '0075'
|
||||
down_revision: Union[str, None] = '0074'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Tariff: bonus_days_per_purchase
|
||||
op.add_column(
|
||||
'tariffs',
|
||||
sa.Column('bonus_days_per_purchase', sa.Integer(), nullable=False, server_default='0'),
|
||||
)
|
||||
|
||||
# 2. Партнёрские каналы для заданий
|
||||
op.create_table(
|
||||
'task_partner_channels',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('channel_id', sa.String(100), unique=True, nullable=False, index=True),
|
||||
sa.Column('title', sa.String(255), nullable=False),
|
||||
sa.Column('channel_link', sa.String(500), nullable=True),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('true')),
|
||||
sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# 3. Шаблоны заданий
|
||||
op.create_table(
|
||||
'tasks',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('title', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='{}'),
|
||||
sa.Column('description', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='{}'),
|
||||
sa.Column('icon', sa.String(50), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('true')),
|
||||
sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('task_type', sa.String(32), nullable=False, index=True),
|
||||
sa.Column('target_value', sa.BigInteger(), nullable=False, server_default='1'),
|
||||
sa.Column('target_meta', sa.JSON(), nullable=False, server_default='{}'),
|
||||
sa.Column('reward_type', sa.String(32), nullable=False),
|
||||
sa.Column('reward_value', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.Column('reward_meta', sa.JSON(), nullable=False, server_default='{}'),
|
||||
sa.Column('allow_user_choice', sa.Boolean(), nullable=False, server_default=sa.text('false')),
|
||||
sa.Column('user_audience', sa.String(16), nullable=False, server_default='both'),
|
||||
sa.Column(
|
||||
'promo_group_id',
|
||||
sa.Integer(),
|
||||
sa.ForeignKey('promo_groups.id', ondelete='SET NULL'),
|
||||
nullable=True,
|
||||
index=True,
|
||||
),
|
||||
sa.Column(
|
||||
'parent_task_id',
|
||||
sa.Integer(),
|
||||
sa.ForeignKey('tasks.id', ondelete='SET NULL'),
|
||||
nullable=True,
|
||||
index=True,
|
||||
),
|
||||
sa.Column('level', sa.Integer(), nullable=False, server_default='1'),
|
||||
sa.Column('starts_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('ends_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# 4. Прогресс пользователей
|
||||
op.create_table(
|
||||
'user_task_progress',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column(
|
||||
'user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True
|
||||
),
|
||||
sa.Column(
|
||||
'task_id', sa.Integer(), sa.ForeignKey('tasks.id', ondelete='CASCADE'), nullable=False, index=True
|
||||
),
|
||||
sa.Column('current_value', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.Column('period_started_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('baseline_value', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('claimed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('reward_granted_meta', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.UniqueConstraint('user_id', 'task_id', name='uq_user_task'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('user_task_progress')
|
||||
op.drop_table('tasks')
|
||||
op.drop_table('task_partner_channels')
|
||||
op.drop_column('tariffs', 'bonus_days_per_purchase')
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = 'remnawave-bedolaga-telegram-bot'
|
||||
version = "3.49.0"
|
||||
version = "3.53.0"
|
||||
description = 'Telegram bot for RemnaWave VPN service'
|
||||
readme = 'README.md'
|
||||
license = { text = 'MIT' }
|
||||
@@ -26,7 +26,6 @@ dependencies = [
|
||||
'rich>=14.0',
|
||||
'aiohttp-socks>=0.10.1',
|
||||
'httpx[socks]>=0.27.0',
|
||||
'pycryptodome>=3.21.0',
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
Vendored
-549
@@ -1,549 +0,0 @@
|
||||
"""Tests for Apple In-App Purchase service and integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
from app.config import settings
|
||||
from app.external.apple_iap import AppleIAPService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend() -> str:
|
||||
return 'asyncio'
|
||||
|
||||
|
||||
def _enable_apple_iap(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, 'APPLE_IAP_ENABLED', True, raising=False)
|
||||
monkeypatch.setattr(settings, 'APPLE_IAP_KEY_ID', 'TEST_KEY_ID', raising=False)
|
||||
monkeypatch.setattr(settings, 'APPLE_IAP_ISSUER_ID', 'test-issuer-id', raising=False)
|
||||
monkeypatch.setattr(settings, 'APPLE_IAP_BUNDLE_ID', 'com.bitnet.vpnclient', raising=False)
|
||||
monkeypatch.setattr(settings, 'APPLE_IAP_ENVIRONMENT', 'Sandbox', raising=False)
|
||||
# Use a dummy key -- we won't actually sign in tests
|
||||
monkeypatch.setattr(settings, 'APPLE_IAP_PRIVATE_KEY', 'dummy-key', raising=False)
|
||||
monkeypatch.setattr(
|
||||
settings,
|
||||
'APPLE_IAP_PRODUCTS',
|
||||
json.dumps(
|
||||
{
|
||||
'com.bitnet.vpnclient.topup.100': 10_000,
|
||||
'com.bitnet.vpnclient.topup.300': 30_000,
|
||||
'com.bitnet.vpnclient.topup.500': 50_000,
|
||||
'com.bitnet.vpnclient.topup.1000': 100_000,
|
||||
'com.bitnet.vpnclient.topup.3000': 300_000,
|
||||
}
|
||||
),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Product mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProductMapping:
|
||||
"""Test product ID to kopeks mapping."""
|
||||
|
||||
def test_all_products_mapped(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_apple_iap(monkeypatch)
|
||||
products = settings.get_apple_iap_products()
|
||||
assert len(products) == 5
|
||||
|
||||
def test_product_100(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_apple_iap(monkeypatch)
|
||||
products = settings.get_apple_iap_products()
|
||||
assert products['com.bitnet.vpnclient.topup.100'] == 10_000
|
||||
|
||||
def test_product_300(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_apple_iap(monkeypatch)
|
||||
products = settings.get_apple_iap_products()
|
||||
assert products['com.bitnet.vpnclient.topup.300'] == 30_000
|
||||
|
||||
def test_product_500(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_apple_iap(monkeypatch)
|
||||
products = settings.get_apple_iap_products()
|
||||
assert products['com.bitnet.vpnclient.topup.500'] == 50_000
|
||||
|
||||
def test_product_1000(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_apple_iap(monkeypatch)
|
||||
products = settings.get_apple_iap_products()
|
||||
assert products['com.bitnet.vpnclient.topup.1000'] == 100_000
|
||||
|
||||
def test_product_3000(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_apple_iap(monkeypatch)
|
||||
products = settings.get_apple_iap_products()
|
||||
assert products['com.bitnet.vpnclient.topup.3000'] == 300_000
|
||||
|
||||
def test_unknown_product_not_in_map(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_apple_iap(monkeypatch)
|
||||
products = settings.get_apple_iap_products()
|
||||
assert 'com.bitnet.vpnclient.topup.999' not in products
|
||||
|
||||
def test_invalid_json_returns_empty(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, 'APPLE_IAP_PRODUCTS', 'invalid-json', raising=False)
|
||||
products = settings.get_apple_iap_products()
|
||||
assert products == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_apple_iap_enabled()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAppleIAPEnabled:
|
||||
"""Test is_apple_iap_enabled() helper."""
|
||||
|
||||
def test_enabled_with_all_params(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_apple_iap(monkeypatch)
|
||||
assert settings.is_apple_iap_enabled() is True
|
||||
|
||||
def test_disabled_when_flag_false(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_apple_iap(monkeypatch)
|
||||
monkeypatch.setattr(settings, 'APPLE_IAP_ENABLED', False, raising=False)
|
||||
assert settings.is_apple_iap_enabled() is False
|
||||
|
||||
def test_disabled_when_key_id_missing(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_apple_iap(monkeypatch)
|
||||
monkeypatch.setattr(settings, 'APPLE_IAP_KEY_ID', None, raising=False)
|
||||
assert settings.is_apple_iap_enabled() is False
|
||||
|
||||
def test_disabled_when_issuer_id_missing(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_apple_iap(monkeypatch)
|
||||
monkeypatch.setattr(settings, 'APPLE_IAP_ISSUER_ID', None, raising=False)
|
||||
assert settings.is_apple_iap_enabled() is False
|
||||
|
||||
def test_disabled_when_no_private_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_apple_iap(monkeypatch)
|
||||
monkeypatch.setattr(settings, 'APPLE_IAP_PRIVATE_KEY', None, raising=False)
|
||||
monkeypatch.setattr(settings, 'APPLE_IAP_PRIVATE_KEY_PATH', None, raising=False)
|
||||
assert settings.is_apple_iap_enabled() is False
|
||||
|
||||
def test_enabled_with_key_path_only(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_apple_iap(monkeypatch)
|
||||
monkeypatch.setattr(settings, 'APPLE_IAP_PRIVATE_KEY', None, raising=False)
|
||||
monkeypatch.setattr(settings, 'APPLE_IAP_PRIVATE_KEY_PATH', '/tmp/test.p8', raising=False) # noqa: S108
|
||||
assert settings.is_apple_iap_enabled() is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# validate_transaction_info
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTransactionValidation:
|
||||
"""Test validate_transaction_info."""
|
||||
|
||||
def test_valid_transaction(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_apple_iap(monkeypatch)
|
||||
service = AppleIAPService()
|
||||
txn_info = {
|
||||
'bundleId': 'com.bitnet.vpnclient',
|
||||
'productId': 'com.bitnet.vpnclient.topup.100',
|
||||
'type': 'Consumable',
|
||||
}
|
||||
result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100')
|
||||
assert result is None # None means valid
|
||||
|
||||
def test_wrong_bundle_id(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_apple_iap(monkeypatch)
|
||||
service = AppleIAPService()
|
||||
txn_info = {
|
||||
'bundleId': 'com.other.app',
|
||||
'productId': 'com.bitnet.vpnclient.topup.100',
|
||||
'type': 'Consumable',
|
||||
}
|
||||
result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100')
|
||||
assert result is not None
|
||||
assert 'Bundle ID' in result
|
||||
|
||||
def test_wrong_product_id(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_apple_iap(monkeypatch)
|
||||
service = AppleIAPService()
|
||||
txn_info = {
|
||||
'bundleId': 'com.bitnet.vpnclient',
|
||||
'productId': 'com.bitnet.vpnclient.topup.500',
|
||||
'type': 'Consumable',
|
||||
}
|
||||
result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100')
|
||||
assert result is not None
|
||||
assert 'Product ID' in result
|
||||
|
||||
def test_wrong_type(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_apple_iap(monkeypatch)
|
||||
service = AppleIAPService()
|
||||
txn_info = {
|
||||
'bundleId': 'com.bitnet.vpnclient',
|
||||
'productId': 'com.bitnet.vpnclient.topup.100',
|
||||
'type': 'Auto-Renewable Subscription',
|
||||
}
|
||||
result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100')
|
||||
assert result is not None
|
||||
assert 'type' in result
|
||||
|
||||
def test_revoked_transaction(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_apple_iap(monkeypatch)
|
||||
service = AppleIAPService()
|
||||
txn_info = {
|
||||
'bundleId': 'com.bitnet.vpnclient',
|
||||
'productId': 'com.bitnet.vpnclient.topup.100',
|
||||
'type': 'Consumable',
|
||||
'revocationDate': 1700000000000,
|
||||
}
|
||||
result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100')
|
||||
assert result is not None
|
||||
assert 'revoked' in result.lower()
|
||||
|
||||
def test_valid_without_revocation(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A transaction without revocationDate should be valid."""
|
||||
_enable_apple_iap(monkeypatch)
|
||||
service = AppleIAPService()
|
||||
txn_info = {
|
||||
'bundleId': 'com.bitnet.vpnclient',
|
||||
'productId': 'com.bitnet.vpnclient.topup.100',
|
||||
'type': 'Consumable',
|
||||
}
|
||||
result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100')
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Environment URL selection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBaseUrl:
|
||||
"""Test environment URL selection."""
|
||||
|
||||
def test_production_url(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_apple_iap(monkeypatch)
|
||||
service = AppleIAPService()
|
||||
url = service._get_base_url('Production')
|
||||
assert 'api.storekit.itunes.apple.com' in url
|
||||
|
||||
def test_sandbox_url(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_apple_iap(monkeypatch)
|
||||
service = AppleIAPService()
|
||||
url = service._get_base_url('Sandbox')
|
||||
assert 'api.storekit-sandbox.itunes.apple.com' in url
|
||||
|
||||
def test_default_uses_config(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_apple_iap(monkeypatch)
|
||||
service = AppleIAPService()
|
||||
url = service._get_base_url()
|
||||
assert 'sandbox' in url # Fixture sets Sandbox
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _decode_jws_payload (raw decode, no verification)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestJWSPayloadDecoding:
|
||||
"""Test _decode_jws_payload."""
|
||||
|
||||
def test_decode_valid_jws(self) -> None:
|
||||
service = AppleIAPService()
|
||||
header = base64.urlsafe_b64encode(b'{"alg":"ES256"}').rstrip(b'=').decode()
|
||||
payload_data = {'bundleId': 'com.test', 'productId': 'test.product'}
|
||||
payload = base64.urlsafe_b64encode(json.dumps(payload_data).encode()).rstrip(b'=').decode()
|
||||
signature = base64.urlsafe_b64encode(b'fake-signature').rstrip(b'=').decode()
|
||||
jws = f'{header}.{payload}.{signature}'
|
||||
|
||||
result = service._decode_jws_payload(jws)
|
||||
assert result is not None
|
||||
assert result['bundleId'] == 'com.test'
|
||||
assert result['productId'] == 'test.product'
|
||||
|
||||
def test_decode_invalid_jws(self) -> None:
|
||||
service = AppleIAPService()
|
||||
result = service._decode_jws_payload('not-a-jws')
|
||||
assert result is None
|
||||
|
||||
def test_decode_empty_string(self) -> None:
|
||||
service = AppleIAPService()
|
||||
result = service._decode_jws_payload('')
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _verify_and_decode_jws (x5c + ES256 verification)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVerifyAndDecodeJWS:
|
||||
"""Test _verify_and_decode_jws -- the full x5c chain + signature path."""
|
||||
|
||||
def test_rejects_bad_format(self) -> None:
|
||||
service = AppleIAPService()
|
||||
assert service._verify_and_decode_jws('only-two.parts') is None
|
||||
|
||||
def test_rejects_missing_x5c(self) -> None:
|
||||
service = AppleIAPService()
|
||||
# Valid 3-part JWS but header has no x5c
|
||||
header = base64.urlsafe_b64encode(b'{"alg":"ES256"}').rstrip(b'=').decode()
|
||||
payload = base64.urlsafe_b64encode(b'{}').rstrip(b'=').decode()
|
||||
sig = base64.urlsafe_b64encode(b'sig').rstrip(b'=').decode()
|
||||
assert service._verify_and_decode_jws(f'{header}.{payload}.{sig}') is None
|
||||
|
||||
def test_rejects_empty_x5c(self) -> None:
|
||||
service = AppleIAPService()
|
||||
header = base64.urlsafe_b64encode(json.dumps({'alg': 'ES256', 'x5c': []}).encode()).rstrip(b'=').decode()
|
||||
payload = base64.urlsafe_b64encode(b'{}').rstrip(b'=').decode()
|
||||
sig = base64.urlsafe_b64encode(b'sig').rstrip(b'=').decode()
|
||||
assert service._verify_and_decode_jws(f'{header}.{payload}.{sig}') is None
|
||||
|
||||
def test_verify_notification_delegates(self) -> None:
|
||||
"""verify_notification should delegate to _verify_and_decode_jws."""
|
||||
service = AppleIAPService()
|
||||
service._verify_and_decode_jws = MagicMock(return_value={'test': True})
|
||||
result = service.verify_notification('signed.payload.jws')
|
||||
service._verify_and_decode_jws.assert_called_once_with('signed.payload.jws')
|
||||
assert result == {'test': True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# verify_transaction with mocked HTTP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.anyio('asyncio')
|
||||
class TestVerifyTransaction:
|
||||
"""Test verify_transaction with mocked _fetch_transaction."""
|
||||
|
||||
@staticmethod
|
||||
def _ok_response(signed_info: str = 'header.payload.sig') -> MagicMock:
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.json.return_value = {'signedTransactionInfo': signed_info}
|
||||
return resp
|
||||
|
||||
@staticmethod
|
||||
def _error_response(status: int, text: str = '') -> MagicMock:
|
||||
resp = MagicMock()
|
||||
resp.status_code = status
|
||||
resp.text = text
|
||||
return resp
|
||||
|
||||
async def test_successful_verification(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_apple_iap(monkeypatch)
|
||||
service = AppleIAPService()
|
||||
|
||||
txn_data = {
|
||||
'bundleId': 'com.bitnet.vpnclient',
|
||||
'productId': 'com.bitnet.vpnclient.topup.100',
|
||||
'type': 'Consumable',
|
||||
'transactionId': '2000000123456789',
|
||||
'environment': 'Sandbox',
|
||||
}
|
||||
|
||||
monkeypatch.setattr(service, '_fetch_transaction', AsyncMock(return_value=self._ok_response()))
|
||||
monkeypatch.setattr(service, '_verify_and_decode_jws', lambda token: txn_data)
|
||||
|
||||
result = await service.verify_transaction('2000000123456789', 'Sandbox')
|
||||
|
||||
assert result is not None
|
||||
assert result['bundleId'] == 'com.bitnet.vpnclient'
|
||||
assert result['transactionId'] == '2000000123456789'
|
||||
|
||||
async def test_verification_with_jws_failure(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""If _verify_and_decode_jws returns None, verify_transaction returns None."""
|
||||
_enable_apple_iap(monkeypatch)
|
||||
service = AppleIAPService()
|
||||
|
||||
monkeypatch.setattr(service, '_fetch_transaction', AsyncMock(return_value=self._ok_response()))
|
||||
monkeypatch.setattr(service, '_verify_and_decode_jws', lambda token: None)
|
||||
|
||||
result = await service.verify_transaction('2000000123456789', 'Sandbox')
|
||||
assert result is None
|
||||
|
||||
async def test_transaction_not_found_both_envs(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""404 on primary triggers fallback; 404 on fallback returns None."""
|
||||
_enable_apple_iap(monkeypatch)
|
||||
service = AppleIAPService()
|
||||
|
||||
fetch_mock = AsyncMock(return_value=self._error_response(404))
|
||||
monkeypatch.setattr(service, '_fetch_transaction', fetch_mock)
|
||||
|
||||
result = await service.verify_transaction('nonexistent', 'Sandbox')
|
||||
assert result is None
|
||||
# Should have been called twice (primary + fallback)
|
||||
assert fetch_mock.call_count == 2
|
||||
|
||||
async def test_fallback_succeeds_on_404(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""404 on primary, 200 on fallback -- should succeed."""
|
||||
_enable_apple_iap(monkeypatch)
|
||||
service = AppleIAPService()
|
||||
|
||||
txn_data = {'bundleId': 'com.bitnet.vpnclient', 'type': 'Consumable'}
|
||||
responses = [self._error_response(404), self._ok_response()]
|
||||
fetch_mock = AsyncMock(side_effect=responses)
|
||||
monkeypatch.setattr(service, '_fetch_transaction', fetch_mock)
|
||||
monkeypatch.setattr(service, '_verify_and_decode_jws', lambda token: txn_data)
|
||||
|
||||
result = await service.verify_transaction('12345', 'Production')
|
||||
assert result is not None
|
||||
assert fetch_mock.call_count == 2
|
||||
|
||||
async def test_network_error_no_retry(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Network error (None response) should not retry."""
|
||||
_enable_apple_iap(monkeypatch)
|
||||
service = AppleIAPService()
|
||||
|
||||
fetch_mock = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(service, '_fetch_transaction', fetch_mock)
|
||||
|
||||
result = await service.verify_transaction('12345', 'Sandbox')
|
||||
assert result is None
|
||||
assert fetch_mock.call_count == 1 # no fallback on network error
|
||||
|
||||
async def test_5xx_no_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""5xx errors should not trigger fallback (only 4xx does)."""
|
||||
_enable_apple_iap(monkeypatch)
|
||||
service = AppleIAPService()
|
||||
|
||||
fetch_mock = AsyncMock(return_value=self._error_response(500, 'Internal'))
|
||||
monkeypatch.setattr(service, '_fetch_transaction', fetch_mock)
|
||||
|
||||
result = await service.verify_transaction('12345', 'Sandbox')
|
||||
assert result is None
|
||||
assert fetch_mock.call_count == 1
|
||||
|
||||
async def test_rate_limit_triggers_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""429 is 4xx -> triggers fallback."""
|
||||
_enable_apple_iap(monkeypatch)
|
||||
service = AppleIAPService()
|
||||
|
||||
fetch_mock = AsyncMock(return_value=self._error_response(429))
|
||||
monkeypatch.setattr(service, '_fetch_transaction', fetch_mock)
|
||||
|
||||
result = await service.verify_transaction('123', 'Sandbox')
|
||||
assert result is None
|
||||
assert fetch_mock.call_count == 2 # primary + fallback
|
||||
|
||||
async def test_no_signed_transaction_info(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_apple_iap(monkeypatch)
|
||||
service = AppleIAPService()
|
||||
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.json.return_value = {} # missing signedTransactionInfo
|
||||
monkeypatch.setattr(service, '_fetch_transaction', AsyncMock(return_value=resp))
|
||||
|
||||
result = await service.verify_transaction('123', 'Sandbox')
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestApplePurchaseRequestSchema:
|
||||
"""Test ApplePurchaseRequest pydantic validation."""
|
||||
|
||||
def test_valid_request(self) -> None:
|
||||
from app.cabinet.schemas.apple_iap import ApplePurchaseRequest
|
||||
|
||||
req = ApplePurchaseRequest(
|
||||
product_id='com.bitnet.vpnclient.topup.100',
|
||||
transaction_id='2000000123456789',
|
||||
)
|
||||
assert req.transaction_id == '2000000123456789'
|
||||
|
||||
def test_rejects_non_numeric_transaction_id(self) -> None:
|
||||
from app.cabinet.schemas.apple_iap import ApplePurchaseRequest
|
||||
|
||||
with pytest.raises(Exception, match='digits'):
|
||||
ApplePurchaseRequest(
|
||||
product_id='com.bitnet.vpnclient.topup.100',
|
||||
transaction_id='abc-not-numeric',
|
||||
)
|
||||
|
||||
def test_rejects_empty_transaction_id(self) -> None:
|
||||
from app.cabinet.schemas.apple_iap import ApplePurchaseRequest
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
ApplePurchaseRequest(
|
||||
product_id='com.bitnet.vpnclient.topup.100',
|
||||
transaction_id='',
|
||||
)
|
||||
|
||||
def test_rejects_too_long_transaction_id(self) -> None:
|
||||
from app.cabinet.schemas.apple_iap import ApplePurchaseRequest
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
ApplePurchaseRequest(
|
||||
product_id='com.bitnet.vpnclient.topup.100',
|
||||
transaction_id='1' * 65,
|
||||
)
|
||||
|
||||
def test_no_environment_field(self) -> None:
|
||||
"""Schema should not accept environment -- it's server-side only."""
|
||||
from app.cabinet.schemas.apple_iap import ApplePurchaseRequest
|
||||
|
||||
req = ApplePurchaseRequest(
|
||||
product_id='com.bitnet.vpnclient.topup.100',
|
||||
transaction_id='123',
|
||||
)
|
||||
assert not hasattr(req, 'environment')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sandbox detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSandboxDetection:
|
||||
"""Test that sandbox transactions don't credit real balance."""
|
||||
|
||||
def test_sandbox_env_detected_from_txn_info(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""validate_transaction_info does not reject sandbox env -- that's handled at the route level."""
|
||||
_enable_apple_iap(monkeypatch)
|
||||
service = AppleIAPService()
|
||||
txn_info = {
|
||||
'bundleId': 'com.bitnet.vpnclient',
|
||||
'productId': 'com.bitnet.vpnclient.topup.100',
|
||||
'type': 'Consumable',
|
||||
'environment': 'Sandbox',
|
||||
}
|
||||
result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100')
|
||||
assert result is None # validation passes -- sandbox check is higher up
|
||||
|
||||
def test_production_txn_on_production_passes(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Production environment in txn_info + Production config = proceed normally."""
|
||||
_enable_apple_iap(monkeypatch)
|
||||
monkeypatch.setattr(settings, 'APPLE_IAP_ENVIRONMENT', 'Production', raising=False)
|
||||
txn_info = {'environment': 'Production'}
|
||||
is_sandbox = txn_info.get('environment') == 'Sandbox'
|
||||
assert is_sandbox is False
|
||||
|
||||
def test_sandbox_txn_on_production_detected(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Sandbox environment in txn_info + Production config = sandbox detected."""
|
||||
_enable_apple_iap(monkeypatch)
|
||||
monkeypatch.setattr(settings, 'APPLE_IAP_ENVIRONMENT', 'Production', raising=False)
|
||||
txn_info = {'environment': 'Sandbox'}
|
||||
is_sandbox = txn_info.get('environment') == 'Sandbox'
|
||||
should_skip_balance = is_sandbox and settings.APPLE_IAP_ENVIRONMENT == 'Production'
|
||||
assert should_skip_balance is True
|
||||
|
||||
def test_sandbox_txn_on_sandbox_credits_normally(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Sandbox environment in txn_info + Sandbox config = credit normally (testing)."""
|
||||
_enable_apple_iap(monkeypatch)
|
||||
monkeypatch.setattr(settings, 'APPLE_IAP_ENVIRONMENT', 'Sandbox', raising=False)
|
||||
txn_info = {'environment': 'Sandbox'}
|
||||
is_sandbox = txn_info.get('environment') == 'Sandbox'
|
||||
should_skip_balance = is_sandbox and settings.APPLE_IAP_ENVIRONMENT == 'Production'
|
||||
assert should_skip_balance is False
|
||||
@@ -883,36 +883,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycryptodome"
|
||||
version = "3.23.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycryptodomex"
|
||||
version = "3.23.0"
|
||||
@@ -1185,7 +1155,6 @@ dependencies = [
|
||||
{ name = "fastapi", extra = ["standard"] },
|
||||
{ name = "httpx", extra = ["socks"] },
|
||||
{ name = "packaging" },
|
||||
{ name = "pycryptodome" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "pyyaml" },
|
||||
@@ -1218,7 +1187,6 @@ requires-dist = [
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.129.0" },
|
||||
{ name = "httpx", extras = ["socks"], specifier = ">=0.27.0" },
|
||||
{ name = "packaging", specifier = ">=26.0" },
|
||||
{ name = "pycryptodome", specifier = ">=3.21.0" },
|
||||
{ name = "pyjwt", specifier = ">=2.11.0" },
|
||||
{ name = "python-dateutil", specifier = ">=2.9.0.post0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.3" },
|
||||
|
||||
Reference in New Issue
Block a user