Compare commits

...

131 Commits

Author SHA1 Message Date
Egor 1aae8783ca Update Dockerfile 2025-10-26 22:09:13 +03:00
Egor 5997a741fc Update docker-registry.yml 2025-10-26 22:09:01 +03:00
Egor c5d522e6b8 Update docker-hub.yml 2025-10-26 22:08:51 +03:00
Egor fa1d128d76 Merge pull request #1560 from Fr1ngg/dev4
Clear saved cart when cancelling subscription checkout
2025-10-26 21:36:44 +03:00
Egor f30516a6c2 Merge pull request #1559 from Fr1ngg/dh2k0d-bedolaga/fix-button-visibility-after-order-cancellation
Clear saved cart when cancelling subscription checkout
2025-10-26 21:32:30 +03:00
Egor 1feb0538cf Clear saved cart after subscription cancellation 2025-10-26 21:32:16 +03:00
Egor 3a791d624f Merge pull request #1558 from Fr1ngg/dev4
Dev4
2025-10-26 20:28:12 +03:00
Egor fe0f1f7641 Merge pull request #1557 from Fr1ngg/9scuix-bedolaga/fix-subscription-auto-purchase-after-top-up
Handle YooKassa auto-purchase subscription refresh
2025-10-26 20:27:34 +03:00
Egor 212783ae3d Fix auto-purchase subscription refresh after YooKassa top-up 2025-10-26 20:26:35 +03:00
Egor 7d72fc9555 Merge pull request #1556 from Fr1ngg/revert-1554-0ru012-bedolaga/fix-subscription-auto-purchase-after-top-up
Revert "Handle auto-purchase for subscription extensions after top-up"
2025-10-26 20:26:25 +03:00
Egor fb4df5cee1 Revert "Handle auto-purchase for subscription extensions after top-up" 2025-10-26 20:26:12 +03:00
Egor 45cd847b44 Merge pull request #1555 from Fr1ngg/dev4
Автопокупка и продление после пополнения баланса
2025-10-26 19:51:25 +03:00
Egor 98fd97dea2 Merge pull request #1554 from Fr1ngg/0ru012-bedolaga/fix-subscription-auto-purchase-after-top-up
Handle auto-purchase for subscription extensions after top-up
2025-10-26 19:40:50 +03:00
Egor e334086ad1 Fix auto-purchase for subscription extensions 2025-10-26 19:40:31 +03:00
Egor 2e792911f1 Merge pull request #1553 from Fr1ngg/revert-1552-hq8vy3-bedolaga/fix-subscription-auto-purchase-after-top-up
Revert "Handle simple subscription star purchase flow"
2025-10-26 19:40:00 +03:00
Egor 878b666a0b Revert "Handle simple subscription star purchase flow" 2025-10-26 19:39:51 +03:00
Egor 69c3239469 Merge pull request #1552 from Fr1ngg/hq8vy3-bedolaga/fix-subscription-auto-purchase-after-top-up
Handle simple subscription star purchase flow
2025-10-26 19:15:25 +03:00
Egor b8d48745c5 Handle simple subscription stars purchases 2025-10-26 19:15:02 +03:00
Egor 6b64e73d6e Merge pull request #1551 from Fr1ngg/revert-1550-yp0t6x-bedolaga/fix-subscription-auto-purchase-after-top-up
Revert "Fix auto-purchase after top-up to use miniapp purchase service"
2025-10-26 19:14:21 +03:00
Egor b433a96cd6 Revert "Fix auto-purchase after top-up to use miniapp purchase service" 2025-10-26 19:14:12 +03:00
Egor 0e4c83299d Merge pull request #1550 from Fr1ngg/yp0t6x-bedolaga/fix-subscription-auto-purchase-after-top-up
Fix auto-purchase after top-up to use miniapp purchase service
2025-10-26 18:51:54 +03:00
Egor c4d1f3eccc Fix auto-purchase to use miniapp service submit 2025-10-26 18:50:13 +03:00
Egor 22dcf425cb Merge pull request #1547 from Fr1ngg/revert-1544-revert-1543-tfkufc-bedolaga/add-automatic-subscription-purchase-feature
Revert "Revert "feat: auto purchase subscription after top-up""
2025-10-26 18:27:10 +03:00
Egor edf4132c2d Revert "Revert "feat: auto purchase subscription after top-up"" 2025-10-26 18:27:01 +03:00
Egor a56e14c736 Merge pull request #1546 from Fr1ngg/revert-1545-67lhig-bedolaga/add-automatic-subscription-purchase-feature
Revert "Add automatic subscription purchase after balance top-up"
2025-10-26 18:26:41 +03:00
Egor 2e3c707745 Revert "Add automatic subscription purchase after balance top-up" 2025-10-26 18:26:32 +03:00
Egor ebd8a10fd9 Merge pull request #1545 from Fr1ngg/67lhig-bedolaga/add-automatic-subscription-purchase-feature
Add automatic subscription purchase after balance top-up
2025-10-26 18:04:30 +03:00
Egor a681a9ff1b Add automatic subscription purchase after balance top-up 2025-10-26 18:04:13 +03:00
Egor d11458c4a8 Merge pull request #1544 from Fr1ngg/revert-1543-tfkufc-bedolaga/add-automatic-subscription-purchase-feature
Revert "feat: auto purchase subscription after top-up"
2025-10-26 18:03:58 +03:00
Egor 9d6e17b609 Revert "feat: auto purchase subscription after top-up" 2025-10-26 18:03:49 +03:00
Egor cb27487021 Merge pull request #1543 from Fr1ngg/tfkufc-bedolaga/add-automatic-subscription-purchase-feature
feat: auto purchase subscription after top-up
2025-10-26 17:56:15 +03:00
Egor e1e6b40a19 feat: auto purchase subscription after top-up 2025-10-26 17:55:51 +03:00
Egor 2616292f97 Merge pull request #1542 from Fr1ngg/revert-1541-bedolaga-oew5ch
Revert "Add automatic subscription purchase after top-ups"
2025-10-26 17:39:13 +03:00
Egor 2e1dfa6638 Revert "Add automatic subscription purchase after top-ups" 2025-10-26 17:39:04 +03:00
Egor 3a730492cb Merge pull request #1541 from Fr1ngg/bedolaga-oew5ch
Add automatic subscription purchase after top-ups
2025-10-26 17:35:16 +03:00
Egor c35da4b98d Add automatic subscription purchase after top-ups 2025-10-26 17:35:01 +03:00
Egor 5e98c25020 Merge pull request #1539 from Fr1ngg/dev4
Сервис автопроверки транзакций update
2025-10-26 10:47:42 +03:00
Egor 11a4d5b436 Merge pull request #1538 from Fr1ngg/revert-1536-revert-1533-4i58ws-bedolaga/add-auto-check-service-for-user-deposits
Revert "Revert "Improve Pal24 and Heleket verification coverage""
2025-10-26 10:46:48 +03:00
Egor 93c733aac0 Revert "Revert "Improve Pal24 and Heleket verification coverage"" 2025-10-26 10:46:38 +03:00
Egor 5b03e7ab8c Merge pull request #1537 from Fr1ngg/main
w
2025-10-26 10:46:24 +03:00
Egor 33912331af Merge pull request #1536 from Fr1ngg/revert-1533-4i58ws-bedolaga/add-auto-check-service-for-user-deposits
Revert "Improve Pal24 and Heleket verification coverage"
2025-10-26 10:45:44 +03:00
Egor aacc07835e Revert "Improve Pal24 and Heleket verification coverage" 2025-10-26 10:45:35 +03:00
Egor 86df2a1408 Update README.md 2025-10-26 10:44:32 +03:00
Egor 018de17c5b Merge pull request #1534 from Fr1ngg/74yls6-bedolaga/fix-back-button-error-during-payment
Handle back navigation from YooKassa SBP payments
2025-10-26 10:38:11 +03:00
Egor 66afa2b924 Handle back navigation from YooKassa SBP payments 2025-10-26 10:37:49 +03:00
Egor 4ee32fe0de Merge pull request #1533 from Fr1ngg/4i58ws-bedolaga/add-auto-check-service-for-user-deposits
Improve Pal24 and Heleket verification coverage
2025-10-26 10:28:57 +03:00
Egor 5b59ca3c4f Display transaction numbers in admin payment list 2025-10-26 10:28:32 +03:00
Egor 0108b0fc22 Merge pull request #1532 from Fr1ngg/revert-1531-ue9uqd-bedolaga/add-auto-check-service-for-user-deposits
Revert "Improve Pal24 and Heleket verification coverage"
2025-10-26 10:28:18 +03:00
Egor 2e575f82af Revert "Improve Pal24 and Heleket verification coverage" 2025-10-26 10:28:10 +03:00
Egor e8af09041d Merge pull request #1531 from Fr1ngg/ue9uqd-bedolaga/add-auto-check-service-for-user-deposits
Improve Pal24 and Heleket verification coverage
2025-10-26 10:15:51 +03:00
Egor 1cf041fb86 Improve Pal24 and Heleket verification coverage 2025-10-26 10:15:31 +03:00
Egor 63074cb009 Merge pull request #1529 from Fr1ngg/revert-1528-3wrea3-bedolaga/add-auto-check-service-for-user-deposits
Revert "Fix CryptoBot admin status checks"
2025-10-26 09:58:22 +03:00
Egor 89b9a6e04f Revert "Fix CryptoBot admin status checks" 2025-10-26 09:58:13 +03:00
Egor 4efa344e43 Merge pull request #1528 from Fr1ngg/3wrea3-bedolaga/add-auto-check-service-for-user-deposits
Fix CryptoBot admin status checks
2025-10-26 09:47:04 +03:00
Egor 2d3d1ba5d3 Fix CryptoBot admin status checks 2025-10-26 09:46:46 +03:00
Egor 849d781e92 Merge pull request #1527 from Fr1ngg/revert-1524-utdzdq-bedolaga/add-auto-check-service-for-user-deposits
Revert "Guard YooKassa manual checks from duplicate processing"
2025-10-26 09:46:33 +03:00
Egor 48a988aa43 Revert "Guard YooKassa manual checks from duplicate processing" 2025-10-26 09:46:25 +03:00
Egor ec71f733ad Merge pull request #1526 from Fr1ngg/revert-1525-dev4
Revert "Сервис проверки платежей"
2025-10-26 09:46:04 +03:00
Egor 051121a366 Revert "Сервис проверки платежей" 2025-10-26 09:45:56 +03:00
Egor e2c2b9127c Merge pull request #1525 from Fr1ngg/dev4
Сервис проверки платежей
2025-10-26 09:32:29 +03:00
Egor fdf7263bb0 Merge pull request #1524 from Fr1ngg/utdzdq-bedolaga/add-auto-check-service-for-user-deposits
Guard YooKassa manual checks from duplicate processing
2025-10-26 09:25:40 +03:00
Egor c8dcde0919 Guard auto verification commits 2025-10-26 09:25:14 +03:00
Egor 9db0cd6328 Merge pull request #1523 from Fr1ngg/revert-1522-z30p3c-bedolaga/add-auto-check-service-for-user-deposits
Revert "Guard YooKassa manual checks from duplicate processing"
2025-10-26 09:24:57 +03:00
Egor 2375bf51b3 Revert "Guard YooKassa manual checks from duplicate processing" 2025-10-26 09:24:49 +03:00
Egor 3631b49d6d Merge pull request #1522 from Fr1ngg/z30p3c-bedolaga/add-auto-check-service-for-user-deposits
Guard YooKassa manual checks from duplicate processing
2025-10-26 09:15:50 +03:00
Egor 3938b56802 Guard YooKassa manual checks from duplicate processing 2025-10-26 09:15:35 +03:00
Egor 3c3b793a9f Merge pull request #1521 from Fr1ngg/revert-1520-vcxme6-bedolaga/add-auto-check-service-for-user-deposits
Revert "Log payment verification service activation during startup"
2025-10-26 09:09:55 +03:00
Egor 8985a89a9f Revert "Log payment verification service activation during startup" 2025-10-26 09:09:46 +03:00
Egor 5750e6a4d2 Merge pull request #1520 from Fr1ngg/vcxme6-bedolaga/add-auto-check-service-for-user-deposits
Log payment verification service activation during startup
2025-10-26 08:54:28 +03:00
Egor 8d7f73edec Log payment verification service activation 2025-10-26 08:54:05 +03:00
Egor 4853d5b19b Merge pull request #1519 from Fr1ngg/revert-1518-x45isk-bedolaga/add-auto-check-service-for-user-deposits
Revert "Prevent admin payment detail handler from handling manual check callbacks"
2025-10-26 08:52:53 +03:00
Egor 901f5fd90d Revert "Prevent admin payment detail handler from handling manual check callbacks" 2025-10-26 08:52:46 +03:00
Egor 89e3865019 Merge pull request #1518 from Fr1ngg/x45isk-bedolaga/add-auto-check-service-for-user-deposits
Prevent admin payment detail handler from handling manual check callbacks
2025-10-26 08:36:43 +03:00
Egor 538b2b52d3 Avoid admin payment detail handler catching manual checks 2025-10-26 08:36:27 +03:00
Egor f1c9df8471 Merge pull request #1517 from Fr1ngg/revert-1516-kvif2x-bedolaga/add-auto-check-service-for-user-deposits
Revert "Show CryptoBot and Stars invoices in admin payments"
2025-10-26 08:32:39 +03:00
Egor 9e09e97ed8 Revert "Show CryptoBot and Stars invoices in admin payments" 2025-10-26 08:32:30 +03:00
Egor e50355be23 Merge pull request #1516 from Fr1ngg/kvif2x-bedolaga/add-auto-check-service-for-user-deposits
Show CryptoBot and Stars invoices in admin payments
2025-10-26 08:27:40 +03:00
Egor bd036fb4a9 Show CryptoBot and Stars invoices in admin payments 2025-10-26 08:27:22 +03:00
Egor 0181f8e5fb Merge pull request #1515 from Fr1ngg/revert-1514-vpe5pb-bedolaga/add-auto-check-service-for-user-deposits
Revert "Include CryptoBot and Stars invoices in admin checks"
2025-10-26 08:27:12 +03:00
Egor f5eea6798a Revert "Include CryptoBot and Stars invoices in admin checks" 2025-10-26 08:27:04 +03:00
Egor 7c8d0254b2 Merge pull request #1514 from Fr1ngg/vpe5pb-bedolaga/add-auto-check-service-for-user-deposits
Include CryptoBot and Stars invoices in admin checks
2025-10-26 08:26:31 +03:00
Egor 49c3c1c0c9 Include CryptoBot and Stars invoices in admin checks 2025-10-26 08:25:43 +03:00
Egor 38522442d1 Merge pull request #1513 from Fr1ngg/revert-1512-6709dc-bedolaga/add-auto-check-service-for-user-deposits
Revert "Include CryptoBot and Stars in admin payment verification"
2025-10-26 08:25:11 +03:00
Egor 0dacd79598 Revert "Include CryptoBot and Stars in admin payment verification" 2025-10-26 08:25:03 +03:00
Egor 2022654055 Merge pull request #1512 from Fr1ngg/6709dc-bedolaga/add-auto-check-service-for-user-deposits
Include CryptoBot and Stars in admin payment verification
2025-10-26 08:18:48 +03:00
Egor f3a1de6335 Include CryptoBot and Stars in admin top-up checks 2025-10-26 08:18:30 +03:00
Egor 0b5b89ea9a Merge pull request #1511 from Fr1ngg/revert-1510-k70a53-bedolaga/add-auto-check-service-for-user-deposits
Revert "Fix admin YooKassa manual check lookup"
2025-10-26 08:18:16 +03:00
Egor 8e21c34c8c Revert "Fix admin YooKassa manual check lookup" 2025-10-26 08:18:07 +03:00
Egor 400f3b01d6 Merge pull request #1510 from Fr1ngg/k70a53-bedolaga/add-auto-check-service-for-user-deposits
Fix admin YooKassa manual check lookup
2025-10-26 07:38:34 +03:00
Egor 6a0b2aabaf Add payment service wrapper for YooKassa local lookup 2025-10-26 07:38:17 +03:00
Egor 13a1ddad11 Merge pull request #1509 from Fr1ngg/revert-1508-ur8a6k-bedolaga/add-auto-check-service-for-user-deposits
Revert "Add admin top-up verification tools"
2025-10-26 07:37:32 +03:00
Egor ed404a0e54 Revert "Add admin top-up verification tools" 2025-10-26 07:37:15 +03:00
Egor 05acfc0733 Merge pull request #1508 from Fr1ngg/ur8a6k-bedolaga/add-auto-check-service-for-user-deposits
Add admin top-up verification tools
2025-10-26 07:24:23 +03:00
Egor 969cd6ed90 Add admin top-up verification tools 2025-10-26 07:24:03 +03:00
Egor 939c9b9b09 Merge pull request #1507 from Fr1ngg/revert-1506-6evcye-bedolaga/add-auto-check-service-for-user-deposits
Revert "Add admin pending payment verification endpoints"
2025-10-26 07:23:33 +03:00
Egor 051c0db106 Revert "Add admin pending payment verification endpoints" 2025-10-26 07:23:24 +03:00
Egor e5fbe005f8 Merge pull request #1506 from Fr1ngg/6evcye-bedolaga/add-auto-check-service-for-user-deposits
Add admin pending payment verification endpoints
2025-10-26 07:20:38 +03:00
Egor 63efb31785 Add admin APIs for pending payment checks 2025-10-26 07:20:23 +03:00
Egor 32586e177b Merge pull request #1505 from Fr1ngg/revert-1504-ig0xdj-bedolaga/add-auto-check-service-for-user-deposits
Revert "Add admin top-up monitoring and manual payment checks"
2025-10-26 07:19:49 +03:00
Egor 651dba2648 Revert "Add admin top-up monitoring and manual payment checks" 2025-10-26 07:19:41 +03:00
Egor 1bbcf2a95c Merge pull request #1504 from Fr1ngg/ig0xdj-bedolaga/add-auto-check-service-for-user-deposits
Add admin top-up monitoring and manual payment checks
2025-10-26 07:16:53 +03:00
Egor 82af176018 Add admin top-up monitoring and manual payment checks 2025-10-26 07:16:35 +03:00
Egor c10a4e9f8a Merge pull request #1503 from Fr1ngg/dev4
Fix mulen/юкасса webhooks
2025-10-26 06:56:37 +03:00
Egor 9727e07464 Merge pull request #1502 from Fr1ngg/9cf17y-bedolaga/improve-webhook-handling-for-payments
Improve YooKassa webhook recovery
2025-10-26 06:52:39 +03:00
Egor b780fe2278 Improve YooKassa webhook recovery 2025-10-26 06:52:18 +03:00
Egor 947d61f2a8 Merge pull request #1501 from Fr1ngg/revert-1500-pfpi1q-bedolaga/improve-webhook-handling-for-payments
Revert "Improve MulenPay hook resilience"
2025-10-26 06:47:15 +03:00
Egor 27725ec1a7 Revert "Improve MulenPay hook resilience" 2025-10-26 06:47:08 +03:00
Egor 93a29463f4 Merge pull request #1500 from Fr1ngg/pfpi1q-bedolaga/improve-webhook-handling-for-payments
Improve MulenPay hook resilience
2025-10-26 06:07:12 +03:00
Egor 84cfcf45fa Improve MulenPay hook resilience 2025-10-26 06:04:16 +03:00
Egor 6651e59f63 Merge pull request #1499 from Fr1ngg/main
ц
2025-10-26 05:55:27 +03:00
Egor ed46b7c919 Update miniapp-setup.md 2025-10-24 13:23:23 +03:00
Egor 88cd9fbc5b Update README.md 2025-10-24 13:14:17 +03:00
Egor 46fc5827ca Merge pull request #1497 from Fr1ngg/dev4
Dev4
2025-10-24 10:05:03 +03:00
Egor 65d386f3b2 Merge pull request #1496 from Fr1ngg/0x48r9-bedolaga/fix-datetime-error-in-payment-status-check
Normalize Heleket payment timestamps before saving
2025-10-24 10:01:40 +03:00
Egor 9db640931e Normalize Heleket paid_at timestamps to UTC 2025-10-24 10:01:23 +03:00
Egor 53e4dec12d Merge pull request #1495 from Fr1ngg/dev4
w
2025-10-24 09:45:03 +03:00
Egor 174d9f9b84 Merge pull request #1494 from Fr1ngg/ur3x81-bedolaga/expand-bot-api-for-surveys
Optimize poll listing response counts
2025-10-24 09:39:57 +03:00
Egor 30ec07f7fe Avoid loading poll responses in list endpoint 2025-10-24 09:39:39 +03:00
Egor dbf1fd8085 Merge pull request #1493 from Fr1ngg/revert-1492-yc9gsm-bedolaga/expand-bot-api-for-surveys
Revert "Document poll management endpoints in admin guide"
2025-10-24 09:36:37 +03:00
Egor 32e1f231d3 Revert "Document poll management endpoints in admin guide" 2025-10-24 09:36:28 +03:00
Egor 96b788693d Merge pull request #1492 from Fr1ngg/yc9gsm-bedolaga/expand-bot-api-for-surveys
Document poll management endpoints in admin guide
2025-10-24 09:32:52 +03:00
Egor a0e00c3735 Document poll administration API 2025-10-24 09:32:38 +03:00
Egor 51e68cf215 Merge pull request #1491 from Fr1ngg/wugnye-bedolaga/expand-bot-api-for-system-logs
Expose system log through admin API
2025-10-24 09:20:48 +03:00
Egor fa3ed3af81 Add system log endpoints to admin API 2025-10-24 09:20:34 +03:00
Egor 6e72cbb5b0 Merge pull request #1490 from Fr1ngg/u14a7y-bedolaga/expand-api-for-bot-logs-visibility
feat: expose monitoring logs through web api
2025-10-24 09:08:07 +03:00
Egor 5f316f85b3 feat: expose monitoring logs through web api 2025-10-24 09:06:08 +03:00
Egor 2abd1fd9df Merge pull request #1489 from Fr1ngg/main
w
2025-10-24 08:56:20 +03:00
Egor fcb5fedf9f Update README.md 2025-10-23 13:31:36 +03:00
Egor d35c9ad87e Merge pull request #1487 from Fr1ngg/dbunp5-bedolaga/fix-double-reward-issue-in-poll-system
Prevent double payout when rewarding poll
2025-10-23 08:21:19 +03:00
Egor 0cc0368b0b Prevent double payout when rewarding poll 2025-10-23 08:17:18 +03:00
Egor 00fb154cb9 Merge pull request #1485 from Fr1ngg/main
w
2025-10-23 08:12:14 +03:00
51 changed files with 6103 additions and 378 deletions
+3 -3
View File
@@ -36,15 +36,15 @@ jobs:
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.5.5-$(git rev-parse --short HEAD)"
VERSION="v2.5.6-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.5.5-dev-$(git rev-parse --short HEAD)"
VERSION="v2.5.6-dev-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.5.5-pr-$(git rev-parse --short HEAD)"
VERSION="v2.5.6-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
+3 -3
View File
@@ -49,13 +49,13 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.5.5-$(git rev-parse --short HEAD)"
VERSION="v2.5.6-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.5.5-dev-$(git rev-parse --short HEAD)"
VERSION="v2.5.6-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v2.5.5-pr-$(git rev-parse --short HEAD)"
VERSION="v2.5.6-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v2.5.5"
ARG VERSION="v2.5.6"
ARG BUILD_DATE
ARG VCS_REF
+4 -3
View File
@@ -2,7 +2,8 @@
<div align="center">
![Logo](./assets/logo2.svg)
<img width="1024" height="1024" alt="ChatGPT Image 23 окт 2025 г , 13_18_33" src="https://github.com/user-attachments/assets/17ad0128-231d-4553-9f4b-ce0644da796c" />
**🤖 Современный Telegram-бот для управления VPN подписками через Remnawave API**
@@ -57,7 +58,7 @@
- 🛡️ **Защита панели** - поддержка [remnawave-reverse-proxy](https://github.com/eGamesAPI/remnawave-reverse-proxy)
- 🗄️ **Бекапы/Восстановление** - автобекапы и восстановление бд прямо в боте с уведомлениями в топики
- ✍️ **Проверка на подписку** - проверяет подписку на канал
- 🔄 **Автосинхронизация** - фоновая синхронизация серверов с Remnawave по расписанию
- 🔄 **Автосинхронизация** - фоновая синхронизация подписок и серверов(сквадов) с Remnawave по расписанию
### 📚 Поддерживаемые методы авторизации
@@ -747,7 +748,7 @@ LOG_FILE=logs/bot.log
- 💸 **Автоматические скидки** при оплате и автопродлении
- 👥 Реферальная программа с комиссиями и бонусами
- 📊 Аналитика доходов и конверсии рефералов
- 🔗 Генерация реферальных ссылок и QR-кодов
- 🔗 Генерация реферальных ссылок и QR кодов
💎 **Промо-группы и скидки**
- 🏷️ **Система промогрупп** с индивидуальными скидками
+2
View File
@@ -58,6 +58,7 @@ from app.handlers.admin import (
privacy_policy as admin_privacy_policy,
public_offer as admin_public_offer,
faq as admin_faq,
payments as admin_payments,
)
from app.handlers.stars_payments import register_stars_handlers
@@ -172,6 +173,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_privacy_policy.register_handlers(dp)
admin_public_offer.register_handlers(dp)
admin_faq.register_handlers(dp)
admin_payments.register_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
user_polls.register_handlers(dp)
+35 -1
View File
@@ -18,6 +18,9 @@ DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS = [
]
logger = logging.getLogger(__name__)
class Settings(BaseSettings):
BOT_TOKEN: str
@@ -182,7 +185,11 @@ class Settings(BaseSettings):
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
DISABLE_TOPUP_BUTTONS: bool = False
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10
AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False
# Настройки простой покупки
SIMPLE_SUBSCRIPTION_ENABLED: bool = False
SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30
@@ -621,6 +628,15 @@ class Settings(BaseSettings):
return normalized in {"1", "true", "yes", "on"}
return bool(value)
def is_auto_purchase_after_topup_enabled(self) -> bool:
value = getattr(self, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", False)
if isinstance(value, str):
normalized = value.strip().lower()
return normalized in {"1", "true", "yes", "on"}
return bool(value)
def get_available_languages(self) -> List[str]:
try:
@@ -839,6 +855,24 @@ class Settings(BaseSettings):
and self.WATA_TERMINAL_PUBLIC_ID is not None
)
def is_payment_verification_auto_check_enabled(self) -> bool:
return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED
def get_payment_verification_auto_check_interval(self) -> int:
try:
minutes = int(self.PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES)
except (TypeError, ValueError): # pragma: no cover - защитная проверка конфигурации
minutes = 10
if minutes <= 0:
logger.warning(
"Некорректный интервал автопроверки платежей: %s. Используется значение по умолчанию 10 минут.",
self.PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES,
)
return 10
return minutes
def get_cryptobot_base_url(self) -> str:
if self.CRYPTOBOT_TESTNET:
return "https://testnet-pay.crypt.bot"
+32
View File
@@ -263,3 +263,35 @@ async def get_poll_statistics(db: AsyncSession, poll_id: int) -> dict:
"reward_sum_kopeks": reward_sum,
"questions": questions,
}
async def get_poll_responses_with_answers(
db: AsyncSession,
poll_id: int,
*,
limit: int,
offset: int,
) -> tuple[list[PollResponse], int]:
total_result = await db.execute(
select(func.count()).select_from(PollResponse).where(PollResponse.poll_id == poll_id)
)
total = int(total_result.scalar_one() or 0)
if total == 0:
return [], 0
result = await db.execute(
select(PollResponse)
.options(
selectinload(PollResponse.user),
selectinload(PollResponse.answers).selectinload(PollAnswer.question),
selectinload(PollResponse.answers).selectinload(PollAnswer.option),
)
.where(PollResponse.poll_id == poll_id)
.order_by(PollResponse.sent_at.asc())
.offset(offset)
.limit(limit)
)
responses = result.scalars().unique().all()
return responses, total
+29 -5
View File
@@ -333,18 +333,42 @@ class TicketCRUD:
*,
limit: int = 50,
offset: int = 0,
action: Optional[str] = None,
) -> List[SupportAuditLog]:
from sqlalchemy import select, desc
result = await db.execute(
select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at)).offset(offset).limit(limit)
)
query = select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at))
if action:
query = query.where(SupportAuditLog.action == action)
result = await db.execute(query.offset(offset).limit(limit))
return result.scalars().all()
@staticmethod
async def count_support_audit(db: AsyncSession) -> int:
async def count_support_audit(db: AsyncSession, action: Optional[str] = None) -> int:
from sqlalchemy import select, func
result = await db.execute(select(func.count()).select_from(SupportAuditLog))
query = select(func.count()).select_from(SupportAuditLog)
if action:
query = query.where(SupportAuditLog.action == action)
result = await db.execute(query)
return int(result.scalar() or 0)
@staticmethod
async def list_support_audit_actions(db: AsyncSession) -> List[str]:
from sqlalchemy import select
result = await db.execute(
select(SupportAuditLog.action)
.where(SupportAuditLog.action.isnot(None))
.distinct()
.order_by(SupportAuditLog.action)
)
return [row[0] for row in result.fetchall()]
@staticmethod
async def get_open_tickets_count(db: AsyncSession) -> int:
+34 -14
View File
@@ -19,10 +19,10 @@ class CryptoBotService:
self.webhook_secret = settings.CRYPTOBOT_WEBHOOK_SECRET
async def _make_request(
self,
method: str,
endpoint: str,
data: Optional[Dict] = None
self,
method: str,
endpoint: str,
data: Optional[Dict] = None,
) -> Optional[Dict[str, Any]]:
if not self.api_token:
@@ -37,11 +37,18 @@ class CryptoBotService:
try:
async with aiohttp.ClientSession() as session:
request_kwargs: Dict[str, Any] = {"headers": headers}
if method.upper() == "GET":
if data:
request_kwargs["params"] = data
elif data:
request_kwargs["json"] = data
async with session.request(
method,
url,
headers=headers,
json=data if data else None
method,
url,
**request_kwargs,
) as response:
response_data = await response.json()
@@ -95,21 +102,34 @@ class CryptoBotService:
asset: Optional[str] = None,
status: Optional[str] = None,
offset: int = 0,
count: int = 100
count: int = 100,
invoice_ids: Optional[list] = None,
) -> Optional[list]:
data = {
'offset': offset,
'count': count
}
if asset:
data['asset'] = asset
if status:
data['status'] = status
return await self._make_request('GET', 'getInvoices', data)
if invoice_ids:
data['invoice_ids'] = invoice_ids
result = await self._make_request('GET', 'getInvoices', data)
if isinstance(result, dict):
items = result.get('items')
return items if isinstance(items, list) else []
if isinstance(result, list):
return result
return []
async def get_balance(self) -> Optional[list]:
return await self._make_request('GET', 'getBalance')
+29 -2
View File
@@ -41,7 +41,13 @@ class HeleketService:
raw = f"{encoded}{api_key}"
return hashlib.md5(raw.encode("utf-8")).hexdigest()
async def _request(self, endpoint: str, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
async def _request(
self,
endpoint: str,
payload: Dict[str, Any],
*,
params: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
if not self.is_configured:
logger.error("Heleket сервис не настроен: merchant или api_key отсутствуют")
return None
@@ -59,7 +65,12 @@ class HeleketService:
try:
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, data=body.encode("utf-8"), headers=headers) as response:
async with session.post(
url,
data=body.encode("utf-8"),
headers=headers,
params=params,
) as response:
text = await response.text()
if response.content_type != "application/json":
logger.error("Ответ Heleket не JSON (%s): %s", response.content_type, text)
@@ -104,6 +115,22 @@ class HeleketService:
return await self._request("payment/info", payload)
async def list_payments(
self,
*,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
cursor: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
payload: Dict[str, Any] = {}
if date_from:
payload["date_from"] = date_from
if date_to:
payload["date_to"] = date_to
params = {"cursor": cursor} if cursor else None
return await self._request("payment/list", payload, params=params)
def verify_webhook_signature(self, payload: Dict[str, Any]) -> bool:
if not self.is_configured:
logger.warning("Heleket сервис не настроен, подпись пропускается")
+596
View File
@@ -0,0 +1,596 @@
from __future__ import annotations
import html
import math
from typing import Optional
from aiogram import Dispatcher, F, types
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, User
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.services.payment_verification_service import (
PendingPayment,
SUPPORTED_MANUAL_CHECK_METHODS,
get_payment_record,
list_recent_pending_payments,
run_manual_check,
)
from app.utils.decorators import admin_required, error_handler
from app.utils.formatters import format_datetime, format_time_ago, format_username
PAGE_SIZE = 6
def _method_display(method: PaymentMethod) -> str:
if method == PaymentMethod.MULENPAY:
return settings.get_mulenpay_display_name()
if method == PaymentMethod.PAL24:
return "PayPalych"
if method == PaymentMethod.WATA:
return "WATA"
if method == PaymentMethod.HELEKET:
return "Heleket"
if method == PaymentMethod.YOOKASSA:
return "YooKassa"
if method == PaymentMethod.CRYPTOBOT:
return "CryptoBot"
if method == PaymentMethod.TELEGRAM_STARS:
return "Telegram Stars"
return method.value
def _status_info(
record: PendingPayment,
*,
texts,
) -> tuple[str, str]:
status = (record.status or "").lower()
if record.is_paid:
return "", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
if record.method == PaymentMethod.PAL24:
mapping = {
"new": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"process": ("", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
"success": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"fail": ("", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
"canceled": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
"cancel": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
}
return mapping.get(status, ("", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
if record.method == PaymentMethod.MULENPAY:
mapping = {
"created": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"processing": ("", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
"hold": ("🔒", texts.t("ADMIN_PAYMENT_STATUS_ON_HOLD", "🔒 Hold")),
"success": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"canceled": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
"cancel": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
"error": ("⚠️", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
}
return mapping.get(status, ("", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
if record.method == PaymentMethod.WATA:
mapping = {
"opened": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"pending": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"processing": ("", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
"paid": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"closed": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"declined": ("", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
"canceled": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
"expired": ("", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")),
}
return mapping.get(status, ("", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
if record.method == PaymentMethod.HELEKET:
if status in {"pending", "created", "waiting", "check", "processing"}:
return "", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")
if status in {"paid", "paid_over"}:
return "", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
if status in {"cancel", "canceled", "fail", "failed", "expired"}:
return "", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")
return "", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")
if record.method == PaymentMethod.YOOKASSA:
mapping = {
"pending": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"waiting_for_capture": ("", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
"succeeded": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"canceled": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
}
return mapping.get(status, ("", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
if record.method == PaymentMethod.CRYPTOBOT:
mapping = {
"active": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"paid": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"expired": ("", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")),
}
return mapping.get(status, ("", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
if record.method == PaymentMethod.TELEGRAM_STARS:
if record.is_paid:
return "", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
return "", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")
return "", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")
def _is_checkable(record: PendingPayment) -> bool:
if record.method not in SUPPORTED_MANUAL_CHECK_METHODS:
return False
if not record.is_recent():
return False
status = (record.status or "").lower()
if record.method == PaymentMethod.PAL24:
return status in {"new", "process"}
if record.method == PaymentMethod.MULENPAY:
return status in {"created", "processing", "hold"}
if record.method == PaymentMethod.WATA:
return status in {"opened", "pending", "processing", "inprogress", "in_progress"}
if record.method == PaymentMethod.HELEKET:
return status not in {"paid", "paid_over", "cancel", "canceled", "fail", "failed", "expired"}
if record.method == PaymentMethod.YOOKASSA:
return status in {"pending", "waiting_for_capture"}
if record.method == PaymentMethod.CRYPTOBOT:
return status in {"active"}
return False
def _record_display_number(record: PendingPayment) -> str:
if record.identifier:
return str(record.identifier)
return str(record.local_id)
def _build_list_keyboard(
records: list[PendingPayment],
*,
page: int,
total_pages: int,
language: str,
) -> InlineKeyboardMarkup:
buttons: list[list[InlineKeyboardButton]] = []
texts = get_texts(language)
for record in records:
number = _record_display_number(record)
details_template = texts.t("ADMIN_PAYMENTS_ITEM_DETAILS", "📄 #{number}")
try:
button_text = details_template.format(number=number)
except Exception: # pragma: no cover - fallback for broken localization
button_text = f"📄 {number}"
buttons.append(
[
InlineKeyboardButton(
text=button_text,
callback_data=f"admin_payment_{record.method.value}_{record.local_id}",
)
]
)
if total_pages > 1:
navigation_row: list[InlineKeyboardButton] = []
if page > 1:
navigation_row.append(
InlineKeyboardButton(
text="⬅️",
callback_data=f"admin_payments_page_{page - 1}",
)
)
navigation_row.append(
InlineKeyboardButton(
text=f"{page}/{total_pages}",
callback_data="admin_payments_page_current",
)
)
if page < total_pages:
navigation_row.append(
InlineKeyboardButton(
text="➡️",
callback_data=f"admin_payments_page_{page + 1}",
)
)
buttons.append(navigation_row)
buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def _build_detail_keyboard(
record: PendingPayment,
*,
language: str,
) -> InlineKeyboardMarkup:
texts = get_texts(language)
rows: list[list[InlineKeyboardButton]] = []
payment = record.payment
payment_url = getattr(payment, "payment_url", None)
if record.method == PaymentMethod.PAL24:
payment_url = payment.link_url or payment.link_page_url or payment_url
elif record.method == PaymentMethod.WATA:
payment_url = payment.url or payment_url
elif record.method == PaymentMethod.YOOKASSA:
payment_url = getattr(payment, "confirmation_url", None) or payment_url
elif record.method == PaymentMethod.CRYPTOBOT:
payment_url = (
payment.bot_invoice_url
or payment.mini_app_invoice_url
or payment.web_app_invoice_url
or payment_url
)
if payment_url:
rows.append(
[
InlineKeyboardButton(
text=texts.t("ADMIN_PAYMENT_OPEN_LINK", "🔗 Open link"),
url=payment_url,
)
]
)
if _is_checkable(record):
rows.append(
[
InlineKeyboardButton(
text=texts.t("ADMIN_PAYMENT_CHECK_BUTTON", "🔁 Check status"),
callback_data=f"admin_payment_check_{record.method.value}_{record.local_id}",
)
]
)
rows.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_payments")])
return InlineKeyboardMarkup(inline_keyboard=rows)
def _format_user_line(user: User) -> str:
username = format_username(user.username, user.telegram_id, user.full_name)
return f"👤 {html.escape(username)} (<code>{user.telegram_id}</code>)"
def _build_record_lines(
record: PendingPayment,
*,
index: int,
texts,
language: str,
) -> list[str]:
amount = settings.format_price(record.amount_kopeks)
if record.method == PaymentMethod.CRYPTOBOT:
crypto_amount = getattr(record.payment, "amount", None)
crypto_asset = getattr(record.payment, "asset", None)
if crypto_amount and crypto_asset:
amount = f"{crypto_amount} {crypto_asset}"
method_name = _method_display(record.method)
emoji, status_text = _status_info(record, texts=texts)
created = format_datetime(record.created_at)
age = format_time_ago(record.created_at, language)
identifier = (
html.escape(str(record.identifier)) if record.identifier else ""
)
display_number = html.escape(_record_display_number(record))
lines = [
f"{index}. <b>{html.escape(method_name)}</b> — {amount}",
f" {emoji} {status_text}",
f" 🕒 {created} ({age})",
_format_user_line(record.user),
]
if identifier:
lines.append(f" 🆔 ID: <code>{identifier}</code>")
else:
lines.append(f" 🆔 ID: <code>{display_number}</code>")
return lines
def _build_payment_details_text(record: PendingPayment, *, texts, language: str) -> str:
method_name = _method_display(record.method)
emoji, status_text = _status_info(record, texts=texts)
amount = settings.format_price(record.amount_kopeks)
if record.method == PaymentMethod.CRYPTOBOT:
crypto_amount = getattr(record.payment, "amount", None)
crypto_asset = getattr(record.payment, "asset", None)
if crypto_amount and crypto_asset:
amount = f"{crypto_amount} {crypto_asset}"
created = format_datetime(record.created_at)
age = format_time_ago(record.created_at, language)
raw_identifier = record.identifier if record.identifier else record.local_id
identifier = html.escape(str(raw_identifier)) if raw_identifier is not None else ""
lines = [
texts.t("ADMIN_PAYMENT_DETAILS_TITLE", "💳 <b>Payment details</b>"),
"",
f"<b>{html.escape(method_name)}</b>",
f"{emoji} {status_text}",
"",
f"💰 {texts.t('ADMIN_PAYMENT_AMOUNT', 'Amount')}: {amount}",
f"🕒 {texts.t('ADMIN_PAYMENT_CREATED', 'Created')}: {created} ({age})",
f"🆔 ID: <code>{identifier}</code>",
_format_user_line(record.user),
]
if record.expires_at:
expires_at = format_datetime(record.expires_at)
lines.append(f"{texts.t('ADMIN_PAYMENT_EXPIRES', 'Expires')}: {expires_at}")
payment = record.payment
if record.method == PaymentMethod.PAL24:
if getattr(payment, "payment_status", None):
lines.append(
f"💳 {texts.t('ADMIN_PAYMENT_GATEWAY_STATUS', 'Gateway status')}: "
f"{html.escape(str(payment.payment_status))}"
)
if getattr(payment, "payment_method", None):
lines.append(
f"🏦 {texts.t('ADMIN_PAYMENT_GATEWAY_METHOD', 'Method')}: "
f"{html.escape(str(payment.payment_method))}"
)
if getattr(payment, "balance_amount", None):
lines.append(
f"💱 {texts.t('ADMIN_PAYMENT_GATEWAY_AMOUNT', 'Gateway amount')}: "
f"{html.escape(str(payment.balance_amount))}"
)
if getattr(payment, "payer_account", None):
lines.append(
f"👛 {texts.t('ADMIN_PAYMENT_GATEWAY_ACCOUNT', 'Payer account')}: "
f"{html.escape(str(payment.payer_account))}"
)
if record.method == PaymentMethod.MULENPAY:
if getattr(payment, "mulen_payment_id", None):
lines.append(
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
f"{html.escape(str(payment.mulen_payment_id))}"
)
if record.method == PaymentMethod.WATA:
if getattr(payment, "order_id", None):
lines.append(
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
f"{html.escape(str(payment.order_id))}"
)
if getattr(payment, "terminal_public_id", None):
lines.append(
f"🏦 Terminal: {html.escape(str(payment.terminal_public_id))}"
)
if record.method == PaymentMethod.HELEKET:
if getattr(payment, "order_id", None):
lines.append(
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
f"{html.escape(str(payment.order_id))}"
)
if getattr(payment, "payer_amount", None) and getattr(payment, "payer_currency", None):
lines.append(
f"🪙 {texts.t('ADMIN_PAYMENT_PAYER_AMOUNT', 'Paid amount')}: "
f"{html.escape(str(payment.payer_amount))} {html.escape(str(payment.payer_currency))}"
)
if record.method == PaymentMethod.YOOKASSA:
if getattr(payment, "payment_method_type", None):
lines.append(
f"💳 {texts.t('ADMIN_PAYMENT_GATEWAY_METHOD', 'Method')}: "
f"{html.escape(str(payment.payment_method_type))}"
)
if getattr(payment, "confirmation_url", None):
lines.append(texts.t("ADMIN_PAYMENT_HAS_LINK", "🔗 Payment link is available above."))
if record.method == PaymentMethod.CRYPTOBOT:
if getattr(payment, "amount", None) and getattr(payment, "asset", None):
lines.append(
f"🪙 {texts.t('ADMIN_PAYMENT_CRYPTO_AMOUNT', 'Crypto amount')}: "
f"{html.escape(str(payment.amount))} {html.escape(str(payment.asset))}"
)
if getattr(payment, "bot_invoice_url", None) or getattr(payment, "mini_app_invoice_url", None):
lines.append(
texts.t("ADMIN_PAYMENT_HAS_LINK", "🔗 Payment link is available above.")
)
if getattr(payment, "status", None):
lines.append(
f"📊 {texts.t('ADMIN_PAYMENT_GATEWAY_STATUS', 'Gateway status')}: "
f"{html.escape(str(payment.status))}"
)
if record.method == PaymentMethod.TELEGRAM_STARS:
description = getattr(payment, "description", "") or ""
if description:
lines.append(f"📝 {html.escape(description)}")
if getattr(payment, "external_id", None):
lines.append(
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
f"{html.escape(str(payment.external_id))}"
)
if _is_checkable(record):
lines.append("")
lines.append(texts.t("ADMIN_PAYMENT_CHECK_HINT", "️ You can trigger a manual status check."))
return "\n".join(lines)
def _parse_method_and_id(payload: str, *, prefix: str) -> Optional[tuple[PaymentMethod, int]]:
suffix = payload[len(prefix) :]
try:
method_str, identifier = suffix.rsplit("_", 1)
method = PaymentMethod(method_str)
payment_id = int(identifier)
return method, payment_id
except (ValueError, KeyError):
return None
@admin_required
@error_handler
async def show_payments_overview(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
) -> None:
texts = get_texts(db_user.language)
page = 1
if callback.data.startswith("admin_payments_page_"):
try:
page = int(callback.data.split("_")[-1])
except ValueError:
page = 1
records = await list_recent_pending_payments(db)
total = len(records)
total_pages = max(1, math.ceil(total / PAGE_SIZE))
if page < 1:
page = 1
if page > total_pages:
page = total_pages
start_index = (page - 1) * PAGE_SIZE
page_records = records[start_index : start_index + PAGE_SIZE]
header = texts.t("ADMIN_PAYMENTS_TITLE", "💳 <b>Top-up verification</b>")
description = texts.t(
"ADMIN_PAYMENTS_DESCRIPTION",
"Pending invoices created during the last 24 hours.",
)
notice = texts.t(
"ADMIN_PAYMENTS_NOTICE",
"Only invoices younger than 24 hours and waiting for payment can be checked.",
)
lines = [header, "", description]
if page_records:
for idx, record in enumerate(page_records, start=start_index + 1):
lines.extend(_build_record_lines(record, index=idx, texts=texts, language=db_user.language))
lines.append("")
lines.append(notice)
else:
empty_text = texts.t("ADMIN_PAYMENTS_EMPTY", "No pending top-ups in the last 24 hours.")
lines.append("")
lines.append(empty_text)
keyboard = _build_list_keyboard(
page_records,
page=page,
total_pages=total_pages,
language=db_user.language,
)
await callback.message.edit_text(
"\n".join(line for line in lines if line is not None),
parse_mode="HTML",
reply_markup=keyboard,
)
await callback.answer()
async def _render_payment_details(
callback: types.CallbackQuery,
db_user: User,
record: PendingPayment,
) -> None:
texts = get_texts(db_user.language)
text = _build_payment_details_text(record, texts=texts, language=db_user.language)
keyboard = _build_detail_keyboard(record, language=db_user.language)
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
@admin_required
@error_handler
async def show_payment_details(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
) -> None:
parsed = _parse_method_and_id(callback.data, prefix="admin_payment_")
if not parsed:
await callback.answer("❌ Invalid payment reference", show_alert=True)
return
method, payment_id = parsed
record = await get_payment_record(db, method, payment_id)
if not record:
await callback.answer("❌ Платеж не найден", show_alert=True)
return
await _render_payment_details(callback, db_user, record)
await callback.answer()
@admin_required
@error_handler
async def manual_check_payment(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
) -> None:
parsed = _parse_method_and_id(callback.data, prefix="admin_payment_check_")
if not parsed:
await callback.answer("❌ Invalid payment reference", show_alert=True)
return
method, payment_id = parsed
record = await get_payment_record(db, method, payment_id)
texts = get_texts(db_user.language)
if not record:
await callback.answer(texts.t("ADMIN_PAYMENT_NOT_FOUND", "Payment not found."), show_alert=True)
return
if not _is_checkable(record):
await callback.answer(
texts.t("ADMIN_PAYMENT_CHECK_NOT_AVAILABLE", "Manual check is not available for this invoice."),
show_alert=True,
)
return
payment_service = PaymentService(callback.bot)
updated = await run_manual_check(db, method, payment_id, payment_service)
if not updated:
await callback.answer(
texts.t("ADMIN_PAYMENT_CHECK_FAILED", "Failed to refresh the payment status."),
show_alert=True,
)
return
await _render_payment_details(callback, db_user, updated)
if updated.status != record.status or updated.is_paid != record.is_paid:
emoji, status_text = _status_info(updated, texts=texts)
message = texts.t(
"ADMIN_PAYMENT_CHECK_SUCCESS",
"Status updated: {status}",
).format(status=f"{emoji} {status_text}")
else:
message = texts.t(
"ADMIN_PAYMENT_CHECK_NO_CHANGES",
"Status is unchanged after the check.",
)
await callback.answer(message, show_alert=True)
def register_handlers(dp: Dispatcher) -> None:
dp.callback_query.register(manual_check_payment, F.data.startswith("admin_payment_check_"))
dp.callback_query.register(
show_payment_details,
F.data.startswith("admin_payment_") & ~F.data.startswith("admin_payment_check_"),
)
dp.callback_query.register(show_payments_overview, F.data.startswith("admin_payments_page_"))
dp.callback_query.register(show_payments_overview, F.data == "admin_payments")
+29 -7
View File
@@ -1,6 +1,7 @@
import html
import logging
from aiogram import Dispatcher, types, F
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
@@ -182,15 +183,36 @@ async def show_payment_methods(
state: FSMContext
):
from app.utils.payment_utils import get_payment_methods_text
texts = get_texts(db_user.language)
payment_text = get_payment_methods_text(db_user.language)
await callback.message.edit_text(
payment_text,
reply_markup=get_payment_methods_keyboard(0, db_user.language),
parse_mode="HTML"
)
keyboard = get_payment_methods_keyboard(0, db_user.language)
try:
await callback.message.edit_text(
payment_text,
reply_markup=keyboard,
parse_mode="HTML"
)
except TelegramBadRequest:
try:
await callback.message.edit_caption(
payment_text,
reply_markup=keyboard,
parse_mode="HTML"
)
except TelegramBadRequest:
try:
await callback.message.delete()
except TelegramBadRequest:
pass
await callback.message.answer(
payment_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await callback.answer()
+20 -4
View File
@@ -677,6 +677,7 @@ async def handle_simple_subscription_payment_method(
try:
payment_service = PaymentService(callback.bot)
purchase_service = SubscriptionPurchaseService()
resolved_squad_uuid = await _ensure_simple_subscription_squad_uuid(
db,
@@ -696,8 +697,23 @@ async def handle_simple_subscription_payment_method(
if payment_method == "stars":
# Оплата через Telegram Stars
order = await purchase_service.create_subscription_order(
db=db,
user_id=db_user.id,
period_days=subscription_params["period_days"],
device_limit=subscription_params["device_limit"],
traffic_limit_gb=subscription_params["traffic_limit_gb"],
squad_uuid=resolved_squad_uuid,
payment_method="telegram_stars",
total_price_kopeks=price_kopeks,
)
if not order:
await callback.answer("❌ Не удалось подготовить заказ. Попробуйте позже.", show_alert=True)
return
stars_count = settings.rubles_to_stars(settings.kopeks_to_rubles(price_kopeks))
await callback.bot.send_invoice(
chat_id=callback.from_user.id,
title=f"Подписка на {subscription_params['period_days']} дней",
@@ -707,7 +723,9 @@ async def handle_simple_subscription_payment_method(
f"Устройства: {subscription_params['device_limit']}\n"
f"Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}"
),
payload=f"simple_sub_{db_user.id}_{subscription_params['period_days']}",
payload=(
f"simple_sub_{db_user.id}_{order.id}_{subscription_params['period_days']}"
),
provider_token="", # Пустой токен для Telegram Stars
currency="XTR", # Telegram Stars
prices=[types.LabeledPrice(label="Подписка", amount=stars_count)]
@@ -727,8 +745,6 @@ async def handle_simple_subscription_payment_method(
return
# Создаем заказ на подписку
purchase_service = SubscriptionPurchaseService()
order = await purchase_service.create_subscription_order(
db=db,
user_id=db_user.id,
+1 -1
View File
@@ -20,7 +20,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
try:
logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}")
allowed_prefixes = ("balance_", "admin_stars_test_")
allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_")
if not query.invoice_payload or not query.invoice_payload.startswith(allowed_prefixes):
logger.warning(f"Невалидный payload: {query.invoice_payload}")
+4
View File
@@ -56,6 +56,7 @@ from app.services.subscription_checkout_service import (
should_offer_checkout_resume,
)
from app.services.subscription_service import SubscriptionService
from app.services.user_cart_service import user_cart_service
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.services.promo_offer_service import promo_offer_service
from app.states import SubscriptionStates
@@ -259,6 +260,9 @@ async def handle_subscription_cancel(
await state.clear()
await clear_subscription_checkout_draft(db_user.id)
# Удаляем сохраненную корзину, чтобы не показывать кнопку возврата
await user_cart_service.delete_user_cart(db_user.id)
from app.handlers.menu import show_main_menu
await show_main_menu(callback, db_user, db)
+13 -2
View File
@@ -1069,12 +1069,16 @@ async def confirm_extend_subscription(
# Подготовим данные для сохранения в корзину
cart_data = {
'cart_mode': 'extend',
'subscription_id': subscription.id,
'period_days': days,
'total_price': price,
'user_id': db_user.id,
'saved_cart': True,
'missing_amount': missing_kopeks,
'return_to_cart': True
'return_to_cart': True,
'description': f"Продление подписки на {days} дней",
'consume_promo_offer': bool(promo_component["discount"] > 0),
}
await user_cart_service.save_user_cart(db_user.id, cart_data)
@@ -2624,12 +2628,19 @@ async def _extend_existing_subscription(
# Подготовим данные для сохранения в корзину
from app.services.user_cart_service import user_cart_service
cart_data = {
'cart_mode': 'extend',
'subscription_id': current_subscription.id,
'period_days': period_days,
'total_price': price_kopeks,
'user_id': db_user.id,
'saved_cart': True,
'missing_amount': missing_kopeks,
'return_to_cart': True
'return_to_cart': True,
'description': f"Продление подписки на {period_days} дней",
'device_limit': device_limit,
'traffic_limit_gb': traffic_limit_gb,
'squad_uuid': squad_uuid,
'consume_promo_offer': False,
}
await user_cart_service.save_user_cart(db_user.id, cart_data)
+6
View File
@@ -53,6 +53,12 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
callback_data="admin_submenu_system",
),
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_MAIN_PAYMENTS", "💳 Пополнения"),
callback_data="admin_payments",
)
],
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
])
+38
View File
@@ -130,6 +130,7 @@
"ADMIN_MAIN_SETTINGS": "⚙️ Settings",
"ADMIN_MAIN_SUPPORT": "🛟 Support",
"ADMIN_MAIN_SYSTEM": "🛠️ System",
"ADMIN_MAIN_PAYMENTS": "💳 Top-ups",
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Users / Subscriptions",
"ADMIN_MESSAGES": "📨 Broadcasts",
"ADMIN_MESSAGES_ALL_USERS": "📨 All users",
@@ -164,6 +165,39 @@
"ADMIN_MONITORING_STOP": "⏸️ Stop",
"ADMIN_MONITORING_STOP_HARD": "⏹️ Stop",
"ADMIN_MONITORING_TEST_NOTIFICATIONS": "🧪 Test notifications",
"ADMIN_PAYMENTS_TITLE": "💳 <b>Top-up verification</b>",
"ADMIN_PAYMENTS_DESCRIPTION": "Pending top-up invoices created during the last 24 hours.",
"ADMIN_PAYMENTS_NOTICE": "Only invoices younger than 24 hours and waiting for payment can be checked.",
"ADMIN_PAYMENTS_EMPTY": "No pending top-up invoices found in the last 24 hours.",
"ADMIN_PAYMENTS_ITEM_DETAILS": "📄 #{number}",
"ADMIN_PAYMENT_STATUS_PENDING": "Pending",
"ADMIN_PAYMENT_STATUS_PROCESSING": "Processing",
"ADMIN_PAYMENT_STATUS_PAID": "Paid",
"ADMIN_PAYMENT_STATUS_FAILED": "Failed",
"ADMIN_PAYMENT_STATUS_CANCELED": "Cancelled",
"ADMIN_PAYMENT_STATUS_UNKNOWN": "Unknown status",
"ADMIN_PAYMENT_STATUS_ON_HOLD": "On hold",
"ADMIN_PAYMENT_STATUS_EXPIRED": "Expired",
"ADMIN_PAYMENT_DETAILS_TITLE": "💳 <b>Payment details</b>",
"ADMIN_PAYMENT_AMOUNT": "Amount",
"ADMIN_PAYMENT_CREATED": "Created",
"ADMIN_PAYMENT_EXPIRES": "Expires",
"ADMIN_PAYMENT_GATEWAY_STATUS": "Gateway status",
"ADMIN_PAYMENT_GATEWAY_METHOD": "Method",
"ADMIN_PAYMENT_GATEWAY_AMOUNT": "Gateway amount",
"ADMIN_PAYMENT_GATEWAY_ACCOUNT": "Payer account",
"ADMIN_PAYMENT_GATEWAY_ID": "Gateway ID",
"ADMIN_PAYMENT_PAYER_AMOUNT": "Paid amount",
"ADMIN_PAYMENT_CRYPTO_AMOUNT": "Crypto amount",
"ADMIN_PAYMENT_HAS_LINK": "🔗 A payment link is available via the button above.",
"ADMIN_PAYMENT_OPEN_LINK": "🔗 Open link",
"ADMIN_PAYMENT_CHECK_BUTTON": "🔁 Check status",
"ADMIN_PAYMENT_CHECK_HINT": "️ You can trigger a manual status check.",
"ADMIN_PAYMENT_CHECK_NOT_AVAILABLE": "Manual status check is not available for this invoice.",
"ADMIN_PAYMENT_CHECK_FAILED": "Failed to refresh the payment status.",
"ADMIN_PAYMENT_CHECK_SUCCESS": "Status updated: {status}",
"ADMIN_PAYMENT_CHECK_NO_CHANGES": "Status did not change after the check.",
"ADMIN_PAYMENT_NOT_FOUND": "Payment not found.",
"ADMIN_NODE_DISABLE": "⏸️ Disable",
"ADMIN_NODE_ENABLE": "▶️ Enable",
"ADMIN_NODE_RESTART": "🔄 Restart",
@@ -746,6 +780,10 @@
"BALANCE_SUPPORT_REQUEST": "🛠️ Request via support",
"BALANCE_TOPUP": "💳 Top up balance",
"BALANCE_TOPUP_CART_REMINDER_DETAILED": "\\n💡 <b>Balance top-up required</b>\\n\\nYour cart contains items totaling {total_amount}, but your current balance is insufficient.\\n\\n💳 <b>Top up your balance</b> to complete the purchase.\\n\\nChoose a top-up method:",
"AUTO_PURCHASE_SUBSCRIPTION_SUCCESS": "✅ Your {period} subscription was purchased automatically after topping up your balance.",
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED": "✅ Subscription automatically extended for {period}.",
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS": "⏰ New expiration date: {date}.",
"AUTO_PURCHASE_SUBSCRIPTION_HINT": "Open the My subscription section to access your link and setup instructions.",
"BALANCE_TOP_UP": "💳 Top up",
"BLOCK_BY_TIME": "⏳ Temporary block",
"BLOCK_FOREVER": "🚫 Block permanently",
+38
View File
@@ -130,6 +130,7 @@
"ADMIN_MAIN_SETTINGS": "⚙️ Настройки",
"ADMIN_MAIN_SUPPORT": "🛟 Поддержка",
"ADMIN_MAIN_SYSTEM": "🛠️ Система",
"ADMIN_MAIN_PAYMENTS": "💳 Пополнения",
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки",
"ADMIN_MESSAGES": "📨 Рассылки",
"ADMIN_MESSAGES_ALL_USERS": "📨 Всем пользователям",
@@ -164,6 +165,39 @@
"ADMIN_MONITORING_STOP": "⏸️ Остановить",
"ADMIN_MONITORING_STOP_HARD": "⏹️ Остановить",
"ADMIN_MONITORING_TEST_NOTIFICATIONS": "🧪 Тест уведомлений",
"ADMIN_PAYMENTS_TITLE": "💳 <b>Проверка пополнений</b>",
"ADMIN_PAYMENTS_DESCRIPTION": "Список счетов на пополнение, созданных за последние 24 часа и ожидающих оплаты.",
"ADMIN_PAYMENTS_NOTICE": "Проверять можно только счета моложе 24 часов и со статусом ожидания.",
"ADMIN_PAYMENTS_EMPTY": "За последние 24 часа не найдено счетов на пополнение в ожидании.",
"ADMIN_PAYMENTS_ITEM_DETAILS": "📄 №{number}",
"ADMIN_PAYMENT_STATUS_PENDING": "Ожидает оплаты",
"ADMIN_PAYMENT_STATUS_PROCESSING": "Обрабатывается",
"ADMIN_PAYMENT_STATUS_PAID": "Оплачен",
"ADMIN_PAYMENT_STATUS_FAILED": "Ошибка",
"ADMIN_PAYMENT_STATUS_CANCELED": "Отменён",
"ADMIN_PAYMENT_STATUS_UNKNOWN": "Статус неизвестен",
"ADMIN_PAYMENT_STATUS_ON_HOLD": "На удержании",
"ADMIN_PAYMENT_STATUS_EXPIRED": "Просрочен",
"ADMIN_PAYMENT_DETAILS_TITLE": "💳 <b>Детали платежа</b>",
"ADMIN_PAYMENT_AMOUNT": "Сумма",
"ADMIN_PAYMENT_CREATED": "Создан",
"ADMIN_PAYMENT_EXPIRES": "Истекает",
"ADMIN_PAYMENT_GATEWAY_STATUS": "Статус в платёжке",
"ADMIN_PAYMENT_GATEWAY_METHOD": "Метод оплаты",
"ADMIN_PAYMENT_GATEWAY_AMOUNT": "Сумма в платёжке",
"ADMIN_PAYMENT_GATEWAY_ACCOUNT": "Счёт плательщика",
"ADMIN_PAYMENT_GATEWAY_ID": "ID в платёжке",
"ADMIN_PAYMENT_PAYER_AMOUNT": "Оплачено",
"ADMIN_PAYMENT_CRYPTO_AMOUNT": "Сумма в криптовалюте",
"ADMIN_PAYMENT_HAS_LINK": "🔗 Ссылка на оплату доступна в кнопке выше.",
"ADMIN_PAYMENT_OPEN_LINK": "🔗 Открыть ссылку",
"ADMIN_PAYMENT_CHECK_BUTTON": "🔁 Проверить статус",
"ADMIN_PAYMENT_CHECK_HINT": "ℹ️ Можно запустить ручную проверку статуса.",
"ADMIN_PAYMENT_CHECK_NOT_AVAILABLE": "Для этого счёта ручная проверка недоступна.",
"ADMIN_PAYMENT_CHECK_FAILED": "Не удалось обновить статус платежа.",
"ADMIN_PAYMENT_CHECK_SUCCESS": "Статус обновлён: {status}",
"ADMIN_PAYMENT_CHECK_NO_CHANGES": "Статус не изменился после проверки.",
"ADMIN_PAYMENT_NOT_FOUND": "Платёж не найден.",
"ADMIN_NODE_DISABLE": "⏸️ Отключить",
"ADMIN_NODE_ENABLE": "▶️ Включить",
"ADMIN_NODE_RESTART": "🔄 Перезагрузить",
@@ -746,6 +780,10 @@
"BALANCE_SUPPORT_REQUEST": "🛠️ Запрос через поддержку",
"BALANCE_TOPUP": "💳 Пополнить баланс",
"BALANCE_TOPUP_CART_REMINDER_DETAILED": "\n💡 <b>Требуется пополнение баланса</b>\n\nВ вашей корзине находятся товары на общую сумму {total_amount}, но на балансе недостаточно средств.\n\n💳 <b>Пополните баланс</b>, чтобы завершить покупку.\n\nВыберите способ пополнения:",
"AUTO_PURCHASE_SUBSCRIPTION_SUCCESS": "✅ Подписка на {period} автоматически оформлена после пополнения баланса.",
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED": "✅ Подписка автоматически продлена на {period}.",
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS": "⏰ Новая дата окончания: {date}.",
"AUTO_PURCHASE_SUBSCRIPTION_HINT": "Перейдите в раздел «Моя подписка», чтобы получить ссылку и инструкции.",
"BALANCE_TOP_UP": "💳 Пополнить",
"BLOCK_BY_TIME": "⏳ Блокировка по времени",
"BLOCK_FOREVER": "🚫 Заблокировать",
+23 -6
View File
@@ -1759,26 +1759,43 @@ class MonitoringService:
return []
async def get_monitoring_logs_count(
self,
self,
db: AsyncSession,
event_type: Optional[str] = None
) -> int:
try:
from sqlalchemy import select, func
query = select(func.count(MonitoringLog.id))
if event_type:
query = query.where(MonitoringLog.event_type == event_type)
result = await db.execute(query)
count = result.scalar()
return count or 0
except Exception as e:
logger.error(f"Ошибка получения количества логов: {e}")
return 0
async def get_monitoring_event_types(self, db: AsyncSession) -> List[str]:
try:
from sqlalchemy import select
result = await db.execute(
select(MonitoringLog.event_type)
.where(MonitoringLog.event_type.isnot(None))
.distinct()
.order_by(MonitoringLog.event_type)
)
return [row[0] for row in result.fetchall() if row[0]]
except Exception as e:
logger.error(f"Ошибка получения списка типов событий мониторинга: {e}")
return []
async def cleanup_old_logs(self, db: AsyncSession, days: int = 30) -> int:
try:
+131 -23
View File
@@ -1,6 +1,8 @@
import asyncio
import hashlib
import json
import logging
from typing import Optional, Dict, Any
from typing import Optional, Dict, Any, Tuple
import aiohttp
@@ -17,6 +19,10 @@ class MulenPayService:
self.shop_id = settings.MULENPAY_SHOP_ID
self.secret_key = settings.MULENPAY_SECRET_KEY
self.base_url = settings.MULENPAY_BASE_URL.rstrip("/")
self._timeout = aiohttp.ClientTimeout(total=30, connect=10, sock_read=25)
self._max_retries = 3
self._retry_delay = 0.5
self._retryable_statuses = {500, 502, 503, 504}
@property
def is_configured(self) -> bool:
@@ -45,31 +51,115 @@ class MulenPayService:
"Content-Type": "application/json",
}
try:
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.request(
last_error: Optional[BaseException] = None
for attempt in range(1, self._max_retries + 1):
try:
async with aiohttp.ClientSession(timeout=self._timeout) as session:
async with session.request(
method,
url,
headers=headers,
json=json_data,
params=params,
) as response:
data, raw_text = await self._deserialize_response(response)
if response.status >= 400:
logger.error(
"MulenPay API error %s %s: %s",
response.status,
endpoint,
raw_text,
)
if (
response.status in self._retryable_statuses
and attempt < self._max_retries
):
await self._sleep_with_backoff(attempt)
continue
return None
if data is None:
if raw_text:
logger.warning(
"MulenPay returned unexpected payload for %s: %s",
endpoint,
raw_text,
)
return None
return data
except asyncio.CancelledError:
logger.debug("MulenPay request cancelled: %s %s", method, endpoint)
raise
except asyncio.TimeoutError as error:
last_error = error
logger.warning(
"MulenPay request timeout (%s %s) attempt %s/%s",
method,
url,
headers=headers,
json=json_data,
params=params,
) as response:
data = await response.json(content_type=None)
endpoint,
attempt,
self._max_retries,
)
except aiohttp.ClientError as error:
last_error = error
logger.warning(
"MulenPay client error (%s %s) attempt %s/%s: %s",
method,
endpoint,
attempt,
self._max_retries,
error,
)
except Exception as error: # pragma: no cover - safety
logger.error("Unexpected MulenPay error: %s", error, exc_info=True)
return None
if response.status >= 400:
logger.error(
"MulenPay API error %s %s: %s", response.status, endpoint, data
)
return None
if attempt < self._max_retries:
await self._sleep_with_backoff(attempt)
return data
except aiohttp.ClientError as error:
logger.error("MulenPay API request error: %s", error)
return None
except Exception as error: # pragma: no cover - safety
logger.error("Unexpected MulenPay error: %s", error, exc_info=True)
return None
if isinstance(last_error, asyncio.TimeoutError):
logger.error(
"MulenPay request timed out after %s attempts: %s %s",
self._max_retries,
method,
endpoint,
)
elif last_error is not None:
logger.error(
"MulenPay request failed after %s attempts (%s %s): %s",
self._max_retries,
method,
endpoint,
last_error,
)
return None
async def _sleep_with_backoff(self, attempt: int) -> None:
await asyncio.sleep(self._retry_delay * attempt)
async def _deserialize_response(
self, response: aiohttp.ClientResponse
) -> Tuple[Optional[Dict[str, Any]], str]:
raw_text = await response.text()
if not raw_text:
return None, ""
content_type = response.headers.get("Content-Type", "")
if "json" in content_type.lower() or not content_type:
try:
return json.loads(raw_text), raw_text
except json.JSONDecodeError as error:
logger.error(
"Failed to decode MulenPay JSON response %s: %s",
response.url,
error,
)
return None, raw_text
return None, raw_text
@staticmethod
def _format_amount(amount_kopeks: int) -> str:
@@ -124,3 +214,21 @@ class MulenPayService:
async def get_payment(self, payment_id: int) -> Optional[Dict[str, Any]]:
return await self._request("GET", f"/v2/payments/{payment_id}")
async def list_payments(
self,
*,
offset: int = 0,
limit: int = 100,
uuid: Optional[str] = None,
status: Optional[int] = None,
) -> Optional[Dict[str, Any]]:
params = {
"offset": max(0, offset),
"limit": max(1, min(limit, 1000)),
}
if uuid:
params["uuid"] = uuid
if status is not None:
params["status"] = status
return await self._request("GET", "/v2/payments", params=params)
+108 -2
View File
@@ -11,6 +11,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.subscription_auto_purchase_service import (
auto_purchase_saved_cart_after_topup,
)
from app.utils.currency_converter import currency_converter
from app.utils.user_utils import format_referrer_info
@@ -294,9 +297,29 @@ class CryptoBotPaymentMixin:
try:
from app.services.user_cart_service import user_cart_service
from aiogram import types
has_saved_cart = await user_cart_service.has_user_cart(user.id)
auto_purchase_success = False
if has_saved_cart:
try:
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
db,
user,
bot=getattr(self, "bot", None),
)
except Exception as auto_error:
logger.error(
"Ошибка автоматической покупки подписки для пользователя %s: %s",
user.id,
auto_error,
exc_info=True,
)
if auto_purchase_success:
has_saved_cart = False
if has_saved_cart and getattr(self, "bot", None):
# Если у пользователя есть сохраненная корзина,
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
from app.localization.texts import get_texts
@@ -326,7 +349,10 @@ class CryptoBotPaymentMixin:
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
reply_markup=keyboard
)
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
logger.info(
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
user.id,
)
except Exception as e:
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
@@ -337,3 +363,83 @@ class CryptoBotPaymentMixin:
"Ошибка обработки CryptoBot webhook: %s", error, exc_info=True
)
return False
async def get_cryptobot_payment_status(
self,
db: AsyncSession,
local_payment_id: int,
) -> Optional[Dict[str, Any]]:
"""Запрашивает актуальный статус CryptoBot invoice и синхронизирует его."""
cryptobot_crud = import_module("app.database.crud.cryptobot")
payment = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id)
if not payment:
logger.warning("CryptoBot платеж %s не найден", local_payment_id)
return None
if not self.cryptobot_service:
logger.warning("CryptoBot сервис не инициализирован для ручной проверки")
return {"payment": payment}
invoice_id = payment.invoice_id
try:
invoices = await self.cryptobot_service.get_invoices(
invoice_ids=[invoice_id]
)
except Exception as error: # pragma: no cover - network errors
logger.error(
"Ошибка запроса статуса CryptoBot invoice %s: %s",
invoice_id,
error,
)
return {"payment": payment}
remote_invoice: Optional[Dict[str, Any]] = None
if invoices:
for item in invoices:
if str(item.get("invoice_id")) == str(invoice_id):
remote_invoice = item
break
if not remote_invoice:
logger.info(
"CryptoBot invoice %s не найден через API при ручной проверке",
invoice_id,
)
refreshed = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id)
return {"payment": refreshed or payment}
status = (remote_invoice.get("status") or "").lower()
paid_at_str = remote_invoice.get("paid_at")
paid_at = None
if paid_at_str:
try:
paid_at = datetime.fromisoformat(paid_at_str.replace("Z", "+00:00")).replace(
tzinfo=None
)
except Exception: # pragma: no cover - defensive parsing
paid_at = None
if status == "paid":
webhook_payload = {
"update_type": "invoice_paid",
"payload": {
"invoice_id": remote_invoice.get("invoice_id") or invoice_id,
"amount": remote_invoice.get("amount") or payment.amount,
"asset": remote_invoice.get("asset") or payment.asset,
"paid_at": paid_at_str,
"payload": remote_invoice.get("payload") or payment.payload,
},
}
await self.process_cryptobot_webhook(db, webhook_payload)
else:
if status and status != (payment.status or "").lower():
await cryptobot_crud.update_cryptobot_payment_status(
db,
invoice_id,
status,
paid_at,
)
refreshed = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id)
return {"payment": refreshed or payment}
+84 -13
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import logging
import secrets
import time
from datetime import datetime
from datetime import datetime, timezone, timedelta
from importlib import import_module
from typing import Any, Dict, Optional
@@ -236,6 +236,9 @@ class HeleketPaymentMixin:
except (ValueError, TypeError):
paid_at = None
if paid_at and paid_at.tzinfo is not None:
paid_at = paid_at.astimezone(timezone.utc).replace(tzinfo=None)
updated_payment = await heleket_crud.update_heleket_payment(
db,
payment.uuid,
@@ -414,27 +417,40 @@ class HeleketPaymentMixin:
logger.error("Heleket платеж с id=%s не найден", local_payment_id)
return None
payload: Optional[Dict[str, Any]] = None
try:
response = await self.heleket_service.get_payment_info( # type: ignore[union-attr]
uuid=payment.uuid,
order_id=payment.order_id,
)
except Exception as error: # pragma: no cover - defensive
logger.exception("Ошибка получения статуса Heleket платежа %s: %s", payment.uuid, error)
return payment
if not response:
logger.warning(
"Heleket API вернул пустой ответ при проверке платежа %s", payment.uuid
logger.exception(
"Ошибка получения статуса Heleket платежа %s: %s",
payment.uuid,
error,
)
return payment
else:
if response:
result = response.get("result") if isinstance(response, dict) else None
if isinstance(result, dict):
payload = dict(result)
else:
logger.error(
"Некорректный ответ Heleket API при проверке платежа %s: %s",
payment.uuid,
response,
)
result = response.get("result") if isinstance(response, dict) else None
if not isinstance(result, dict):
logger.error("Некорректный ответ Heleket API при проверке платежа %s: %s", payment.uuid, response)
return payment
if payload is None:
fallback = await self._lookup_heleket_payment_history(payment)
if not fallback:
logger.warning(
"Heleket API не вернул информацию по платежу %s",
payment.uuid,
)
return payment
payload = dict(fallback)
payload: Dict[str, Any] = dict(result)
payload.setdefault("uuid", payment.uuid)
payload.setdefault("order_id", payment.order_id)
@@ -445,3 +461,58 @@ class HeleketPaymentMixin:
)
return updated_payment or payment
async def _lookup_heleket_payment_history(
self,
payment: "HeleketPayment",
) -> Optional[Dict[str, Any]]:
service = getattr(self, "heleket_service", None)
if not service:
return None
created_at = getattr(payment, "created_at", None)
date_from_str: Optional[str] = None
date_to_str: Optional[str] = None
if isinstance(created_at, datetime):
start = created_at - timedelta(days=2)
end = created_at + timedelta(days=2)
date_from_str = start.strftime("%Y-%m-%d %H:%M:%S")
date_to_str = end.strftime("%Y-%m-%d %H:%M:%S")
cursor: Optional[str] = None
for _ in range(10):
response = await service.list_payments(
date_from=date_from_str,
date_to=date_to_str,
cursor=cursor,
)
if not response or not isinstance(response, dict):
return None
result = response.get("result")
if not isinstance(result, dict):
return None
items = result.get("items")
if isinstance(items, list):
for item in items:
if not isinstance(item, dict):
continue
uuid = str(item.get("uuid") or "").strip()
order_id = str(item.get("order_id") or "").strip()
if uuid and uuid == str(payment.uuid):
return item
if order_id and order_id == str(payment.order_id):
return item
paginate = result.get("paginate")
cursor = None
if isinstance(paginate, dict):
next_cursor = paginate.get("nextCursor")
if isinstance(next_cursor, str) and next_cursor:
cursor = next_cursor
if not cursor:
break
return None
+80 -30
View File
@@ -11,6 +11,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.subscription_auto_purchase_service import (
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
@@ -325,9 +328,29 @@ class MulenPayPaymentMixin:
try:
from app.services.user_cart_service import user_cart_service
from aiogram import types
has_saved_cart = await user_cart_service.has_user_cart(user.id)
auto_purchase_success = False
if has_saved_cart:
try:
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
db,
user,
bot=getattr(self, "bot", None),
)
except Exception as auto_error:
logger.error(
"Ошибка автоматической покупки подписки для пользователя %s: %s",
user.id,
auto_error,
exc_info=True,
)
if auto_purchase_success:
has_saved_cart = False
if has_saved_cart and getattr(self, "bot", None):
# Если у пользователя есть сохраненная корзина,
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
from app.localization.texts import get_texts
@@ -359,7 +382,10 @@ class MulenPayPaymentMixin:
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
reply_markup=keyboard
)
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
logger.info(
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
user.id,
)
except Exception as e:
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
@@ -440,35 +466,59 @@ class MulenPayPaymentMixin:
response = await self.mulenpay_service.get_payment(
payment.mulen_payment_id
)
if response and response.get("success"):
remote_data = response.get("payment")
if isinstance(remote_data, dict):
remote_status_code = remote_data.get("status")
mapped_status = self._map_mulenpay_status(remote_status_code)
if response:
if isinstance(response, dict) and response.get("success"):
remote_data = response.get("payment")
elif isinstance(response, dict) and "status" in response and "id" in response:
remote_data = response
if not remote_data and getattr(self, "mulenpay_service", None):
list_response = await self.mulenpay_service.list_payments(
limit=100,
uuid=payment.uuid,
)
items = []
if isinstance(list_response, dict):
items = list_response.get("items") or []
if items:
for candidate in items:
if not isinstance(candidate, dict):
continue
candidate_id = candidate.get("id")
candidate_uuid = candidate.get("uuid")
if (
(candidate_id is not None and candidate_id == payment.mulen_payment_id)
or (candidate_uuid and candidate_uuid == payment.uuid)
):
remote_data = candidate
break
if mapped_status == "success" and not payment.is_paid:
await self.process_mulenpay_callback(
db,
{
"uuid": payment.uuid,
"payment_status": "success",
"id": remote_data.get("id"),
"amount": remote_data.get("amount"),
},
)
payment = await payment_module.get_mulenpay_payment_by_local_id(
db, local_payment_id
)
elif mapped_status and mapped_status != payment.status:
await payment_module.update_mulenpay_payment_status(
db,
payment=payment,
status=mapped_status,
mulen_payment_id=remote_data.get("id"),
)
payment = await payment_module.get_mulenpay_payment_by_local_id(
db, local_payment_id
)
if isinstance(remote_data, dict):
remote_status_code = remote_data.get("status")
mapped_status = self._map_mulenpay_status(remote_status_code)
if mapped_status == "success" and not payment.is_paid:
await self.process_mulenpay_callback(
db,
{
"uuid": payment.uuid,
"payment_status": "success",
"id": remote_data.get("id"),
"amount": remote_data.get("amount"),
},
)
payment = await payment_module.get_mulenpay_payment_by_local_id(
db, local_payment_id
)
elif mapped_status and mapped_status != payment.status:
await payment_module.update_mulenpay_payment_status(
db,
payment=payment,
status=mapped_status,
mulen_payment_id=remote_data.get("id"),
)
payment = await payment_module.get_mulenpay_payment_by_local_id(
db, local_payment_id
)
return {
"payment": payment,
+219 -44
View File
@@ -6,13 +6,16 @@ import logging
from datetime import datetime
from importlib import import_module
import uuid
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.pal24_service import Pal24APIError
from app.services.subscription_auto_purchase_service import (
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
@@ -434,6 +437,25 @@ class Pal24PaymentMixin:
from aiogram import types
has_saved_cart = await user_cart_service.has_user_cart(user.id)
auto_purchase_success = False
if has_saved_cart:
try:
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
db,
user,
bot=getattr(self, "bot", None),
)
except Exception as auto_error:
logger.error(
"Ошибка автоматической покупки подписки для пользователя %s: %s",
user.id,
auto_error,
exc_info=True,
)
if auto_purchase_success:
has_saved_cart = False
if has_saved_cart and getattr(self, "bot", None):
from app.localization.texts import get_texts
@@ -475,6 +497,11 @@ class Pal24PaymentMixin:
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
user.id,
)
else:
logger.info(
"У пользователя %s нет сохраненной корзины или автопокупка выполнена",
user.id,
)
except Exception as error:
logger.error(
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
@@ -507,53 +534,135 @@ class Pal24PaymentMixin:
return None
remote_status: Optional[str] = None
remote_data: Optional[Dict[str, Any]] = None
remote_payloads: Dict[str, Any] = {}
payment_info_candidates: List[Dict[str, Optional[str]]] = []
service = getattr(self, "pal24_service", None)
if service and payment.bill_id:
bill_id_str = str(payment.bill_id)
try:
response = await service.get_bill_status(payment.bill_id)
remote_data = response
remote_status = response.get("status") or response.get("bill", {}).get("status")
payment_info = self._extract_remote_payment_info(response)
if remote_status:
normalized_remote = str(remote_status).upper()
update_kwargs: Dict[str, Any] = {
"status": normalized_remote,
"payment_status": payment_info.get("status") or remote_status,
}
if payment_info.get("id"):
update_kwargs["payment_id"] = payment_info["id"]
if payment_info.get("method"):
update_kwargs["payment_method"] = payment_info["method"]
if payment_info.get("balance_amount"):
update_kwargs["balance_amount"] = payment_info["balance_amount"]
if payment_info.get("balance_currency"):
update_kwargs["balance_currency"] = payment_info["balance_currency"]
if payment_info.get("account"):
update_kwargs["payer_account"] = payment_info["account"]
if normalized_remote in getattr(service, "BILL_SUCCESS_STATES", {"SUCCESS"}):
update_kwargs["is_paid"] = True
if not payment.paid_at:
update_kwargs["paid_at"] = datetime.utcnow()
elif normalized_remote in getattr(service, "BILL_FAILED_STATES", {"FAIL"}):
update_kwargs["is_paid"] = False
elif normalized_remote in getattr(service, "BILL_PENDING_STATES", {"NEW", "PROCESS"}):
update_kwargs.setdefault("is_paid", False)
payment = await payment_module.update_pal24_payment_status(
db,
payment,
**update_kwargs,
)
response = await service.get_bill_status(bill_id_str)
except Pal24APIError as error:
logger.error(
"Ошибка Pal24 API при получении статуса: %s", error
)
logger.error("Ошибка Pal24 API при получении статуса счёта: %s", error)
else:
if response:
remote_payloads["bill_status"] = response
status_value = response.get("status") or (response.get("bill") or {}).get("status")
if status_value:
remote_status = str(status_value).upper()
extracted = self._extract_remote_payment_info(response)
if extracted:
payment_info_candidates.append(extracted)
if payment.payment_id:
payment_id_str = str(payment.payment_id)
try:
payment_response = await service.get_payment_status(payment_id_str)
except Pal24APIError as error:
logger.error("Ошибка Pal24 API при получении статуса платежа: %s", error)
else:
if payment_response:
remote_payloads["payment_status"] = payment_response
extracted = self._extract_remote_payment_info(payment_response)
if extracted:
payment_info_candidates.append(extracted)
try:
payments_response = await service.get_bill_payments(bill_id_str)
except Pal24APIError as error:
logger.error("Ошибка Pal24 API при получении списка платежей: %s", error)
else:
if payments_response:
remote_payloads["bill_payments"] = payments_response
for candidate in self._collect_payment_candidates(payments_response):
extracted = self._extract_remote_payment_info(candidate)
if extracted:
payment_info_candidates.append(extracted)
payment_info = self._select_best_payment_info(payment, payment_info_candidates)
if payment_info:
remote_payloads.setdefault("selected_payment", payment_info)
bill_success = getattr(service, "BILL_SUCCESS_STATES", {"SUCCESS"}) if service else {"SUCCESS"}
bill_failed = getattr(service, "BILL_FAILED_STATES", {"FAIL"}) if service else {"FAIL"}
bill_pending = getattr(service, "BILL_PENDING_STATES", {"NEW", "PROCESS"}) if service else {"NEW", "PROCESS"}
update_status = payment.status or "NEW"
update_kwargs: Dict[str, Any] = {}
is_paid_update: Optional[bool] = None
if remote_status:
update_status = remote_status
if remote_status in bill_success:
is_paid_update = True
elif remote_status in bill_failed:
is_paid_update = False
elif remote_status in bill_pending and is_paid_update is None:
is_paid_update = False
payment_status_code: Optional[str] = None
if payment_info:
payment_status_code = (payment_info.get("status") or "").upper() or None
if payment_status_code:
existing_status = (getattr(payment, "payment_status", "") or "").upper()
if payment_status_code != existing_status:
update_kwargs["payment_status"] = payment_status_code
payment_id_value = payment_info.get("id")
if payment_id_value and payment_id_value != (payment.payment_id or ""):
update_kwargs["payment_id"] = payment_id_value
method_value = payment_info.get("method")
if method_value:
normalized_method = self._normalize_payment_method(method_value)
if normalized_method != (payment.payment_method or ""):
update_kwargs["payment_method"] = normalized_method
balance_amount = payment_info.get("balance_amount")
if balance_amount and balance_amount != (payment.balance_amount or ""):
update_kwargs["balance_amount"] = balance_amount
balance_currency = payment_info.get("balance_currency")
if balance_currency and balance_currency != (payment.balance_currency or ""):
update_kwargs["balance_currency"] = balance_currency
payer_account = payment_info.get("account")
if payer_account and payer_account != (payment.payer_account or ""):
update_kwargs["payer_account"] = payer_account
if payment_status_code:
success_states = {"SUCCESS", "OVERPAID"}
failed_states = {"FAIL"}
pending_states = {"NEW", "PROCESS", "UNDERPAID"}
if payment_status_code in success_states:
is_paid_update = True
elif payment_status_code in failed_states and is_paid_update is not True:
is_paid_update = False
elif payment_status_code in pending_states and is_paid_update is None:
is_paid_update = False
if not remote_status and payment_status_code:
update_status = payment_status_code
if is_paid_update is not None and is_paid_update != bool(payment.is_paid):
update_kwargs["is_paid"] = is_paid_update
if is_paid_update and not payment.paid_at:
update_kwargs.setdefault("paid_at", datetime.utcnow())
current_status = payment.status or ""
effective_status = update_status or current_status or "NEW"
needs_update = bool(update_kwargs) or effective_status != current_status
if needs_update:
payment = await payment_module.update_pal24_payment_status(
db,
payment,
status=effective_status,
**update_kwargs,
)
remote_status_for_return = remote_status or payment_status_code
remote_data = remote_payloads or None
if payment.is_paid and not payment.transaction_id:
try:
@@ -576,7 +685,7 @@ class Pal24PaymentMixin:
"payment": payment,
"status": payment.status,
"is_paid": payment.is_paid,
"remote_status": remote_status,
"remote_status": remote_status_for_return,
"remote_data": remote_data,
}
@@ -621,11 +730,26 @@ class Pal24PaymentMixin:
or candidate.get("payer_account")
or candidate.get("AccountNumber")
),
"bill_id": _stringify(
candidate.get("bill_id")
or candidate.get("BillId")
or candidate.get("billId")
),
}
if not isinstance(remote_data, dict):
return {}
lower_keys = {str(key).lower() for key in remote_data.keys()}
has_status = any(key in lower_keys for key in ("status", "payment_status"))
has_identifier = any(
key in lower_keys
for key in ("payment_id", "from_card", "account_amount", "id")
) or "bill_id" in lower_keys
if has_status and has_identifier and "bill" not in lower_keys:
return _normalize(remote_data)
search_spaces = [remote_data]
bill_section = remote_data.get("bill") or remote_data.get("Bill")
if isinstance(bill_section, dict):
@@ -641,8 +765,59 @@ class Pal24PaymentMixin:
if candidate:
return _normalize(candidate)
data_section = remote_data.get("data") or remote_data.get("Data")
candidate = _pick_candidate(data_section)
if candidate:
return _normalize(candidate)
return {}
@staticmethod
def _collect_payment_candidates(remote_data: Any) -> List[Dict[str, Any]]:
candidates: List[Dict[str, Any]] = []
def _visit(value: Any) -> None:
if isinstance(value, dict):
lower_keys = {str(key).lower() for key in value.keys()}
has_status = any(key in lower_keys for key in ("status", "payment_status"))
has_identifier = any(
key in lower_keys
for key in ("id", "payment_id", "bill_id", "from_card", "account_amount")
)
if has_status and has_identifier and value not in candidates:
candidates.append(value)
for nested in value.values():
_visit(nested)
elif isinstance(value, list):
for item in value:
_visit(item)
_visit(remote_data)
return candidates
@staticmethod
def _select_best_payment_info(
payment: Any,
candidates: List[Dict[str, Optional[str]]],
) -> Dict[str, Optional[str]]:
if not candidates:
return {}
payment_id = str(getattr(payment, "payment_id", "") or "")
bill_id = str(getattr(payment, "bill_id", "") or "")
for candidate in candidates:
candidate_id = str(candidate.get("id") or "")
if payment_id and candidate_id == payment_id:
return candidate
for candidate in candidates:
candidate_bill = str(candidate.get("bill_id") or "")
if bill_id and candidate_bill == bill_id:
return candidate
return candidates[0]
@staticmethod
def _normalize_payment_method(payment_method: Optional[str]) -> str:
mapping = {
+507 -171
View File
@@ -7,6 +7,7 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal, ROUND_FLOOR, ROUND_HALF_UP
from typing import Optional
@@ -19,11 +20,22 @@ from app.database.crud.transaction import create_transaction
from app.database.crud.user import get_user_by_id
from app.database.models import PaymentMethod, TransactionType
from app.external.telegram_stars import TelegramStarsService
from app.services.subscription_auto_purchase_service import (
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class _SimpleSubscriptionPayload:
"""Данные для простой подписки, извлечённые из payload звёздного платежа."""
subscription_id: Optional[int]
period_days: Optional[int]
class TelegramStarsMixin:
"""Mixin с операциями создания и обработки платежей через Telegram Stars."""
@@ -85,7 +97,6 @@ class TelegramStarsMixin:
telegram_payment_charge_id: str,
) -> bool:
"""Финализирует платеж, пришедший из Telegram Stars, и обновляет баланс пользователя."""
del payload # payload пока не используется, но оставляем аргумент для совместимости.
try:
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(
stars_amount
@@ -96,12 +107,28 @@ class TelegramStarsMixin:
)
)
simple_payload = self._parse_simple_subscription_payload(
payload,
user_id,
)
transaction_description = (
f"Оплата подписки через Telegram Stars ({stars_amount} ⭐)"
if simple_payload
else f"Пополнение через Telegram Stars ({stars_amount} ⭐)"
)
transaction_type = (
TransactionType.SUBSCRIPTION_PAYMENT
if simple_payload
else TransactionType.DEPOSIT
)
transaction = await create_transaction(
db=db,
user_id=user_id,
type=TransactionType.DEPOSIT,
type=transaction_type,
amount_kopeks=amount_kopeks,
description=f"Пополнение через Telegram Stars ({stars_amount} ⭐)",
description=transaction_description,
payment_method=PaymentMethod.TELEGRAM_STARS,
external_id=telegram_payment_charge_id,
is_completed=True,
@@ -115,178 +142,487 @@ class TelegramStarsMixin:
)
return False
# Запоминаем старые значения, чтобы корректно построить уведомления.
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
# Обновляем баланс в БД.
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = getattr(user, "promo_group", None)
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = (
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
)
await db.commit()
description_for_referral = (
f"Пополнение Stars: {settings.format_price(amount_kopeks)} ({stars_amount} ⭐)"
)
logger.info(
"🔍 Проверка реферальной логики для описания: '%s'",
description_for_referral,
)
lower_description = description_for_referral.lower()
contains_allowed_keywords = any(
word in lower_description
for word in ["пополнение", "stars", "yookassa", "topup"]
)
contains_forbidden_keywords = any(
word in lower_description for word in ["комиссия", "бонус"]
)
allow_referral = contains_allowed_keywords and not contains_forbidden_keywords
if allow_referral:
logger.info(
"🔞 Вызов process_referral_topup для пользователя %s",
user_id,
)
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db, user_id, amount_kopeks, getattr(self, "bot", None)
)
except Exception as error:
logger.error(
"Ошибка обработки реферального пополнения: %s", error
)
else:
logger.info(
"❌ Описание '%s' не подходит для реферальной логики",
description_for_referral,
if simple_payload:
return await self._finalize_simple_subscription_stars_payment(
db=db,
user=user,
transaction=transaction,
amount_kopeks=amount_kopeks,
stars_amount=stars_amount,
payload_data=simple_payload,
telegram_payment_charge_id=telegram_payment_charge_id,
)
if was_first_topup and not user.has_made_first_topup:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
logger.info(
"💰 Баланс пользователя %s изменен: %s%s (Δ +%s)",
user.telegram_id,
old_balance,
user.balance_kopeks,
amount_kopeks,
return await self._finalize_stars_balance_topup(
db=db,
user=user,
transaction=transaction,
amount_kopeks=amount_kopeks,
stars_amount=stars_amount,
telegram_payment_charge_id=telegram_payment_charge_id,
)
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import (
AdminNotificationService,
)
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления о пополнении Stars: %s",
error,
exc_info=True
)
if getattr(self, "bot", None):
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
"✅ <b>Пополнение успешно!</b>\n\n"
f"⭐ Звезд: {stars_amount}\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
"🦊 Способ: Telegram Stars\n"
f"🆔 Транзакция: {telegram_payment_charge_id[:8]}...\n\n"
"Баланс пополнен автоматически!"
),
parse_mode="HTML",
reply_markup=keyboard,
)
logger.info(
"✅ Отправлено уведомление пользователю %s о пополнении на %s",
user.telegram_id,
settings.format_price(amount_kopeks),
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления о пополнении Stars: %s",
error,
)
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
try:
from app.services.user_cart_service import user_cart_service
from aiogram import types
has_saved_cart = await user_cart_service.has_user_cart(user.id)
if has_saved_cart and getattr(self, "bot", None):
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
from app.localization.texts import get_texts
texts = get_texts(user.language)
cart_message = texts.t(
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
"🛒 У вас есть неоформленный заказ.\n\n"
"Вы можете продолжить оформление с теми же параметрами."
)
# Создаем клавиатуру с кнопками
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout"
)],
[types.InlineKeyboardButton(
text="💰 Мой баланс",
callback_data="menu_balance"
)],
[types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu"
)]
])
await self.bot.send_message(
chat_id=user.telegram_id,
text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n{cart_message}",
reply_markup=keyboard
)
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
except Exception as e:
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
logger.info(
"✅ Обработан Stars платеж: пользователь %s, %s звезд → %s",
user_id,
stars_amount,
settings.format_price(amount_kopeks),
)
return True
except Exception as error:
logger.error("Ошибка обработки Stars платежа: %s", error, exc_info=True)
return False
@staticmethod
def _parse_simple_subscription_payload(
payload: str,
expected_user_id: int,
) -> Optional[_SimpleSubscriptionPayload]:
"""Пытается извлечь параметры простой подписки из payload звёздного платежа."""
prefix = "simple_sub_"
if not payload or not payload.startswith(prefix):
return None
tail = payload[len(prefix) :]
parts = tail.split("_", 2)
if len(parts) < 3:
logger.warning(
"Payload Stars simple subscription имеет некорректный формат: %s",
payload,
)
return None
user_part, subscription_part, period_part = parts
try:
payload_user_id = int(user_part)
except ValueError:
logger.warning(
"Не удалось разобрать user_id в payload Stars simple subscription: %s",
payload,
)
return None
if payload_user_id != expected_user_id:
logger.warning(
"Получен payload Stars simple subscription с чужим user_id: %s (ожидался %s)",
payload_user_id,
expected_user_id,
)
return None
try:
subscription_id = int(subscription_part)
except ValueError:
logger.warning(
"Не удалось разобрать subscription_id в payload Stars simple subscription: %s",
payload,
)
return None
period_days: Optional[int] = None
try:
period_days = int(period_part)
except ValueError:
logger.warning(
"Не удалось разобрать период в payload Stars simple subscription: %s",
payload,
)
return _SimpleSubscriptionPayload(
subscription_id=subscription_id,
period_days=period_days,
)
async def _finalize_simple_subscription_stars_payment(
self,
db: AsyncSession,
user,
transaction,
amount_kopeks: int,
stars_amount: int,
payload_data: _SimpleSubscriptionPayload,
telegram_payment_charge_id: str,
) -> bool:
"""Активация простой подписки, оплаченной через Telegram Stars."""
period_days = payload_data.period_days or settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS
pending_subscription = None
if payload_data.subscription_id is not None:
try:
from sqlalchemy import select
from app.database.models import Subscription
result = await db.execute(
select(Subscription).where(
Subscription.id == payload_data.subscription_id,
Subscription.user_id == user.id,
)
)
pending_subscription = result.scalar_one_or_none()
except Exception as lookup_error: # pragma: no cover - диагностический лог
logger.error(
"Ошибка поиска pending подписки %s для пользователя %s: %s",
payload_data.subscription_id,
user.id,
lookup_error,
exc_info=True,
)
pending_subscription = None
if not pending_subscription:
logger.error(
"Не найдена pending подписка %s для пользователя %s",
payload_data.subscription_id,
user.id,
)
return False
if payload_data.period_days is None:
start_point = pending_subscription.start_date or datetime.utcnow()
end_point = pending_subscription.end_date or start_point
computed_days = max(1, (end_point - start_point).days or 0)
period_days = max(period_days, computed_days)
try:
from app.database.crud.subscription import activate_pending_subscription
subscription = await activate_pending_subscription(
db=db,
user_id=user.id,
period_days=period_days,
)
except Exception as error:
logger.error(
"Ошибка активации pending подписки для пользователя %s: %s",
user.id,
error,
exc_info=True,
)
return False
if not subscription:
logger.error(
"Не удалось активировать pending подписку пользователя %s",
user.id,
)
return False
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
remnawave_user = await subscription_service.create_remnawave_user(
db,
subscription,
)
if remnawave_user:
await db.refresh(subscription)
except Exception as sync_error: # pragma: no cover - диагностический лог
logger.error(
"Ошибка синхронизации подписки с RemnaWave для пользователя %s: %s",
user.id,
sync_error,
exc_info=True,
)
period_display = period_days
if not period_display and getattr(subscription, "start_date", None) and getattr(
subscription, "end_date", None
):
period_display = max(1, (subscription.end_date - subscription.start_date).days or 0)
if not period_display:
period_display = settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS
if getattr(self, "bot", None):
try:
from aiogram import types
from app.localization.texts import get_texts
texts = get_texts(user.language)
traffic_limit = getattr(subscription, "traffic_limit_gb", 0) or 0
traffic_label = (
"Безлимит" if traffic_limit == 0 else f"{int(traffic_limit)} ГБ"
)
success_message = (
"✅ <b>Подписка успешно активирована!</b>\n\n"
f"📅 Период: {period_display} дней\n"
f"📱 Устройства: {getattr(subscription, 'device_limit', 1)}\n"
f"📊 Трафик: {traffic_label}\n"
f"⭐ Оплата: {stars_amount} ⭐ ({settings.format_price(amount_kopeks)})\n\n"
"🔗 Для подключения перейдите в раздел 'Моя подписка'"
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text="📱 Моя подписка",
callback_data="menu_subscription",
)
],
[
types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu",
)
],
]
)
await self.bot.send_message(
chat_id=user.telegram_id,
text=success_message,
reply_markup=keyboard,
parse_mode="HTML",
)
logger.info(
"✅ Пользователь %s получил уведомление об оплате подписки через Stars",
user.telegram_id,
)
except Exception as error: # pragma: no cover - диагностический лог
logger.error(
"Ошибка отправки уведомления о подписке через Stars: %s",
error,
exc_info=True,
)
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self.bot)
await notification_service.send_subscription_purchase_notification(
db,
user,
subscription,
transaction,
period_display,
was_trial_conversion=False,
)
except Exception as admin_error: # pragma: no cover - диагностический лог
logger.error(
"Ошибка уведомления администраторов о подписке через Stars: %s",
admin_error,
exc_info=True,
)
logger.info(
"✅ Обработан Stars платеж как покупка подписки: пользователь %s, %s звезд → %s",
user.id,
stars_amount,
settings.format_price(amount_kopeks),
)
return True
async def _finalize_stars_balance_topup(
self,
db: AsyncSession,
user,
transaction,
amount_kopeks: int,
stars_amount: int,
telegram_payment_charge_id: str,
) -> bool:
"""Начисляет баланс пользователю после оплаты Stars и запускает автопокупку."""
# Запоминаем старые значения, чтобы корректно построить уведомления.
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
# Обновляем баланс в БД.
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = getattr(user, "promo_group", None)
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
await db.commit()
description_for_referral = (
f"Пополнение Stars: {settings.format_price(amount_kopeks)} ({stars_amount} ⭐)"
)
logger.info(
"🔍 Проверка реферальной логики для описания: '%s'",
description_for_referral,
)
lower_description = description_for_referral.lower()
contains_allowed_keywords = any(
word in lower_description for word in ["пополнение", "stars", "yookassa", "topup"]
)
contains_forbidden_keywords = any(
word in lower_description for word in ["комиссия", "бонус"]
)
allow_referral = contains_allowed_keywords and not contains_forbidden_keywords
if allow_referral:
logger.info(
"🔞 Вызов process_referral_topup для пользователя %s",
user.id,
)
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
amount_kopeks,
getattr(self, "bot", None),
)
except Exception as error: # pragma: no cover - диагностический лог
logger.error(
"Ошибка обработки реферального пополнения: %s",
error,
)
else:
logger.info(
"❌ Описание '%s' не подходит для реферальной логики",
description_for_referral,
)
if was_first_topup and not user.has_made_first_topup:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
logger.info(
"💰 Баланс пользователя %s изменен: %s%s (Δ +%s)",
user.telegram_id,
old_balance,
user.balance_kopeks,
amount_kopeks,
)
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error: # pragma: no cover - диагностический лог
logger.error(
"Ошибка отправки уведомления о пополнении Stars: %s",
error,
exc_info=True,
)
if getattr(self, "bot", None):
try:
keyboard = await self.build_topup_success_keyboard(user)
charge_id_short = (telegram_payment_charge_id or getattr(transaction, "external_id", ""))[:8]
await self.bot.send_message(
user.telegram_id,
(
"✅ <b>Пополнение успешно!</b>\n\n"
f"⭐ Звезд: {stars_amount}\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
"🦊 Способ: Telegram Stars\n"
f"🆔 Транзакция: {charge_id_short}...\n\n"
"Баланс пополнен автоматически!"
),
parse_mode="HTML",
reply_markup=keyboard,
)
logger.info(
"✅ Отправлено уведомление пользователю %s о пополнении на %s",
user.telegram_id,
settings.format_price(amount_kopeks),
)
except Exception as error: # pragma: no cover - диагностический лог
logger.error(
"Ошибка отправки уведомления о пополнении Stars: %s",
error,
)
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
try:
from aiogram import types
from app.localization.texts import get_texts
from app.services.user_cart_service import user_cart_service
has_saved_cart = await user_cart_service.has_user_cart(user.id)
auto_purchase_success = False
if has_saved_cart:
try:
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
db,
user,
bot=getattr(self, "bot", None),
)
except Exception as auto_error: # pragma: no cover - диагностический лог
logger.error(
"Ошибка автоматической покупки подписки для пользователя %s: %s",
user.id,
auto_error,
exc_info=True,
)
if auto_purchase_success:
has_saved_cart = False
if has_saved_cart and getattr(self, "bot", None):
texts = get_texts(user.language)
cart_message = texts.t(
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
"🛒 У вас есть неоформленный заказ.\n\n"
"Вы можете продолжить оформление с теми же параметрами.",
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout",
)
],
[
types.InlineKeyboardButton(
text="💰 Мой баланс",
callback_data="menu_balance",
)
],
[
types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu",
)
],
]
)
await self.bot.send_message(
chat_id=user.telegram_id,
text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n{cart_message}",
reply_markup=keyboard,
)
logger.info(
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
user.id,
)
except Exception as error: # pragma: no cover - диагностический лог
logger.error(
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
user.id,
error,
exc_info=True,
)
logger.info(
"✅ Обработан Stars платеж: пользователь %s, %s звезд → %s",
user.id,
stars_amount,
settings.format_price(amount_kopeks),
)
return True
+154 -15
View File
@@ -12,12 +12,61 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.subscription_auto_purchase_service import (
auto_purchase_saved_cart_after_topup,
)
from app.services.wata_service import WataAPIError, WataService
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
def _extract_transaction_id(payment: Any, remote_link: Optional[Dict[str, Any]] = None) -> Optional[str]:
"""Try to find the remote WATA transaction identifier from stored payloads."""
def _from_mapping(mapping: Any) -> Optional[str]:
if isinstance(mapping, str):
try:
import json
mapping = json.loads(mapping)
except Exception: # pragma: no cover - defensive parsing
return None
if not isinstance(mapping, dict):
return None
for key in ("id", "transaction_id", "transactionId"):
value = mapping.get(key)
if not value:
continue
value_str = str(value)
if "-" in value_str:
return value_str
return None
candidate = None
if hasattr(payment, "callback_payload"):
candidate = _from_mapping(getattr(payment, "callback_payload"))
if candidate:
return candidate
metadata = getattr(payment, "metadata_json", None)
if isinstance(metadata, dict):
if "transaction" in metadata:
candidate = _from_mapping(metadata.get("transaction"))
if candidate:
return candidate
candidate = _from_mapping(metadata)
if candidate:
return candidate
candidate = _from_mapping(remote_link)
if candidate:
return candidate
return None
class WataPaymentMixin:
"""Encapsulates creation and status handling for WATA payment links."""
@@ -226,6 +275,7 @@ class WataPaymentMixin:
remote_link: Optional[Dict[str, Any]] = None
transaction_payload: Optional[Dict[str, Any]] = None
transaction_id: Optional[str] = None
if getattr(self, "wata_service", None) and payment.payment_link_id:
try:
@@ -253,29 +303,84 @@ class WataPaymentMixin:
remote_status_normalized = (remote_status or "").lower()
if remote_status_normalized in {"closed", "paid"} and not payment.is_paid:
transaction_id = _extract_transaction_id(payment, remote_link)
if transaction_id:
try:
transaction_payload = await self.wata_service.get_transaction( # type: ignore[union-attr]
transaction_id
)
except WataAPIError as error:
logger.error(
"Ошибка получения WATA транзакции %s: %s",
transaction_id,
error,
)
except Exception as error: # pragma: no cover - safety net
logger.exception(
"Непредвиденная ошибка при запросе WATA транзакции %s: %s",
transaction_id,
error,
)
if not transaction_payload:
try:
tx_response = await self.wata_service.search_transactions( # type: ignore[union-attr]
order_id=payment.order_id,
payment_link_id=payment.payment_link_id,
status="Paid",
limit=5,
)
items = tx_response.get("items") or []
for item in items:
if (item or {}).get("status") == "Paid":
transaction_payload = item
break
except WataAPIError as error:
logger.error(
"Ошибка поиска WATA транзакций для %s: %s",
payment.payment_link_id,
error,
)
except Exception as error: # pragma: no cover - safety net
logger.exception("Непредвиденная ошибка при поиске WATA транзакции: %s", error)
if (
not transaction_payload
and not payment.is_paid
and getattr(self, "wata_service", None)
):
fallback_transaction_id = transaction_id or _extract_transaction_id(payment)
if fallback_transaction_id:
try:
tx_response = await self.wata_service.search_transactions( # type: ignore[union-attr]
order_id=payment.order_id,
payment_link_id=payment.payment_link_id,
status="Paid",
limit=5,
transaction_payload = await self.wata_service.get_transaction( # type: ignore[union-attr]
fallback_transaction_id
)
items = tx_response.get("items") or []
for item in items:
if (item or {}).get("status") == "Paid":
transaction_payload = item
break
except WataAPIError as error:
logger.error(
"Ошибка поиска WATA транзакций для %s: %s",
payment.payment_link_id,
"Ошибка повторного запроса WATA транзакции %s: %s",
fallback_transaction_id,
error,
)
except Exception as error: # pragma: no cover - safety net
logger.exception("Непредвиденная ошибка при поиске WATA транзакции: %s", error)
logger.exception(
"Непредвиденная ошибка при повторном запросе WATA транзакции %s: %s",
fallback_transaction_id,
error,
)
if transaction_payload and not payment.is_paid:
payment = await self._finalize_wata_payment(db, payment, transaction_payload)
normalized_status = None
if isinstance(transaction_payload, dict):
raw_status = transaction_payload.get("status") or transaction_payload.get("statusName")
if raw_status:
normalized_status = str(raw_status).lower()
if normalized_status == "paid":
payment = await self._finalize_wata_payment(db, payment, transaction_payload)
else:
logger.debug(
"WATA транзакция %s в статусе %s, повторная обработка не требуется",
transaction_id or getattr(payment, "payment_link_id", ""),
normalized_status or "unknown",
)
return {
"payment": payment,
@@ -293,7 +398,22 @@ class WataPaymentMixin:
) -> Any:
payment_module = import_module("app.services.payment_service")
paid_at = WataService._parse_datetime(transaction_payload.get("paymentTime"))
if isinstance(transaction_payload, dict):
paid_status = transaction_payload.get("status") or transaction_payload.get("statusName")
else:
paid_status = None
if paid_status and str(paid_status).lower() not in {"paid", "declined", "pending"}:
logger.debug(
"Неизвестный статус WATA транзакции %s: %s",
getattr(payment, "payment_link_id", ""),
paid_status,
)
paid_at = None
if isinstance(transaction_payload, dict):
paid_at = WataService._parse_datetime(transaction_payload.get("paymentTime"))
if not paid_at and getattr(payment, "paid_at", None):
paid_at = payment.paid_at
existing_metadata = dict(getattr(payment, "metadata_json", {}) or {})
existing_metadata["transaction"] = transaction_payload
@@ -407,6 +527,25 @@ class WataPaymentMixin:
from aiogram import types
has_saved_cart = await user_cart_service.has_user_cart(user.id)
auto_purchase_success = False
if has_saved_cart:
try:
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
db,
user,
bot=getattr(self, "bot", None),
)
except Exception as auto_error:
logger.error(
"Ошибка автоматической покупки подписки для пользователя %s: %s",
user.id,
auto_error,
exc_info=True,
)
if auto_purchase_success:
has_saved_cart = False
if has_saved_cart and getattr(self, "bot", None):
from app.localization.texts import get_texts
+318 -5
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import logging
from datetime import datetime
from decimal import Decimal, InvalidOperation
from importlib import import_module
from typing import Any, Dict, Optional, TYPE_CHECKING
@@ -15,6 +16,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.subscription_auto_purchase_service import (
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
@@ -206,6 +210,108 @@ class YooKassaPaymentMixin:
logger.error("Ошибка создания платежа YooKassa СБП: %s", error)
return None
async def get_yookassa_payment_status(
self,
db: AsyncSession,
local_payment_id: int,
) -> Optional[Dict[str, Any]]:
"""Запрашивает статус платежа в YooKassa и синхронизирует локальные данные."""
payment_module = import_module("app.services.payment_service")
payment = await payment_module.get_yookassa_payment_by_local_id(db, local_payment_id)
if not payment:
return None
remote_data: Optional[Dict[str, Any]] = None
if getattr(self, "yookassa_service", None):
try:
remote_data = await self.yookassa_service.get_payment_info( # type: ignore[union-attr]
payment.yookassa_payment_id
)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
"Ошибка получения статуса YooKassa %s: %s",
payment.yookassa_payment_id,
error,
)
if remote_data:
status = remote_data.get("status") or payment.status
paid = bool(remote_data.get("paid", getattr(payment, "is_paid", False)))
captured_raw = remote_data.get("captured_at")
captured_at = None
if captured_raw:
try:
captured_at = datetime.fromisoformat(
str(captured_raw).replace("Z", "+00:00")
).replace(tzinfo=None)
except Exception as parse_error: # pragma: no cover - diagnostic log
logger.debug(
"Не удалось распарсить captured_at %s: %s",
captured_raw,
parse_error,
)
captured_at = None
payment_method_type = remote_data.get("payment_method_type")
updated_payment = await payment_module.update_yookassa_payment_status(
db,
payment.yookassa_payment_id,
status=status,
is_paid=paid,
is_captured=paid and status == "succeeded",
captured_at=captured_at,
payment_method_type=payment_method_type,
)
if updated_payment:
payment = updated_payment
transaction_id = getattr(payment, "transaction_id", None)
if (
payment.status == "succeeded"
and getattr(payment, "is_paid", False)
):
if not transaction_id:
try:
await db.refresh(payment)
transaction_id = getattr(payment, "transaction_id", None)
except Exception as refresh_error: # pragma: no cover - defensive logging
logger.warning(
"Не удалось обновить состояние платежа YooKassa %s перед повторной обработкой: %s",
payment.yookassa_payment_id,
refresh_error,
exc_info=True,
)
if transaction_id:
logger.info(
"Пропускаем повторную обработку платежа YooKassa %s: уже связан с транзакцией %s",
payment.yookassa_payment_id,
transaction_id,
)
else:
try:
await self._process_successful_yookassa_payment(db, payment)
except Exception as process_error: # pragma: no cover - defensive logging
logger.error(
"Ошибка обработки успешного платежа YooKassa %s: %s",
payment.yookassa_payment_id,
process_error,
exc_info=True,
)
return {
"payment": payment,
"status": payment.status,
"is_paid": getattr(payment, "is_paid", False),
"remote_data": remote_data,
}
async def _process_successful_yookassa_payment(
self,
db: AsyncSession,
@@ -255,12 +361,17 @@ class YooKassaPaymentMixin:
is_completed=True,
)
await payment_module.link_yookassa_payment_to_transaction(
linked_payment = await payment_module.link_yookassa_payment_to_transaction(
db,
payment.yookassa_payment_id,
transaction.id,
)
if linked_payment:
payment.transaction_id = getattr(linked_payment, "transaction_id", transaction.id)
if hasattr(linked_payment, "transaction"):
payment.transaction = linked_payment.transaction
user = await payment_module.get_user_by_id(db, payment.user_id)
if user:
if is_simple_subscription:
@@ -357,7 +468,31 @@ class YooKassaPaymentMixin:
from app.services.user_cart_service import user_cart_service
try:
has_saved_cart = await user_cart_service.has_user_cart(user.id)
logger.info(f"Результат проверки корзины для пользователя {user.id}: {has_saved_cart}")
logger.info(
"Результат проверки корзины для пользователя %s: %s",
user.id,
has_saved_cart,
)
auto_purchase_success = False
if has_saved_cart:
try:
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
db,
user,
bot=getattr(self, "bot", None),
)
except Exception as auto_error:
logger.error(
"Ошибка автоматической покупки подписки для пользователя %s: %s",
user.id,
auto_error,
exc_info=True,
)
if auto_purchase_success:
has_saved_cart = False
if has_saved_cart and getattr(self, "bot", None):
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
@@ -402,7 +537,10 @@ class YooKassaPaymentMixin:
f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}"
)
else:
logger.info(f"У пользователя {user.id} нет сохраненной корзины или бот недоступен")
logger.info(
"У пользователя %s нет сохраненной корзины, бот недоступен или покупка уже выполнена",
user.id,
)
except Exception as e:
logger.error(
f"Критическая ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}",
@@ -545,14 +683,40 @@ class YooKassaPaymentMixin:
logger.warning(
"Локальный платеж для YooKassa id %s не найден", yookassa_payment_id
)
return False
payment = await self._restore_missing_yookassa_payment(db, event_object)
if not payment:
logger.error(
"Не удалось восстановить локальную запись платежа YooKassa %s",
yookassa_payment_id,
)
return False
payment.status = event_object.get("status", payment.status)
payment.confirmation_url = event_object.get("confirmation_url")
payment.confirmation_url = self._extract_confirmation_url(event_object)
payment.payment_method_type = (
(event_object.get("payment_method") or {}).get("type")
or payment.payment_method_type
)
payment.refundable = event_object.get("refundable", getattr(payment, "refundable", False))
current_paid = bool(getattr(payment, "is_paid", getattr(payment, "paid", False)))
payment.is_paid = bool(event_object.get("paid", current_paid))
captured_at_raw = event_object.get("captured_at")
if captured_at_raw:
try:
payment.captured_at = datetime.fromisoformat(
captured_at_raw.replace("Z", "+00:00")
).replace(tzinfo=None)
except Exception as error:
logger.debug(
"Не удалось распарсить captured_at=%s: %s",
captured_at_raw,
error,
)
await db.commit()
await db.refresh(payment)
@@ -565,3 +729,152 @@ class YooKassaPaymentMixin:
payment.status,
)
return True
async def _restore_missing_yookassa_payment(
self,
db: AsyncSession,
event_object: Dict[str, Any],
) -> Optional["YooKassaPayment"]:
"""Создает локальную запись платежа на основе данных webhook, если она отсутствует."""
yookassa_payment_id = event_object.get("id")
if not yookassa_payment_id:
return None
metadata = self._normalise_yookassa_metadata(event_object.get("metadata"))
user_id_raw = metadata.get("user_id") or metadata.get("userId")
if user_id_raw is None:
logger.error(
"Webhook YooKassa %s не содержит user_id в metadata. Невозможно восстановить платеж.",
yookassa_payment_id,
)
return None
try:
user_id = int(user_id_raw)
except (TypeError, ValueError):
logger.error(
"Webhook YooKassa %s содержит некорректный user_id=%s",
yookassa_payment_id,
user_id_raw,
)
return None
amount_info = event_object.get("amount") or {}
amount_value = amount_info.get("value")
currency = (amount_info.get("currency") or "RUB").upper()
if amount_value is None:
logger.error(
"Webhook YooKassa %s не содержит сумму платежа",
yookassa_payment_id,
)
return None
try:
amount_kopeks = int((Decimal(str(amount_value)) * 100).quantize(Decimal("1")))
except (InvalidOperation, ValueError) as error:
logger.error(
"Некорректная сумма в webhook YooKassa %s: %s (%s)",
yookassa_payment_id,
amount_value,
error,
)
return None
description = event_object.get("description") or metadata.get("description") or "YooKassa платеж"
payment_method_type = (event_object.get("payment_method") or {}).get("type")
yookassa_created_at = None
created_at_raw = event_object.get("created_at")
if created_at_raw:
try:
yookassa_created_at = datetime.fromisoformat(
created_at_raw.replace("Z", "+00:00")
).replace(tzinfo=None)
except Exception as error: # pragma: no cover - диагностический лог
logger.debug(
"Не удалось распарсить created_at=%s для YooKassa %s: %s",
created_at_raw,
yookassa_payment_id,
error,
)
payment_module = import_module("app.services.payment_service")
local_payment = await payment_module.create_yookassa_payment(
db=db,
user_id=user_id,
yookassa_payment_id=yookassa_payment_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
status=event_object.get("status", "pending"),
confirmation_url=self._extract_confirmation_url(event_object),
metadata_json=metadata,
payment_method_type=payment_method_type,
yookassa_created_at=yookassa_created_at,
test_mode=bool(event_object.get("test") or event_object.get("test_mode")),
)
if not local_payment:
return None
await payment_module.update_yookassa_payment_status(
db=db,
yookassa_payment_id=yookassa_payment_id,
status=event_object.get("status", local_payment.status),
is_paid=bool(event_object.get("paid")),
is_captured=event_object.get("status") == "succeeded",
captured_at=self._parse_datetime(event_object.get("captured_at")),
payment_method_type=payment_method_type,
)
return await payment_module.get_yookassa_payment_by_id(db, yookassa_payment_id)
@staticmethod
def _normalise_yookassa_metadata(metadata: Any) -> Dict[str, Any]:
if isinstance(metadata, dict):
return metadata
if isinstance(metadata, list):
normalised: Dict[str, Any] = {}
for item in metadata:
key = item.get("key") if isinstance(item, dict) else None
if key:
normalised[key] = item.get("value")
return normalised
if isinstance(metadata, str):
try:
import json
parsed = json.loads(metadata)
if isinstance(parsed, dict):
return parsed
except json.JSONDecodeError:
logger.debug("Не удалось распарсить metadata webhook YooKassa: %s", metadata)
return {}
@staticmethod
def _extract_confirmation_url(event_object: Dict[str, Any]) -> Optional[str]:
if "confirmation_url" in event_object:
return event_object.get("confirmation_url")
confirmation = event_object.get("confirmation")
if isinstance(confirmation, dict):
return confirmation.get("confirmation_url") or confirmation.get("return_url")
return None
@staticmethod
def _parse_datetime(raw_value: Optional[str]) -> Optional[datetime]:
if not raw_value:
return None
try:
return datetime.fromisoformat(raw_value.replace("Z", "+00:00")).replace(tzinfo=None)
except Exception:
return None
+10
View File
@@ -40,6 +40,11 @@ async def create_yookassa_payment(*args, **kwargs):
return await yk_crud.create_yookassa_payment(*args, **kwargs)
async def update_yookassa_payment_status(*args, **kwargs):
yk_crud = import_module("app.database.crud.yookassa")
return await yk_crud.update_yookassa_payment_status(*args, **kwargs)
async def link_yookassa_payment_to_transaction(*args, **kwargs):
yk_crud = import_module("app.database.crud.yookassa")
return await yk_crud.link_yookassa_payment_to_transaction(*args, **kwargs)
@@ -50,6 +55,11 @@ async def get_yookassa_payment_by_id(*args, **kwargs):
return await yk_crud.get_yookassa_payment_by_id(*args, **kwargs)
async def get_yookassa_payment_by_local_id(*args, **kwargs):
yk_crud = import_module("app.database.crud.yookassa")
return await yk_crud.get_yookassa_payment_by_local_id(*args, **kwargs)
async def create_transaction(*args, **kwargs):
transaction_crud = import_module("app.database.crud.transaction")
return await transaction_crud.create_transaction(*args, **kwargs)
@@ -0,0 +1,767 @@
"""Helpers for inspecting and manually checking pending top-up payments."""
from __future__ import annotations
import asyncio
import logging
import re
from collections import Counter
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional
from sqlalchemy import desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.database import AsyncSessionLocal
from app.database.models import (
CryptoBotPayment,
HeleketPayment,
MulenPayPayment,
Pal24Payment,
PaymentMethod,
Transaction,
TransactionType,
User,
WataPayment,
YooKassaPayment,
)
logger = logging.getLogger(__name__)
PENDING_MAX_AGE = timedelta(hours=24)
@dataclass(slots=True)
class PendingPayment:
"""Normalized representation of a provider specific payment entry."""
method: PaymentMethod
local_id: int
identifier: str
amount_kopeks: int
status: str
is_paid: bool
created_at: datetime
user: User
payment: Any
expires_at: Optional[datetime] = None
def is_recent(self, max_age: timedelta = PENDING_MAX_AGE) -> bool:
return (datetime.utcnow() - self.created_at) <= max_age
SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
{
PaymentMethod.YOOKASSA,
PaymentMethod.MULENPAY,
PaymentMethod.PAL24,
PaymentMethod.WATA,
PaymentMethod.HELEKET,
PaymentMethod.CRYPTOBOT,
}
)
SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
{
PaymentMethod.YOOKASSA,
PaymentMethod.MULENPAY,
PaymentMethod.PAL24,
PaymentMethod.WATA,
PaymentMethod.CRYPTOBOT,
}
)
def method_display_name(method: PaymentMethod) -> str:
if method == PaymentMethod.MULENPAY:
return settings.get_mulenpay_display_name()
if method == PaymentMethod.PAL24:
return "PayPalych"
if method == PaymentMethod.YOOKASSA:
return "YooKassa"
if method == PaymentMethod.WATA:
return "WATA"
if method == PaymentMethod.CRYPTOBOT:
return "CryptoBot"
if method == PaymentMethod.HELEKET:
return "Heleket"
if method == PaymentMethod.TELEGRAM_STARS:
return "Telegram Stars"
return method.value
def _method_is_enabled(method: PaymentMethod) -> bool:
if method == PaymentMethod.YOOKASSA:
return settings.is_yookassa_enabled()
if method == PaymentMethod.MULENPAY:
return settings.is_mulenpay_enabled()
if method == PaymentMethod.PAL24:
return settings.is_pal24_enabled()
if method == PaymentMethod.WATA:
return settings.is_wata_enabled()
if method == PaymentMethod.CRYPTOBOT:
return settings.is_cryptobot_enabled()
if method == PaymentMethod.HELEKET:
return settings.is_heleket_enabled()
return False
def get_enabled_auto_methods() -> List[PaymentMethod]:
return [
method
for method in SUPPORTED_AUTO_CHECK_METHODS
if _method_is_enabled(method)
]
class AutoPaymentVerificationService:
"""Background checker that periodically refreshes pending payments."""
def __init__(self) -> None:
self._task: Optional[asyncio.Task[None]] = None
self._payment_service: Optional["PaymentService"] = None
def set_payment_service(self, payment_service: "PaymentService") -> None:
self._payment_service = payment_service
def is_running(self) -> bool:
return self._task is not None and not self._task.done()
async def start(self) -> None:
await self.stop()
if not settings.is_payment_verification_auto_check_enabled():
logger.info("Автопроверка пополнений отключена настройками")
return
if not self._payment_service:
logger.warning(
"Автопроверка пополнений не запущена: PaymentService не инициализирован"
)
return
methods = get_enabled_auto_methods()
if not methods:
logger.info(
"Автопроверка пополнений не запущена: нет активных провайдеров"
)
return
display_names = ", ".join(
sorted(method_display_name(method) for method in methods)
)
interval_minutes = settings.get_payment_verification_auto_check_interval()
self._task = asyncio.create_task(self._auto_check_loop())
logger.info(
"🔄 Автопроверка пополнений запущена (каждые %s мин) для: %s",
interval_minutes,
display_names,
)
async def stop(self) -> None:
if self._task and not self._task.done():
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
async def _auto_check_loop(self) -> None:
try:
while True:
interval_minutes = settings.get_payment_verification_auto_check_interval()
try:
if (
settings.is_payment_verification_auto_check_enabled()
and self._payment_service
):
methods = get_enabled_auto_methods()
if methods:
await self._run_checks(methods)
else:
logger.debug(
"Автопроверка пополнений: активных провайдеров нет"
)
else:
logger.debug(
"Автопроверка пополнений: отключена настройками или сервис не готов"
)
except asyncio.CancelledError:
raise
except Exception as error: # noqa: BLE001 - логируем непредвиденные ошибки
logger.error(
"Ошибка автопроверки пополнений: %s",
error,
exc_info=True,
)
await asyncio.sleep(max(1, interval_minutes) * 60)
except asyncio.CancelledError:
logger.info("Автопроверка пополнений остановлена")
raise
async def _run_checks(self, methods: List[PaymentMethod]) -> None:
if not self._payment_service:
return
async with AsyncSessionLocal() as session:
try:
pending = await list_recent_pending_payments(session)
candidates = [
record
for record in pending
if record.method in methods and not record.is_paid
]
if not candidates:
logger.debug(
"Автопроверка пополнений: подходящих ожидающих платежей нет"
)
return
counts = Counter(record.method for record in candidates)
summary = ", ".join(
f"{method_display_name(method)}: {count}"
for method, count in sorted(
counts.items(), key=lambda item: method_display_name(item[0])
)
)
logger.info(
"🔄 Автопроверка пополнений: найдено %s инвойсов (%s)",
len(candidates),
summary,
)
for record in candidates:
refreshed = await run_manual_check(
session,
record.method,
record.local_id,
self._payment_service,
)
if not refreshed:
logger.debug(
"Автопроверка пополнений: не удалось обновить %s %s",
method_display_name(record.method),
record.identifier,
)
continue
if refreshed.is_paid and not record.is_paid:
logger.info(
"%s %s отмечен как оплаченный после автопроверки",
method_display_name(refreshed.method),
refreshed.identifier,
)
elif refreshed.status != record.status:
logger.info(
"%s %s обновлён: %s%s",
method_display_name(refreshed.method),
refreshed.identifier,
record.status or "",
refreshed.status or "",
)
else:
logger.debug(
"Автопроверка пополнений: %s %s без изменений (%s)",
method_display_name(refreshed.method),
refreshed.identifier,
refreshed.status or "",
)
if session.in_transaction():
await session.commit()
except Exception:
if session.in_transaction():
await session.rollback()
raise
auto_payment_verification_service = AutoPaymentVerificationService()
def _is_pal24_pending(payment: Pal24Payment) -> bool:
if payment.is_paid:
return False
status = (payment.status or "").upper()
return status in {"NEW", "PROCESS"}
def _is_mulenpay_pending(payment: MulenPayPayment) -> bool:
if payment.is_paid:
return False
status = (payment.status or "").lower()
return status in {"created", "processing", "hold"}
def _is_wata_pending(payment: WataPayment) -> bool:
if payment.is_paid:
return False
status = (payment.status or "").lower()
return status not in {
"paid",
"closed",
"declined",
"canceled",
"cancelled",
"expired",
}
def _is_heleket_pending(payment: HeleketPayment) -> bool:
if payment.is_paid:
return False
status = (payment.status or "").lower()
return status not in {"paid", "paid_over", "cancel", "canceled", "failed", "fail", "expired"}
def _is_yookassa_pending(payment: YooKassaPayment) -> bool:
if getattr(payment, "is_paid", False) and payment.status == "succeeded":
return False
status = (payment.status or "").lower()
return status in {"pending", "waiting_for_capture"}
def _is_cryptobot_pending(payment: CryptoBotPayment) -> bool:
status = (payment.status or "").lower()
return status == "active"
def _parse_cryptobot_amount_kopeks(payment: CryptoBotPayment) -> int:
payload = payment.payload or ""
match = re.search(r"_(\d+)$", payload)
if match:
try:
return int(match.group(1))
except ValueError:
return 0
return 0
def _metadata_is_balance(payment: YooKassaPayment) -> bool:
metadata = getattr(payment, "metadata_json", {}) or {}
payment_type = str(metadata.get("type") or metadata.get("payment_type") or "").lower()
return payment_type.startswith("balance_topup")
def _build_record(method: PaymentMethod, payment: Any, *, identifier: str, amount_kopeks: int,
status: str, is_paid: bool, expires_at: Optional[datetime] = None) -> Optional[PendingPayment]:
user = getattr(payment, "user", None)
if user is None:
logger.debug("Skipping %s payment %s without linked user", method.value, identifier)
return None
created_at = getattr(payment, "created_at", None)
if not isinstance(created_at, datetime):
logger.debug("Skipping %s payment %s without valid created_at", method.value, identifier)
return None
local_id = getattr(payment, "id", None)
if local_id is None:
logger.debug("Skipping %s payment without local id", method.value)
return None
return PendingPayment(
method=method,
local_id=int(local_id),
identifier=identifier,
amount_kopeks=amount_kopeks,
status=status,
is_paid=is_paid,
created_at=created_at,
user=user,
payment=payment,
expires_at=expires_at,
)
async def _fetch_pal24_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
stmt = (
select(Pal24Payment)
.options(selectinload(Pal24Payment.user))
.where(Pal24Payment.created_at >= cutoff)
.order_by(desc(Pal24Payment.created_at))
)
result = await db.execute(stmt)
records: List[PendingPayment] = []
for payment in result.scalars().all():
if not _is_pal24_pending(payment):
continue
record = _build_record(
PaymentMethod.PAL24,
payment,
identifier=payment.bill_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or "",
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, "expires_at", None),
)
if record:
records.append(record)
return records
async def _fetch_mulenpay_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
stmt = (
select(MulenPayPayment)
.options(selectinload(MulenPayPayment.user))
.where(MulenPayPayment.created_at >= cutoff)
.order_by(desc(MulenPayPayment.created_at))
)
result = await db.execute(stmt)
records: List[PendingPayment] = []
for payment in result.scalars().all():
if not _is_mulenpay_pending(payment):
continue
record = _build_record(
PaymentMethod.MULENPAY,
payment,
identifier=payment.uuid,
amount_kopeks=payment.amount_kopeks,
status=payment.status or "",
is_paid=bool(payment.is_paid),
)
if record:
records.append(record)
return records
async def _fetch_wata_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
stmt = (
select(WataPayment)
.options(selectinload(WataPayment.user))
.where(WataPayment.created_at >= cutoff)
.order_by(desc(WataPayment.created_at))
)
result = await db.execute(stmt)
records: List[PendingPayment] = []
for payment in result.scalars().all():
if not _is_wata_pending(payment):
continue
record = _build_record(
PaymentMethod.WATA,
payment,
identifier=payment.payment_link_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or "",
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, "expires_at", None),
)
if record:
records.append(record)
return records
async def _fetch_heleket_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
stmt = (
select(HeleketPayment)
.options(selectinload(HeleketPayment.user))
.where(HeleketPayment.created_at >= cutoff)
.order_by(desc(HeleketPayment.created_at))
)
result = await db.execute(stmt)
records: List[PendingPayment] = []
for payment in result.scalars().all():
if not _is_heleket_pending(payment):
continue
record = _build_record(
PaymentMethod.HELEKET,
payment,
identifier=payment.uuid,
amount_kopeks=payment.amount_kopeks,
status=payment.status or "",
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, "expires_at", None),
)
if record:
records.append(record)
return records
async def _fetch_yookassa_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
stmt = (
select(YooKassaPayment)
.options(selectinload(YooKassaPayment.user))
.where(YooKassaPayment.created_at >= cutoff)
.order_by(desc(YooKassaPayment.created_at))
)
result = await db.execute(stmt)
records: List[PendingPayment] = []
for payment in result.scalars().all():
if payment.transaction_id:
continue
if not _metadata_is_balance(payment):
continue
if not _is_yookassa_pending(payment):
continue
record = _build_record(
PaymentMethod.YOOKASSA,
payment,
identifier=payment.yookassa_payment_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or "",
is_paid=bool(getattr(payment, "is_paid", False)),
)
if record:
records.append(record)
return records
async def _fetch_cryptobot_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
stmt = (
select(CryptoBotPayment)
.options(selectinload(CryptoBotPayment.user))
.where(CryptoBotPayment.created_at >= cutoff)
.order_by(desc(CryptoBotPayment.created_at))
)
result = await db.execute(stmt)
records: List[PendingPayment] = []
for payment in result.scalars().all():
status = (payment.status or "").lower()
if not _is_cryptobot_pending(payment) and status != "paid":
continue
amount_kopeks = _parse_cryptobot_amount_kopeks(payment)
record = _build_record(
PaymentMethod.CRYPTOBOT,
payment,
identifier=payment.invoice_id,
amount_kopeks=amount_kopeks,
status=payment.status or "",
is_paid=bool(payment.is_paid),
)
if record:
records.append(record)
return records
async def _fetch_stars_transactions(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
stmt = (
select(Transaction)
.options(selectinload(Transaction.user))
.where(
Transaction.created_at >= cutoff,
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.payment_method == PaymentMethod.TELEGRAM_STARS.value,
)
.order_by(desc(Transaction.created_at))
)
result = await db.execute(stmt)
records: List[PendingPayment] = []
for transaction in result.scalars().all():
record = _build_record(
PaymentMethod.TELEGRAM_STARS,
transaction,
identifier=transaction.external_id or str(transaction.id),
amount_kopeks=transaction.amount_kopeks,
status="paid" if transaction.is_completed else "pending",
is_paid=bool(transaction.is_completed),
)
if record:
records.append(record)
return records
async def list_recent_pending_payments(
db: AsyncSession,
*,
max_age: timedelta = PENDING_MAX_AGE,
) -> List[PendingPayment]:
"""Return pending payments (top-ups) from supported providers within the age window."""
cutoff = datetime.utcnow() - max_age
tasks: Iterable[List[PendingPayment]] = (
await _fetch_yookassa_payments(db, cutoff),
await _fetch_pal24_payments(db, cutoff),
await _fetch_mulenpay_payments(db, cutoff),
await _fetch_wata_payments(db, cutoff),
await _fetch_heleket_payments(db, cutoff),
await _fetch_cryptobot_payments(db, cutoff),
await _fetch_stars_transactions(db, cutoff),
)
records: List[PendingPayment] = []
for batch in tasks:
records.extend(batch)
records.sort(key=lambda item: item.created_at, reverse=True)
return records
async def get_payment_record(
db: AsyncSession,
method: PaymentMethod,
local_payment_id: int,
) -> Optional[PendingPayment]:
"""Load single payment record and normalize it to :class:`PendingPayment`."""
cutoff = datetime.utcnow() - PENDING_MAX_AGE
if method == PaymentMethod.PAL24:
payment = await db.get(Pal24Payment, local_payment_id)
if not payment:
return None
await db.refresh(payment, attribute_names=["user"])
return _build_record(
method,
payment,
identifier=payment.bill_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or "",
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, "expires_at", None),
)
if method == PaymentMethod.MULENPAY:
payment = await db.get(MulenPayPayment, local_payment_id)
if not payment:
return None
await db.refresh(payment, attribute_names=["user"])
return _build_record(
method,
payment,
identifier=payment.uuid,
amount_kopeks=payment.amount_kopeks,
status=payment.status or "",
is_paid=bool(payment.is_paid),
)
if method == PaymentMethod.WATA:
payment = await db.get(WataPayment, local_payment_id)
if not payment:
return None
await db.refresh(payment, attribute_names=["user"])
return _build_record(
method,
payment,
identifier=payment.payment_link_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or "",
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, "expires_at", None),
)
if method == PaymentMethod.HELEKET:
payment = await db.get(HeleketPayment, local_payment_id)
if not payment:
return None
await db.refresh(payment, attribute_names=["user"])
return _build_record(
method,
payment,
identifier=payment.uuid,
amount_kopeks=payment.amount_kopeks,
status=payment.status or "",
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, "expires_at", None),
)
if method == PaymentMethod.YOOKASSA:
payment = await db.get(YooKassaPayment, local_payment_id)
if not payment:
return None
await db.refresh(payment, attribute_names=["user"])
if payment.created_at < cutoff:
logger.debug("YooKassa payment %s is older than cutoff", payment.id)
return _build_record(
method,
payment,
identifier=payment.yookassa_payment_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or "",
is_paid=bool(getattr(payment, "is_paid", False)),
)
if method == PaymentMethod.CRYPTOBOT:
payment = await db.get(CryptoBotPayment, local_payment_id)
if not payment:
return None
await db.refresh(payment, attribute_names=["user"])
amount_kopeks = _parse_cryptobot_amount_kopeks(payment)
return _build_record(
method,
payment,
identifier=payment.invoice_id,
amount_kopeks=amount_kopeks,
status=payment.status or "",
is_paid=bool(payment.is_paid),
)
if method == PaymentMethod.TELEGRAM_STARS:
transaction = await db.get(Transaction, local_payment_id)
if not transaction:
return None
await db.refresh(transaction, attribute_names=["user"])
if transaction.payment_method != PaymentMethod.TELEGRAM_STARS.value:
return None
return _build_record(
method,
transaction,
identifier=transaction.external_id or str(transaction.id),
amount_kopeks=transaction.amount_kopeks,
status="paid" if transaction.is_completed else "pending",
is_paid=bool(transaction.is_completed),
)
logger.debug("Unsupported payment method requested: %s", method)
return None
async def run_manual_check(
db: AsyncSession,
method: PaymentMethod,
local_payment_id: int,
payment_service: "PaymentService",
) -> Optional[PendingPayment]:
"""Trigger provider specific status refresh and return the updated record."""
try:
if method == PaymentMethod.PAL24:
result = await payment_service.get_pal24_payment_status(db, local_payment_id)
payment = result.get("payment") if result else None
elif method == PaymentMethod.MULENPAY:
result = await payment_service.get_mulenpay_payment_status(db, local_payment_id)
payment = result.get("payment") if result else None
elif method == PaymentMethod.WATA:
result = await payment_service.get_wata_payment_status(db, local_payment_id)
payment = result.get("payment") if result else None
elif method == PaymentMethod.HELEKET:
payment = await payment_service.sync_heleket_payment_status(
db, local_payment_id=local_payment_id
)
elif method == PaymentMethod.YOOKASSA:
result = await payment_service.get_yookassa_payment_status(db, local_payment_id)
payment = result.get("payment") if result else None
elif method == PaymentMethod.CRYPTOBOT:
result = await payment_service.get_cryptobot_payment_status(db, local_payment_id)
payment = result.get("payment") if result else None
else:
logger.warning("Manual check requested for unsupported method %s", method)
return None
if not payment:
return None
return await get_payment_record(db, method, local_payment_id)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
"Manual status check failed for %s payment %s: %s",
method.value,
local_payment_id,
error,
exc_info=True,
)
return None
if TYPE_CHECKING: # pragma: no cover
from app.services.payment_service import PaymentService
+9 -3
View File
@@ -168,6 +168,8 @@ async def reward_user_for_poll(
db: AsyncSession,
response: PollResponse,
) -> int:
await db.refresh(response, with_for_update=True)
poll = response.poll
if not poll.reward_enabled or poll.reward_amount_kopeks <= 0:
return 0
@@ -178,6 +180,9 @@ async def reward_user_for_poll(
user = response.user
description = f"Награда за участие в опросе \"{poll.title}\""
response.reward_given = True
response.reward_amount_kopeks = poll.reward_amount_kopeks
success = await add_user_balance(
db,
user,
@@ -189,9 +194,10 @@ async def reward_user_for_poll(
if not success:
return 0
response.reward_given = True
response.reward_amount_kopeks = poll.reward_amount_kopeks
await db.commit()
await db.refresh(
response,
attribute_names=["reward_given", "reward_amount_kopeks"],
)
return poll.reward_amount_kopeks
@@ -0,0 +1,601 @@
"""Automatic subscription purchase from a saved cart after balance top-up."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Optional
from aiogram import Bot
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.subscription import extend_subscription
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import Subscription, TransactionType, User
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.subscription_checkout_service import clear_subscription_checkout_draft
from app.services.subscription_purchase_service import (
PurchaseOptionsContext,
PurchasePricingResult,
PurchaseSelection,
PurchaseValidationError,
PurchaseBalanceError,
MiniAppSubscriptionPurchaseService,
)
from app.services.subscription_service import SubscriptionService
from app.services.user_cart_service import user_cart_service
from app.utils.pricing_utils import format_period_description
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class AutoPurchaseContext:
"""Aggregated data prepared for automatic checkout processing."""
context: PurchaseOptionsContext
pricing: PurchasePricingResult
selection: PurchaseSelection
service: MiniAppSubscriptionPurchaseService
@dataclass(slots=True)
class AutoExtendContext:
"""Data required to automatically extend an existing subscription."""
subscription: Subscription
period_days: int
price_kopeks: int
description: str
device_limit: Optional[int] = None
traffic_limit_gb: Optional[int] = None
squad_uuid: Optional[str] = None
consume_promo_offer: bool = False
async def _prepare_auto_purchase(
db: AsyncSession,
user: User,
cart_data: dict,
) -> Optional[AutoPurchaseContext]:
"""Builds purchase context and pricing for a saved cart."""
period_days = int(cart_data.get("period_days") or 0)
if period_days <= 0:
logger.info(
"🔁 Автопокупка: у пользователя %s нет корректного периода в сохранённой корзине",
user.telegram_id,
)
return None
miniapp_service = MiniAppSubscriptionPurchaseService()
context = await miniapp_service.build_options(db, user)
period_config = context.period_map.get(f"days:{period_days}")
if not period_config:
logger.warning(
"🔁 Автопокупка: период %s дней недоступен для пользователя %s",
period_days,
user.telegram_id,
)
return None
traffic_value = cart_data.get("traffic_gb")
if traffic_value is None:
traffic_value = (
period_config.traffic.current_value
if period_config.traffic.current_value is not None
else period_config.traffic.default_value or 0
)
else:
traffic_value = int(traffic_value)
devices = int(cart_data.get("devices") or period_config.devices.current or 1)
servers = list(cart_data.get("countries") or [])
if not servers:
servers = list(period_config.servers.default_selection)
selection = PurchaseSelection(
period=period_config,
traffic_value=traffic_value,
servers=servers,
devices=devices,
)
pricing = await miniapp_service.calculate_pricing(db, context, selection)
return AutoPurchaseContext(
context=context,
pricing=pricing,
selection=selection,
service=miniapp_service,
)
def _safe_int(value: Optional[object], default: int = 0) -> int:
try:
return int(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return default
async def _prepare_auto_extend_context(
user: User,
cart_data: dict,
) -> Optional[AutoExtendContext]:
subscription = getattr(user, "subscription", None)
if subscription is None:
logger.info(
"🔁 Автопокупка: у пользователя %s нет активной подписки для продления",
user.telegram_id,
)
return None
saved_subscription_id = cart_data.get("subscription_id")
if saved_subscription_id is not None:
saved_subscription_id = _safe_int(saved_subscription_id, subscription.id)
if saved_subscription_id != subscription.id:
logger.warning(
"🔁 Автопокупка: сохранённая подписка %s не совпадает с текущей %s у пользователя %s",
saved_subscription_id,
subscription.id,
user.telegram_id,
)
return None
period_days = _safe_int(cart_data.get("period_days"))
price_kopeks = _safe_int(
cart_data.get("total_price")
or cart_data.get("price")
or cart_data.get("final_price"),
)
if period_days <= 0:
logger.warning(
"🔁 Автопокупка: некорректное количество дней продления (%s) у пользователя %s",
period_days,
user.telegram_id,
)
return None
if price_kopeks <= 0:
logger.warning(
"🔁 Автопокупка: некорректная цена продления (%s) у пользователя %s",
price_kopeks,
user.telegram_id,
)
return None
description = cart_data.get("description") or f"Продление подписки на {period_days} дней"
device_limit = cart_data.get("device_limit")
if device_limit is not None:
device_limit = _safe_int(device_limit, subscription.device_limit)
traffic_limit_gb = cart_data.get("traffic_limit_gb")
if traffic_limit_gb is not None:
traffic_limit_gb = _safe_int(traffic_limit_gb, subscription.traffic_limit_gb or 0)
squad_uuid = cart_data.get("squad_uuid")
consume_promo_offer = bool(cart_data.get("consume_promo_offer"))
return AutoExtendContext(
subscription=subscription,
period_days=period_days,
price_kopeks=price_kopeks,
description=description,
device_limit=device_limit,
traffic_limit_gb=traffic_limit_gb,
squad_uuid=squad_uuid,
consume_promo_offer=consume_promo_offer,
)
def _apply_extension_updates(context: AutoExtendContext) -> None:
subscription = context.subscription
if subscription.is_trial:
subscription.is_trial = False
subscription.status = "active"
if context.traffic_limit_gb is not None:
subscription.traffic_limit_gb = context.traffic_limit_gb
if context.device_limit is not None:
subscription.device_limit = max(subscription.device_limit, context.device_limit)
if context.squad_uuid and context.squad_uuid not in (subscription.connected_squads or []):
subscription.connected_squads = (subscription.connected_squads or []) + [context.squad_uuid]
else:
if context.traffic_limit_gb not in (None, 0):
subscription.traffic_limit_gb = context.traffic_limit_gb
if (
context.device_limit is not None
and context.device_limit > subscription.device_limit
):
subscription.device_limit = context.device_limit
if context.squad_uuid and context.squad_uuid not in (subscription.connected_squads or []):
subscription.connected_squads = (subscription.connected_squads or []) + [context.squad_uuid]
async def _auto_extend_subscription(
db: AsyncSession,
user: User,
cart_data: dict,
*,
bot: Optional[Bot] = None,
) -> bool:
try:
prepared = await _prepare_auto_extend_context(user, cart_data)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
"❌ Автопокупка: ошибка подготовки данных продления для пользователя %s: %s",
user.telegram_id,
error,
exc_info=True,
)
return False
if prepared is None:
return False
if user.balance_kopeks < prepared.price_kopeks:
logger.info(
"🔁 Автопокупка: у пользователя %s недостаточно средств для продления (%s < %s)",
user.telegram_id,
user.balance_kopeks,
prepared.price_kopeks,
)
return False
try:
deducted = await subtract_user_balance(
db,
user,
prepared.price_kopeks,
prepared.description,
consume_promo_offer=prepared.consume_promo_offer,
)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
"❌ Автопокупка: ошибка списания средств при продлении пользователя %s: %s",
user.telegram_id,
error,
exc_info=True,
)
return False
if not deducted:
logger.warning(
"❌ Автопокупка: списание средств для продления подписки пользователя %s не выполнено",
user.telegram_id,
)
return False
subscription = prepared.subscription
old_end_date = subscription.end_date
_apply_extension_updates(prepared)
try:
updated_subscription = await extend_subscription(
db,
subscription,
prepared.period_days,
)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
"❌ Автопокупка: не удалось продлить подписку пользователя %s: %s",
user.telegram_id,
error,
exc_info=True,
)
return False
transaction = None
try:
transaction = await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=prepared.price_kopeks,
description=prepared.description,
)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
"⚠️ Автопокупка: не удалось зафиксировать транзакцию продления для пользователя %s: %s",
user.telegram_id,
error,
exc_info=True,
)
subscription_service = SubscriptionService()
try:
await subscription_service.update_remnawave_user(
db,
updated_subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason="продление подписки",
)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
"⚠️ Автопокупка: не удалось обновить RemnaWave пользователя %s после продления: %s",
user.telegram_id,
error,
)
await user_cart_service.delete_user_cart(user.id)
await clear_subscription_checkout_draft(user.id)
texts = get_texts(getattr(user, "language", "ru"))
period_label = format_period_description(
prepared.period_days,
getattr(user, "language", "ru"),
)
new_end_date = updated_subscription.end_date
end_date_label = new_end_date.strftime("%d.%m.%Y %H:%M")
if bot:
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_subscription_extension_notification(
db,
user,
updated_subscription,
transaction,
prepared.period_days,
old_end_date,
new_end_date=new_end_date,
balance_after=user.balance_kopeks,
)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
"⚠️ Автопокупка: не удалось уведомить администраторов о продлении пользователя %s: %s",
user.telegram_id,
error,
)
try:
auto_message = texts.t(
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED",
"✅ Subscription automatically extended for {period}.",
).format(period=period_label)
details_message = texts.t(
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS",
"New expiration date: {date}.",
).format(date=end_date_label)
hint_message = texts.t(
"AUTO_PURCHASE_SUBSCRIPTION_HINT",
"Open the My subscription section to access your link.",
)
full_message = "\n\n".join(
part.strip()
for part in [auto_message, details_message, hint_message]
if part and part.strip()
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 My subscription"),
callback_data="menu_subscription",
)
],
[
InlineKeyboardButton(
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "🏠 Main menu"),
callback_data="back_to_menu",
)
],
]
)
await bot.send_message(
chat_id=user.telegram_id,
text=full_message,
reply_markup=keyboard,
parse_mode="HTML",
)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
"⚠️ Автопокупка: не удалось уведомить пользователя %s о продлении: %s",
user.telegram_id,
error,
)
logger.info(
"✅ Автопокупка: подписка продлена на %s дней для пользователя %s",
prepared.period_days,
user.telegram_id,
)
return True
async def auto_purchase_saved_cart_after_topup(
db: AsyncSession,
user: User,
*,
bot: Optional[Bot] = None,
) -> bool:
"""Attempts to automatically purchase a subscription from a saved cart."""
if not settings.is_auto_purchase_after_topup_enabled():
return False
if not user or not getattr(user, "id", None):
return False
cart_data = await user_cart_service.get_user_cart(user.id)
if not cart_data:
return False
logger.info(
"🔁 Автопокупка: обнаружена сохранённая корзина у пользователя %s", user.telegram_id
)
cart_mode = cart_data.get("cart_mode") or cart_data.get("mode")
if cart_mode == "extend":
return await _auto_extend_subscription(db, user, cart_data, bot=bot)
try:
prepared = await _prepare_auto_purchase(db, user, cart_data)
except PurchaseValidationError as error:
logger.error(
"❌ Автопокупка: ошибка валидации корзины пользователя %s: %s",
user.telegram_id,
error,
)
return False
except Exception as error: # pragma: no cover - defensive logging
logger.error(
"❌ Автопокупка: непредвиденная ошибка при подготовке корзины %s: %s",
user.telegram_id,
error,
exc_info=True,
)
return False
if prepared is None:
return False
pricing = prepared.pricing
selection = prepared.selection
if pricing.final_total <= 0:
logger.warning(
"❌ Автопокупка: итоговая сумма для пользователя %s некорректна (%s)",
user.telegram_id,
pricing.final_total,
)
return False
if user.balance_kopeks < pricing.final_total:
logger.info(
"🔁 Автопокупка: у пользователя %s недостаточно средств (%s < %s)",
user.telegram_id,
user.balance_kopeks,
pricing.final_total,
)
return False
purchase_service = prepared.service
try:
purchase_result = await purchase_service.submit_purchase(
db,
prepared.context,
pricing,
)
except PurchaseBalanceError:
logger.info(
"🔁 Автопокупка: баланс пользователя %s изменился и стал недостаточным",
user.telegram_id,
)
return False
except PurchaseValidationError as error:
logger.error(
"❌ Автопокупка: не удалось подтвердить корзину пользователя %s: %s",
user.telegram_id,
error,
)
return False
except Exception as error: # pragma: no cover - defensive logging
logger.error(
"❌ Автопокупка: ошибка оформления подписки для пользователя %s: %s",
user.telegram_id,
error,
exc_info=True,
)
return False
await user_cart_service.delete_user_cart(user.id)
await clear_subscription_checkout_draft(user.id)
subscription = purchase_result.get("subscription")
transaction = purchase_result.get("transaction")
was_trial_conversion = purchase_result.get("was_trial_conversion", False)
texts = get_texts(getattr(user, "language", "ru"))
if bot:
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_subscription_purchase_notification(
db,
user,
subscription,
transaction,
selection.period.days,
was_trial_conversion,
)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
"⚠️ Автопокупка: не удалось отправить уведомление админам (%s): %s",
user.telegram_id,
error,
)
try:
period_label = format_period_description(
selection.period.days,
getattr(user, "language", "ru"),
)
auto_message = texts.t(
"AUTO_PURCHASE_SUBSCRIPTION_SUCCESS",
"✅ Subscription purchased automatically after balance top-up ({period}).",
).format(period=period_label)
hint_message = texts.t(
"AUTO_PURCHASE_SUBSCRIPTION_HINT",
"Open the My subscription section to access your link.",
)
purchase_message = purchase_result.get("message", "")
full_message = "\n\n".join(
part.strip()
for part in [auto_message, purchase_message, hint_message]
if part and part.strip()
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 My subscription"),
callback_data="menu_subscription",
)
],
[
InlineKeyboardButton(
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "🏠 Main menu"),
callback_data="back_to_menu",
)
],
]
)
await bot.send_message(
chat_id=user.telegram_id,
text=full_message,
reply_markup=keyboard,
parse_mode="HTML",
)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
"⚠️ Автопокупка: не удалось уведомить пользователя %s: %s",
user.telegram_id,
error,
)
logger.info(
"✅ Автопокупка: подписка на %s дней оформлена для пользователя %s",
selection.period.days,
user.telegram_id,
)
return True
__all__ = ["auto_purchase_saved_cart_after_topup"]
+19 -1
View File
@@ -3,6 +3,7 @@ from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Sequence, Tuple
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import PERIOD_PRICES, settings
@@ -1060,7 +1061,24 @@ class MiniAppSubscriptionPurchaseService:
await db.refresh(user)
subscription = getattr(user, "subscription", None)
subscription = context.subscription
if subscription is not None and getattr(subscription, "id", None):
try:
await db.refresh(subscription)
except Exception as refresh_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to refresh existing subscription %s: %s",
getattr(subscription, "id", None),
refresh_error,
)
else:
result = await db.execute(
select(Subscription).where(Subscription.user_id == user.id)
)
subscription = result.scalar_one_or_none()
if subscription is not None:
context.subscription = subscription
was_trial_conversion = False
now = datetime.utcnow()
+32
View File
@@ -72,6 +72,7 @@ class BotConfigurationService:
"LOCALIZATION": "🌍 Языки интерфейса",
"CHANNEL": "📣 Обязательная подписка",
"PAYMENT": "💳 Общие платежные настройки",
"PAYMENT_VERIFICATION": "🕵️ Проверка платежей",
"TELEGRAM": "⭐ Telegram Stars",
"CRYPTOBOT": "🪙 CryptoBot",
"HELEKET": "🪙 Heleket",
@@ -124,6 +125,7 @@ class BotConfigurationService:
"LOCALIZATION": "Доступные языки, локализация интерфейса и выбор языка.",
"CHANNEL": "Настройки обязательной подписки на канал или группу.",
"PAYMENT": "Общие тексты платежей, описания чеков и шаблоны.",
"PAYMENT_VERIFICATION": "Автоматическая проверка пополнений и интервал выполнения.",
"YOOKASSA": "Интеграция с YooKassa: идентификаторы магазина и вебхуки.",
"CRYPTOBOT": "CryptoBot и криптоплатежи через Telegram.",
"HELEKET": "Heleket: криптоплатежи, ключи мерчанта и вебхуки.",
@@ -234,6 +236,7 @@ class BotConfigurationService:
"PAYMENT_SUBSCRIPTION_DESCRIPTION": "PAYMENT",
"PAYMENT_BALANCE_TEMPLATE": "PAYMENT",
"PAYMENT_SUBSCRIPTION_TEMPLATE": "PAYMENT",
"AUTO_PURCHASE_AFTER_TOPUP_ENABLED": "PAYMENT",
"SIMPLE_SUBSCRIPTION_ENABLED": "SIMPLE_SUBSCRIPTION",
"SIMPLE_SUBSCRIPTION_PERIOD_DAYS": "SIMPLE_SUBSCRIPTION",
"SIMPLE_SUBSCRIPTION_DEVICE_LIMIT": "SIMPLE_SUBSCRIPTION",
@@ -292,6 +295,7 @@ class BotConfigurationService:
"MULENPAY_": "MULENPAY",
"PAL24_": "PAL24",
"PAYMENT_": "PAYMENT",
"PAYMENT_VERIFICATION_": "PAYMENT_VERIFICATION",
"WATA_": "WATA",
"EXTERNAL_ADMIN_": "EXTERNAL_ADMIN",
"SIMPLE_SUBSCRIPTION_": "SIMPLE_SUBSCRIPTION",
@@ -453,6 +457,34 @@ class BotConfigurationService:
"warning": "Пустой токен или неверный вебхук приведут к отказам платежей.",
"dependencies": "CRYPTOBOT_API_TOKEN, CRYPTOBOT_WEBHOOK_SECRET",
},
"PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED": {
"description": (
"Запускает фоновую проверку ожидающих пополнений и повторно обращается "
"к платёжным провайдерам без участия администратора."
),
"format": "Булево значение.",
"example": "Включено, чтобы автоматически перепроверять зависшие платежи.",
"warning": "Требует активных интеграций YooKassa, {mulenpay_name}, PayPalych, WATA или CryptoBot.",
},
"PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES": {
"description": (
"Интервал между автоматическими проверками ожидающих пополнений в минутах."
),
"format": "Целое число не меньше 1.",
"example": "10",
"warning": "Слишком малый интервал может привести к частым обращениям к платёжным API.",
"dependencies": "PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED",
},
"AUTO_PURCHASE_AFTER_TOPUP_ENABLED": {
"description": (
"При достаточном балансе автоматически оформляет сохранённую подписку сразу после пополнения."
),
"format": "Булево значение.",
"example": "true",
"warning": (
"Используйте с осторожностью: средства будут списаны мгновенно, если корзина найдена."
),
},
"SUPPORT_TICKET_SLA_MINUTES": {
"description": "Лимит времени для ответа модераторов на тикет в минутах.",
"format": "Целое число от 1 до 1440.",
+30 -5
View File
@@ -14,6 +14,9 @@ from app.database.crud.transaction import (
from app.database.crud.user import get_user_by_telegram_id
from app.external.tribute import TributeService as TributeAPI
from app.services.payment_service import PaymentService
from app.services.subscription_auto_purchase_service import (
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
@@ -267,17 +270,36 @@ class TributeService:
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
from app.services.user_cart_service import user_cart_service
has_saved_cart = await user_cart_service.has_user_cart(user.id)
auto_purchase_success = False
if has_saved_cart:
try:
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
session,
user,
bot=self.bot,
)
except Exception as auto_error:
logger.error(
"Ошибка автоматической покупки подписки для пользователя %s: %s",
user.id,
auto_error,
exc_info=True,
)
if auto_purchase_success:
has_saved_cart = False
if has_saved_cart and self.bot:
# Если у пользователя есть сохраненная корзина,
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
from app.localization.texts import get_texts
from aiogram import types
texts = get_texts(user.language)
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
total_amount=settings.format_price(amount_kopeks)
)
# Создаем клавиатуру с кнопками
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
@@ -293,13 +315,16 @@ class TributeService:
callback_data="back_to_menu"
)]
])
await self.bot.send_message(
chat_id=user_id,
text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n{cart_message}",
reply_markup=keyboard
)
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user_id}")
logger.info(
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
user_id,
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления об успешном платеже: {e}")
+15 -1
View File
@@ -13,8 +13,9 @@ from .routes import (
config,
health,
main_menu_buttons,
promocodes,
miniapp,
polls,
promocodes,
promo_groups,
promo_offers,
pages,
@@ -25,6 +26,7 @@ from .routes import (
tokens,
transactions,
users,
logs,
)
@@ -69,6 +71,12 @@ OPENAPI_TAGS = [
"name": "promo-offers",
"description": "Управление промо-предложениями, шаблонами и журналом событий.",
},
{
"name": "logs",
"description": (
"Журналы мониторинга бота, действий модераторов поддержки и системный лог-файл."
),
},
{
"name": "auth",
"description": "Управление токенами доступа к административному API.",
@@ -84,6 +92,10 @@ OPENAPI_TAGS = [
"name": "miniapp",
"description": "Endpoint для Telegram Mini App с информацией о подписке пользователя.",
},
{
"name": "polls",
"description": "Создание опросов, удаление, статистика и ответы пользователей.",
},
{
"name": "pages",
"description": "Управление контентом публичных страниц: оферта, политика, FAQ и правила.",
@@ -138,5 +150,7 @@ def create_web_api_app() -> FastAPI:
app.include_router(tokens.router, prefix="/tokens", tags=["auth"])
app.include_router(remnawave.router, prefix="/remnawave", tags=["remnawave"])
app.include_router(miniapp.router, prefix="/miniapp", tags=["miniapp"])
app.include_router(polls.router, prefix="/polls", tags=["polls"])
app.include_router(logs.router, prefix="/logs", tags=["logs"])
return app
+4
View File
@@ -3,6 +3,7 @@ from . import (
health,
main_menu_buttons,
miniapp,
polls,
promo_offers,
pages,
promo_groups,
@@ -13,6 +14,7 @@ from . import (
tokens,
transactions,
users,
logs,
)
__all__ = [
@@ -20,6 +22,7 @@ __all__ = [
"health",
"main_menu_buttons",
"miniapp",
"polls",
"promo_offers",
"pages",
"promo_groups",
@@ -30,4 +33,5 @@ __all__ = [
"tokens",
"transactions",
"users",
"logs",
]
+239
View File
@@ -0,0 +1,239 @@
"""Маршруты административного API для просмотра логов."""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Security
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.ticket import TicketCRUD
from app.services.monitoring_service import monitoring_service
from ..dependencies import get_db_session, require_api_token
from ..schemas.logs import (
MonitoringLogEntry,
MonitoringLogListResponse,
MonitoringLogTypeListResponse,
SupportAuditActionsResponse,
SupportAuditLogEntry,
SupportAuditLogListResponse,
SystemLogPreviewResponse,
)
router = APIRouter()
logger = logging.getLogger(__name__)
SYSTEM_LOG_PREVIEW_LIMIT_DEFAULT = 4000
SYSTEM_LOG_PREVIEW_LIMIT_MAX = 20000
def _resolve_system_log_path() -> Path:
path = Path(settings.LOG_FILE)
if not path.is_absolute():
path = Path.cwd() / path
return path
async def _read_system_log(path: Path) -> tuple[str, int, Optional[float]]:
def _read() -> tuple[str, int, float]:
content = path.read_text(encoding="utf-8", errors="ignore")
stats = path.stat()
return content, stats.st_size, stats.st_mtime
return await run_in_threadpool(_read)
def _format_timestamp(timestamp: Optional[float]) -> Optional[datetime]:
if timestamp is None:
return None
return datetime.fromtimestamp(timestamp, tz=timezone.utc)
@router.get("/system", response_model=SystemLogPreviewResponse)
async def get_system_log_preview(
_: Any = Security(require_api_token),
preview_limit: int = Query(
SYSTEM_LOG_PREVIEW_LIMIT_DEFAULT,
ge=500,
le=SYSTEM_LOG_PREVIEW_LIMIT_MAX,
description="Количество символов предпросмотра от конца файла",
),
) -> SystemLogPreviewResponse:
"""Получить предпросмотр системного лог-файла бота."""
log_path = _resolve_system_log_path()
if not log_path.exists() or not log_path.is_file():
return SystemLogPreviewResponse(
path=str(log_path),
exists=False,
updated_at=None,
size_bytes=0,
size_chars=0,
preview="",
preview_chars=0,
preview_truncated=False,
download_url="/logs/system/download",
)
try:
content, size_bytes, mtime = await _read_system_log(log_path)
except FileNotFoundError:
logger.warning("Лог-файл %s исчез во время чтения", log_path)
return SystemLogPreviewResponse(
path=str(log_path),
exists=False,
updated_at=None,
size_bytes=0,
size_chars=0,
preview="",
preview_chars=0,
preview_truncated=False,
download_url="/logs/system/download",
)
except Exception as error: # pragma: no cover - защита от неожиданных ошибок чтения
logger.error("Ошибка чтения лог-файла %s: %s", log_path, error)
raise HTTPException(status_code=500, detail="Не удалось прочитать лог-файл") from error
preview_text = content[-preview_limit:] if preview_limit > 0 else ""
truncated = len(content) > len(preview_text)
return SystemLogPreviewResponse(
path=str(log_path),
exists=True,
updated_at=_format_timestamp(mtime),
size_bytes=size_bytes,
size_chars=len(content),
preview=preview_text,
preview_chars=len(preview_text),
preview_truncated=truncated,
download_url="/logs/system/download",
)
@router.get("/system/download")
async def download_system_log(
_: Any = Security(require_api_token),
) -> FileResponse:
"""Скачать полный лог-файл бота."""
log_path = _resolve_system_log_path()
if not log_path.exists() or not log_path.is_file():
raise HTTPException(status_code=404, detail="Лог-файл не найден")
try:
return FileResponse(
log_path,
media_type="text/plain",
filename=log_path.name,
)
except Exception as error: # pragma: no cover - защита от неожиданных ошибок отдачи файла
logger.error("Ошибка отправки лог-файла %s: %s", log_path, error)
raise HTTPException(status_code=500, detail="Не удалось отправить лог-файл") from error
@router.get("/monitoring", response_model=MonitoringLogListResponse)
async def list_monitoring_logs(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200, description="Количество записей на странице"),
offset: int = Query(0, ge=0, description="Смещение от начала списка"),
event_type: Optional[str] = Query(
default=None,
max_length=100,
description="Фильтр по типу события",
),
) -> MonitoringLogListResponse:
"""Получить список логов мониторинга с пагинацией."""
per_page = limit
page = (offset // per_page) + 1
raw_logs = await monitoring_service.get_monitoring_logs(
db,
event_type=event_type,
page=page,
per_page=per_page,
)
total = await monitoring_service.get_monitoring_logs_count(db, event_type=event_type)
return MonitoringLogListResponse(
total=total,
limit=limit,
offset=offset,
items=[MonitoringLogEntry(**entry) for entry in raw_logs],
)
@router.get("/monitoring/event-types", response_model=MonitoringLogTypeListResponse)
async def list_monitoring_event_types(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> MonitoringLogTypeListResponse:
"""Получить список доступных типов событий мониторинга."""
event_types = await monitoring_service.get_monitoring_event_types(db)
return MonitoringLogTypeListResponse(items=event_types)
@router.get("/support", response_model=SupportAuditLogListResponse)
async def list_support_audit_logs(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200, description="Количество записей на странице"),
offset: int = Query(0, ge=0, description="Смещение от начала списка"),
action: Optional[str] = Query(
default=None,
max_length=50,
description="Фильтр по типу действия модератора",
),
) -> SupportAuditLogListResponse:
"""Получить список аудита действий модераторов поддержки."""
logs = await TicketCRUD.list_support_audit(
db,
limit=limit,
offset=offset,
action=action,
)
total = await TicketCRUD.count_support_audit(db, action=action)
return SupportAuditLogListResponse(
total=total,
limit=limit,
offset=offset,
items=[
SupportAuditLogEntry(
id=log.id,
actor_user_id=log.actor_user_id,
actor_telegram_id=log.actor_telegram_id,
is_moderator=log.is_moderator,
action=log.action,
ticket_id=log.ticket_id,
target_user_id=log.target_user_id,
details=log.details,
created_at=log.created_at,
)
for log in logs
],
)
@router.get("/support/actions", response_model=SupportAuditActionsResponse)
async def list_support_audit_actions(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> SupportAuditActionsResponse:
"""Получить список действий, доступных в аудите поддержки."""
actions = await TicketCRUD.list_support_audit_actions(db)
return SupportAuditActionsResponse(items=actions)
+308
View File
@@ -0,0 +1,308 @@
from __future__ import annotations
from typing import Any
from fastapi import (
APIRouter,
Depends,
HTTPException,
Query,
Response,
Security,
status,
)
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.crud.poll import (
create_poll,
delete_poll as delete_poll_record,
get_poll_by_id,
get_poll_responses_with_answers,
get_poll_statistics,
)
from app.database.models import Poll, PollAnswer, PollOption, PollQuestion, PollResponse
from ..dependencies import get_db_session, require_api_token
from ..schemas.polls import (
PollAnswerResponse,
PollCreateRequest,
PollDetailResponse,
PollListResponse,
PollOptionStats,
PollQuestionOptionResponse,
PollQuestionResponse,
PollQuestionStats,
PollResponsesListResponse,
PollStatisticsResponse,
PollSummaryResponse,
PollUserResponse,
)
router = APIRouter()
def _format_price(kopeks: int) -> float:
return round(kopeks / 100, 2)
def _serialize_option(option: PollOption) -> PollQuestionOptionResponse:
return PollQuestionOptionResponse(
id=option.id,
text=option.text,
order=option.order,
)
def _serialize_question(question: PollQuestion) -> PollQuestionResponse:
options = [
_serialize_option(option)
for option in sorted(question.options, key=lambda item: item.order)
]
return PollQuestionResponse(
id=question.id,
text=question.text,
order=question.order,
options=options,
)
def _serialize_poll_summary(
poll: Poll,
responses_count: int | None = None,
) -> PollSummaryResponse:
questions = getattr(poll, "questions", [])
if responses_count is None:
responses = getattr(poll, "responses", [])
responses_count = len(responses)
return PollSummaryResponse(
id=poll.id,
title=poll.title,
description=poll.description,
reward_enabled=poll.reward_enabled,
reward_amount_kopeks=poll.reward_amount_kopeks,
reward_amount_rubles=_format_price(poll.reward_amount_kopeks),
questions_count=len(questions),
responses_count=responses_count,
created_at=poll.created_at,
updated_at=poll.updated_at,
)
def _serialize_poll_detail(poll: Poll) -> PollDetailResponse:
questions = [
_serialize_question(question)
for question in sorted(poll.questions, key=lambda item: item.order)
]
return PollDetailResponse(
id=poll.id,
title=poll.title,
description=poll.description,
reward_enabled=poll.reward_enabled,
reward_amount_kopeks=poll.reward_amount_kopeks,
reward_amount_rubles=_format_price(poll.reward_amount_kopeks),
questions=questions,
created_at=poll.created_at,
updated_at=poll.updated_at,
)
def _serialize_answer(answer: PollAnswer) -> PollAnswerResponse:
question = getattr(answer, "question", None)
option = getattr(answer, "option", None)
return PollAnswerResponse(
question_id=question.id if question else answer.question_id,
question_text=question.text if question else None,
option_id=option.id if option else answer.option_id,
option_text=option.text if option else None,
created_at=answer.created_at,
)
def _serialize_user_response(response: PollResponse) -> PollUserResponse:
user = getattr(response, "user", None)
answers = [
_serialize_answer(answer)
for answer in sorted(response.answers, key=lambda item: item.created_at)
]
return PollUserResponse(
id=response.id,
user_id=getattr(user, "id", None),
user_telegram_id=getattr(user, "telegram_id", None),
user_username=getattr(user, "username", None),
sent_at=response.sent_at,
started_at=response.started_at,
completed_at=response.completed_at,
reward_given=response.reward_given,
reward_amount_kopeks=response.reward_amount_kopeks,
reward_amount_rubles=_format_price(response.reward_amount_kopeks),
answers=answers,
)
@router.get("", response_model=PollListResponse)
async def list_polls(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> PollListResponse:
total_result = await db.execute(select(func.count()).select_from(Poll))
total = int(total_result.scalar_one() or 0)
if total == 0:
return PollListResponse(items=[], total=0, limit=limit, offset=offset)
result = await db.execute(
select(Poll)
.options(selectinload(Poll.questions))
.order_by(Poll.created_at.desc())
.offset(offset)
.limit(limit)
)
polls = result.scalars().unique().all()
if not polls:
return PollListResponse(items=[], total=total, limit=limit, offset=offset)
poll_ids = [poll.id for poll in polls]
counts_result = await db.execute(
select(PollResponse.poll_id, func.count(PollResponse.id))
.where(PollResponse.poll_id.in_(poll_ids))
.group_by(PollResponse.poll_id)
)
responses_counts = {poll_id: count for poll_id, count in counts_result.all()}
return PollListResponse(
items=[
_serialize_poll_summary(
poll, responses_counts.get(poll.id, 0)
)
for poll in polls
],
total=total,
limit=limit,
offset=offset,
)
@router.get("/{poll_id}", response_model=PollDetailResponse)
async def get_poll(
poll_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PollDetailResponse:
poll = await get_poll_by_id(db, poll_id)
if not poll:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found")
return _serialize_poll_detail(poll)
@router.post("", response_model=PollDetailResponse, status_code=status.HTTP_201_CREATED)
async def create_poll_endpoint(
payload: PollCreateRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PollDetailResponse:
poll = await create_poll(
db,
title=payload.title,
description=payload.description,
reward_enabled=payload.reward_enabled,
reward_amount_kopeks=payload.reward_amount_kopeks,
created_by=None,
questions=[
{
"text": question.text,
"options": [option.text for option in question.options],
}
for question in payload.questions
],
)
poll = await get_poll_by_id(db, poll.id)
return _serialize_poll_detail(poll)
@router.delete("/{poll_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_poll(
poll_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> Response:
success = await delete_poll_record(db, poll_id)
if not success:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found")
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/{poll_id}/stats", response_model=PollStatisticsResponse)
async def get_poll_stats(
poll_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PollStatisticsResponse:
poll = await db.get(Poll, poll_id)
if not poll:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found")
stats = await get_poll_statistics(db, poll_id)
formatted_questions = [
PollQuestionStats(
id=question_data["id"],
text=question_data["text"],
order=question_data["order"],
options=[
PollOptionStats(
id=option_data["id"],
text=option_data["text"],
count=option_data["count"],
)
for option_data in question_data.get("options", [])
],
)
for question_data in stats.get("questions", [])
]
return PollStatisticsResponse(
poll_id=poll.id,
poll_title=poll.title,
total_responses=stats.get("total_responses", 0),
completed_responses=stats.get("completed_responses", 0),
reward_sum_kopeks=stats.get("reward_sum_kopeks", 0),
reward_sum_rubles=_format_price(stats.get("reward_sum_kopeks", 0)),
questions=formatted_questions,
)
@router.get("/{poll_id}/responses", response_model=PollResponsesListResponse)
async def get_poll_responses(
poll_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> PollResponsesListResponse:
poll_exists = await db.get(Poll, poll_id)
if not poll_exists:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found")
responses, total = await get_poll_responses_with_answers(
db,
poll_id,
limit=limit,
offset=offset,
)
items = [_serialize_user_response(response) for response in responses]
return PollResponsesListResponse(
items=items,
total=total,
limit=limit,
offset=offset,
)
+90
View File
@@ -0,0 +1,90 @@
"""Pydantic-схемы для работы с логами административного API."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
class MonitoringLogEntry(BaseModel):
"""Запись лога мониторинга."""
id: int
event_type: str = Field(..., description="Тип события мониторинга")
message: str = Field(..., description="Краткое описание события")
data: Optional[Dict[str, Any]] = Field(
default=None,
description="Дополнительные данные события",
)
is_success: bool = Field(..., description="Флаг успешности выполнения операции")
created_at: datetime = Field(..., description="Дата и время создания записи")
class MonitoringLogListResponse(BaseModel):
"""Ответ со списком логов мониторинга."""
total: int = Field(..., ge=0)
limit: int = Field(..., ge=1)
offset: int = Field(..., ge=0)
items: List[MonitoringLogEntry]
class MonitoringLogTypeListResponse(BaseModel):
"""Ответ со списком доступных типов событий мониторинга."""
items: List[str] = Field(default_factory=list)
class SupportAuditLogEntry(BaseModel):
"""Запись аудита модераторов поддержки."""
id: int
actor_user_id: Optional[int]
actor_telegram_id: int
is_moderator: bool
action: str
ticket_id: Optional[int]
target_user_id: Optional[int]
details: Optional[Dict[str, Any]] = None
created_at: datetime
class SupportAuditLogListResponse(BaseModel):
"""Ответ со списком аудита поддержки."""
total: int = Field(..., ge=0)
limit: int = Field(..., ge=1)
offset: int = Field(..., ge=0)
items: List[SupportAuditLogEntry]
class SupportAuditActionsResponse(BaseModel):
"""Ответ со списком доступных действий аудита поддержки."""
items: List[str] = Field(default_factory=list)
class SystemLogPreviewResponse(BaseModel):
"""Ответ с превью системного лог-файла бота."""
path: str = Field(..., description="Абсолютный путь до лог-файла")
exists: bool = Field(..., description="Флаг наличия лог-файла")
updated_at: Optional[datetime] = Field(
default=None,
description="Дата и время последнего изменения лог-файла",
)
size_bytes: int = Field(..., ge=0, description="Размер лог-файла в байтах")
size_chars: int = Field(..., ge=0, description="Количество символов в лог-файле")
preview: str = Field(
default="",
description="Фрагмент содержимого лог-файла, возвращаемый для предпросмотра",
)
preview_chars: int = Field(..., ge=0, description="Размер предпросмотра в символах")
preview_truncated: bool = Field(
..., description="Флаг усечения предпросмотра относительно полного файла"
)
download_url: Optional[str] = Field(
default=None,
description="Относительный путь до endpoint для скачивания лог-файла",
)
+171
View File
@@ -0,0 +1,171 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, field_validator, model_validator
class PollOptionCreate(BaseModel):
text: str = Field(..., min_length=1, max_length=500)
@field_validator("text")
@classmethod
def strip_text(cls, value: str) -> str:
text = value.strip()
if not text:
raise ValueError("Option text cannot be empty")
return text
class PollQuestionCreate(BaseModel):
text: str = Field(..., min_length=1, max_length=1000)
options: list[PollOptionCreate] = Field(..., min_length=2)
@field_validator("text")
@classmethod
def strip_question_text(cls, value: str) -> str:
text = value.strip()
if not text:
raise ValueError("Question text cannot be empty")
return text
@field_validator("options")
@classmethod
def validate_options(cls, value: list[PollOptionCreate]) -> list[PollOptionCreate]:
seen: set[str] = set()
for option in value:
normalized = option.text.lower()
if normalized in seen:
raise ValueError("Option texts must be unique within a question")
seen.add(normalized)
return value
class PollCreateRequest(BaseModel):
title: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = Field(default=None, max_length=4000)
reward_enabled: bool = False
reward_amount_kopeks: int = Field(default=0, ge=0, le=1_000_000_000)
questions: list[PollQuestionCreate] = Field(..., min_length=1)
@field_validator("title")
@classmethod
def strip_title(cls, value: str) -> str:
title = value.strip()
if not title:
raise ValueError("Title cannot be empty")
return title
@field_validator("description")
@classmethod
def normalize_description(cls, value: Optional[str]) -> Optional[str]:
if value is None:
return None
description = value.strip()
return description or None
@model_validator(mode="after")
def validate_reward(self) -> "PollCreateRequest":
if self.reward_enabled and self.reward_amount_kopeks <= 0:
raise ValueError("Reward amount must be positive when rewards are enabled")
if not self.reward_enabled:
self.reward_amount_kopeks = 0
return self
class PollQuestionOptionResponse(BaseModel):
id: int
text: str
order: int
class PollQuestionResponse(BaseModel):
id: int
text: str
order: int
options: list[PollQuestionOptionResponse]
class PollSummaryResponse(BaseModel):
id: int
title: str
description: Optional[str]
reward_enabled: bool
reward_amount_kopeks: int
reward_amount_rubles: float
questions_count: int
responses_count: int
created_at: datetime
updated_at: datetime
class PollDetailResponse(BaseModel):
id: int
title: str
description: Optional[str]
reward_enabled: bool
reward_amount_kopeks: int
reward_amount_rubles: float
questions: list[PollQuestionResponse]
created_at: datetime
updated_at: datetime
class PollListResponse(BaseModel):
items: list[PollSummaryResponse]
total: int
limit: int
offset: int
class PollOptionStats(BaseModel):
id: int
text: str
count: int
class PollQuestionStats(BaseModel):
id: int
text: str
order: int
options: list[PollOptionStats]
class PollStatisticsResponse(BaseModel):
poll_id: int
poll_title: str
total_responses: int
completed_responses: int
reward_sum_kopeks: int
reward_sum_rubles: float
questions: list[PollQuestionStats]
class PollAnswerResponse(BaseModel):
question_id: Optional[int]
question_text: Optional[str]
option_id: Optional[int]
option_text: Optional[str]
created_at: datetime
class PollUserResponse(BaseModel):
id: int
user_id: Optional[int]
user_telegram_id: Optional[int]
user_username: Optional[str]
sent_at: datetime
started_at: Optional[datetime]
completed_at: Optional[datetime]
reward_given: bool
reward_amount_kopeks: int
reward_amount_rubles: float
answers: list[PollAnswerResponse]
class PollResponsesListResponse(BaseModel):
items: list[PollUserResponse]
total: int
limit: int
offset: int
+45 -7
View File
@@ -36,13 +36,51 @@ curl -H "X-API-Key: super-secret-token" http://127.0.0.1:8080/health
1. При необходимости отредактируйте `miniapp/app-config.json`, чтобы настроить инструкции и ссылки на нужные клиенты.
2. Убедитесь, что файлы доступны для чтения пользователем веб-сервера.
## 5. Настройка кнопки в Telegram
## 5. Размещение статических файлов в Docker
Если reverse-proxy (nginx или Caddy) работает внутри контейнера, смонтируйте каталог `miniapp` внутрь него по пути `/miniapp`. Та
ким образом файлы `index.html` и `app-config.json` будут доступны серверу без копирования образа каждый раз при обновлении.
### Пример для docker-compose с Caddy
```yaml
services:
caddy:
image: caddy:2
restart: unless-stopped
volumes:
- ./miniapp:/miniapp:ro # монтируем локальную папку внутрь контейнера
- ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro
ports:
- "80:80"
- "443:443"
```
### Пример для docker-compose с nginx
```yaml
services:
nginx:
image: nginx:1.25-alpine
restart: unless-stopped
volumes:
- ./miniapp:/miniapp:ro # общий каталог статических файлов
- ./deploy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
ports:
- "80:80"
- "443:443"
```
Внутри конфигурации прокси укажите путь `/miniapp` как корневой для статических файлов или используйте директиву `alias`.
При пересборке проекта структура останется той же, достаточно обновить файлы в локальной папке.
## 6. Настройка кнопки в Telegram
1. В дминке бота настраиваем параметры - Конфигурации бота - Прочее - Miniapp
2. Перезапустите бота.
3. При необходимости задайте кастомную кнопку на миниапп через `@BotFather` (команда `/setmenu` -> Web App URL).
## 6. Конфигурация nginx
## 7. Конфигурация nginx
```nginx
server {
@@ -54,7 +92,7 @@ server {
ssl_certificate_key /etc/letsencrypt/live/miniapp.example.com/privkey.pem;
# Статические файлы мини-приложения
root /var/www/remnawave-miniapp;
root /miniapp;
index index.html;
location = /miniapp/app-config.json {
@@ -79,12 +117,12 @@ server {
}
```
## 7. Конфигурация Caddy
## 8. Конфигурация Caddy
```caddy
miniapp.example.com {
encode gzip zstd
root * /var/www/remnawave-miniapp
root * /miniapp
file_server
@config path /app-config.json
@@ -98,13 +136,13 @@ miniapp.example.com {
```
Caddy автоматически выпустит сертификаты через ACME. Убедитесь, что порт 443 проброшен и домен указывает на сервер.
## 8. Проверка работы
## 9. Проверка работы
1. Откройте мини-приложение прямо в Telegram или через браузер: `https://miniapp.example.com`.
2. В консоли разработчика убедитесь, что запрос к `https://miniapp.example.com/miniapp/subscription` возвращает JSON с данными подписки.
3. Проверьте, что ссылки из блока «Подключить подписку» открываются и копируются без ошибок.
## 9. Диагностика
## 10. Диагностика
| Симптом | Возможная причина | Проверка |
|---------|------------------|----------|
+84
View File
@@ -135,10 +135,94 @@ curl -X POST "http://127.0.0.1:8080/tokens" \
| `PATCH` | `/promo-offers/templates/{id}` | Обновить текст, кнопки и параметры шаблона.
| `GET` | `/promo-offers/logs` | Журнал операций с промо-предложениями (активации, списания, выключения).
| `GET` | `/tokens` | Управление токенами доступа.
| `GET` | `/polls` | Список опросов с постраничной навигацией.
| `GET` | `/polls/{id}` | Детали опроса с вопросами и вариантами ответов.
| `POST` | `/polls` | Создать опрос: заголовок, описание, вопросы и варианты.
| `DELETE` | `/polls/{id}` | Удалить опрос целиком.
| `GET` | `/polls/{id}/stats` | Сводная статистика по ответам и начисленным наградам.
| `GET` | `/polls/{id}/responses` | Ответы пользователей с детализацией по вопросам.
| `GET` | `/logs/monitoring` | Логи мониторинга бота с пагинацией и фильтрами по типу события.
| `GET` | `/logs/monitoring/event-types` | Справочник доступных типов событий мониторинга.
| `GET` | `/logs/support` | Журнал действий модераторов поддержки (блокировки, закрытия тикетов).
| `GET` | `/logs/support/actions` | Справочник возможных действий в аудите поддержки.
| `GET` | `/logs/system` | Предпросмотр системного лог-файла бота с метаданными.
| `GET` | `/logs/system/download` | Скачивание полного лог-файла бота (`text/plain`).
> Раздел **promo-offers** в Swagger объединяет работу с персональными предложениями: выдачу скидок/бонусов пользователям, настройку
> текстов шаблонов и просмотр журнала операций (активации, автосписания, отключения просроченных акций).
### Логи бота
В административном API появился раздел **logs**. Он позволяет:
- Просматривать общие логи мониторинга (`GET /logs/monitoring`) с поддержкой пагинации (`limit`, `offset`) и фильтра по типу события (`event_type`).
- Получать справочник доступных типов событий (`GET /logs/monitoring/event-types`). Это удобно для построения фильтров во внешней админке.
- Отслеживать действия модераторов поддержки (`GET /logs/support`) с пагинацией и возможностью фильтровать по конкретному действию (`action`).
- Запрашивать список возможных действий для UI (`GET /logs/support/actions`).
- Просматривать системный лог-файл бота (`GET /logs/system`). Endpoint возвращает метаданные (путь, время изменения, размер в байтах/символах) и фрагмент конца файла, размер которого можно регулировать параметром `preview_limit` (от 500 до 20 000 символов).
- Скачивать полный системный лог в текстовом формате (`GET /logs/system/download`).
Все эндпоинты защищены токеном API и возвращают структуру с общим количеством записей, текущим `limit`/`offset` и массивом объектов. Это упрощает реализацию таблиц и постраничной навигации во внешних административных интерфейсах.
### Управление опросами
Раздел **polls** в административном API позволяет создавать и анализировать опросы, которые бот рассылает пользователям.
#### Список и детали
- `GET /polls` — возвращает массив объектов с базовой информацией: название, описание, флаги награды, количество вопросов и ответов.
- `GET /polls/{id}` — раскрывает структуру конкретного опроса, включая упорядоченные вопросы и варианты ответов. Подходит для предпросмотра перед публикацией.
#### Создание опроса
Для создания опроса отправьте JSON, соответствующий схеме:
```json
{
"title": "Оценка нового тарифа",
"description": "Помогите улучшить продукт — ответ займет до 2 минут",
"reward_enabled": true,
"reward_amount_kopeks": 1000,
"questions": [
{
"text": "Насколько вы довольны скоростью соединения?",
"options": [
{ "text": "Очень доволен" },
{ "text": "Скорее доволен" },
{ "text": "Нейтрально" },
{ "text": "Скорее недоволен" },
{ "text": "Очень недоволен" }
]
},
{
"text": "Какие улучшения вы ждёте?",
"options": [
{ "text": "Стабильность" },
{ "text": "Скорость" },
{ "text": "Поддержка" }
]
}
]
}
```
Требования валидации:
- Заголовок (`title`) — от 1 до 255 символов, не пустой после обрезки пробелов.
- Описание (`description`) — до 4000 символов, пробелы по краям удаляются.
- Если `reward_enabled=true`, сумма вознаграждения (`reward_amount_kopeks`) должна быть положительной. При `false` значение автоматически сбрасывается в `0`.
- Каждый вопрос содержит минимум два уникальных варианта ответа.
В ответ API вернёт созданный опрос с назначенными идентификаторами и полем `reward_amount_rubles`, которое удобно показывать в интерфейсе.
#### Удаление и статистика
- `DELETE /polls/{id}` — удаляет опрос и связанные с ним вопросы/ответы. Используйте с осторожностью, операция необратима.
- `GET /polls/{id}/stats` — агрегированная статистика: общее количество ответов, завершённых прохождений и сумма выданных наград. Для каждого вопроса возвращается количество выборов по вариантам.
- `GET /polls/{id}/responses` — список ответов пользователей с пагинацией (`limit`, `offset`). Каждый элемент содержит временные метки (`sent_at`, `started_at`, `completed_at`), данные пользователя (ID, username, Telegram ID), информацию о выданной награде и массив ответов с текстами вопросов/вариантов.
Такой формат позволяет без дополнительного запроса показать детализацию на фронтенде или выгрузить данные в CSV.
### RemnaWave интеграция
После включения веб-API в Swagger (`WEB_API_DOCS_ENABLED=true`) появится раздел **remnawave**. Он объединяет эндпоинты для управления панелью RemnaWave и синхронизации данных бота:
+98 -2
View File
@@ -13,6 +13,14 @@ from app.database.database import init_db
from app.services.monitoring_service import monitoring_service
from app.services.maintenance_service import maintenance_service
from app.services.payment_service import PaymentService
from app.services.payment_verification_service import (
PENDING_MAX_AGE,
SUPPORTED_MANUAL_CHECK_METHODS,
auto_payment_verification_service,
get_enabled_auto_methods,
method_display_name,
)
from app.database.models import PaymentMethod
from app.services.version_service import version_service
from app.external.webhook_server import WebhookServer
from app.external.heleket_webhook import start_heleket_webhook_server
@@ -214,6 +222,67 @@ async def main():
logger.error(f"❌ Ошибка запуска автосинхронизации RemnaWave: {e}")
payment_service = PaymentService(bot)
auto_payment_verification_service.set_payment_service(payment_service)
verification_providers: list[str] = []
auto_verification_active = False
async with timeline.stage(
"Сервис проверки пополнений",
"💳",
success_message="Ручная проверка активна",
) as stage:
for method in SUPPORTED_MANUAL_CHECK_METHODS:
if method == PaymentMethod.YOOKASSA and settings.is_yookassa_enabled():
verification_providers.append("YooKassa")
elif method == PaymentMethod.MULENPAY and settings.is_mulenpay_enabled():
verification_providers.append(settings.get_mulenpay_display_name())
elif method == PaymentMethod.PAL24 and settings.is_pal24_enabled():
verification_providers.append("PayPalych")
elif method == PaymentMethod.WATA and settings.is_wata_enabled():
verification_providers.append("WATA")
elif method == PaymentMethod.HELEKET and settings.is_heleket_enabled():
verification_providers.append("Heleket")
elif method == PaymentMethod.CRYPTOBOT and settings.is_cryptobot_enabled():
verification_providers.append("CryptoBot")
if verification_providers:
hours = int(PENDING_MAX_AGE.total_seconds() // 3600)
stage.log(
"Ожидающие пополнения автоматически отбираются не старше "
f"{hours}ч"
)
stage.log(
"Доступна ручная проверка для: "
+ ", ".join(sorted(verification_providers))
)
stage.success(
f"Активно провайдеров: {len(verification_providers)}"
)
else:
stage.skip("Нет активных провайдеров для ручной проверки")
if settings.is_payment_verification_auto_check_enabled():
auto_methods = get_enabled_auto_methods()
if auto_methods:
interval_minutes = settings.get_payment_verification_auto_check_interval()
auto_labels = ", ".join(
sorted(method_display_name(method) for method in auto_methods)
)
stage.log(
"Автопроверка каждые "
f"{interval_minutes} мин: {auto_labels}"
)
else:
stage.log(
"Автопроверка включена, но нет активных провайдеров"
)
else:
stage.log("Автопроверка отключена настройками")
await auto_payment_verification_service.start()
auto_verification_active = auto_payment_verification_service.is_running()
if auto_verification_active:
stage.log("Фоновая автопроверка запущена")
async with timeline.stage(
"Внешняя админка",
@@ -423,6 +492,18 @@ async def main():
f"Проверка версий: {'Включен' if version_check_task else 'Отключен'}",
f"Отчеты: {'Включен' if reporting_service.is_running() else 'Отключен'}",
]
services_lines.append(
"Проверка пополнений: "
+ ("Включена" if verification_providers else "Отключена")
)
services_lines.append(
"Автопроверка пополнений: "
+ (
"Включена"
if auto_payment_verification_service.is_running()
else "Отключена"
)
)
timeline.log_section("Активные фоновые сервисы", services_lines, icon="📄")
timeline.log_summary()
@@ -484,7 +565,14 @@ async def main():
if settings.is_version_check_enabled():
logger.info("🔄 Перезапуск сервиса проверки версий...")
version_check_task = asyncio.create_task(version_service.start_periodic_check())
if auto_verification_active and not auto_payment_verification_service.is_running():
logger.warning(
"Сервис автопроверки пополнений остановился, пробуем перезапустить..."
)
await auto_payment_verification_service.start()
auto_verification_active = auto_payment_verification_service.is_running()
if polling_task.done():
exception = polling_task.exception()
if exception:
@@ -503,7 +591,15 @@ async def main():
timeline.log_summary()
summary_logged = True
logger.info("🛑 Начинается корректное завершение работы...")
logger.info("ℹ️ Остановка сервиса автопроверки пополнений...")
try:
await auto_payment_verification_service.stop()
except Exception as error:
logger.error(
f"Ошибка остановки сервиса автопроверки пополнений: {error}"
)
if yookassa_server_task and not yookassa_server_task.done():
logger.info("ℹ️ Остановка YooKassa webhook сервера...")
yookassa_server_task.cancel()
+139 -1
View File
@@ -2,8 +2,10 @@
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from typing import Any, Dict, Optional
from typing import Any, Dict, Optional, Sequence
import sys
import pytest
@@ -16,6 +18,57 @@ from app.config import settings # noqa: E402
from app.services.mulenpay_service import MulenPayService # noqa: E402
class _DummyResponse:
def __init__(
self,
*,
status: int,
body: str = "{}",
headers: Optional[Dict[str, str]] = None,
url: str = "https://mulenpay.test/endpoint",
) -> None:
self.status = status
self._body = body
self.headers = headers or {"Content-Type": "application/json"}
self.url = url
async def __aenter__(self) -> "_DummyResponse":
return self
async def __aexit__(self, exc_type, exc, tb) -> bool: # pragma: no cover - interface
return False
async def text(self) -> str:
return self._body
class _DummySession:
def __init__(self, result: Any) -> None:
self._result = result
async def __aenter__(self) -> "_DummySession":
return self
async def __aexit__(self, exc_type, exc, tb) -> bool: # pragma: no cover - interface
return False
def request(self, *args: Any, **kwargs: Any) -> Any:
if isinstance(self._result, BaseException):
raise self._result
return self._result
def _session_factory(responses: Sequence[Any]) -> Any:
call_state = {"index": 0}
def _factory(*_args: Any, **_kwargs: Any) -> _DummySession:
index = min(call_state["index"], len(responses) - 1)
call_state["index"] += 1
return _DummySession(responses[index])
return _factory
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
@@ -105,3 +158,88 @@ async def test_get_payment(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(service, "_request", fake_request, raising=False)
result = await service.get_payment(123)
assert result == {"id": 123, "status": "paid"}
@pytest.mark.anyio("asyncio")
async def test_request_success(monkeypatch: pytest.MonkeyPatch) -> None:
_enable_service(monkeypatch)
service = MulenPayService()
response_payload = {"ok": True}
monkeypatch.setattr(
"app.services.mulenpay_service.aiohttp.ClientSession",
_session_factory([
_DummyResponse(status=200, body=json.dumps(response_payload)),
]),
)
result = await service._request("GET", "/ping")
assert result == response_payload
@pytest.mark.anyio("asyncio")
async def test_request_retries_on_server_error(monkeypatch: pytest.MonkeyPatch) -> None:
_enable_service(monkeypatch)
service = MulenPayService()
service._max_retries = 2
sleep_calls: list[float] = []
async def fake_sleep(delay: float) -> None:
sleep_calls.append(delay)
monkeypatch.setattr(
"app.services.mulenpay_service.asyncio.sleep",
fake_sleep,
)
monkeypatch.setattr(
"app.services.mulenpay_service.aiohttp.ClientSession",
_session_factory(
[
_DummyResponse(status=502, body="{\"error\": \"bad gateway\"}"),
_DummyResponse(status=200, body="{\"ok\": true}"),
]
),
)
result = await service._request("GET", "/retry")
assert result == {"ok": True}
assert sleep_calls == [service._retry_delay]
@pytest.mark.anyio("asyncio")
async def test_request_returns_none_after_timeouts(monkeypatch: pytest.MonkeyPatch) -> None:
_enable_service(monkeypatch)
service = MulenPayService()
service._max_retries = 2
async def fake_sleep(_delay: float) -> None:
return None
monkeypatch.setattr(
"app.services.mulenpay_service.asyncio.sleep",
fake_sleep,
)
monkeypatch.setattr(
"app.services.mulenpay_service.aiohttp.ClientSession",
_session_factory([asyncio.TimeoutError()]),
)
result = await service._request("GET", "/timeout")
assert result is None
@pytest.mark.anyio("asyncio")
async def test_request_reraises_cancelled(monkeypatch: pytest.MonkeyPatch) -> None:
_enable_service(monkeypatch)
service = MulenPayService()
monkeypatch.setattr(
"app.services.mulenpay_service.aiohttp.ClientSession",
_session_factory([asyncio.CancelledError()]),
)
with pytest.raises(asyncio.CancelledError):
await service._request("GET", "/cancel")
@@ -50,6 +50,8 @@ class StubHeleketService:
self.info_response = info_response
self.calls: list[Dict[str, Any]] = []
self.info_calls: list[Dict[str, Optional[str]]] = []
self.list_response: Optional[Dict[str, Any]] = None
self.list_calls: list[Dict[str, Optional[str]]] = []
async def create_payment(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
self.calls.append(payload)
@@ -64,6 +66,18 @@ class StubHeleketService:
self.info_calls.append({"uuid": uuid, "order_id": order_id})
return self.info_response
async def list_payments(
self,
*,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
cursor: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
self.list_calls.append(
{"date_from": date_from, "date_to": date_to, "cursor": cursor}
)
return self.list_response
def _make_service(stub: Optional[StubHeleketService]) -> PaymentService:
service = PaymentService.__new__(PaymentService) # type: ignore[call-arg]
@@ -249,3 +263,53 @@ async def test_sync_heleket_payment_status_without_response(monkeypatch: pytest.
assert result is payment
assert stub.info_calls == [{"uuid": payment.uuid, "order_id": payment.order_id}]
assert stub.list_calls # fallback to history should be attempted
@pytest.mark.anyio("asyncio")
async def test_sync_heleket_payment_status_history_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
stub = StubHeleketService(response=None, info_response=None)
stub.list_response = {
"state": 0,
"result": {
"items": [
{
"uuid": "heleket-uuid",
"order_id": "order-123",
"status": "paid",
"payment_amount": "150.00",
}
],
"paginate": {"nextCursor": None},
},
}
service = _make_service(stub)
db = DummySession()
payment = SimpleNamespace(
id=77,
uuid="heleket-uuid",
order_id="order-123",
status="check",
user_id=8,
)
async def fake_get_by_id(db, payment_id):
assert payment_id == payment.id
return payment
captured: Dict[str, Any] = {}
async def fake_process(self, db, payload, *, metadata_key):
captured["payload"] = payload
captured["metadata_key"] = metadata_key
return SimpleNamespace(**payload)
monkeypatch.setattr(heleket_crud, "get_heleket_payment_by_id", fake_get_by_id, raising=False)
monkeypatch.setattr(PaymentService, "_process_heleket_payload", fake_process, raising=False)
result = await service.sync_heleket_payment_status(db, local_payment_id=payment.id)
assert result is not None
assert captured["payload"]["status"] == "paid"
assert stub.list_calls
+132 -1
View File
@@ -4,6 +4,7 @@ from pathlib import Path
from typing import Any, Dict, Optional
import sys
from datetime import datetime
from types import SimpleNamespace
import pytest
@@ -34,7 +35,12 @@ class DummyLocalPayment:
class StubPal24Service:
def __init__(self, *, configured: bool = True, response: Optional[Dict[str, Any]] = None) -> None:
def __init__(
self,
*,
configured: bool = True,
response: Optional[Dict[str, Any]] = None,
) -> None:
self.is_configured = configured
self.response = response or {
"success": True,
@@ -45,6 +51,12 @@ class StubPal24Service:
}
self.calls: list[Dict[str, Any]] = []
self.raise_error: Optional[Exception] = None
self.status_response: Optional[Dict[str, Any]] = {"status": "NEW"}
self.payment_status_response: Optional[Dict[str, Any]] = None
self.bill_payments_response: Optional[Dict[str, Any]] = None
self.status_calls: list[str] = []
self.payment_status_calls: list[str] = []
self.bill_payments_calls: list[str] = []
async def create_bill(self, **kwargs: Any) -> Dict[str, Any]:
self.calls.append(kwargs)
@@ -52,6 +64,18 @@ class StubPal24Service:
raise self.raise_error
return self.response
async def get_bill_status(self, bill_id: str) -> Optional[Dict[str, Any]]:
self.status_calls.append(bill_id)
return self.status_response
async def get_payment_status(self, payment_id: str) -> Optional[Dict[str, Any]]:
self.payment_status_calls.append(payment_id)
return self.payment_status_response
async def get_bill_payments(self, bill_id: str) -> Optional[Dict[str, Any]]:
self.bill_payments_calls.append(bill_id)
return self.bill_payments_response
def _make_service(stub: Optional[StubPal24Service]) -> PaymentService:
service = PaymentService.__new__(PaymentService) # type: ignore[call-arg]
@@ -198,3 +222,110 @@ async def test_create_pal24_payment_handles_api_errors(monkeypatch: pytest.Monke
language="ru",
)
assert result is None
@pytest.mark.anyio("asyncio")
async def test_get_pal24_payment_status_updates_from_remote(monkeypatch: pytest.MonkeyPatch) -> None:
stub = StubPal24Service()
stub.status_response = {"status": "SUCCESS"}
stub.payment_status_response = {
"success": True,
"id": "PAY-1",
"bill_id": "BILL-1",
"status": "SUCCESS",
"payment_method": "SBP",
"account_amount": "700.00",
"from_card": "676754******1234",
}
stub.bill_payments_response = {
"data": [
{
"id": "PAY-1",
"bill_id": "BILL-1",
"status": "SUCCESS",
"from_card": "676754******1234",
"payment_method": "SBP",
}
]
}
service = _make_service(stub)
db = DummySession()
payment = SimpleNamespace(
id=99,
bill_id="BILL-1",
payment_id=None,
payment_status="NEW",
payment_method=None,
balance_amount=None,
balance_currency=None,
payer_account=None,
status="NEW",
is_paid=False,
paid_at=None,
transaction_id=None,
user_id=1,
)
async def fake_get_by_id(db: DummySession, payment_id: int) -> SimpleNamespace:
assert payment_id == payment.id
return payment
async def fake_update_status(
db: DummySession,
payment_obj: SimpleNamespace,
*,
status: str,
**kwargs: Any,
) -> SimpleNamespace:
payment_obj.status = status
payment_obj.last_status = status
for key, value in kwargs.items():
setattr(payment_obj, key, value)
if "is_paid" in kwargs:
payment_obj.is_paid = kwargs["is_paid"]
await db.commit()
return payment_obj
async def fake_finalize(
self: PaymentService,
db: DummySession,
payment_obj: Any,
*,
payment_id: Optional[str] = None,
trigger: str,
) -> bool:
return False
monkeypatch.setattr(
payment_service_module,
"get_pal24_payment_by_id",
fake_get_by_id,
raising=False,
)
monkeypatch.setattr(
payment_service_module,
"update_pal24_payment_status",
fake_update_status,
raising=False,
)
monkeypatch.setattr(
PaymentService,
"_finalize_pal24_payment",
fake_finalize,
raising=False,
)
result = await service.get_pal24_payment_status(db, local_payment_id=payment.id)
assert result is not None
assert payment.status == "SUCCESS"
assert payment.payment_id == "PAY-1"
assert payment.payment_status == "SUCCESS"
assert payment.payment_method == "sbp"
assert payment.is_paid is True
assert stub.status_calls == ["BILL-1"]
assert stub.payment_status_calls in ([], ["PAY-1"])
assert result["remote_status"] == "SUCCESS"
assert result["remote_data"] and "bill_status" in result["remote_data"]
@@ -1,5 +1,7 @@
"""Тесты для Telegram Stars-сценариев внутри PaymentService."""
from datetime import datetime, timedelta
from decimal import Decimal
from pathlib import Path
from typing import Any, Dict, Optional
import sys
@@ -25,12 +27,17 @@ class DummyBot:
def __init__(self) -> None:
self.calls: list[Dict[str, Any]] = []
self.sent_messages: list[Dict[str, Any]] = []
async def create_invoice_link(self, **kwargs: Any) -> str:
"""Эмулируем создание платежной ссылки и сохраняем параметры вызова."""
self.calls.append(kwargs)
return "https://t.me/invoice/stars"
async def send_message(self, **kwargs: Any) -> None:
"""Фиксируем отправленные сообщения пользователю."""
self.sent_messages.append(kwargs)
def _make_service(bot: Optional[DummyBot]) -> PaymentService:
"""Создаёт экземпляр PaymentService без выполнения полного конструктора."""
@@ -41,6 +48,80 @@ def _make_service(bot: Optional[DummyBot]) -> PaymentService:
return service
class DummySession:
"""Минимальная заглушка AsyncSession для проверки сценариев Stars."""
def __init__(self, pending_subscription: "DummySubscription") -> None:
self.pending_subscription = pending_subscription
self.commits: int = 0
self.refreshed: list[Any] = []
async def execute(self, *_args: Any, **_kwargs: Any) -> Any:
class _Result:
def __init__(self, subscription: "DummySubscription") -> None:
self._subscription = subscription
def scalar_one_or_none(self) -> "DummySubscription":
return self._subscription
return _Result(self.pending_subscription)
async def commit(self) -> None:
self.commits += 1
async def refresh(self, obj: Any) -> None:
self.refreshed.append(obj)
class DummySubscription:
"""Упрощённая модель подписки для тестов."""
def __init__(
self,
subscription_id: int,
*,
traffic_limit_gb: int = 0,
device_limit: int = 1,
period_days: int = 30,
) -> None:
self.id = subscription_id
self.traffic_limit_gb = traffic_limit_gb
self.device_limit = device_limit
self.status = "pending"
self.start_date = datetime(2024, 1, 1)
self.end_date = self.start_date + timedelta(days=period_days)
class DummyUser:
"""Минимальные данные пользователя для тестов Stars-покупки."""
def __init__(self, user_id: int = 501, telegram_id: int = 777) -> None:
self.id = user_id
self.telegram_id = telegram_id
self.language = "ru"
self.balance_kopeks = 0
self.has_made_first_topup = False
self.promo_group = None
self.subscription = None
class DummyTransaction:
"""Локальная транзакция, созданная в тестах."""
def __init__(self, external_id: str) -> None:
self.external_id = external_id
class DummySubscriptionService:
"""Заглушка SubscriptionService, запоминающая вызовы."""
def __init__(self) -> None:
self.calls: list[tuple[Any, Any]] = []
async def create_remnawave_user(self, db: Any, subscription: Any) -> object:
self.calls.append((db, subscription))
return object()
@pytest.mark.anyio("asyncio")
async def test_create_stars_invoice_calculates_stars(monkeypatch: pytest.MonkeyPatch) -> None:
"""Количество звёзд должно рассчитываться по курсу с округлением вниз и нижним порогом 1."""
@@ -140,3 +221,127 @@ async def test_create_stars_invoice_requires_bot() -> None:
amount_kopeks=1000,
description="Пополнение",
)
@pytest.mark.anyio("asyncio")
async def test_process_stars_payment_simple_subscription_success(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Оплата простой подписки через Stars активирует pending подписку и уведомляет пользователя."""
bot = DummyBot()
service = _make_service(bot)
pending_subscription = DummySubscription(subscription_id=321, device_limit=2)
db = DummySession(pending_subscription)
user = DummyUser(user_id=900, telegram_id=123456)
activated_subscription = DummySubscription(subscription_id=321, device_limit=2)
transaction_holder: Dict[str, DummyTransaction] = {}
async def fake_create_transaction(**kwargs: Any) -> DummyTransaction:
transaction = DummyTransaction(external_id=kwargs.get("external_id", ""))
transaction_holder["value"] = transaction
return transaction
async def fake_get_user_by_id(_db: Any, _user_id: int) -> DummyUser:
return user
async def fake_activate_pending_subscription(
db: Any,
user_id: int,
period_days: Optional[int] = None,
) -> DummySubscription:
activated_subscription.start_date = pending_subscription.start_date
activated_subscription.end_date = activated_subscription.start_date + timedelta(
days=period_days or 30
)
return activated_subscription
subscription_service_stub = DummySubscriptionService()
admin_calls: list[Dict[str, Any]] = []
class AdminNotificationStub:
def __init__(self, _bot: Any) -> None:
self.bot = _bot
async def send_subscription_purchase_notification(
self,
db: Any,
user_obj: Any,
subscription: Any,
transaction: Any,
period_days: int,
was_trial_conversion: bool,
) -> None:
admin_calls.append(
{
"user": user_obj,
"subscription": subscription,
"transaction": transaction,
"period": period_days,
"was_trial": was_trial_conversion,
}
)
monkeypatch.setattr(
"app.services.payment.stars.create_transaction",
fake_create_transaction,
raising=False,
)
monkeypatch.setattr(
"app.services.payment.stars.get_user_by_id",
fake_get_user_by_id,
raising=False,
)
monkeypatch.setattr(
"app.database.crud.subscription.activate_pending_subscription",
fake_activate_pending_subscription,
raising=False,
)
monkeypatch.setattr(
"app.services.subscription_service.SubscriptionService",
lambda: subscription_service_stub,
raising=False,
)
monkeypatch.setattr(
"app.services.admin_notification_service.AdminNotificationService",
AdminNotificationStub,
raising=False,
)
monkeypatch.setattr(
type(settings),
"format_price",
lambda self, amount: f"{amount / 100:.0f}",
raising=False,
)
monkeypatch.setattr(
settings,
"SIMPLE_SUBSCRIPTION_PERIOD_DAYS",
30,
raising=False,
)
monkeypatch.setattr(
"app.services.payment.stars.TelegramStarsService.calculate_rubles_from_stars",
lambda stars: Decimal("100"),
raising=False,
)
payload = f"simple_sub_{user.id}_{pending_subscription.id}_30"
result = await service.process_stars_payment(
db=db,
user_id=user.id,
stars_amount=5,
payload=payload,
telegram_payment_charge_id="charge12345",
)
assert result is True
assert user.balance_kopeks == 0, "Баланс не должен меняться при оплате подписки"
assert subscription_service_stub.calls == [(db, activated_subscription)]
assert len(admin_calls) == 1
assert admin_calls[0]["subscription"] is activated_subscription
assert admin_calls[0]["period"] == 30
assert bot.sent_messages, "Пользователь должен получить уведомление"
assert "Подписка успешно активирована" in bot.sent_messages[0]["text"]
assert transaction_holder["value"].external_id == "charge12345"
+176 -1
View File
@@ -6,7 +6,7 @@ from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace, ModuleType
from typing import Any, Dict
from typing import Any, Dict, Optional
import sys
import pytest
@@ -496,6 +496,166 @@ async def test_process_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch)
assert admin_calls
@pytest.mark.anyio("asyncio")
async def test_process_yookassa_webhook_restores_missing_payment(
monkeypatch: pytest.MonkeyPatch,
) -> None:
bot = DummyBot()
service = _make_service(bot)
fake_session = FakeSession()
restored_payment = SimpleNamespace(
yookassa_payment_id="yk_456",
user_id=21,
amount_kopeks=0,
status="pending",
is_paid=False,
transaction_id=None,
description="",
payment_method_type=None,
confirmation_url=None,
metadata_json=None,
test_mode=False,
refundable=False,
)
get_calls = {"count": 0}
async def fake_get_payment(db, payment_id):
get_calls["count"] += 1
if get_calls["count"] == 1:
return None
return restored_payment
async def fake_create_payment(**kwargs: Any):
restored_payment.user_id = kwargs["user_id"]
restored_payment.amount_kopeks = kwargs["amount_kopeks"]
restored_payment.status = kwargs["status"]
restored_payment.description = kwargs["description"]
restored_payment.payment_method_type = kwargs["payment_method_type"]
restored_payment.confirmation_url = kwargs["confirmation_url"]
restored_payment.metadata_json = kwargs["metadata_json"]
restored_payment.test_mode = kwargs["test_mode"]
restored_payment.yookassa_payment_id = kwargs["yookassa_payment_id"]
restored_payment.yookassa_created_at = kwargs["yookassa_created_at"]
return restored_payment
async def fake_update_status(
db,
yookassa_payment_id,
status,
is_paid,
is_captured,
captured_at,
payment_method_type,
):
restored_payment.status = status
restored_payment.is_paid = is_paid
restored_payment.is_captured = is_captured
restored_payment.captured_at = captured_at
restored_payment.payment_method_type = payment_method_type
return restored_payment
async def fake_link(db, yookassa_payment_id, transaction_id):
restored_payment.transaction_id = transaction_id
monkeypatch.setattr(payment_service_module, "get_yookassa_payment_by_id", fake_get_payment)
monkeypatch.setattr(payment_service_module, "create_yookassa_payment", fake_create_payment)
monkeypatch.setattr(payment_service_module, "update_yookassa_payment_status", fake_update_status)
monkeypatch.setattr(payment_service_module, "link_yookassa_payment_to_transaction", fake_link)
transactions: list[Dict[str, Any]] = []
async def fake_create_transaction(db, **kwargs):
transactions.append(kwargs)
return SimpleNamespace(id=555, **kwargs)
monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction)
user = SimpleNamespace(
id=21,
telegram_id=2100,
balance_kopeks=0,
has_made_first_topup=False,
promo_group=None,
subscription=None,
referred_by_id=None,
referrer=None,
)
async def fake_get_user(db, user_id):
return user
monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user)
monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}", raising=False)
referral_mock = SimpleNamespace(process_referral_topup=AsyncMock())
monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_mock)
admin_calls: list[Any] = []
class DummyAdminService:
def __init__(self, bot):
self.bot = bot
async def send_balance_topup_notification(self, *args, **kwargs):
admin_calls.append((args, kwargs))
monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot)))
service.build_topup_success_keyboard = AsyncMock(return_value=None)
payload = {
"object": {
"id": "yk_456",
"status": "succeeded",
"paid": True,
"amount": {"value": "150.00", "currency": "RUB"},
"metadata": {"user_id": "21", "payment_purpose": "balance_topup"},
"description": "Пополнение",
"payment_method": {"type": "bank_card"},
"created_at": "2024-01-02T12:00:00Z",
"captured_at": "2024-01-02T12:05:00Z",
"confirmation": {"confirmation_url": "https://pay.example"},
}
}
result = await service.process_yookassa_webhook(fake_session, payload)
assert result is True
assert get_calls["count"] >= 2 # повторный запрос после восстановления
assert restored_payment.amount_kopeks == 15000
assert restored_payment.is_paid is True
assert transactions and transactions[0]["amount_kopeks"] == 15000
assert restored_payment.transaction_id == 555
assert user.balance_kopeks == 15000
assert bot.sent_messages
assert admin_calls
@pytest.mark.anyio("asyncio")
async def test_process_yookassa_webhook_missing_metadata(monkeypatch: pytest.MonkeyPatch) -> None:
service = _make_service(DummyBot())
db = FakeSession()
async def fake_get_payment(db_session, payment_id):
return None
create_mock = AsyncMock()
update_mock = AsyncMock()
monkeypatch.setattr(payment_service_module, "get_yookassa_payment_by_id", fake_get_payment)
monkeypatch.setattr(payment_service_module, "create_yookassa_payment", create_mock)
monkeypatch.setattr(payment_service_module, "update_yookassa_payment_status", update_mock)
payload = {"object": {"id": "yk_missing", "status": "succeeded", "paid": True}}
result = await service.process_yookassa_webhook(db, payload)
assert result is False
create_mock.assert_not_awaited()
update_mock.assert_not_awaited()
@pytest.mark.anyio("asyncio")
async def test_process_yookassa_webhook_missing_id(monkeypatch: pytest.MonkeyPatch) -> None:
bot = DummyBot()
@@ -669,6 +829,21 @@ async def test_get_pal24_payment_status_auto_finalize(monkeypatch: pytest.Monkey
},
}
async def get_payment_status(self, payment_id: str) -> Optional[Dict[str, Any]]:
return None
async def get_bill_payments(self, bill_id: str) -> Optional[Dict[str, Any]]:
return {
"data": [
{
"id": "trs-auto-1",
"bill_id": bill_id,
"status": "SUCCESS",
"payment_method": "SBP",
}
]
}
service.pal24_service = DummyPal24Service()
fake_session = FakeSession()
@@ -0,0 +1,300 @@
import pytest
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, MagicMock
from app.config import settings
from app.database.models import User
from app.services.subscription_auto_purchase_service import auto_purchase_saved_cart_after_topup
from app.services.subscription_purchase_service import (
PurchaseDevicesConfig,
PurchaseOptionsContext,
PurchasePeriodConfig,
PurchasePricingResult,
PurchaseSelection,
PurchaseServersConfig,
PurchaseTrafficConfig,
)
from sqlalchemy.ext.asyncio import AsyncSession
class DummyTexts:
def t(self, key: str, default: str):
return default
def format_price(self, value: int) -> str:
return f"{value / 100:.0f}"
@pytest.mark.asyncio
async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch):
monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True)
user = MagicMock(spec=User)
user.id = 42
user.telegram_id = 4242
user.balance_kopeks = 200_000
user.language = "ru"
user.subscription = None
cart_data = {
"period_days": 30,
"countries": ["ru"],
"traffic_gb": 0,
"devices": 1,
}
traffic_config = PurchaseTrafficConfig(
selectable=False,
mode="fixed",
options=[],
default_value=0,
current_value=0,
)
servers_config = PurchaseServersConfig(
options=[],
min_selectable=0,
max_selectable=0,
default_selection=["ru"],
)
devices_config = PurchaseDevicesConfig(
minimum=1,
maximum=5,
default=1,
current=1,
price_per_device=0,
discounted_price_per_device=0,
price_label="0 ₽",
)
period_config = PurchasePeriodConfig(
id="days:30",
days=30,
months=1,
label="30 дней",
base_price=100_000,
base_price_label="1000 ₽",
base_price_original=100_000,
base_price_original_label=None,
discount_percent=0,
per_month_price=100_000,
per_month_price_label="1000 ₽",
traffic=traffic_config,
servers=servers_config,
devices=devices_config,
)
context = PurchaseOptionsContext(
user=user,
subscription=None,
currency="RUB",
balance_kopeks=user.balance_kopeks,
periods=[period_config],
default_period=period_config,
period_map={"days:30": period_config},
server_uuid_to_id={"ru": 1},
payload={},
)
base_pricing = PurchasePricingResult(
selection=PurchaseSelection(
period=period_config,
traffic_value=0,
servers=["ru"],
devices=1,
),
server_ids=[1],
server_prices_for_period=[100_000],
base_original_total=100_000,
discounted_total=100_000,
promo_discount_value=0,
promo_discount_percent=0,
final_total=100_000,
months=1,
details={"servers_individual_prices": [100_000]},
)
class DummyMiniAppService:
async def build_options(self, db, user):
return context
async def calculate_pricing(self, db, ctx, selection):
return PurchasePricingResult(
selection=selection,
server_ids=base_pricing.server_ids,
server_prices_for_period=base_pricing.server_prices_for_period,
base_original_total=base_pricing.base_original_total,
discounted_total=base_pricing.discounted_total,
promo_discount_value=base_pricing.promo_discount_value,
promo_discount_percent=base_pricing.promo_discount_percent,
final_total=base_pricing.final_total,
months=base_pricing.months,
details=base_pricing.details,
)
async def submit_purchase(self, db, prepared_context, pricing):
return {
"subscription": MagicMock(),
"transaction": MagicMock(),
"was_trial_conversion": False,
"message": "🎉 Subscription purchased",
}
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.MiniAppSubscriptionPurchaseService",
lambda: DummyMiniAppService(),
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart",
AsyncMock(return_value=cart_data),
)
delete_cart_mock = AsyncMock()
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.user_cart_service.delete_user_cart",
delete_cart_mock,
)
clear_draft_mock = AsyncMock()
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.clear_subscription_checkout_draft",
clear_draft_mock,
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.get_texts",
lambda lang: DummyTexts(),
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.format_period_description",
lambda days, lang: f"{days} дней",
)
admin_service_mock = MagicMock()
admin_service_mock.send_subscription_purchase_notification = AsyncMock()
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.AdminNotificationService",
lambda bot: admin_service_mock,
)
bot = AsyncMock()
db_session = AsyncMock(spec=AsyncSession)
result = await auto_purchase_saved_cart_after_topup(db_session, user, bot=bot)
assert result is True
delete_cart_mock.assert_awaited_once_with(user.id)
clear_draft_mock.assert_awaited_once_with(user.id)
bot.send_message.assert_awaited()
admin_service_mock.send_subscription_purchase_notification.assert_awaited()
@pytest.mark.asyncio
async def test_auto_purchase_saved_cart_after_topup_extension(monkeypatch):
monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True)
subscription = MagicMock()
subscription.id = 99
subscription.is_trial = False
subscription.status = "active"
subscription.end_date = datetime.utcnow()
subscription.device_limit = 1
subscription.traffic_limit_gb = 100
subscription.connected_squads = ["squad-a"]
user = MagicMock(spec=User)
user.id = 7
user.telegram_id = 7007
user.balance_kopeks = 200_000
user.language = "ru"
user.subscription = subscription
cart_data = {
"cart_mode": "extend",
"subscription_id": subscription.id,
"period_days": 30,
"total_price": 31_000,
"description": "Продление подписки на 30 дней",
"device_limit": 2,
"traffic_limit_gb": 500,
"squad_uuid": "squad-b",
"consume_promo_offer": True,
}
subtract_mock = AsyncMock(return_value=True)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.subtract_user_balance",
subtract_mock,
)
async def extend_stub(db, current_subscription, days):
current_subscription.end_date = current_subscription.end_date + timedelta(days=days)
return current_subscription
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.extend_subscription",
extend_stub,
)
create_transaction_mock = AsyncMock(return_value=MagicMock())
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.create_transaction",
create_transaction_mock,
)
service_mock = MagicMock()
service_mock.update_remnawave_user = AsyncMock()
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.SubscriptionService",
lambda: service_mock,
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart",
AsyncMock(return_value=cart_data),
)
delete_cart_mock = AsyncMock()
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.user_cart_service.delete_user_cart",
delete_cart_mock,
)
clear_draft_mock = AsyncMock()
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.clear_subscription_checkout_draft",
clear_draft_mock,
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.get_texts",
lambda lang: DummyTexts(),
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.format_period_description",
lambda days, lang: f"{days} дней",
)
admin_service_mock = MagicMock()
admin_service_mock.send_subscription_extension_notification = AsyncMock()
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.AdminNotificationService",
lambda bot: admin_service_mock,
)
bot = AsyncMock()
db_session = AsyncMock(spec=AsyncSession)
result = await auto_purchase_saved_cart_after_topup(db_session, user, bot=bot)
assert result is True
subtract_mock.assert_awaited_once_with(
db_session,
user,
cart_data["total_price"],
cart_data["description"],
consume_promo_offer=True,
)
assert subscription.device_limit == 2
assert subscription.traffic_limit_gb == 500
assert "squad-b" in subscription.connected_squads
delete_cart_mock.assert_awaited_once_with(user.id)
clear_draft_mock.assert_awaited_once_with(user.id)
admin_service_mock.send_subscription_extension_notification.assert_awaited()
bot.send_message.assert_awaited()
service_mock.update_remnawave_user.assert_awaited()
create_transaction_mock.assert_awaited()
+24 -1
View File
@@ -4,6 +4,7 @@ from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, User as TgUser, Message
from sqlalchemy.ext.asyncio import AsyncSession
from app.handlers.subscription.purchase import save_cart_and_redirect_to_topup, return_to_saved_cart, clear_saved_cart
from app.handlers.subscription.autopay import handle_subscription_cancel
from app.database.models import User, Subscription
@pytest.fixture
@@ -207,4 +208,26 @@ async def test_clear_saved_cart(mock_callback_query, mock_state, mock_user, mock
mock_state.clear.assert_called_once()
# Проверяем, что вызван answer
mock_callback_query.answer.assert_called_once()
mock_callback_query.answer.assert_called_once()
@pytest.mark.asyncio
async def test_handle_subscription_cancel_clears_saved_cart(mock_callback_query, mock_state, mock_user, mock_db):
"""Отмена покупки должна очищать сохраненную корзину"""
mock_clear_draft = AsyncMock()
mock_show_main_menu = AsyncMock()
with patch('app.handlers.subscription.autopay.user_cart_service') as mock_cart_service, \
patch('app.handlers.subscription.autopay.clear_subscription_checkout_draft', new=mock_clear_draft), \
patch('app.localization.texts.get_texts', return_value=MagicMock()) as _, \
patch('app.handlers.menu.show_main_menu', new=mock_show_main_menu):
mock_cart_service.delete_user_cart = AsyncMock(return_value=True)
await handle_subscription_cancel(mock_callback_query, mock_state, mock_user, mock_db)
mock_state.clear.assert_called_once()
mock_clear_draft.assert_awaited_once_with(mock_user.id)
mock_cart_service.delete_user_cart.assert_awaited_once_with(mock_user.id)
mock_show_main_menu.assert_awaited_once_with(mock_callback_query, mock_user, mock_db)
mock_callback_query.answer.assert_called_once_with("❌ Покупка отменена")