Compare commits

...
99 Commits
Author SHA1 Message Date
kavoreandGitHub 9ea9f571bb Merge pull request #155 from kavore/dev 2026-01-31 07:13:59 +03:00
kavoreandGitHub 9b4a58f82f Merge pull request #154 from VAQYBIN/dev
Исправление бага при удалении промокода на скидку
2026-01-28 16:18:23 +03:00
VAQYBIN e6adbbece4 Merge branch 'dev' of https://github.com/VAQYBIN/remnawave-tg-shop into dev 2026-01-25 20:43:20 +05:00
VAQYBIN 1e79b15351 fix(promo): Исправлена ошибка с удалением промокода на скидку
Исправлен баг, когда промокод на скидку не мог быть удалён, если пользователь применил скидку, но не воспользовался ей.
Добавлено истечение времени действия промокода, даже если промокод на скидку уже был введён
2026-01-25 20:43:01 +05:00
kavoreandGitHub 6f3c123b3a Merge pull request #153 from VAQYBIN/dev
Промокоды на скидку
2026-01-24 22:15:06 +03:00
VAQYBINandGitHub 4a0f763307 Delete CLAUDE.md 2026-01-23 12:42:40 +05:00
VAQYBIN a121d38fbb fix(payments): Исправлена ошибка с дублированием скидки для всех платёжных систем 2026-01-23 00:00:16 +05:00
VAQYBIN 1e8b97888a fix(promo): Исправлен баг с применением двойной скидки при оплате через CryptoBot 2026-01-21 01:26:10 +05:00
VAQYBIN 9ca2fe487c fix(promo): Исправлена ошибка с зацыкленностью импортов для оплат звёздами и криптой 2026-01-21 01:13:26 +05:00
VAQYBIN 229ce7e1e0 feat(promo): Добавлена поддержка промокодов на скидку для всех платёжных систем 2026-01-21 01:09:49 +05:00
VAQYBIN 39d5fd1856 chore(promo): Добавлена локализация кнопки изменения процента скидки 2026-01-20 01:44:22 +05:00
VAQYBIN fed1c1c960 feat(promo): Добавлена локализация при изменении процента скидки и логи при активации промокода на скидку 2026-01-20 01:26:14 +05:00
VAQYBIN e7df5e539c chore(promo): Добавлены локализации при создании промокодов на скидку 2026-01-20 01:06:18 +05:00
VAQYBIN dcc7f9eb72 fix(promo): Исправлена ошибка с дублированием скидки при использование промокода 2026-01-20 00:51:05 +05:00
VAQYBIN baaf5c457f fix(promo): Исправлена ошибка при применении промокода на скидку 2026-01-20 00:34:17 +05:00
VAQYBIN 62bf5c35a8 fix(db): Добавлены новые таблицы для хранения информации о новых и старых ценах при использовании промокодов на скидку 2026-01-20 00:16:24 +05:00
VAQYBIN d619afff29 test: fix ci/cd 2026-01-19 04:00:10 +05:00
VAQYBIN 3bf9acf4d4 feat(promo): Добавлены промокоды на скидку в процентах 2026-01-19 03:50:33 +05:00
kavoreandGitHub a7ec55d741 Merge pull request #152 from VAQYBIN/main
Add step-by-step install into README.md
2026-01-18 23:09:37 +03:00
VAQYBIN 0701af0f35 feat(docs): Добавлена пошаговая инструкция в README.md по установке бота 2026-01-19 01:06:44 +05:00
kavoreandGitHub c0851c5339 Merge pull request #151 from kavore/dev
bugfix
2026-01-17 23:55:25 +03:00
kavore 785b6c2d41 merge 2026-01-17 23:54:58 +03:00
kavore cce3fd4f58 bugfix #4 2026-01-17 23:23:06 +03:00
kavore 0dd6beebb2 remove nalogo and use custom client 2026-01-17 21:58:12 +03:00
kavore 8fb2a76698 bugfix 2026-01-17 21:25:02 +03:00
kavoreandGitHub 3d6713caa9 Merge pull request #150 from kavore/dev
bugfix and nalogo custom label
2026-01-17 21:11:41 +03:00
kavore 8d8fdce519 bugfix 2026-01-17 21:10:46 +03:00
kavore c385a1466c added custom label 2026-01-17 21:06:58 +03:00
kavoreandGitHub 8e1fdb33d2 Merge pull request #149 from kavore/dev
added nalogo
2026-01-17 16:18:38 +03:00
machka paslaandkavore 29222be1fa .env.example and docker-compose upd 2026-01-17 16:01:29 +03:00
machka pasla 08aca3b28d Add LOG_LEVEL env config and fix Nalogo receipt trigger 2026-01-14 16:03:21 +03:00
machka paslaandGitHub 658139d607 Merge pull request #148 from machka-pasla/lknpd
Lknpd
2026-01-14 14:54:31 +03:00
machka paslaandGitHub 1d4abf7977 Merge branch 'dev' into lknpd 2026-01-14 14:54:11 +03:00
machka pasla 62e8bc08d5 version update 2026-01-06 12:22:30 +03:00
machka pasla 475a30caf4 added my nalog management 2026-01-05 19:43:40 +03:00
machka paslaandGitHub c1593d7868 Merge pull request #145 from tdlcompiler/crypt4-fix 2025-12-30 07:00:41 +03:00
Denis Kartsev 0dbcfe3770 removed crypt4 prefix
panel api already returns a link with the prefix
2025-12-30 04:58:34 +03:00
machka pasla e3ffb87cfc updated crypt 2025-12-29 14:51:50 +03:00
machka pasla b4218ec6a7 Идемпотентная обработка вебхуков YooKassa 2025-12-26 13:44:51 +03:00
machka paslaandGitHub 48f8656ef8 Merge pull request #143 from machka-pasla/dev
bug fix in platega
2025-12-21 11:56:12 +03:00
machka pasla cee8491b3d bug fix 2025-12-21 11:55:49 +03:00
machka paslaandGitHub 4bc3f3b4b3 Merge pull request #142 from machka-pasla/dev
added severpay, platega, happ crypt4 support
2025-12-20 17:42:07 +03:00
machka pasla adb1105621 bug fix 2025-12-20 14:40:47 +03:00
machka pasla c8fd79e4ba shorten hwid 2025-12-20 09:41:10 +03:00
machka pasla 52bc9f71cc bug fix 2025-12-11 22:28:51 +03:00
machka pasla a1d3db0e16 added happ crypt4 links 2025-12-11 22:17:43 +03:00
machka pasla d0e34126e1 upd 2025-12-11 16:13:22 +03:00
machka pasla c96ebea077 upd 2025-12-11 13:51:54 +03:00
machka pasla f00a72ad15 yookassa fix 2025-12-11 12:59:39 +03:00
machka pasla 22171eb66f added gb packets selling 2025-12-11 12:47:32 +03:00
machka pasla 061fdeb72b removed tribute 2025-12-11 09:49:31 +03:00
machka pasla 8eba23574b some yookassa changes 2025-12-09 22:32:10 +03:00
machka pasla 7b650a74a6 severpay fix 2025-12-09 20:48:16 +03:00
machka pasla 805bd4cf60 readme upd 2025-12-09 20:21:46 +03:00
machka pasla 56a32d493f refactor 2025-12-09 20:17:46 +03:00
machka pasla b24a685066 added severpay.io 2025-12-09 19:50:14 +03:00
machka pasla fb06fbd0e1 added platega.io 2025-12-07 20:24:24 +03:00
machka pasla 719369057f fix privacy error 2025-12-04 12:13:17 +03:00
machka paslaandGitHub cea9d98155 Update and rename docker-compose-removed-server.yml to docker-compose-remote-server.yml 2025-11-28 13:16:02 +03:00
machka paslaandGitHub 6cf15e0698 Merge pull request #139
added external squad
2025-11-19 11:30:56 +03:00
machka pasla 2971b9ad2e added external squad 2025-11-19 11:30:34 +03:00
machka paslaandGitHub 3e86fc76b6 Merge pull request #135 from machka-pasla/dev
upd
2025-11-13 23:04:43 +03:00
machka paslaandGitHub 829a18715a Merge pull request #134 from machka-pasla/revert-133-revert-132-fix/sync-admin-duplicate-subscriptions
Revert "Revert "Устранены дубли активных записей, ошибка уникальности и улучшена идемпотентность""
2025-11-13 23:04:13 +03:00
machka paslaandGitHub 844c8e12a7 Revert "Revert "Устранены дубли активных записей, ошибка уникальности и улучшена идемпотентность"" 2025-11-13 23:03:56 +03:00
machka paslaandGitHub ef75f905f4 Merge pull request #133 from machka-pasla/revert-132-fix/sync-admin-duplicate-subscriptions
Revert "Устранены дубли активных записей, ошибка уникальности и улучшена идемпотентность"
2025-11-13 23:01:58 +03:00
machka paslaandGitHub cd816cbe5c Revert "Устранены дубли активных записей, ошибка уникальности и улучшена идемпотентность" 2025-11-13 23:01:46 +03:00
machka paslaandGitHub 2b0fa3a314 Merge pull request #132 from orryxvpn/fix/sync-admin-duplicate-subscriptions
Устранены дубли активных записей, ошибка уникальности и улучшена идемпотентность
2025-11-13 23:01:30 +03:00
machka paslaandGitHub ead990e61c Merge pull request #129 from 3252a8/feature/ref-link
Ref link improvement
2025-11-13 23:00:43 +03:00
orryxvpnandGitHub eb6e343e4c Refactor perform_sync function for readability 2025-11-13 21:30:15 +05:00
3252a8 caf5b88eef Use random string in ref link instead of tg ID
- Enable old links with tg id by default in .env with LEGACY_REFS=true
2025-11-11 22:32:30 +03:00
machka paslaandGitHub 183bef070d Merge pull request #124 from machka-pasla/dev
yookassa bug fix and other
2025-11-10 15:49:45 +03:00
machka pasla 643bf5fabb fix bug 2025-11-07 20:47:09 +03:00
machka pasla 7d446d5f64 upd promo bug 2025-11-06 19:03:34 +03:00
machka paslaandGitHub 73ce689d5e Merge pull request #120 from streletskiy/feature/user-profile-links
Feature/user profile links
2025-11-03 17:27:49 +03:00
Bogdan Strielecki 5791f2e58c Add user profile links
User profile buttons quickly opens user's telegram profile

Add user profile link button to log messages
Add user profile link button to user card in admin panel
Add referrer profile link button to log messages
Add referrer profile link button to user card in admin panel
2025-11-03 16:00:50 +03:00
machka pasla 69cc2cbe29 upd 2025-11-02 15:16:27 +03:00
machka pasla 7df2e127a4 fix 2025-10-31 22:41:48 +03:00
machka pasla d503cf7f7f YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING param added 2025-10-31 21:38:41 +03:00
machka paslaandGitHub c0d70030c3 Merge pull request #117 from machka-pasla/dev
Dev
2025-10-30 20:46:31 +03:00
machka paslaandGitHub 4e7c36dbf7 Merge pull request #115 from streletskiy/feature/paged-users-list
Paged list of all users
2025-10-30 17:57:00 +03:00
Bogdan Strielecki 3b0b88e9a0 Add admin button for delete user from bot and panel 2025-10-30 17:55:08 +03:00
Bogdan Strielecki 207e7751cd Add paged list of all users to admin panel 2025-10-28 15:13:19 +03:00
machka paslaandGitHub c65b608f0b Merge pull request #114 from machka-pasla/dev
more payment info in admin, docker compose for other servers, fixed channel sub check
2025-10-28 14:40:59 +03:00
machka pasla cff8368b7c docker hub push 2025-10-28 14:39:47 +03:00
machka paslaandGitHub 2f367bbd6b Merge pull request #112 from streletskiy/feature/display-user-income
Additional payment info in admin panel user card
2025-10-28 14:31:47 +03:00
machka pasla c3d6324ec6 fixed channel sub check 2025-10-27 15:58:59 +03:00
Bogdan Strielecki 4800a2da80 Additional payment info in admin panel user card
- total income
- total income from all user referrals
2025-10-27 13:38:37 +03:00
machka paslaandGitHub 70a0cd44a1 Merge pull request #111 from freize/main
An example Docker file for deploying a bot on a separate server
2025-10-25 20:02:43 +03:00
machka paslaandGitHub 545c9c7d07 Merge pull request #109 from Snowy-Fluffy/custom-payment-id
Добавление возможности выставлять ID способа оплаты для FreeKassa в env
2025-10-25 20:02:14 +03:00
FreizeandGitHub cd60f496fa Update docker-compose-removed-server.yml 2025-10-25 19:27:57 +03:00
FreizeandGitHub f883065bb9 Add Docker Compose configuration for removed server
This configuration file was created as an example showing the installation of the bot on a separate server.
2025-10-25 19:24:50 +03:00
Snowy-Fluffy b71bd71d8d allows you to set custom payment id for fk 2025-10-24 20:45:43 +03:00
Snowy-Fluffy 0524d24b13 set custom payment id for fk 2025-10-24 20:21:35 +03:00
machka paslaandGitHub cdfd94c814 Merge pull request #108 from machka-pasla/dev
fixed locales and traffic after adding days
2025-10-24 10:34:25 +03:00
machka pasla 6513681125 fixed locales and traffic after adding days 2025-10-24 10:33:10 +03:00
machka paslaandGitHub f321fd04cf Merge pull request #107
fixed ad
2025-10-21 09:45:32 +03:00
machka pasla baede17adf fixed ad 2025-10-21 09:44:21 +03:00
machka paslaandGitHub 17bf8720a3 Merge pull request #105 from machka-pasla/dev
fixed my devices keyboard
2025-10-20 22:28:36 +03:00
machka pasla 280aced20e fixed my devices keyboard 2025-10-20 22:26:50 +03:00
74 changed files with 7181 additions and 2685 deletions
+61 -22
View File
@@ -36,6 +36,10 @@ FREEKASSA_ENABLED=True #
STARS_ENABLED=True # Turn on STARS
TRIBUTE_ENABLED=True # Turn on TRIBUTE
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
PLATEGA_ENABLED=False # Turn on PLATEGA
SEVERPAY_ENABLED=False # Turn on SeverPay
# Order of payment methods (top to bottom). Supported: severpay, freekassa, platega, yookassa, tribute, stars, cryptopay
PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,tribute,stars
# YooKassa Payment Gateway Configuration
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
@@ -44,12 +48,20 @@ YOOKASSA_RETURN_URL=https://t.me/your_bot #
YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com # Default email for sending receipts
YOOKASSA_VAT_CODE=1 # VAT code
YOOKASSA_AUTOPAYMENTS_ENABLED=False # Auto-renew toggle
YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING=True # Force automatic card binding when autopay is enabled (set to False to show the save-card checkbox)
# Nalogo (self-employed receipts)
NALOGO_INN=your_inn # INN for nalog.ru
NALOGO_PASSWORD=your_nalogo_password # Password for nalog.ru
NALOGO_RECEIPT_NAME_SUBSCRIPTION=subscription {months} months # Receipt name for time-based subscriptions ({months} = duration)
NALOGO_RECEIPT_NAME_TRAFFIC=traffic package {gb} GB # Receipt name for traffic packages ({gb} = traffic amount)
# FreeKassa Payment Gateway Configuration
FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa
FREEKASSA_API_KEY=your_api_key # API key for REST requests
FREEKASSA_SECOND_SECRET=your_second_secret # Secret word #2 (used to verify notifications)
FREEKASSA_PAYMENT_IP= # Public IP address reported to FreeKassa
FREEKASSA_PAYMENT_METHOD_ID=44 # Payment method ID, you can get it from https://merchant.freekassa.net/settings/currencies
# CryptoBot Payment Gateway Configuration
CRYPTOPAY_TOKEN= # API token for CryptoPay
@@ -57,6 +69,21 @@ CRYPTOPAY_NETWORK=mainnet #
CRYPTOPAY_CURRENCY_TYPE=fiat # Currency type (fiat or crypto)
CRYPTOPAY_ASSET=RUB # Asset, e.g., RUB, BTC, USDT
# Platega Payment Gateway Configuration
PLATEGA_BASE_URL=https://app.platega.io # Base API URL
PLATEGA_MERCHANT_ID= # Your MerchantId from Platega
PLATEGA_SECRET= # API secret from Platega
PLATEGA_PAYMENT_METHOD=2 # Payment method ID (2=SBP QR, 10=RU cards, 12=International, 13=Crypto)
PLATEGA_RETURN_URL= # Optional: redirect after successful payment (defaults to bot link)
PLATEGA_FAILED_URL= # Optional: redirect after failed/cancelled payment (defaults to return URL)
# SeverPay Payment Gateway Configuration
SEVERPAY_BASE_URL=https://severpay.io/api/merchant # Base API URL
SEVERPAY_MID= # Your MID from SeverPay
SEVERPAY_TOKEN= # API token/secret for signing requests
SEVERPAY_RETURN_URL= # Optional: redirect URL after payment (defaults to bot link)
SEVERPAY_LIFETIME_MINUTES= # Optional: payment link lifetime in minutes (30-4320, leave empty for default)
# Tribute Payment Gateway Configuration
TRIBUTE_API_KEY= # API key for verifying Tribute webhook signatures
TRIBUTE_SKIP_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments
@@ -83,14 +110,19 @@ RUB_PRICE_12_MONTHS=900
STARS_PRICE_12_MONTHS=0
TRIBUTE_LINK_12_MONTHS=
# Traffic Packages (enables traffic sale mode when set)
TRAFFIC_PACKAGES=10:199,50:799 # Format: "<GB>:<price>", comma-separated
STARS_TRAFFIC_PACKAGES=10:2500 # Optional: traffic packages priced in Stars
# Subscription Notifications
SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription
SUBSCRIPTION_NOTIFY_ON_EXPIRE=True # Notify on subscription
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True # Notify after
SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 # Days before expiration to notify
SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription
SUBSCRIPTION_NOTIFY_ON_EXPIRE=True # Notify on subscription
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True # Notify after
SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 # Days before expiration to notify
REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee
REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee
LEGACY_REFS=true # Allow ref_<tg_id> links. Leave unset/true unless you want to disable old links
# Referral Bonus Days
# Bonus for the inviting user
REFERRAL_BONUS_DAYS_1_MONTH=3
@@ -104,39 +136,46 @@ REFEREE_BONUS_DAYS_6_MONTHS=7
REFEREE_BONUS_DAYS_12_MONTHS=15
# Panel API Configuration
PANEL_API_URL=http://your_panel_api_url/api # URL of the panel API
PANEL_API_KEY=your_panel_api_key # Panel API key
PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures
PANEL_API_URL=http://your_panel_api_url/api # URL of the panel API
PANEL_API_KEY=your_panel_api_key # Panel API key
PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures
# User traffic limits (applied for all users)
# 0 means unlimited
USER_TRAFFIC_LIMIT_GB=0 # Traffic limit for users (0 unlimited)
USER_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy (NO_RESET, WEEK, MONTH)
USER_TRAFFIC_LIMIT_GB=0 # Traffic limit for users (0 unlimited)
USER_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy (NO_RESET, WEEK, MONTH)
# Default Internal Squads for Users (Optional, comma-separated UUIDs)
USER_SQUAD_UUIDS=uuid1,uuid2,uuid3
# Default External Squad for Users (Optional, single UUID)
USER_EXTERNAL_SQUAD_UUID= # Optional: UUID from Remnawave External Squads to auto-link new panel users
# Trial Settings
TRIAL_ENABLED=True # Enable the trial period
TRIAL_DURATION_DAYS=5 # Duration of the trial period in days
TRIAL_TRAFFIC_LIMIT_GB=0 # Traffic limit for the trial period (0 = unlimited)
TRIAL_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy for the trial period (NO_RESET, WEEK, MONTH)
TRIAL_ENABLED=True # Enable the trial period
TRIAL_DURATION_DAYS=5 # Duration of the trial period in days
TRIAL_TRAFFIC_LIMIT_GB=0 # Traffic limit for the trial period (0 = unlimited)
TRIAL_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy for the trial period (NO_RESET, WEEK, MONTH)
# Connection link handling (happ crypt4)
CRYPT4_ENABLED=False # Enable happ crypt4 encryption for subscription URLs
CRYPT4_REDIRECT_URL= # Base redirect to wrap the connect button, e.g. https://redir.example.com?url=
# Web Server Settings (for handling webhooks)
WEB_SERVER_HOST="0.0.0.0"
WEB_SERVER_PORT=8080
# Admin Panel Log Pagination
LOGS_PAGE_SIZE=10 # Number of events in the log
LOGS_PAGE_SIZE=10 # Number of events in the log
LOG_LEVEL=INFO # Global log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
# Admin Logging Configuration
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
LOG_THREAD_ID= # Optional: Thread ID for supergroup messages
LOG_NEW_USERS=True # Log new user registrations
LOG_PAYMENTS=True # Log payments
LOG_PROMO_ACTIVATIONS=True # Log promo code activations
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
LOG_THREAD_ID= # Optional: Thread ID for supergroup messages
LOG_NEW_USERS=True # Log new user registrations
LOG_PAYMENTS=True # Log payments
LOG_PROMO_ACTIVATIONS=True # Log promo code activations
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
# Embedded mode thumbnails. Please don't touch this if you don't know what it is.
INLINE_REFERRAL_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/1077/1077114.png
+14 -5
View File
@@ -9,8 +9,9 @@ on:
- dev
env:
REGISTRY: ghcr.io
GHCR_REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
jobs:
build:
@@ -26,18 +27,26 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
images: |
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
${{ env.DOCKERHUB_IMAGE }}
tags: |
type=ref,event=branch
type=ref,event=pr
@@ -56,4 +65,4 @@ jobs:
cache-to: type=gha,mode=max
- name: Image digest
run: echo ${{ steps.meta.outputs.digest }}
run: echo ${{ steps.meta.outputs.digest }}
+14 -2
View File
@@ -9,6 +9,10 @@ on:
paths-ignore:
- 'README.md'
env:
GHCR_IMAGE: ghcr.io/${{ github.repository }}
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
jobs:
build-and-push:
runs-on: ubuntu-latest
@@ -27,6 +31,12 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up QEMU (для эмуляции arm64 на x86)
uses: docker/setup-qemu-action@v3
@@ -40,5 +50,7 @@ jobs:
platforms: linux/amd64,linux/arm64
push: true
tags: |
ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
${{ env.GHCR_IMAGE }}:latest
${{ env.GHCR_IMAGE }}:${{ github.ref_name }}
${{ env.DOCKERHUB_IMAGE }}:latest
${{ env.DOCKERHUB_IMAGE }}:${{ github.ref_name }}
+1 -1
View File
@@ -16,4 +16,4 @@ __pycache__/
*.pid
locales/ru_backup.json
locales/en_backup.json
db/models_old.py
db/models_old.py
+3 -3
View File
@@ -1,4 +1,4 @@
FROM python:3.11-slim AS builder
FROM python:3.12-slim AS builder
WORKDIR /app
@@ -7,11 +7,11 @@ COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir -r requirements.txt
FROM python:3.11-slim
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY . .
+175 -9
View File
@@ -11,7 +11,7 @@
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), CryptoPay, Telegram Stars и Tribute.
- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), Platega, SeverPay, CryptoPay и Telegram Stars.
### Для администраторов:
- **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`.
@@ -24,11 +24,11 @@
## 🚀 Технологии
- **Python 3.11**
- **Python 3.12**
- **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов.
- **aiohttp:** Для запуска веб-сервера (вебхуки).
- **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL.
- **YooKassa, FreeKassa API, aiocryptopay:** Интеграции с платежными системами.
- **YooKassa, FreeKassa API, Platega, SeverPay, aiocryptopay:** Интеграции с платежными системами.
- **Pydantic:** Для управления настройками из `.env` файла.
- **Docker & Docker Compose:** Для контейнеризации и развертывания.
@@ -45,7 +45,7 @@
1. **Клонируйте репозиторий:**
```bash
git clone https://github.com/machka-pasla/remnawave-tg-shop
git clone https://github.com/kavore/remnawave-tg-shop
cd remnawave-tg-shop
```
@@ -80,9 +80,14 @@
| `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. |
| `WEB_SERVER_HOST` | Хост для веб-сервера. | `0.0.0.0` |
| `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` |
| `PAYMENT_METHODS_ORDER` | (Опционально) Порядок отображения кнопок оплаты через запятую. Поддерживаемые ключи: `severpay`, `freekassa`, `platega`, `yookassa`, `stars`, `cryptopay`. Первый будет сверху. |
| `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). |
| `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. |
| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. |
| `YOOKASSA_AUTOPAYMENTS_ENABLED` | Включить автопродление (сохранение карт, автосписания, управление способами оплаты). |
| `YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING` | Требовать обязательную привязку карты при оплате с автосписанием. Установите `false`, чтобы пользователю показывался чекбокс «Сохранить карту». |
| `NALOGO_INN` | ИНН для авторизации в nalog.ru (самозанятый). |
| `NALOGO_PASSWORD` | Пароль для авторизации в nalog.ru (самозанятый). |
| `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). |
| `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. |
| `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). |
@@ -91,8 +96,20 @@
| `FREEKASSA_SECOND_SECRET` | Секретное слово №2 — используется для проверки уведомлений от FreeKassa. |
| `FREEKASSA_PAYMENT_URL` | (Опционально, legacy SCI) Базовый URL платёжной формы FreeKassa. По умолчанию `https://pay.freekassa.ru/`. |
| `FREEKASSA_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. |
| `FREEKASSA_PAYMENT_METHOD_ID` | ID метода оплаты через магазин FreeKassa. По умолчанию `44`. |
| `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). |
| `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). |
| `PLATEGA_ENABLED`| Включить/выключить Platega (`true`/`false`). |
| `PLATEGA_MERCHANT_ID`| MerchantId из личного кабинета Platega. |
| `PLATEGA_SECRET`| API секрет для запросов Platega. |
| `PLATEGA_PAYMENT_METHOD`| ID способа оплаты (2 — SBP QR, 10 — РФ карты, 12 — международные карты, 13 — crypto). |
| `PLATEGA_RETURN_URL`| (Опционально) URL редиректа после успешной оплаты. По умолчанию ссылка на бота. |
| `PLATEGA_FAILED_URL`| (Опционально) URL редиректа при ошибке/отмене. По умолчанию как `PLATEGA_RETURN_URL`. |
| `SEVERPAY_ENABLED` | Включить/выключить SeverPay (`true`/`false`). |
| `SEVERPAY_MID` | MID магазина в SeverPay. |
| `SEVERPAY_TOKEN` | Секрет/токен для подписи запросов SeverPay. |
| `SEVERPAY_BASE_URL` | (Опционально) Базовый URL API SeverPay. По умолчанию `https://severpay.io/api/merchant`. |
| `SEVERPAY_RETURN_URL` | (Опционально) URL редиректа после оплаты (по умолчанию ссылка на бота). |
| `SEVERPAY_LIFETIME_MINUTES` | (Опционально) Время жизни платежной ссылки в минутах (30–4320). |
</details>
<details>
@@ -102,7 +119,6 @@
- `1_MONTH_ENABLED`: `true` или `false`
- `RUB_PRICE_1_MONTH`: Цена в рублях
- `STARS_PRICE_1_MONTH`: Цена в Telegram Stars
- `TRIBUTE_LINK_1_MONTH`: Ссылка для оплаты через Tribute
Аналогичные переменные есть для `3_MONTHS`, `6_MONTHS`, `12_MONTHS`.
</details>
@@ -115,11 +131,12 @@
| `PANEL_API_KEY` | API ключ для доступа к панели. |
| `PANEL_WEBHOOK_SECRET`| Секретный ключ для проверки вебхуков от панели. |
| `USER_SQUAD_UUIDS` | ID отрядов для новых пользователей. |
| `USER_EXTERNAL_SQUAD_UUID` | Опционально. UUID внешнего отряда (External Squad) из [документации Remnawave](https://docs.rw/api), куда автоматически добавляются новые пользователи. |
| `USER_TRAFFIC_LIMIT_GB`| Лимит трафика в ГБ (0 - безлимит). |
| `USER_HWID_DEVICE_LIMIT`| Лимит устройств (HWID) для новых пользователей (0 - безлимит). |
> Раздел "Мои устройства" становится доступен пользователям только при включении `MY_DEVICES_SECTION_ENABLED`. Значение лимита устройств при создании записей в панели берётся из `USER_HWID_DEVICE_LIMIT`.
</gidetails>
</details>
<details>
<summary><b>Настройки пробного периода</b></summary>
@@ -138,15 +155,16 @@
Эта команда скачает образ и запустит сервис в фоновом режиме.
4. **Настройка вебхуков (Обязательно):**
Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Tribute) и панели Remnawave.
Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Platega, SeverPay) и панели Remnawave.
Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом.
**Пути для перенаправления:**
- `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/yookassa`
- `https://<ваш_домен>/webhook/freekassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/freekassa`
- `https://<ваш_домен>/webhook/platega` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/platega`
- `https://<ваш_домен>/webhook/severpay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/severpay`
- `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/cryptopay`
- `https://<ваш_домен>/webhook/tribute` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/tribute`
- `https://<ваш_домен>/webhook/panel` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/panel`
- **Для Telegram:** Бот автоматически установит вебхук, если в `.env` указан `WEBHOOK_BASE_URL`. Путь будет `https://<ваш_домен>/<BOT_TOKEN>`.
@@ -159,10 +177,158 @@
> 💡 Если включена проверка подписки на канал (`REQUIRED_CHANNEL_ID`), добавьте бота администратором в этот канал. Пользователь увидит кнопку «Проверить подписку», и, после первого успешного подтверждения, дальнейшие действия блокироваться не будут.
## Подробная инструкция для развертывания на сервере с панелью Remnawave
### 1. Клонирование репозитория
```bash
git clone https://github.com/kavore/remnawave-tg-shop && cd remnawave-tg-shop
```
### 2. Настройка переменных окружения
```bash
cp .env.example .env && nano .env
```
**Обязательные поля для заполнения:**
- `BOT_TOKEN` - токен телеграмм бота, например, `234567890:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`
- `ADMIN_IDS` - TG ID администраторов, например, `12345678,98765432` и т.д. (через запятую без пробелов)
- `WEBHOOK_BASE_URL` - Обязательно. Базовый URL для вебхуков, например `https://webhook.domain.com`
- `PANEL_API_URL` - URL API вашей панели Remnawave (например, `http://remnawave:3000/api` или `https://panel.domain.com/api`)
- `PANEL_API_KEY` - API ключ для доступа к панели (генерируется из UI-интерфейса панели)
- `PANEL_WEBHOOK_SECRET` - Секретный ключ для проверки вебхуков от панели (берётся из `.env` самой панели)
- `USER_SQUAD_UUIDS` - ID отрядов для новых пользователей
### 3. Настройка Reverse Proxy (Nginx)
Перейдите в директорию конфигурации Nginx панели Remnawave:
```bash
cd /opt/remnawave/nginx && nano nginx.conf
```
Добавьте в `nginx.conf` следующую конфигурацию:
```nginx
upstream remnawave-tg-shop {
server remnawave-tg-shop:8080;
}
map $http_upgrade $connection_upgrade {
default upgrade;
"" close;
}
server {
server_name webhook.domain.com; # Домен для отправки Webhook'ов
listen 443 ssl;
http2 on;
ssl_certificate "/etc/nginx/ssl/webhook_fullchain.pem";
ssl_certificate_key "/etc/nginx/ssl/webhook_privkey.key";
ssl_trusted_certificate "/etc/nginx/ssl/webhook_fullchain.pem";
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_intercept_errors on;
error_page 400 404 500 502 @redirect;
location / {
proxy_pass http://remnawave-tg-shop$request_uri;
}
location @redirect {
return 404;
}
}
```
### 4. Выпуск SSL-сертификата для домена webhook
Убедитесь, что установлены необходимые компоненты, а также откройте 80 порт:
```bash
sudo apt-get install cron socat
curl https://get.acme.sh | sh -s email=EMAIL && source ~/.bashrc
ufw allow 80/tcp && ufw reload
```
Выпустите сертификат:
```bash
acme.sh --set-default-ca --server letsencrypt
acme.sh --issue --standalone -d 'webhook.domain.com' \
--key-file /opt/remnawave/nginx/webhook_privkey.key \
--fullchain-file /opt/remnawave/nginx/webhook_fullchain.pem
```
### 5. Добавление сертификатов в Docker Compose Nginx
Отредактируйте `docker-compose.yml` панели Nginx:
```bash
cd /opt/remnawave/nginx && nano docker-compose.yml
```
Добавьте две строки в секцию `volumes`:
```yaml
services:
remnawave-nginx:
image: nginx:1.26
container_name: remnawave-nginx
hostname: remnawave-nginx
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./fullchain.pem:/etc/nginx/ssl/fullchain.pem:ro
- ./privkey.key:/etc/nginx/ssl/privkey.key:ro
- ./subdomain_fullchain.pem:/etc/nginx/ssl/subdomain_fullchain.pem:ro
- ./subdomain_privkey.key:/etc/nginx/ssl/subdomain_privkey.key:ro
- ./webhook_fullchain.pem:/etc/nginx/ssl/webhook_fullchain.pem:ro # Добавьте эту строку
- ./webhook_privkey.key:/etc/nginx/ssl/webhook_privkey.key:ro # Добавьте эту строку
restart: always
ports:
- '0.0.0.0:443:443'
networks:
- remnawave-network
networks:
remnawave-network:
name: remnawave-network
driver: bridge
external: true
```
### 6. Запуск бота и перезапуск Nginx
Запустите бота:
```bash
cd /root/remnawave-tg-shop && docker compose up -d && docker compose logs -f -t
```
Перезапустите Nginx:
```bash
cd /opt/remnawave/nginx && docker compose down && docker compose up -d && docker compose logs -f -t
```
## 🐳 Docker
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки.
Для автоматической публикации образов настроены GitHub Actions (`.github/workflows`). По умолчанию образы пушатся в GitHub Container Registry и Docker Hub. Добавьте в Secrets репозитория значения `DOCKERHUB_USERNAME` и `DOCKERHUB_TOKEN` (персональный access token или пароль для Docker Hub), чтобы загрузка в Docker Hub работала корректно.
## 📁 Структура проекта
```
+28 -11
View File
@@ -9,10 +9,12 @@ from bot.services.subscription_service import SubscriptionService
from bot.services.referral_service import ReferralService
from bot.services.promo_code_service import PromoCodeService
from bot.services.stars_service import StarsService
from bot.services.tribute_service import TributeService
from bot.services.crypto_pay_service import CryptoPayService
from bot.services.panel_webhook_service import PanelWebhookService
from bot.services.freekassa_service import FreeKassaService
from bot.services.platega_service import PlategaService
from bot.services.severpay_service import SeverPayService
from bot.services.lknpd_service import LknpdService
def build_core_services(
@@ -45,14 +47,23 @@ def build_core_services(
subscription_service=subscription_service,
referral_service=referral_service,
)
tribute_service = TributeService(
bot,
settings,
i18n,
async_session_factory,
panel_service,
subscription_service,
referral_service,
platega_service = PlategaService(
bot=bot,
settings=settings,
i18n=i18n,
async_session_factory=async_session_factory,
subscription_service=subscription_service,
referral_service=referral_service,
default_return_url=bot_username_for_default_return,
)
severpay_service = SeverPayService(
bot=bot,
settings=settings,
i18n=i18n,
async_session_factory=async_session_factory,
subscription_service=subscription_service,
referral_service=referral_service,
default_return_url=bot_username_for_default_return,
)
panel_webhook_service = PanelWebhookService(bot, settings, i18n, async_session_factory, panel_service)
yookassa_service = YooKassaService(
@@ -62,6 +73,11 @@ def build_core_services(
bot_username_for_default_return=bot_username_for_default_return,
settings_obj=settings,
)
lknpd_service = LknpdService(
settings.LKNPD_INN,
settings.LKNPD_PASSWORD,
api_url=settings.LKNPD_API_URL,
)
# Wire services that depend on each other
try:
@@ -80,8 +96,9 @@ def build_core_services(
"stars_service": stars_service,
"cryptopay_service": cryptopay_service,
"freekassa_service": freekassa_service,
"tribute_service": tribute_service,
"panel_webhook_service": panel_webhook_service,
"yookassa_service": yookassa_service,
"lknpd_service": lknpd_service,
"platega_service": platega_service,
"severpay_service": severpay_service,
}
+15 -8
View File
@@ -23,14 +23,16 @@ async def build_and_start_web_app(
app["i18n"] = dp.get("i18n_instance")
for key in (
"yookassa_service",
"lknpd_service",
"subscription_service",
"referral_service",
"panel_service",
"stars_service",
"freekassa_service",
"cryptopay_service",
"tribute_service",
"panel_webhook_service",
"platega_service",
"severpay_service",
):
# Access dispatcher workflow_data directly to avoid sequence protocol issues
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore
@@ -48,15 +50,11 @@ async def build_and_start_web_app(
)
from bot.handlers.user.payment import yookassa_webhook_route
from bot.services.tribute_service import tribute_webhook_route
from bot.services.crypto_pay_service import cryptopay_webhook_route
from bot.services.panel_webhook_service import panel_webhook_route
from bot.services.freekassa_service import freekassa_webhook_route
tribute_path = settings.tribute_webhook_path
if tribute_path.startswith("/"):
app.router.add_post(tribute_path, tribute_webhook_route)
logging.info(f"Tribute webhook route configured at: [POST] {tribute_path}")
from bot.services.platega_service import platega_webhook_route
from bot.services.severpay_service import severpay_webhook_route
cp_path = settings.cryptopay_webhook_path
if cp_path.startswith("/"):
@@ -68,6 +66,16 @@ async def build_and_start_web_app(
app.router.add_post(fk_path, freekassa_webhook_route)
logging.info(f"FreeKassa webhook route configured at: [POST] {fk_path}")
pg_path = settings.platega_webhook_path
if pg_path.startswith("/"):
app.router.add_post(pg_path, platega_webhook_route)
logging.info(f"Platega webhook route configured at: [POST] {pg_path}")
sp_path = settings.severpay_webhook_path
if sp_path.startswith("/"):
app.router.add_post(sp_path, severpay_webhook_route)
logging.info(f"SeverPay webhook route configured at: [POST] {sp_path}")
# YooKassa webhook (register only when base URL present and path configured)
yk_path = settings.yookassa_webhook_path
if settings.WEBHOOK_BASE_URL and yk_path and yk_path.startswith("/"):
@@ -94,4 +102,3 @@ async def build_and_start_web_app(
# Run until cancelled
await asyncio.Event().wait()
+2 -2
View File
@@ -178,7 +178,7 @@ async def ads_delete_cancel(callback: types.CallbackQuery, settings: Settings, i
camp = await ad_dal.get_campaign_by_id(session, camp_id)
if not camp:
await callback.answer(_("admin_ads_not_found", default="Кампания не найдена."), show_alert=True)
await callback.answer(_("admin_ads_not_found"), show_alert=True)
return
try:
stats = await ad_dal.get_campaign_stats(session, camp_id)
@@ -224,7 +224,7 @@ async def ads_delete_confirm(callback: types.CallbackQuery, settings: Settings,
existed = await ad_dal.delete_campaign(session, camp_id)
if not existed:
await callback.answer(_("admin_ads_not_found", default="Кампания не найдена."), show_alert=True)
await callback.answer(_("admin_ads_not_found"), show_alert=True)
return
await session.commit()
-10
View File
@@ -122,7 +122,6 @@ async def process_broadcast_message_handler(
await message.answer(
_(
"admin_broadcast_invalid_html",
default="❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.\nОшибка: {error}",
error=str(e),
)
)
@@ -347,15 +346,6 @@ async def confirm_broadcast_callback_handler(
total_failed = failed_count + dynamic_failed
return _(
"broadcast_queue_result",
default=(
"🚀 Рассылка поставлена в очередь!\n"
"📤 В очередь добавлено: {sent_count}\n"
"❌ Ошибок: {failed_count}\n\n"
"📊 Статус очередей:\n"
"👥 Очередь пользователей: {user_queue_size} сообщений\n"
"📢 Очередь групп: {group_queue_size} сообщений\n\n"
"ℹ️ Сообщения будут отправлены автоматически с соблюдением лимитов Telegram."
),
sent_count=sent_count,
failed_count=total_failed,
user_queue_size=stats["user_queue_size"],
+15 -1
View File
@@ -98,8 +98,22 @@ async def admin_panel_actions_callback_handler(
await admin_user_mgmnt_handlers.unban_user_prompt_handler(
callback, state, i18n_data, settings, session)
elif action == "users_management":
# This is deprecated, kept for compatibility
from . import user_management as admin_user_management_handlers
await admin_user_management_handlers.user_management_menu_handler(
await admin_user_management_handlers.user_search_prompt_handler(
callback, state, i18n_data, settings, session)
elif action == "users_list" and len(action_parts) > 2:
# Route to users list handler with page number
from . import user_management as admin_user_management_handlers
try:
page = int(action_parts[2])
await admin_user_management_handlers.users_list_handler(
callback, i18n_data, settings, session, page)
except (IndexError, ValueError):
await callback.answer("Invalid page number", show_alert=True)
elif action == "users_search_prompt":
from . import user_management as admin_user_management_handlers
await admin_user_management_handlers.user_search_prompt_handler(
callback, state, i18n_data, settings, session)
elif action == "view_banned":
+12 -16
View File
@@ -338,8 +338,7 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
await callback.answer(_(
"admin_logs_csv_export_started",
default="🔄 Начинаю экспорт логов в CSV..."
"admin_logs_csv_export_started"
))
try:
@@ -349,8 +348,7 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
if not logs_models:
await callback.message.answer(_(
"admin_logs_csv_no_data",
default="❌ Нет данных для экспорта"
"admin_logs_csv_no_data"
))
return
@@ -360,16 +358,16 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
# Write header
headers = [
_("admin_csv_header_log_id", default="Log ID"),
_("admin_csv_header_timestamp", default="Timestamp"),
_("admin_csv_header_user_id", default="User ID"),
_("admin_csv_header_telegram_username", default="Telegram Username"),
_("admin_csv_header_telegram_first_name", default="Telegram First Name"),
_("admin_csv_header_event_type", default="Event Type"),
_("admin_csv_header_content", default="Content"),
_("admin_csv_header_is_admin_event", default="Is Admin Event"),
_("admin_csv_header_target_user_id", default="Target User ID"),
_("admin_csv_header_raw_update_preview", default="Raw Update Preview")
_("admin_csv_header_log_id"),
_("admin_csv_header_timestamp"),
_("admin_csv_header_user_id"),
_("admin_csv_header_telegram_username"),
_("admin_csv_header_telegram_first_name"),
_("admin_csv_header_event_type"),
_("admin_csv_header_content"),
_("admin_csv_header_is_admin_event"),
_("admin_csv_header_target_user_id"),
_("admin_csv_header_raw_update_preview")
]
csv_writer.writerow(headers)
@@ -414,7 +412,6 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
csv_file,
caption=_(
"admin_logs_csv_export_success",
default="✅ Экспорт логов завершен!\n\n📊 Записей: {count}\n📅 Дата экспорта: {date}",
count=len(logs_models),
date=now.strftime('%Y-%m-%d %H:%M:%S')
)
@@ -424,6 +421,5 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
logging.error(f"Error exporting logs to CSV: {e}", exc_info=True)
await callback.message.answer(_(
"admin_logs_csv_export_failed",
default="❌ Ошибка при экспорте логов: {error}",
error=str(e)
))
+50 -25
View File
@@ -34,12 +34,20 @@ async def get_payments_with_pagination(session: AsyncSession, page: int = 0,
return payments, total_count
def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str:
def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str, settings: Settings) -> str:
"""Format single payment info as text."""
_ = lambda key, **kwargs: i18n.gettext(lang, key, **kwargs)
pending_statuses = [
'pending',
'pending_yookassa',
'pending_freekassa',
'pending_platega',
'pending_severpay',
'pending_cryptopay',
]
status_emoji = "" if payment.status == 'succeeded' else (
"" if payment.status in ['pending', 'pending_yookassa', 'pending_freekassa'] else ""
"" if payment.status in pending_statuses else ""
)
user_info = f"User {payment.user_id}"
@@ -52,17 +60,27 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str:
provider_text = {
'yookassa': 'YooKassa',
'tribute': 'Tribute',
'telegram_stars': 'Telegram Stars',
'cryptopay': 'CryptoPay',
'freekassa': 'FreeKassa',
'severpay': 'SeverPay',
'platega': 'Platega',
}.get(payment.provider, payment.provider or 'Unknown')
traffic_mode = getattr(settings, "traffic_sale_mode", False)
if traffic_mode:
traffic_val = payment.subscription_duration_months or 0
traffic_display = str(int(traffic_val)) if float(traffic_val).is_integer() else f"{traffic_val:g}"
period_line = _("admin_payment_traffic_label", traffic_gb=traffic_display)
else:
period_line = _("admin_payment_months_label", months=payment.subscription_duration_months or 0)
return (
f"{status_emoji} <b>{payment.amount} {payment.currency}</b>\n"
f"👤 {user_info}\n"
f"💳 {provider_text}\n"
f"📅 {payment_date}\n"
f"{period_line}\n"
f"📋 {payment.status}\n"
f"📝 {payment.description or 'N/A'}"
)
@@ -84,7 +102,7 @@ async def view_payments_handler(callback: types.CallbackQuery, i18n_data: dict,
if not payments and page == 0:
await callback.message.edit_text(
_("admin_no_payments_found", default="Платежи не найдены."),
_("admin_no_payments_found"),
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
)
@@ -92,7 +110,7 @@ async def view_payments_handler(callback: types.CallbackQuery, i18n_data: dict,
return
# Format payments text
text_parts = [_("admin_payments_header", default="💰 <b>Все платежи</b>")]
text_parts = [_("admin_payments_header")]
text_parts.append(_("admin_payments_pagination_info",
shown=len(payments),
total=total_count,
@@ -100,7 +118,7 @@ async def view_payments_handler(callback: types.CallbackQuery, i18n_data: dict,
total_pages=total_pages) + "\n")
for i, payment in enumerate(payments, 1):
text_parts.append(f"<b>{page * page_size + i}.</b> {format_payment_text(payment, i18n, current_lang)}")
text_parts.append(f"<b>{page * page_size + i}.</b> {format_payment_text(payment, i18n, current_lang, settings)}")
text_parts.append("") # Empty line between payments
# Build keyboard with pagination and export
@@ -122,11 +140,11 @@ async def view_payments_handler(callback: types.CallbackQuery, i18n_data: dict,
# Export and refresh buttons
builder.row(
InlineKeyboardButton(
text=_("admin_export_payments_csv", default="📊 Экспорт CSV"),
text=_("admin_export_payments_csv"),
callback_data="payments_export_csv"
),
InlineKeyboardButton(
text=_("admin_refresh_payments", default="🔄 Обновить"),
text=_("admin_refresh_payments"),
callback_data=f"payments_page:{page}"
)
)
@@ -173,7 +191,7 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data:
if not all_payments:
await callback.answer(
_("admin_no_payments_to_export", default="Нет платежей для экспорта."),
_("admin_no_payments_to_export"),
show_alert=True
)
return
@@ -184,22 +202,30 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data:
# Write header
writer.writerow([
_("admin_csv_payment_id", default="ID"),
_("admin_csv_user_id", default="User ID"),
_("admin_csv_username", default="Username"),
_("admin_csv_first_name", default="First Name"),
_("admin_csv_amount", default="Amount"),
_("admin_csv_currency", default="Currency"),
_("admin_csv_provider", default="Provider"),
_("admin_csv_status", default="Status"),
_("admin_csv_description", default="Description"),
_("admin_csv_months", default="Months"),
_("admin_csv_created_at", default="Created At"),
_("admin_csv_provider_payment_id", default="Provider Payment ID")
_("admin_csv_payment_id"),
_("admin_csv_user_id"),
_("admin_csv_username"),
_("admin_csv_first_name"),
_("admin_csv_amount"),
_("admin_csv_currency"),
_("admin_csv_provider"),
_("admin_csv_status"),
_("admin_csv_description"),
_("admin_csv_units"),
_("admin_csv_created_at"),
_("admin_csv_provider_payment_id")
])
traffic_mode = getattr(settings, "traffic_sale_mode", False)
# Write payment data
for payment in all_payments:
units_val = payment.subscription_duration_months or ""
if traffic_mode and units_val not in ("", None):
try:
units_val = str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}"
except Exception:
units_val = payment.subscription_duration_months or ""
writer.writerow([
payment.payment_id,
payment.user_id,
@@ -210,7 +236,7 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data:
payment.provider or "",
payment.status,
payment.description or "",
payment.subscription_duration_months or "",
units_val,
payment.created_at.strftime('%Y-%m-%d %H:%M:%S') if payment.created_at else "",
payment.provider_payment_id or ""
])
@@ -229,13 +255,12 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data:
await callback.message.reply_document(
document=file,
caption=_("admin_payments_export_success",
default="📊 Payments export completed!\nTotal records: {count}",
caption=_("admin_payments_export_success",
count=len(all_payments))
)
await callback.answer(
_("admin_export_sent", default="File sent!"),
_("admin_export_sent"),
show_alert=False
)
+17 -36
View File
@@ -34,8 +34,7 @@ async def create_bulk_promo_prompt_handler(callback: types.CallbackQuery,
# Step 1: Ask for quantity
prompt_text = _(
"admin_bulk_promo_step1_quantity",
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 1 из 4:</b> Количество\n\nВведите количество промокодов для создания (1-100):"
"admin_bulk_promo_step1_quantity"
)
try:
@@ -77,8 +76,7 @@ async def process_bulk_promo_quantity_handler(message: types.Message,
quantity = int(message.text.strip())
if not (1 <= quantity <= 100):
await message.answer(_(
"admin_bulk_promo_invalid_quantity",
default="❌ Количество промокодов должно быть от 1 до 100"
"admin_bulk_promo_invalid_quantity"
))
return
@@ -87,7 +85,6 @@ async def process_bulk_promo_quantity_handler(message: types.Message,
# Step 2: Ask for bonus days
prompt_text = _(
"admin_bulk_promo_step2_bonus_days",
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 2 из 4:</b> Бонусные дни\n\nКоличество: <b>{quantity}</b>\n\nВведите количество бонусных дней для каждого промокода (1-365):",
quantity=quantity
)
@@ -100,8 +97,7 @@ async def process_bulk_promo_quantity_handler(message: types.Message,
except ValueError:
await message.answer(_(
"admin_promo_invalid_number",
default="❌ Введите корректное число"
"admin_promo_invalid_number"
))
except Exception as e:
logging.error(f"Error processing bulk promo quantity: {e}")
@@ -125,8 +121,7 @@ async def process_bulk_promo_bonus_days_handler(message: types.Message,
bonus_days = int(message.text.strip())
if not (1 <= bonus_days <= 365):
await message.answer(_(
"admin_promo_invalid_bonus_days",
default="❌ Количество бонусных дней должно быть от 1 до 365"
"admin_promo_invalid_bonus_days"
))
return
@@ -136,7 +131,6 @@ async def process_bulk_promo_bonus_days_handler(message: types.Message,
data = await state.get_data()
prompt_text = _(
"admin_bulk_promo_step3_max_activations",
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 3 из 4:</b> Лимит активаций\n\nКоличество: <b>{quantity}</b>\nБонусные дни: <b>{bonus_days}</b>\n\nВведите максимальное количество активаций для каждого промокода (1-10000):",
quantity=data.get("quantity"),
bonus_days=bonus_days
)
@@ -150,8 +144,7 @@ async def process_bulk_promo_bonus_days_handler(message: types.Message,
except ValueError:
await message.answer(_(
"admin_promo_invalid_number",
default="❌ Введите корректное число"
"admin_promo_invalid_number"
))
except Exception as e:
logging.error(f"Error processing bulk promo bonus days: {e}")
@@ -175,8 +168,7 @@ async def process_bulk_promo_max_activations_handler(message: types.Message,
max_activations = int(message.text.strip())
if not (1 <= max_activations <= 10000):
await message.answer(_(
"admin_promo_invalid_max_activations",
default="❌ Максимальное количество активаций должно быть от 1 до 10000"
"admin_promo_invalid_max_activations"
))
return
@@ -186,7 +178,6 @@ async def process_bulk_promo_max_activations_handler(message: types.Message,
data = await state.get_data()
prompt_text = _(
"admin_bulk_promo_step4_validity",
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКоличество: <b>{quantity}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активаций: <b>{max_activations}</b>\n\nВыберите срок действия промокодов:",
quantity=data.get("quantity"),
bonus_days=data.get("bonus_days"),
max_activations=max_activations
@@ -196,19 +187,19 @@ async def process_bulk_promo_max_activations_handler(message: types.Message,
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=_("admin_promo_unlimited_validity", default="🔄 Без ограничений"),
text=_("admin_promo_unlimited_validity"),
callback_data="bulk_promo_unlimited_validity"
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_promo_set_validity_days", default="📅 Указать дни"),
text=_("admin_promo_set_validity_days"),
callback_data="bulk_promo_set_validity"
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_back_to_panel", default="🔙 В админ панель"),
text=_("admin_back_to_panel"),
callback_data="admin_action:main"
)
)
@@ -222,8 +213,7 @@ async def process_bulk_promo_max_activations_handler(message: types.Message,
except ValueError:
await message.answer(_(
"admin_promo_invalid_number",
default="❌ Введите корректное число"
"admin_promo_invalid_number"
))
except Exception as e:
logging.error(f"Error processing bulk promo max activations: {e}")
@@ -257,7 +247,6 @@ async def process_bulk_promo_set_validity(callback: types.CallbackQuery,
data = await state.get_data()
prompt_text = _(
"admin_bulk_promo_enter_validity_days",
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКоличество: <b>{quantity}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активаций: <b>{max_activations}</b>\n\nВведите количество дней действия промокодов (1-365):",
quantity=data.get("quantity"),
bonus_days=data.get("bonus_days"),
max_activations=data.get("max_activations")
@@ -296,8 +285,7 @@ async def process_bulk_promo_validity_days_handler(message: types.Message,
validity_days = int(message.text.strip())
if not (1 <= validity_days <= 365):
await message.answer(_(
"admin_promo_invalid_validity_days",
default="❌ Срок действия должен быть от 1 до 365 дней"
"admin_promo_invalid_validity_days"
))
return
@@ -306,8 +294,7 @@ async def process_bulk_promo_validity_days_handler(message: types.Message,
except ValueError:
await message.answer(_(
"admin_promo_invalid_number",
default="❌ Введите корректное число"
"admin_promo_invalid_number"
))
except Exception as e:
logging.error(f"Error processing bulk promo validity days: {e}")
@@ -333,7 +320,6 @@ async def create_bulk_promo_codes_final(callback_or_message,
# Show progress message
progress_text = _(
"admin_bulk_promo_creating",
default="🔄 Создание {quantity} промокодов...",
quantity=quantity
)
@@ -395,12 +381,10 @@ async def create_bulk_promo_codes_final(callback_or_message,
# Success message
success_lines = [
_(
"admin_bulk_promo_created_title",
default="✅ <b>Массовое создание завершено!</b>\n"
"admin_bulk_promo_created_title"
),
_(
"admin_bulk_promo_created_stats",
default="📊 Создано: <b>{created}</b> из <b>{total}</b>",
created=len(created_codes),
total=quantity
)
@@ -409,14 +393,11 @@ async def create_bulk_promo_codes_final(callback_or_message,
if data.get("validity_days"):
validity_text = f"{data['validity_days']} дней"
else:
validity_text = _("admin_promo_unlimited", default="Без ограничений")
validity_text = _("admin_promo_unlimited")
success_lines.append(
_(
"admin_bulk_promo_settings",
default="🎁 Бонусные дни: <b>{bonus_days}</b>\n"
"📊 Макс. активаций: <b>{max_activations}</b>\n"
"⏰ Срок действия: <b>{validity}</b>",
bonus_days=data["bonus_days"],
max_activations=data["max_activations"],
validity=validity_text
@@ -521,7 +502,7 @@ async def create_bulk_promo_codes_final(callback_or_message,
except Exception as e:
logging.error(f"Error creating bulk promo codes: {e}")
error_text = _("error_occurred_try_again", default="❌ Произошла ошибка. Попробуйте снова.")
error_text = _("error_occurred_try_again")
if hasattr(callback_or_message, 'message'): # CallbackQuery
await callback_or_message.message.answer(error_text)
@@ -564,5 +545,5 @@ async def cancel_bulk_promo_creation_state_to_menu(callback: types.CallbackQuery
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings)
)
await callback.answer(_("admin_bulk_promo_creation_cancelled", default="Массовое создание промокодов отменено"))
await state.clear()
await callback.answer(_("admin_bulk_promo_creation_cancelled"))
await state.clear()
+211 -78
View File
@@ -28,26 +28,82 @@ async def create_promo_prompt_handler(callback: types.CallbackQuery,
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
# Step 1: Ask for promo code
# Step 0: Ask for promo type (bonus_days or discount)
prompt_text = _(
"admin_promo_step1_code",
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 1 из 4:</b> Код промокода\n\nВведите код промокода (3-30 символов, только буквы и цифры):"
"admin_promo_step0_type"
)
# Create keyboard for type selection
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=_("admin_promo_type_bonus_days"),
callback_data="promo_type_select:bonus_days"
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_promo_type_discount"),
callback_data="promo_type_select:discount"
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_back_to_panel"),
callback_data="admin_action:main"
)
)
try:
await callback.message.edit_text(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
reply_markup=builder.as_markup(),
parse_mode="HTML")
except Exception as e:
logging.warning(
f"Could not edit message for promo prompt: {e}. Sending new.")
f"Could not edit message for promo type prompt: {e}. Sending new.")
await callback.message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
reply_markup=builder.as_markup(),
parse_mode="HTML")
await callback.answer()
await state.set_state(AdminStates.waiting_for_promo_code)
await state.set_state(AdminStates.waiting_for_promo_type_selection)
# Step 0: Process type selection
@router.callback_query(F.data.startswith("promo_type_select:"), StateFilter(AdminStates.waiting_for_promo_type_selection))
async def process_promo_type_selection(callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error processing type selection.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
promo_type = callback.data.split(":")[-1] # "bonus_days" or "discount"
await state.update_data(promo_type=promo_type)
# Step 1: Ask for promo code
prompt_text = _(
"admin_promo_step1_code"
)
await callback.message.edit_text(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
)
await callback.answer()
await state.set_state(AdminStates.waiting_for_promo_code)
except Exception as e:
logging.error(f"Error processing promo type selection: {e}")
await callback.message.answer(_("error_occurred_try_again"))
await callback.answer()
# Step 1: Process promo code
@@ -68,8 +124,7 @@ async def process_promo_code_handler(message: types.Message,
code_str = message.text.strip().upper()
if not (3 <= len(code_str) <= 30 and code_str.isalnum()):
await message.answer(_(
"admin_promo_invalid_code_format",
default="❌ Код промокода должен содержать 3-30 символов (только буквы и цифры)"
"admin_promo_invalid_code_format"
))
return
@@ -77,26 +132,36 @@ async def process_promo_code_handler(message: types.Message,
existing_promo = await promo_code_dal.get_promo_code_by_code(session, code_str)
if existing_promo:
await message.answer(_(
"admin_promo_code_already_exists",
default="❌ Промокод с таким кодом уже существует"
"admin_promo_code_already_exists"
))
return
await state.update_data(promo_code=code_str)
# Step 2: Ask for bonus days
prompt_text = _(
"admin_promo_step2_bonus_days",
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 2 из 4:</b> Бонусные дни\n\nКод: <b>{code}</b>\n\nВведите количество бонусных дней (1-365):",
code=code_str
)
# Get promo type from state
data = await state.get_data()
promo_type = data.get("promo_type", "bonus_days")
# Step 2: Ask for bonus days OR discount percentage based on type
if promo_type == "discount":
prompt_text = _(
"admin_promo_step2_discount_percentage",
code=code_str
)
next_state = AdminStates.waiting_for_promo_discount_percentage
else:
prompt_text = _(
"admin_promo_step2_bonus_days",
code=code_str
)
next_state = AdminStates.waiting_for_promo_bonus_days
await message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
)
await state.set_state(AdminStates.waiting_for_promo_bonus_days)
await state.set_state(next_state)
except Exception as e:
logging.error(f"Error processing promo code: {e}")
@@ -120,39 +185,83 @@ async def process_promo_bonus_days_handler(message: types.Message,
bonus_days = int(message.text.strip())
if not (1 <= bonus_days <= 365):
await message.answer(_(
"admin_promo_invalid_bonus_days",
default="❌ Количество бонусных дней должно быть от 1 до 365"
"admin_promo_invalid_bonus_days"
))
return
await state.update_data(bonus_days=bonus_days)
# Step 3: Ask for max activations
data = await state.get_data()
prompt_text = _(
"admin_promo_step3_max_activations",
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 3 из 4:</b> Лимит активаций\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\n\nВведите максимальное количество активаций (1-10000):",
code=data.get("promo_code"),
bonus_days=bonus_days
)
await message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
)
await state.set_state(AdminStates.waiting_for_promo_max_activations)
except ValueError:
await message.answer(_(
"admin_promo_invalid_number",
default="❌ Введите корректное число"
"admin_promo_invalid_number"
))
except Exception as e:
logging.error(f"Error processing promo bonus days: {e}")
await message.answer(_("error_occurred_try_again"))
# Step 2: Process discount percentage
@router.message(AdminStates.waiting_for_promo_discount_percentage, F.text)
async def process_promo_discount_percentage_handler(message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
discount_percentage = int(message.text.strip())
if not (1 <= discount_percentage <= 100):
await message.answer(
"❌ Discount percentage must be between 1 and 100."
)
return
await state.update_data(discount_percentage=discount_percentage)
# Step 3: Ask for max activations
data = await state.get_data()
prompt_text = _(
"admin_promo_step3_max_activations_discount",
code=data.get("promo_code"),
discount_percentage=discount_percentage
)
await message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
)
await state.set_state(AdminStates.waiting_for_promo_max_activations)
except ValueError:
await message.answer(_(
"admin_promo_invalid_number"
))
except Exception as e:
logging.error(f"Error processing discount percentage: {e}")
await message.answer(_("error_occurred_try_again"))
# Step 3: Process max activations
@router.message(AdminStates.waiting_for_promo_max_activations, F.text)
async def process_promo_max_activations_handler(message: types.Message,
@@ -170,40 +279,48 @@ async def process_promo_max_activations_handler(message: types.Message,
max_activations = int(message.text.strip())
if not (1 <= max_activations <= 10000):
await message.answer(_(
"admin_promo_invalid_max_activations",
default="❌ Максимальное количество активаций должно быть от 1 до 10000"
"admin_promo_invalid_max_activations"
))
return
await state.update_data(max_activations=max_activations)
# Step 4: Ask for validity
data = await state.get_data()
prompt_text = _(
"admin_promo_step4_validity",
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активаций: <b>{max_activations}</b>\n\nВыберите срок действия промокода:",
code=data.get("promo_code"),
bonus_days=data.get("bonus_days"),
max_activations=max_activations
)
promo_type = data.get("promo_type", "bonus_days")
if promo_type == "discount":
prompt_text = _(
"admin_promo_step4_validity_discount",
code=data.get("promo_code"),
discount_percentage=data.get("discount_percentage"),
max_activations=max_activations
)
else:
prompt_text = _(
"admin_promo_step4_validity",
code=data.get("promo_code"),
bonus_days=data.get("bonus_days"),
max_activations=max_activations
)
# Create keyboard for validity options
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=_("admin_promo_unlimited_validity", default="🔄 Без ограничений"),
text=_("admin_promo_unlimited_validity"),
callback_data="promo_unlimited_validity"
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_promo_set_validity_days", default="📅 Указать дни"),
text=_("admin_promo_set_validity_days"),
callback_data="promo_set_validity"
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_back_to_panel", default="🔙 В админ панель"),
text=_("admin_back_to_panel"),
callback_data="admin_action:main"
)
)
@@ -217,8 +334,7 @@ async def process_promo_max_activations_handler(message: types.Message,
except ValueError:
await message.answer(_(
"admin_promo_invalid_number",
default="❌ Введите корректное число"
"admin_promo_invalid_number"
))
except Exception as e:
logging.error(f"Error processing promo max activations: {e}")
@@ -250,13 +366,15 @@ async def process_promo_set_validity(callback: types.CallbackQuery,
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
data = await state.get_data()
prompt_text = _(
"admin_promo_enter_validity_days",
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активаций: <b>{max_activations}</b>\n\nВведите количество дней действия промокода (1-365):",
code=data.get("promo_code"),
bonus_days=data.get("bonus_days"),
max_activations=data.get("max_activations")
)
promo_type = data.get("promo_type", "bonus_days")
# Display the correct text based on promo type
if promo_type == "discount":
value_info = f"{data.get('discount_percentage')}%"
else:
value_info = f"{data.get('bonus_days')} дней"
prompt_text = f"⏰ Введите количество дней действия промокода (1-365):\n\nКод: <b>{data.get('promo_code')}</b>\n{'Скидка' if promo_type == 'discount' else 'Бонус'}: <b>{value_info}</b>\nМакс. активаций: <b>{data.get('max_activations')}</b>"
try:
await callback.message.edit_text(
@@ -291,8 +409,7 @@ async def process_promo_validity_days_handler(message: types.Message,
validity_days = int(message.text.strip())
if not (1 <= validity_days <= 365):
await message.answer(_(
"admin_promo_invalid_validity_days",
default="❌ Срок действия должен быть от 1 до 365 дней"
"admin_promo_invalid_validity_days"
))
return
@@ -301,8 +418,7 @@ async def process_promo_validity_days_handler(message: types.Message,
except ValueError:
await message.answer(_(
"admin_promo_invalid_number",
default="❌ Введите корректное число"
"admin_promo_invalid_number"
))
except Exception as e:
logging.error(f"Error processing promo validity days: {e}")
@@ -323,45 +439,60 @@ async def create_promo_code_final(callback_or_message,
try:
data = await state.get_data()
promo_type = data.get("promo_type", "bonus_days")
# Prepare promo code data
promo_data = {
"code": data["promo_code"],
"bonus_days": data["bonus_days"],
"promo_type": promo_type,
"max_activations": data["max_activations"],
"current_activations": 0,
"is_active": True,
"created_by_admin_id": callback_or_message.from_user.id,
"created_at": datetime.now(timezone.utc)
}
# Set type-specific fields
if promo_type == "discount":
promo_data["discount_percentage"] = data["discount_percentage"]
promo_data["bonus_days"] = None
else:
promo_data["bonus_days"] = data["bonus_days"]
promo_data["discount_percentage"] = None
# Set validity
if data.get("validity_days"):
promo_data["valid_until"] = datetime.now(timezone.utc) + timedelta(days=data["validity_days"])
else:
promo_data["valid_until"] = None
# Create promo code
created_promo = await promo_code_dal.create_promo_code(session, promo_data)
await session.commit()
# Log successful creation
logging.info(f"Promo code '{data['promo_code']}' created with ID {created_promo.promo_code_id}")
logging.info(f"Promo code '{data['promo_code']}' ({promo_type}) created with ID {created_promo.promo_code_id}")
# Success message
valid_until_str = _("admin_promo_unlimited", default="Без ограничений") if not data.get("validity_days") else f"{data['validity_days']} дней"
success_text = _(
"admin_promo_created_success",
default="✅ <b>Промокод успешно создан!</b>\n\n"
"🎟 Код: <code>{code}</code>\n"
"🎁 Бонусные дни: <b>{bonus_days}</b>\n"
"📊 Макс. активаций: <b>{max_activations}</b>\n"
"⏰ Срок действия: <b>{valid_until_str}</b>",
code=data["promo_code"],
bonus_days=data["bonus_days"],
max_activations=data["max_activations"],
valid_until_str=valid_until_str
)
valid_until_str = _("admin_promo_unlimited") if not data.get("validity_days") else f"{data['validity_days']} дней"
# Format success message based on type
if promo_type == "discount":
success_text = _(
"admin_promo_created_success_discount",
code=data["promo_code"],
discount_percentage=data['discount_percentage'],
max_activations=data["max_activations"],
valid_until_str=valid_until_str
)
else:
success_text = _(
"admin_promo_created_success",
code=data["promo_code"],
bonus_days=data['bonus_days'],
max_activations=data["max_activations"],
valid_until_str=valid_until_str
)
if hasattr(callback_or_message, 'message'): # CallbackQuery
try:
@@ -388,7 +519,7 @@ async def create_promo_code_final(callback_or_message,
except Exception as e:
logging.error(f"Error creating promo code: {e}")
error_text = _("error_occurred_try_again", default="❌ Произошла ошибка. Попробуйте снова.")
error_text = _("error_occurred_try_again")
if hasattr(callback_or_message, 'message'): # CallbackQuery
await callback_or_message.message.answer(error_text)
@@ -403,8 +534,10 @@ async def create_promo_code_final(callback_or_message,
@router.callback_query(
F.data == "admin_action:main",
StateFilter(
AdminStates.waiting_for_promo_type_selection,
AdminStates.waiting_for_promo_code,
AdminStates.waiting_for_promo_bonus_days,
AdminStates.waiting_for_promo_discount_percentage,
AdminStates.waiting_for_promo_max_activations,
AdminStates.waiting_for_promo_validity_days,
),
@@ -432,5 +565,5 @@ async def cancel_promo_creation_state_to_menu(callback: types.CallbackQuery,
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings)
)
await callback.answer(_("admin_promo_creation_cancelled", default="Создание промокода отменено"))
await state.clear()
await callback.answer(_("admin_promo_creation_cancelled"))
await state.clear()
+70 -17
View File
@@ -47,9 +47,19 @@ async def get_promo_detail_text_and_keyboard(promo_id: int, session: AsyncSessio
created = promo.created_at.strftime("%d.%m.%Y %H:%M") if promo.created_at else "N/A"
# Determine promo type and value to display
promo_type = getattr(promo, "promo_type", "bonus_days")
if promo_type == "discount":
type_name = _("admin_promo_type_discount")
value_line = _("admin_promo_card_discount_percentage", percentage=promo.discount_percentage)
else:
type_name = _("admin_promo_type_bonus_days")
value_line = _("admin_promo_card_bonus_days", days=promo.bonus_days)
text = "\n".join([
_("admin_promo_card_title", code=promo.code),
_("admin_promo_card_bonus_days", days=promo.bonus_days),
_("admin_promo_card_type", type=type_name),
value_line,
_("admin_promo_card_activations", current=promo.current_activations, max=promo.max_activations),
_("admin_promo_card_validity", validity=validity),
_("admin_promo_card_status", status=status),
@@ -76,12 +86,22 @@ async def view_promo_codes_handler(callback: types.CallbackQuery, i18n_data: dic
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
promo_models = await promo_code_dal.get_all_active_promo_codes(session, limit=20, offset=0)
text = f"{_('admin_active_promos_list_header')}\n\n{_('admin_no_active_promos')}" if not promo_models else "\n".join(
[_("admin_active_promos_list_header"), ""] + [
f"{get_promo_status_emoji_and_text(p, i18n, current_lang)[0]} <code>{p.code}</code> | 🎁 {p.bonus_days}д | 📊 {p.current_activations}/{p.max_activations} | ⏰ {p.valid_until.strftime('%d.%m.%Y') if p.valid_until else _('admin_promo_valid_indefinitely')}"
for p in promo_models
]
)
if not promo_models:
text = f"{_('admin_active_promos_list_header')}\n\n{_('admin_no_active_promos')}"
else:
promo_lines = [_("admin_active_promos_list_header"), ""]
for p in promo_models:
status_emoji = get_promo_status_emoji_and_text(p, i18n, current_lang)[0]
promo_type = getattr(p, "promo_type", "bonus_days")
if promo_type == "discount":
value_display = f"💰 {p.discount_percentage}%"
else:
value_display = f"🎁 {p.bonus_days}д"
validity_display = p.valid_until.strftime('%d.%m.%Y') if p.valid_until else _('admin_promo_valid_indefinitely')
promo_lines.append(
f"{status_emoji} <code>{p.code}</code> | {value_display} | 📊 {p.current_activations}/{p.max_activations} | ⏰ {validity_display}"
)
text = "\n".join(promo_lines)
await callback.message.edit_text(text, reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), parse_mode="HTML")
await callback.answer()
@@ -300,7 +320,9 @@ async def promo_export_all_handler(callback: types.CallbackQuery, i18n_data: dic
# CSV headers (forced to English)
writer.writerow([
i18n.gettext(export_lang, "admin_promo_csv_code"),
"Type",
i18n.gettext(export_lang, "admin_promo_csv_bonus_days"),
"Discount %",
i18n.gettext(export_lang, "admin_promo_csv_max_activations"),
i18n.gettext(export_lang, "admin_promo_csv_current_activations"),
i18n.gettext(export_lang, "admin_promo_csv_status"),
@@ -309,15 +331,22 @@ async def promo_export_all_handler(callback: types.CallbackQuery, i18n_data: dic
i18n.gettext(export_lang, "admin_promo_csv_created_at"),
i18n.gettext(export_lang, "admin_promo_csv_created_by_admin_id"),
])
for promo in all_promos:
# Определяем статус
status_emoji, status_text = get_promo_status_emoji_and_text(promo, i18n, export_lang)
# Determine promo type and values
promo_type = getattr(promo, "promo_type", "bonus_days")
bonus_days_val = promo.bonus_days if promo_type == "bonus_days" else "N/A"
discount_val = promo.discount_percentage if promo_type == "discount" else "N/A"
# Формируем данные для CSV
row = [
promo.code,
promo.bonus_days,
promo_type,
bonus_days_val,
discount_val,
promo.max_activations,
promo.current_activations,
status_text,
@@ -374,13 +403,26 @@ async def promo_edit_select_handler(callback: types.CallbackQuery, i18n_data: di
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
promo_id = int(callback.data.split(":")[1])
# Get promo to check type
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
if not promo:
await callback.answer(_("admin_promo_not_found"), show_alert=True)
return
promo_type = getattr(promo, "promo_type", "bonus_days")
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_bonus_days"), callback_data=f"promo_edit_field:bonus_days:{promo_id}"))
# Show appropriate edit option based on type
if promo_type == "discount":
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_discount_percentage"), callback_data=f"promo_edit_field:discount_percentage:{promo_id}"))
else:
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_bonus_days"), callback_data=f"promo_edit_field:bonus_days:{promo_id}"))
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_max_activations"), callback_data=f"promo_edit_field:max_activations:{promo_id}"))
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_validity"), callback_data=f"promo_edit_field:valid_until:{promo_id}"))
builder.row(InlineKeyboardButton(text=_("admin_promo_back_to_detail_button"), callback_data=f"promo_detail:{promo_id}"))
await callback.message.edit_text(_("admin_promo_edit_select_field"), reply_markup=builder.as_markup())
await callback.answer()
@@ -394,14 +436,19 @@ async def promo_edit_field_handler(callback: types.CallbackQuery, state: FSMCont
action, field, promo_id_str = callback.data.split(":")
await state.update_data(promo_id=int(promo_id_str), field_to_edit=field)
prompts = {
"bonus_days": "admin_promo_prompt_bonus_days",
"discount_percentage": "admin_promo_prompt_discount_percentage",
"max_activations": "admin_promo_prompt_max_activations",
"valid_until": "admin_promo_prompt_validity_days"
}
prompt_key = prompts.get(field, "error_occurred_try_again")
prompt_text = _(prompt_key)
await state.set_state(AdminStates.waiting_for_promo_edit_details)
await callback.message.edit_text(_(prompts.get(field, "error_occurred_try_again")))
await callback.message.edit_text(prompt_text)
await callback.answer()
@router.message(StateFilter(AdminStates.waiting_for_promo_edit_details))
@@ -418,9 +465,15 @@ async def process_promo_edit_details(message: types.Message, state: FSMContext,
try:
value = message.text
update_data = {}
if field == "bonus_days":
update_data["bonus_days"] = int(value)
elif field == "discount_percentage":
discount_pct = int(value)
if not (1 <= discount_pct <= 100):
await message.answer("❌ Discount percentage must be between 1 and 100.")
return
update_data["discount_percentage"] = discount_pct
elif field == "max_activations":
update_data["max_activations"] = int(value)
elif field == "valid_until":
@@ -433,7 +486,7 @@ async def process_promo_edit_details(message: types.Message, state: FSMContext,
if await promo_code_dal.update_promo_code(session, promo_id, update_data):
await session.commit()
await message.answer(_("admin_promo_edit_success"))
# Reset state and show updated details
await state.clear()
text, keyboard = await get_promo_detail_text_and_keyboard(promo_id, session, i18n, current_lang)
+43 -30
View File
@@ -34,30 +34,30 @@ async def show_statistics_handler(callback: types.CallbackQuery,
user_stats = await user_dal.get_enhanced_user_statistics(session)
stats_text_parts.append(
f"\n<b>👥 {_('admin_enhanced_users_stats_header', default='Пользователи')}</b>"
f"\n<b>👥 {_('admin_enhanced_users_stats_header')}</b>"
)
stats_text_parts.append(
f"📊 {_('admin_user_stats_total_label', default='Всего')}: <b>{user_stats['total_users']}</b>"
f"📊 {_('admin_user_stats_total_label')}: <b>{user_stats['total_users']}</b>"
)
# Removed: Active today moved to panel stats
stats_text_parts.append(
f"💳 {_('admin_user_stats_paid_subs_label', default='С платной подпиской')}: <b>{user_stats['paid_subscriptions']}</b>"
f"💳 {_('admin_user_stats_paid_subs_label')}: <b>{user_stats['paid_subscriptions']}</b>"
)
stats_text_parts.append(
f"🆓 {_('admin_user_stats_trial_label', default='На пробном периоде')}: <b>{user_stats['trial_users']}</b>"
f"🆓 {_('admin_user_stats_trial_label')}: <b>{user_stats['trial_users']}</b>"
)
stats_text_parts.append(
f"😴 {_('admin_user_stats_inactive_label', default='Неактивных')}: <b>{user_stats['inactive_users']}</b>"
f"😴 {_('admin_user_stats_inactive_label')}: <b>{user_stats['inactive_users']}</b>"
)
stats_text_parts.append(
f"🚫 {_('admin_user_stats_banned_label', default='Заблокированных')}: <b>{user_stats['banned_users']}</b>"
f"🚫 {_('admin_user_stats_banned_label')}: <b>{user_stats['banned_users']}</b>"
)
stats_text_parts.append(
f"🎁 {_('admin_user_stats_referral_label', default='Привлечено по реферальной программе')}: <b>{user_stats['referral_users']}</b>"
f"🎁 {_('admin_user_stats_referral_label')}: <b>{user_stats['referral_users']}</b>"
)
# Panel Statistics - moved above financial
stats_text_parts.append(f"\n<b>🖥 {_('admin_panel_stats_header', default='Статистика панели')}</b>")
stats_text_parts.append(f"\n<b>🖥 {_('admin_panel_stats_header')}</b>")
try:
async with PanelApiService(settings) as panel_service:
@@ -80,12 +80,12 @@ async def show_statistics_handler(callback: types.CallbackQuery,
total_users = users.get('totalUsers', 0)
online_now = online_stats.get('onlineNow', 0)
stats_text_parts.append(f"🟢 {_('admin_panel_online_label', default='Онлайн')}: <b>{online_now}</b>")
stats_text_parts.append(f"📊 {_('admin_panel_active_label', default='Активных')}: <b>{active_users}</b>")
stats_text_parts.append(f"🔴 {_('admin_panel_disabled_label', default='Отключенных')}: <b>{disabled_users}</b>")
stats_text_parts.append(f"{_('admin_panel_expired_label', default='Истекшие')}: <b>{expired_users}</b>")
stats_text_parts.append(f"⚠️ {_('admin_panel_limited_label', default='Ограниченные')}: <b>{limited_users}</b>")
stats_text_parts.append(f"👥 {_('admin_panel_total_users_label', default='Всего пользователей')}: <b>{total_users}</b>")
stats_text_parts.append(f"🟢 {_('admin_panel_online_label')}: <b>{online_now}</b>")
stats_text_parts.append(f"📊 {_('admin_panel_active_label')}: <b>{active_users}</b>")
stats_text_parts.append(f"🔴 {_('admin_panel_disabled_label')}: <b>{disabled_users}</b>")
stats_text_parts.append(f"{_('admin_panel_expired_label')}: <b>{expired_users}</b>")
stats_text_parts.append(f"⚠️ {_('admin_panel_limited_label')}: <b>{limited_users}</b>")
stats_text_parts.append(f"👥 {_('admin_panel_total_users_label')}: <b>{total_users}</b>")
# System resources
memory = system_stats.get('memory', {})
@@ -93,9 +93,9 @@ async def show_statistics_handler(callback: types.CallbackQuery,
memory_total = memory.get('total', 1)
memory_used = memory.get('used', 0)
memory_usage = (memory_used / memory_total) * 100 if memory_total > 0 else 0
stats_text_parts.append(f"💾 {_('admin_panel_memory_usage_label', default='Использование RAM')}: <b>{memory_usage:.1f}%</b>")
stats_text_parts.append(f"💾 {_('admin_panel_memory_usage_label')}: <b>{memory_usage:.1f}%</b>")
else:
stats_text_parts.append(f"⚠️ {_('admin_panel_system_stats_error', default='Ошибка получения системной статистики')}")
stats_text_parts.append(f"⚠️ {_('admin_panel_system_stats_error')}")
# Bandwidth stats
if bandwidth_stats:
@@ -107,13 +107,13 @@ async def show_statistics_handler(callback: types.CallbackQuery,
if week_traffic:
week_total = week_traffic.get('current', '0 B')
stats_text_parts.append(f"📊 {_('admin_panel_traffic_week_label', default='Трафик за неделю')}: <b>{week_total}</b>")
stats_text_parts.append(f"📊 {_('admin_panel_traffic_week_label')}: <b>{week_total}</b>")
if month_traffic:
month_total = month_traffic.get('current', '0 B')
stats_text_parts.append(f"📊 {_('admin_panel_traffic_month_label', default='Трафик за месяц')}: <b>{month_total}</b>")
stats_text_parts.append(f"📊 {_('admin_panel_traffic_month_label')}: <b>{month_total}</b>")
else:
stats_text_parts.append(f"⚠️ {_('admin_panel_bandwidth_stats_error', default='Ошибка получения статистики трафика')}")
stats_text_parts.append(f"⚠️ {_('admin_panel_bandwidth_stats_error')}")
# Nodes stats
if nodes_stats and 'lastSevenDays' in nodes_stats:
@@ -124,35 +124,35 @@ async def show_statistics_handler(callback: types.CallbackQuery,
unique_nodes.add(node_data.get('nodeName', ''))
total_nodes_count = len(unique_nodes)
# Assume all nodes are active since we don't have status info
stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label', default='Активных нод')}: <b>{total_nodes_count}/{total_nodes_count}</b>")
stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label')}: <b>{total_nodes_count}/{total_nodes_count}</b>")
else:
# Use nodes total from system stats as fallback
nodes_info = system_stats.get('nodes', {}) if system_stats else {}
total_online = nodes_info.get('totalOnline', 0)
stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label', default='Активных нод')}: <b>{total_online}</b>")
stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label')}: <b>{total_online}</b>")
except Exception as e:
logging.error(f"Failed to fetch panel statistics: {e}", exc_info=True)
stats_text_parts.append(f"{_('admin_panel_stats_fetch_error', default='Ошибка получения данных с панели')}")
stats_text_parts.append(f"⚠️ {_('admin_panel_stats_error_details', default='Детали')}: {str(e)}")
stats_text_parts.append(f"{_('admin_panel_stats_fetch_error')}")
stats_text_parts.append(f"⚠️ {_('admin_panel_stats_error_details')}: {str(e)}")
# Financial statistics
financial_stats = await payment_dal.get_financial_statistics(session)
stats_text_parts.append(
f"\n<b>💰 {_('admin_financial_stats_header', default='Финансовая статистика')}</b>"
f"\n<b>💰 {_('admin_financial_stats_header')}</b>"
)
stats_text_parts.append(
f"📅 {_('admin_financial_today_label', default='За сегодня')}: <b>{financial_stats['today_revenue']:.2f} RUB</b> ({financial_stats['today_payments_count']} {_('admin_financial_payments_label', default='платежей')})"
f"📅 {_('admin_financial_today_label')}: <b>{financial_stats['today_revenue']:.2f} RUB</b> ({financial_stats['today_payments_count']} {_('admin_financial_payments_label')})"
)
stats_text_parts.append(
f"📅 {_('admin_financial_week_label', default='За неделю')}: <b>{financial_stats['week_revenue']:.2f} RUB</b>"
f"📅 {_('admin_financial_week_label')}: <b>{financial_stats['week_revenue']:.2f} RUB</b>"
)
stats_text_parts.append(
f"📅 {_('admin_financial_month_label', default='За месяц')}: <b>{financial_stats['month_revenue']:.2f} RUB</b>"
f"📅 {_('admin_financial_month_label')}: <b>{financial_stats['month_revenue']:.2f} RUB</b>"
)
stats_text_parts.append(
f"🏆 {_('admin_financial_all_time_label', default='За все время')}: <b>{financial_stats['all_time_revenue']:.2f} RUB</b>"
f"🏆 {_('admin_financial_all_time_label')}: <b>{financial_stats['all_time_revenue']:.2f} RUB</b>"
)
last_payments_models: List[
@@ -162,8 +162,21 @@ async def show_statistics_handler(callback: types.CallbackQuery,
stats_text_parts.append(
f"\n<b>{_('admin_stats_recent_payments_header')}</b>")
for payment in last_payments_models:
status_emoji = "" if payment.status == 'succeeded' else (
"" if payment.status in ['pending', 'pending_yookassa', 'pending_freekassa'] else "")
pending_statuses = [
"pending",
"pending_yookassa",
"pending_freekassa",
"pending_platega",
"pending_severpay",
"pending_cryptopay",
]
status_emoji = (
""
if payment.status == "succeeded"
else ""
if payment.status in pending_statuses
else ""
)
user_info = f"User {payment.user_id}"
if payment.user and payment.user.username:
+182 -68
View File
@@ -3,6 +3,7 @@ from aiogram import Router, types, Bot
from aiogram.filters import Command
from typing import Optional, Union
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import update, or_
from datetime import datetime, timezone
from config.settings import Settings
@@ -10,14 +11,19 @@ from bot.services.panel_api_service import PanelApiService
from bot.services.notification_service import NotificationService
from db.dal import user_dal, subscription_dal, panel_sync_dal
from db.models import Subscription
from bot.middlewares.i18n import JsonI18n
router = Router(name="admin_sync_router")
async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
settings: Settings, i18n_instance: JsonI18n) -> dict:
async def perform_sync(
panel_service: PanelApiService,
session: AsyncSession,
settings: Settings,
i18n_instance: JsonI18n,
) -> dict:
"""
Perform panel synchronization and return results
Returns dict with status, details, and sync statistics
@@ -27,7 +33,7 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
users_updated = 0
subscriptions_synced_count = 0
sync_errors = []
# Additional counters for detailed logging
users_without_telegram_id = 0
users_not_found_in_db = 0
@@ -52,7 +58,12 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
session, "success", status_msg, 0, 0
)
await session.commit()
return {"status": "success", "details": status_msg, "users_synced": 0, "subs_synced": 0}
return {
"status": "success",
"details": status_msg,
"users_synced": 0,
"subs_synced": 0,
}
total_panel_users = len(panel_users_data)
logging.info(f"Starting sync for {total_panel_users} panel users.")
@@ -61,12 +72,16 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
try:
panel_records_checked += 1
panel_uuid = panel_user_dict.get("uuid")
panel_subscription_uuid = panel_user_dict.get("subscriptionUuid") or panel_user_dict.get("shortUuid")
panel_subscription_uuid = panel_user_dict.get("subscriptionUuid") or panel_user_dict.get(
"shortUuid"
)
telegram_id_from_panel = panel_user_dict.get("telegramId")
if not panel_uuid:
sync_errors.append(f"Panel user missing UUID: {panel_user_dict}")
logging.warning(f"Skipping panel user without UUID: {panel_user_dict}")
logging.warning(
f"Skipping panel user without UUID: {panel_user_dict}"
)
continue
# Track users without telegram ID
@@ -75,22 +90,35 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
# Try to find existing user in local DB
existing_user = None
# First, try to find by telegram ID if available
if telegram_id_from_panel:
existing_user = await user_dal.get_user_by_id(session, telegram_id_from_panel)
existing_user = await user_dal.get_user_by_id(
session, telegram_id_from_panel
)
if existing_user:
logging.debug(f"Found user by telegramId {telegram_id_from_panel}")
logging.debug(
f"Found user by telegramId {telegram_id_from_panel}"
)
# If not found by telegram ID, try to find by panel UUID
if not existing_user:
existing_user = await user_dal.get_user_by_panel_uuid(session, panel_uuid)
existing_user = await user_dal.get_user_by_panel_uuid(
session, panel_uuid
)
if existing_user:
logging.info(f"Found user by panel UUID {panel_uuid}, telegramId: {existing_user.user_id}")
logging.info(
f"Found user by panel UUID {panel_uuid}, telegramId: {existing_user.user_id}"
)
# Update telegram ID if it was missing in panel data but we have local user
if telegram_id_from_panel and existing_user.user_id != telegram_id_from_panel:
logging.warning(f"TelegramId mismatch: panel={telegram_id_from_panel}, local={existing_user.user_id}")
if (
telegram_id_from_panel
and existing_user.user_id != telegram_id_from_panel
):
logging.warning(
f"TelegramId mismatch: panel={telegram_id_from_panel}, local={existing_user.user_id}"
)
if not existing_user:
users_not_found_in_db += 1
if telegram_id_from_panel:
@@ -100,26 +128,36 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
"user_id": telegram_id_from_panel,
"username": None, # Username will be updated when user interacts with bot
"first_name": None, # Panel doesn't provide this info
"last_name": None, # Panel doesn't provide this info
"last_name": None, # Panel doesn't provide this info
"language_code": "ru", # Default language
"panel_user_uuid": panel_uuid,
"is_banned": False,
"referred_by_id": None
"referred_by_id": None,
}
new_user, was_created = await user_dal.create_user(session, user_data)
new_user, was_created = await user_dal.create_user(
session, user_data
)
if was_created:
users_created += 1
logging.info(f"Created new user {telegram_id_from_panel} from panel sync with UUID {panel_uuid}")
logging.info(
f"Created new user {telegram_id_from_panel} from panel sync with UUID {panel_uuid}"
)
existing_user = new_user
except Exception as e_create:
sync_errors.append(f"Error creating user {telegram_id_from_panel}: {str(e_create)}")
logging.error(f"Error creating user {telegram_id_from_panel}: {e_create}")
sync_errors.append(
f"Error creating user {telegram_id_from_panel}: {str(e_create)}"
)
logging.error(
f"Error creating user {telegram_id_from_panel}: {e_create}"
)
continue
else:
logging.debug(f"Panel user with UUID {panel_uuid} (no telegramId) not found in local DB - skipping")
logging.debug(
f"Panel user with UUID {panel_uuid} (no telegramId) not found in local DB - skipping"
)
continue
# User found in local DB
@@ -134,20 +172,29 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
existing_user.panel_user_uuid = panel_uuid
user_was_updated = True
users_uuid_updated += 1
logging.info(f"Updated panel UUID for user {actual_user_id}: {panel_uuid}")
logging.info(
f"Updated panel UUID for user {actual_user_id}: {panel_uuid}"
)
# Ensure panel description contains Telegram fields
try:
if panel_uuid and existing_user:
description_text = "\n".join([
existing_user.username or "",
existing_user.first_name or "",
existing_user.last_name or "",
])
description_text = "\n".join(
[
existing_user.username or "",
existing_user.first_name or "",
existing_user.last_name or "",
]
)
# Update description only when it differs from the current one on panel
current_panel_description = (panel_user_dict.get("description") or "").strip()
current_panel_description = (
panel_user_dict.get("description") or ""
).strip()
desired_description = description_text.strip()
if desired_description and desired_description != current_panel_description:
if (
desired_description
and desired_description != current_panel_description
):
await panel_service.update_user_details_on_panel(
panel_uuid, {"description": description_text}
)
@@ -159,13 +206,13 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
# Sync subscription data
panel_expire_at_iso = panel_user_dict.get("expireAt")
panel_status = panel_user_dict.get("status", "UNKNOWN")
if panel_expire_at_iso:
try:
panel_expire_at = datetime.fromisoformat(
panel_expire_at_iso.replace("Z", "+00:00")
)
# Prefer syncing by concrete subscription UUID (shortUuid/subscriptionUuid)
subscription_uuid_from_panel = (
panel_user_dict.get("subscriptionUuid")
@@ -173,6 +220,27 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
)
if subscription_uuid_from_panel:
# Если панель говорит, что подписка ACTIVE — сначала деактивируем все другие активные
if panel_status == "ACTIVE":
await session.execute(
update(Subscription)
.where(
Subscription.panel_user_uuid == panel_uuid,
Subscription.is_active.is_(True),
or_(
Subscription.panel_subscription_uuid
!= subscription_uuid_from_panel,
Subscription.panel_subscription_uuid.is_(
None
),
),
)
.values(
is_active=False,
status_from_panel="INACTIVE",
)
)
# Try to find subscription by its panel_subscription_uuid first (idempotent)
existing_sub_by_uuid = (
await subscription_dal.get_subscription_by_panel_subscription_uuid(
@@ -197,7 +265,8 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
subscriptions_updated += 1
user_was_updated = True
logging.info(
f"Synced existing subscription {existing_sub_by_uuid.subscription_id} for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}"
f"Synced existing subscription {existing_sub_by_uuid.subscription_id} "
f"for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}"
)
else:
# Create a new subscription only when we have a concrete subscription UUID
@@ -212,6 +281,7 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
"is_active": panel_status == "ACTIVE",
"status_from_panel": panel_status,
"traffic_limit_bytes": settings.user_traffic_limit_bytes,
"auto_renew_enabled": False,
}
created_sub = await subscription_dal.upsert_subscription(
session, sub_payload
@@ -220,12 +290,15 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
subscriptions_created += 1
user_was_updated = True
logging.info(
f"Created subscription {created_sub.subscription_id} for user {actual_user_id} by panel_sub_uuid {subscription_uuid_from_panel}"
f"Created subscription {created_sub.subscription_id} "
f"for user {actual_user_id} by panel_sub_uuid {subscription_uuid_from_panel}"
)
else:
# No subscription UUID from panel: only update an already active subscription for this user/panel UUID
active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, actual_user_id, panel_uuid
active_sub = (
await subscription_dal.get_active_subscription_by_user_id(
session, actual_user_id, panel_uuid
)
)
if active_sub:
await subscription_dal.update_subscription(
@@ -241,23 +314,30 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
subscriptions_updated += 1
user_was_updated = True
logging.info(
f"Updated active subscription {active_sub.subscription_id} for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}"
f"Updated active subscription {active_sub.subscription_id} "
f"for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}"
)
else:
# Without a concrete subscription UUID we avoid creating new records to keep sync idempotent
logging.debug(
f"No subscriptionUuid for panel user {panel_uuid}; skipped creation for user {actual_user_id}"
)
except Exception as e:
sync_errors.append(f"Error syncing subscription for user {actual_user_id}: {str(e)}")
logging.error(f"Error syncing subscription for user {actual_user_id}: {e}")
sync_errors.append(
f"Error syncing subscription for user {actual_user_id}: {str(e)}"
)
logging.error(
f"Error syncing subscription for user {actual_user_id}: {e}"
)
if user_was_updated:
users_updated += 1
except Exception as e_user:
sync_errors.append(f"Error processing panel user {panel_user_dict.get('uuid', 'unknown')}: {str(e_user)}")
sync_errors.append(
f"Error processing panel user {panel_user_dict.get('uuid', 'unknown')}: {str(e_user)}"
)
logging.error(f"Error syncing user: {e_user}")
# Update sync status
@@ -266,14 +346,26 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
default_lang = settings.DEFAULT_LANGUAGE
additional_stats = ""
if users_without_telegram_id > 0:
additional_stats += i18n_instance.gettext(default_lang, "admin_sync_no_telegram_id", count=users_without_telegram_id)
additional_stats += i18n_instance.gettext(
default_lang,
"admin_sync_no_telegram_id",
count=users_without_telegram_id,
)
if users_not_found_in_db > 0:
additional_stats += i18n_instance.gettext(default_lang, "admin_sync_not_found_in_db", count=users_not_found_in_db)
additional_stats += i18n_instance.gettext(
default_lang,
"admin_sync_not_found_in_db",
count=users_not_found_in_db,
)
if sync_errors:
additional_stats += i18n_instance.gettext(default_lang, "admin_sync_errors", count=len(sync_errors))
additional_stats += i18n_instance.gettext(
default_lang, "admin_sync_errors", count=len(sync_errors)
)
# Build full details using localization
details = i18n_instance.gettext(default_lang, "admin_sync_details",
details = i18n_instance.gettext(
default_lang,
"admin_sync_details",
panel_records_checked=panel_records_checked,
users_found_in_db=users_found_in_db,
users_created=users_created,
@@ -281,11 +373,15 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
subscriptions_synced_count=subscriptions_synced_count,
subscriptions_created=subscriptions_created,
subscriptions_updated=subscriptions_updated,
additional_stats=additional_stats
additional_stats=additional_stats,
)
await panel_sync_dal.update_panel_sync_status(
session, status, details, panel_records_checked, subscriptions_synced_count
session,
status,
details,
panel_records_checked,
subscriptions_synced_count,
)
await session.commit()
@@ -310,19 +406,27 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
"users_synced": users_found_in_db,
"users_created": users_created,
"subs_synced": subscriptions_synced_count,
"errors": sync_errors
"errors": sync_errors,
}
except Exception as e_sync_global:
await session.rollback()
logging.error(f"Global error during sync: {e_sync_global}", exc_info=True)
error_detail = f"Unexpected error during sync: {str(e_sync_global)}"
await panel_sync_dal.update_panel_sync_status(
session, "failed", error_detail, panel_records_checked, subscriptions_synced_count
session,
"failed",
error_detail,
panel_records_checked,
subscriptions_synced_count,
)
return {"status": "failed", "details": error_detail, "errors": [str(e_sync_global)]}
return {
"status": "failed",
"details": error_detail,
"errors": [str(e_sync_global)],
}
@router.message(Command("sync"))
@@ -365,34 +469,40 @@ async def sync_command_handler(
# Use the extracted perform_sync function
try:
sync_result = await perform_sync(panel_service, session, settings, i18n)
status = sync_result.get("status")
details = sync_result.get("details", "No details available")
errors = sync_result.get("errors", [])
# Simple confirmation message to admin
if status == "failed":
await bot.send_message(target_chat_id, _("sync_failed_simple"))
elif status == "completed_with_errors":
await bot.send_message(target_chat_id, _("sync_errors_simple", errors_count=len(errors)))
await bot.send_message(
target_chat_id,
_("sync_errors_simple", errors_count=len(errors)),
)
else:
await bot.send_message(target_chat_id, _("sync_success_simple"))
# Send notification to log channel with proper thread handling
try:
notification_service = NotificationService(bot, settings, i18n)
await notification_service.notify_panel_sync(
status, details,
status,
details,
sync_result.get("users_processed", 0),
sync_result.get("subs_synced", 0)
sync_result.get("subs_synced", 0),
)
except Exception as e_notification:
logging.error(f"Failed to send sync notification: {e_notification}")
except Exception as e_sync_global:
logging.error(f"Global error during /sync command: {e_sync_global}", exc_info=True)
logging.error(
f"Global error during /sync command: {e_sync_global}", exc_info=True
)
await bot.send_message(target_chat_id, _("sync_critical_error"))
# Send notification to log channel about failure
try:
notification_service = NotificationService(bot, settings, i18n)
@@ -400,7 +510,9 @@ async def sync_command_handler(
"failed", str(e_sync_global), 0, 0
)
except Exception as e_notification:
logging.error(f"Failed to send sync failure notification: {e_notification}")
logging.error(
f"Failed to send sync failure notification: {e_notification}"
)
@router.message(Command("syncstatus"))
@@ -419,7 +531,9 @@ async def sync_status_command_handler(
if status_record_model:
last_time_val = status_record_model.last_sync_time
last_time_str = (
last_time_val.strftime("%Y-%m-%d %H:%M:%S UTC") if last_time_val else "N/A"
last_time_val.strftime("%Y-%m-%d %H:%M:%S UTC")
if last_time_val
else "N/A"
)
details_val = status_record_model.details
@@ -436,4 +550,4 @@ async def sync_status_command_handler(
else:
response_text = _("admin_sync_status_never_run")
await message.answer(response_text, parse_mode="HTML")
await message.answer(response_text, parse_mode="HTML")
+484 -121
View File
@@ -4,7 +4,7 @@ from aiogram import Router, F, types, Bot
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.utils.markdown import hcode, hbold
from typing import Optional, Dict, Any
from typing import Optional, Dict, Any, Callable, Awaitable
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime, timezone
@@ -24,25 +24,70 @@ from bot.utils.text_sanitizer import (
sanitize_username,
username_for_display,
)
from bot.utils.telegram_markup import (
is_profile_link_error,
remove_profile_link_buttons,
)
router = Router(name="admin_user_management_router")
USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
async def user_management_menu_handler(callback: types.CallbackQuery,
state: FSMContext, i18n_data: dict,
settings: Settings, session: AsyncSession):
"""Display user management menu"""
async def users_list_handler(callback: types.CallbackQuery,
i18n_data: dict, settings: Settings,
session: AsyncSession, page: int = 0):
"""Display paginated list of all users"""
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error preparing user management.", show_alert=True)
await callback.answer("Error preparing user list.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
# Get paginated users
from bot.keyboards.inline.admin_keyboards import get_users_list_keyboard
from db.dal import user_dal
users = await user_dal.get_all_users_paginated(session, page=page, page_size=15)
total_users = await user_dal.count_all_users(session)
total_pages = max(1, (total_users + 14) // 15)
# Format message
header_text = _(
"admin_users_list_header",
current=page + 1,
total=total_pages,
total_users=total_users
)
keyboard = get_users_list_keyboard(users, page, total_users, i18n, current_lang, page_size=15)
await callback.message.edit_text(
header_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await callback.answer()
except Exception as e:
logging.error(f"Error displaying user list: {e}")
await callback.answer("Ошибка отображения списка пользователей", show_alert=True)
async def user_search_prompt_handler(callback: types.CallbackQuery,
state: FSMContext, i18n_data: dict,
settings: Settings, session: AsyncSession):
"""Display search prompt for user management"""
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error preparing search.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
prompt_text = _(
"admin_user_management_prompt",
default="👤 Управление пользователями\n\nВведите ID пользователя или @username для поиска:"
"admin_user_management_prompt"
)
try:
@@ -61,44 +106,62 @@ async def user_management_menu_handler(callback: types.CallbackQuery,
await state.set_state(AdminStates.waiting_for_user_search)
def get_user_card_keyboard(user_id: int, i18n_instance, lang: str) -> InlineKeyboardBuilder:
def get_user_card_keyboard(user_id: int, i18n_instance, lang: str,
referrer_id: Optional[int] = None) -> InlineKeyboardBuilder:
"""Generate keyboard for user management actions"""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
# Row 1: Trial and Subscription actions
builder.button(
text=_(key="admin_user_reset_trial_button", default="🔄 Сбросить триал"),
text=_(key="admin_user_reset_trial_button"),
callback_data=f"user_action:reset_trial:{user_id}"
)
builder.button(
text=_(key="admin_user_add_subscription_button", default=" Добавить дни"),
text=_(key="admin_user_add_subscription_button"),
callback_data=f"user_action:add_subscription:{user_id}"
)
# Row 2: Block/Unblock and Message
builder.button(
text=_(key="admin_user_toggle_ban_button", default="🚫 Заблокировать/Разблокировать"),
text=_(key="admin_user_toggle_ban_button"),
callback_data=f"user_action:toggle_ban:{user_id}"
)
builder.button(
text=_(key="admin_user_send_message_button", default="✉️ Отправить сообщение"),
text=_(key="admin_user_send_message_button"),
callback_data=f"user_action:send_message:{user_id}"
)
# Row 3: View actions
builder.button(
text=_(key="admin_user_view_logs_button", default="📜 Действия пользователя"),
text=_(key="admin_user_view_logs_button"),
callback_data=f"user_action:view_logs:{user_id}"
)
builder.button(
text=_(key="admin_user_refresh_button", default="🔄 Обновить"),
text=_(key="admin_user_refresh_button"),
callback_data=f"user_action:refresh:{user_id}"
)
# Row 4: Back button
# Row 4: Quick links
builder.button(
text=_(key="admin_user_search_new_button", default="🔍 Найти другого"),
text=_(key="user_card_open_profile_button"),
url=f"tg://user?id={user_id}"
)
if referrer_id:
builder.button(
text=_(key="user_card_open_referrer_profile_button"),
url=f"tg://user?id={referrer_id}"
)
# Row 5: Destructive action
builder.button(
text=_(key="admin_user_delete_button"),
callback_data=f"user_action:delete_user:{user_id}"
)
# Row 6: Navigation
builder.button(
text=_(key="admin_user_search_new_button"),
callback_data="admin_action:users_management"
)
builder.button(
@@ -106,10 +169,39 @@ def get_user_card_keyboard(user_id: int, i18n_instance, lang: str) -> InlineKeyb
callback_data="admin_action:main"
)
builder.adjust(2, 2, 2, 2)
quick_links_width = 2 if referrer_id else 1
builder.adjust(2, 2, 2, quick_links_width, 1, 2)
return builder
async def _send_with_profile_link_fallback(
sender: Callable[..., Awaitable[Any]],
*,
text: str,
markup: Optional[types.InlineKeyboardMarkup],
user_id: int,
parse_mode: Optional[str] = "HTML") -> None:
"""Send text with markup and fallback if Telegram rejects tg://user buttons."""
send_kwargs: Dict[str, Any] = {"text": text, "reply_markup": markup}
if parse_mode is not None:
send_kwargs["parse_mode"] = parse_mode
try:
await sender(**send_kwargs)
except TelegramBadRequest as exc:
if not is_profile_link_error(exc):
raise
logging.warning(
"Telegram rejected profile buttons for user %s: %s. Retrying without tg:// links.",
user_id,
getattr(exc, "message", "") or str(exc),
)
fallback_markup = remove_profile_link_buttons(markup)
send_kwargs["reply_markup"] = fallback_markup
await sender(**send_kwargs)
async def format_user_card(user: User, session: AsyncSession,
subscription_service: SubscriptionService,
i18n_instance, lang: str,
@@ -119,10 +211,10 @@ async def format_user_card(user: User, session: AsyncSession,
# Basic user info
card_parts = []
card_parts.append(f"👤 <b>{_('admin_user_card_title', default='Карточка пользователя')}</b>\n")
card_parts.append(f"👤 <b>{_('admin_user_card_title')}</b>\n")
# User details
na_value = _("admin_user_na_value", default="N/A")
na_value = _("admin_user_na_value")
safe_first_name = sanitize_display_name(user.first_name) if user.first_name else None
user_name = safe_first_name or na_value
if user.username:
@@ -135,23 +227,23 @@ async def format_user_card(user: User, session: AsyncSession,
username_display = na_value
registration_date = user.registration_date.strftime('%Y-%m-%d %H:%M') if user.registration_date else na_value
card_parts.append(f"{_('admin_user_id_label', default='🆔 <b>ID:</b>')} {hcode(str(user.user_id))}")
card_parts.append(f"{_('admin_user_name_label', default='👤 <b>Имя:</b>')} {hcode(user_name)}")
card_parts.append(f"{_('admin_user_username_label', default='📱 <b>Username:</b>')} {hcode(username_display)}")
card_parts.append(f"{_('admin_user_language_label', default='🌍 <b>Язык:</b>')} {hcode(user.language_code or na_value)}")
card_parts.append(f"{_('admin_user_registration_label', default='📅 <b>Регистрация:</b>')} {hcode(registration_date)}")
card_parts.append(f"{_('admin_user_id_label')} {hcode(str(user.user_id))}")
card_parts.append(f"{_('admin_user_name_label')} {hcode(user_name)}")
card_parts.append(f"{_('admin_user_username_label')} {hcode(username_display)}")
card_parts.append(f"{_('admin_user_language_label')} {hcode(user.language_code or na_value)}")
card_parts.append(f"{_('admin_user_registration_label')} {hcode(registration_date)}")
# Ban status
ban_status = _("admin_user_status_banned", default="🚫 Заблокирован") if user.is_banned else _("admin_user_status_active", default="✅ Активен")
card_parts.append(f"{_('admin_user_status_label', default='🛡 <b>Статус:</b>')} {ban_status}")
ban_status = _("admin_user_status_banned") if user.is_banned else _("admin_user_status_active")
card_parts.append(f"{_('admin_user_status_label')} {ban_status}")
# Referral info
if user.referred_by_id:
card_parts.append(f"{_('admin_user_referral_label', default='🎁 <b>Привлечен по реферальной программе от:</b>')} {hcode(str(user.referred_by_id))}")
card_parts.append(f"{_('admin_user_referral_label')} {hcode(str(user.referred_by_id))}")
# Panel info
if user.panel_user_uuid:
card_parts.append(f"{_('admin_user_panel_uuid_label', default='🔗 <b>Panel UUID:</b>')} {hcode(user.panel_user_uuid[:8] + '...' if len(user.panel_user_uuid) > 8 else user.panel_user_uuid)}")
card_parts.append(f"{_('admin_user_panel_uuid_label')} {hcode(user.panel_user_uuid[:8] + '...' if len(user.panel_user_uuid) > 8 else user.panel_user_uuid)}")
card_parts.append("") # Empty line
@@ -159,38 +251,52 @@ async def format_user_card(user: User, session: AsyncSession,
try:
subscription_details = await subscription_service.get_active_subscription_details(session, user.user_id)
if subscription_details:
card_parts.append(f"💳 <b>{_('admin_user_subscription_info', default='Информация о подписке:')}</b>")
card_parts.append(f"💳 <b>{_('admin_user_subscription_info')}</b>")
end_date = subscription_details.get('end_date')
if end_date:
end_date_str = end_date.strftime('%Y-%m-%d %H:%M') if isinstance(end_date, datetime) else str(end_date)
card_parts.append(f"{_('admin_user_subscription_active_until', default='⏰ <b>Действует до:</b>')} {hcode(end_date_str)}")
card_parts.append(f"{_('admin_user_subscription_active_until')} {hcode(end_date_str)}")
status = subscription_details.get('status_from_panel', 'UNKNOWN')
card_parts.append(f"{_('admin_user_panel_status_label', default='📊 <b>Статус на панели:</b>')} {hcode(status)}")
card_parts.append(f"{_('admin_user_panel_status_label')} {hcode(status)}")
traffic_limit = subscription_details.get('traffic_limit_bytes')
traffic_used = subscription_details.get('traffic_used_bytes')
if traffic_limit and traffic_used is not None:
traffic_limit_gb = traffic_limit / (1024**3)
traffic_used_gb = traffic_used / (1024**3)
card_parts.append(f"{_('admin_user_traffic_label', default='📊 <b>Трафик:</b>')} {hcode(f'{traffic_used_gb:.2f}GB / {traffic_limit_gb:.2f}GB')}")
card_parts.append(f"{_('admin_user_traffic_label')} {hcode(f'{traffic_used_gb:.2f}GB / {traffic_limit_gb:.2f}GB')}")
else:
card_parts.append(f"{_('admin_user_subscription_label', default='💼 <b>Подписка:</b>')} {hcode(_('admin_user_subscription_none', default='Нет активной подписки'))}")
card_parts.append(f"{_('admin_user_subscription_label')} {hcode(_('admin_user_subscription_none'))}")
except Exception as e:
logging.error(f"Error getting subscription details for user {user.user_id}: {e}")
card_parts.append(f"{_('admin_user_subscription_label', default='💼 <b>Подписка:</b>')} {hcode(_('admin_user_subscription_error', default='Ошибка загрузки'))}")
card_parts.append(f"{_('admin_user_subscription_label')} {hcode(_('admin_user_subscription_error'))}")
# Statistics
try:
# Count user logs
logs_count = await message_log_dal.count_user_message_logs(session, user.user_id)
card_parts.append(f"{_('admin_user_actions_count_label', default='📜 <b>Всего действий:</b>')} {hcode(str(logs_count))}")
card_parts.append(f"{_('admin_user_actions_count_label')} {hcode(str(logs_count))}")
# Check if user had any subscriptions
had_subscriptions = await subscription_service.has_had_any_subscription(session, user.user_id)
trial_status = _("admin_user_trial_used", default="Использовал") if had_subscriptions else _("admin_user_trial_not_used", default="Не использовал")
card_parts.append(f"{_('admin_user_trial_label', default='🏡 <b>Триал:</b>')} {hcode(trial_status)}")
trial_status = _("admin_user_trial_used") if had_subscriptions else _("admin_user_trial_not_used")
card_parts.append(f"{_('admin_user_trial_label')} {hcode(trial_status)}")
# Financial analytics (admin-only)
try:
from db.dal import payment_dal
# Total amount paid by this user
total_paid = await payment_dal.get_user_total_paid(session, user.user_id)
card_parts.append(f"{_('admin_user_total_paid_label')} {hcode(f'{total_paid:.2f} RUB')}")
# Total revenue from referrals
referral_revenue = await payment_dal.get_referral_revenue(session, user.user_id)
card_parts.append(f"{_('admin_user_referral_revenue_label')} {hcode(f'{referral_revenue:.2f} RUB')}")
except Exception as e_fin:
logging.error(f"Failed to build financial analytics for admin card {user.user_id}: {e_fin}")
# Referral stats
if referral_service is not None:
@@ -198,8 +304,8 @@ async def format_user_card(user: User, session: AsyncSession,
stats = await referral_service.get_referral_stats(session, user.user_id)
invited_count = stats.get('invited_count', 0)
purchased_count = stats.get('purchased_count', 0)
card_parts.append(f"{_('admin_user_invited_friends_label', default='👥 <b>Приглашено друзей:</b>')} {hcode(str(invited_count))}")
card_parts.append(f"{_('admin_user_ref_purchased_label', default='💳 <b>Купили подписку:</b>')} {hcode(str(purchased_count))}")
card_parts.append(f"{_('admin_user_invited_friends_label')} {hcode(str(invited_count))}")
card_parts.append(f"{_('admin_user_ref_purchased_label')} {hcode(str(purchased_count))}")
except Exception as e_rs:
logging.error(f"Failed to build referral stats for admin card {user.user_id}: {e_rs}")
@@ -239,7 +345,6 @@ async def process_user_search_handler(message: types.Message, state: FSMContext,
if not user_model:
await message.answer(_(
"admin_user_not_found",
default="❌ Пользователь не найден: {input}",
input=hcode(input_text)
))
return
@@ -252,18 +357,24 @@ async def process_user_search_handler(message: types.Message, state: FSMContext,
try:
referral_service = ReferralService(settings, subscription_service, message.bot, i18n)
user_card_text = await format_user_card(user_model, session, subscription_service, i18n, current_lang, referral_service)
keyboard = get_user_card_keyboard(user_model.user_id, i18n, current_lang)
keyboard = get_user_card_keyboard(
user_model.user_id,
i18n,
current_lang,
user_model.referred_by_id
)
await message.answer(
user_card_text,
reply_markup=keyboard.as_markup(),
await _send_with_profile_link_fallback(
message.answer,
text=user_card_text,
markup=keyboard.as_markup(),
user_id=user_model.user_id,
parse_mode="HTML"
)
except Exception as e:
logging.error(f"Error displaying user card for {user_model.user_id}: {e}")
await message.answer(_(
"admin_user_card_error",
default="❌ Ошибка отображения карточки пользователя"
"admin_user_card_error"
))
@@ -293,8 +404,7 @@ async def user_action_handler(callback: types.CallbackQuery, state: FSMContext,
user = await user_dal.get_user_by_id(session, user_id)
if not user:
await callback.answer(_(
"admin_user_not_found_action",
default="Пользователь не найден"
"admin_user_not_found_action"
), show_alert=True)
return
@@ -310,6 +420,10 @@ async def user_action_handler(callback: types.CallbackQuery, state: FSMContext,
await handle_view_user_logs(callback, user, session, settings, i18n, current_lang)
elif action == "refresh":
await handle_refresh_user_card(callback, user, subscription_service, session, i18n, current_lang)
elif action == "delete_user":
await handle_delete_user_prompt(
callback, state, user, settings, i18n, current_lang, session
)
else:
await callback.answer(_("admin_unknown_action"), show_alert=True)
@@ -326,8 +440,7 @@ async def handle_reset_trial(callback: types.CallbackQuery, user: User,
await session.commit()
await callback.answer(_(
"admin_user_trial_reset_success",
default="✅ Триал сброшен! Пользователь может активировать триал заново."
"admin_user_trial_reset_success"
), show_alert=True)
# Refresh user card
@@ -337,8 +450,7 @@ async def handle_reset_trial(callback: types.CallbackQuery, user: User,
logging.error(f"Error resetting trial for user {user.user_id}: {e}")
await session.rollback()
await callback.answer(_(
"admin_user_trial_reset_error",
default="❌ Ошибка сброса триала"
"admin_user_trial_reset_error"
), show_alert=True)
@@ -352,7 +464,6 @@ async def handle_add_subscription_prompt(callback: types.CallbackQuery, state: F
prompt_text = _(
"admin_user_add_subscription_prompt",
default="➕ Добавление дней подписки для пользователя {user_id}\n\nВведите количество дней для добавления:",
user_id=user.user_id
)
@@ -383,10 +494,9 @@ async def handle_toggle_ban(callback: types.CallbackQuery, user: User,
await session.commit()
status_text = _("admin_user_ban_action_banned", default="заблокирован") if new_ban_status else _("admin_user_ban_action_unbanned", default="разблокирован")
status_text = _("admin_user_ban_action_banned") if new_ban_status else _("admin_user_ban_action_unbanned")
await callback.answer(_(
"admin_user_ban_toggle_success",
default="✅ Пользователь {status}",
status=status_text
), show_alert=True)
@@ -403,8 +513,7 @@ async def handle_toggle_ban(callback: types.CallbackQuery, user: User,
logging.error(f"Error toggling ban for user {user.user_id}: {e}")
await session.rollback()
await callback.answer(_(
"admin_user_ban_toggle_error",
default="❌ Ошибка изменения статуса блокировки"
"admin_user_ban_toggle_error"
), show_alert=True)
@@ -418,7 +527,6 @@ async def handle_send_message_prompt(callback: types.CallbackQuery, state: FSMCo
prompt_text = _(
"admin_user_send_message_prompt",
default="✉️ Отправка сообщения пользователю {user_id}\n\nВведите текст сообщения:",
user_id=user.user_id
)
@@ -442,13 +550,12 @@ async def handle_view_user_logs(callback: types.CallbackQuery, user: User,
if not logs:
await callback.answer(_(
"admin_user_no_logs",
default="📜 У пользователя нет действий"
"admin_user_no_logs"
), show_alert=True)
return
logs_text_parts = [
f"{_('admin_user_recent_actions_title', default='📜 Последние действия пользователя {user_id}:', user_id=user.user_id)}\n"
f"{_('admin_user_recent_actions_title', user_id=user.user_id)}\n"
]
for log in logs:
@@ -466,11 +573,11 @@ async def handle_view_user_logs(callback: types.CallbackQuery, user: User,
# Create inline keyboard for full logs
builder = InlineKeyboardBuilder()
builder.button(
text=_(key="admin_user_view_all_logs_button", default="📋 Все действия"),
text=_(key="admin_user_view_all_logs_button"),
callback_data=f"admin_logs:view_user:{user.user_id}:0"
)
builder.button(
text=_(key="admin_user_back_to_card_button", default="🔙 К карточке"),
text=_(key="admin_user_back_to_card_button"),
callback_data=f"user_action:refresh:{user.user_id}"
)
builder.adjust(1)
@@ -493,8 +600,7 @@ async def handle_view_user_logs(callback: types.CallbackQuery, user: User,
except Exception as e:
logging.error(f"Error viewing logs for user {user.user_id}: {e}")
await callback.answer(_(
"admin_user_logs_error",
default="❌ Ошибка загрузки действий пользователя"
"admin_user_logs_error"
), show_alert=True)
@@ -513,18 +619,28 @@ async def handle_refresh_user_card(callback: types.CallbackQuery, user: User,
_settings = _Settings()
referral_service = ReferralService(_settings, subscription_service, callback.message.bot, i18n_instance)
user_card_text = await format_user_card(fresh_user, session, subscription_service, i18n_instance, lang, referral_service)
keyboard = get_user_card_keyboard(fresh_user.user_id, i18n_instance, lang)
keyboard = get_user_card_keyboard(
fresh_user.user_id,
i18n_instance,
lang,
fresh_user.referred_by_id
)
markup = keyboard.as_markup()
try:
await callback.message.edit_text(
user_card_text,
reply_markup=keyboard.as_markup(),
await _send_with_profile_link_fallback(
callback.message.edit_text,
text=user_card_text,
markup=markup,
user_id=fresh_user.user_id,
parse_mode="HTML"
)
except Exception:
await callback.message.answer(
user_card_text,
reply_markup=keyboard.as_markup(),
await _send_with_profile_link_fallback(
callback.message.answer,
text=user_card_text,
markup=markup,
user_id=fresh_user.user_id,
parse_mode="HTML"
)
@@ -535,8 +651,199 @@ async def handle_refresh_user_card(callback: types.CallbackQuery, user: User,
await callback.answer("Error refreshing user card", show_alert=True)
# Destructive deletion flow
async def handle_delete_user_prompt(callback: types.CallbackQuery, state: FSMContext,
user: User, settings: Settings, i18n_instance,
lang: str, session: AsyncSession):
"""Trigger confirmation workflow for destructive deletion."""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
admin = callback.from_user
admin_id = admin.id if admin else None
if not admin_id or admin_id not in settings.ADMIN_IDS:
logging.warning(
f"Unauthorized delete attempt by user {admin_id} targeting {user.user_id}."
)
await callback.answer(
_(
"admin_user_delete_not_allowed",
),
show_alert=True,
)
return
await state.update_data(
target_user_id=user.user_id,
delete_initiator_id=admin_id,
)
await state.set_state(AdminStates.waiting_for_user_delete_confirmation)
prompt_text = _(
"admin_user_delete_confirmation_prompt",
user_id=hcode(str(user.user_id)),
)
try:
await callback.message.answer(prompt_text, parse_mode="HTML")
except Exception as e:
logging.error(
f"Failed to send delete confirmation prompt for user {user.user_id}: {e}"
)
await callback.message.reply(prompt_text, parse_mode="HTML")
await callback.answer()
async def _log_admin_user_deletion(
session: AsyncSession,
admin_id: int,
admin_user: Optional[types.User],
target_user_id: int,
) -> None:
"""Store audit log for successful deletion."""
try:
await message_log_dal.create_message_log_no_commit(
session,
{
"user_id": admin_id,
"telegram_username": admin_user.username if admin_user else None,
"telegram_first_name": admin_user.first_name if admin_user else None,
"event_type": "admin:user_deleted",
"content": f"Admin {admin_id} deleted user {target_user_id}",
"raw_update_preview": None,
"is_admin_event": True,
"target_user_id": target_user_id,
"timestamp": datetime.now(timezone.utc),
},
)
except Exception as e:
logging.error(
f"Failed to log deletion audit for admin {admin_id} -> user {target_user_id}: {e}",
exc_info=True,
)
# Message handlers for state-based inputs
@router.message(AdminStates.waiting_for_user_delete_confirmation, F.text)
async def process_delete_user_confirmation_handler(message: types.Message,
state: FSMContext,
settings: Settings,
i18n_data: dict,
panel_service: PanelApiService,
session: AsyncSession):
"""Confirm and execute destructive user deletion."""
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await message.reply("Language service error.")
await state.clear()
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
admin = message.from_user
admin_id = admin.id if admin else None
if not admin_id or admin_id not in settings.ADMIN_IDS:
logging.warning(
f"Unauthorized delete confirmation attempt by user {admin_id}."
)
await message.answer(
_(
"admin_user_delete_not_allowed",
)
)
await state.clear()
return
data = await state.get_data()
target_user_id = data.get("target_user_id")
if not target_user_id:
await message.answer(
_(
"admin_user_delete_state_missing",
)
)
await state.clear()
return
confirmation_input = message.text.strip() if message.text else ""
if confirmation_input.lower() in {"/cancel", "cancel", "отмена"}:
await message.answer(
_(
"admin_user_delete_cancelled",
)
)
await state.clear()
return
if confirmation_input != str(target_user_id):
await message.answer(
_(
"admin_user_delete_mismatch",
)
)
await state.clear()
return
user_model = await user_dal.get_user_by_id(session, target_user_id)
if not user_model:
await message.answer(
_(
"admin_user_delete_already_removed",
)
)
await state.clear()
return
try:
if user_model.panel_user_uuid:
panel_deleted = await panel_service.delete_user_from_panel(
user_model.panel_user_uuid
)
if not panel_deleted:
await message.answer(
_(
"admin_user_delete_panel_error",
)
)
await session.rollback()
await state.clear()
return
deleted = await user_dal.delete_user_and_relations(
session, target_user_id
)
if not deleted:
await message.answer(
_(
"admin_user_delete_already_removed",
)
)
await state.clear()
return
await _log_admin_user_deletion(session, admin_id, admin, target_user_id)
await session.commit()
await message.answer(
_(
"admin_user_delete_success",
user_id=hcode(str(target_user_id)),
),
parse_mode="HTML",
)
except Exception as e:
logging.error(f"Error deleting user {target_user_id}: {e}", exc_info=True)
await session.rollback()
await message.answer(
_(
"admin_user_delete_error",
)
)
finally:
await state.clear()
@router.message(AdminStates.waiting_for_subscription_days_to_add, F.text)
async def process_subscription_days_handler(message: types.Message, state: FSMContext,
settings: Settings, i18n_data: dict,
@@ -563,8 +870,7 @@ async def process_subscription_days_handler(message: types.Message, state: FSMCo
raise ValueError("Invalid days count")
except ValueError:
await message.answer(_(
"admin_user_invalid_days",
default="❌ Неверное количество дней. Введите число от 1 до 3650."
"admin_user_invalid_days"
))
return
@@ -578,7 +884,6 @@ async def process_subscription_days_handler(message: types.Message, state: FSMCo
await session.commit()
await message.answer(_(
"admin_user_subscription_added_success",
default="✅ Успешно добавлено {days} дней подписки пользователю {user_id}",
days=days_to_add,
user_id=target_user_id
))
@@ -588,26 +893,31 @@ async def process_subscription_days_handler(message: types.Message, state: FSMCo
if user:
referral_service = ReferralService(settings, subscription_service, message.bot, i18n)
user_card_text = await format_user_card(user, session, subscription_service, i18n, current_lang, referral_service)
keyboard = get_user_card_keyboard(user.user_id, i18n, current_lang)
keyboard = get_user_card_keyboard(
user.user_id,
i18n,
current_lang,
user.referred_by_id
)
await message.answer(
user_card_text,
reply_markup=keyboard.as_markup(),
await _send_with_profile_link_fallback(
message.answer,
text=user_card_text,
markup=keyboard.as_markup(),
user_id=user.user_id,
parse_mode="HTML"
)
else:
await session.rollback()
await message.answer(_(
"admin_user_subscription_added_error",
default="❌ Ошибка добавления дней подписки"
"admin_user_subscription_added_error"
))
except Exception as e:
logging.error(f"Error adding subscription days for user {target_user_id}: {e}")
await session.rollback()
await message.answer(_(
"admin_user_subscription_added_error",
default="❌ Ошибка добавления дней подписки"
"admin_user_subscription_added_error"
))
await state.clear()
@@ -636,8 +946,7 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte
text = (message.text or message.caption or "").strip()
if len(text) > 4000:
await message.answer(_(
"admin_user_message_too_long",
default="❌ Сообщение слишком длинное (максимум 4000 символов)"
"admin_user_message_too_long"
))
return
@@ -651,16 +960,14 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte
# Prepare admin signature and get content
admin_signature = _(
"admin_direct_message_signature",
default="\n\n---\n💬 Сообщение от администратора"
"admin_direct_message_signature"
)
content = get_message_content(message)
if not content.text and not content.file_id:
await message.answer(_(
"admin_direct_empty_message",
default="❌ Пустое сообщение. Отправьте текст или медиа."
"admin_direct_empty_message"
))
return
@@ -679,7 +986,6 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte
except TelegramBadRequest as e:
await message.answer(_(
"admin_broadcast_invalid_html",
default="❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.\nОшибка: {error}",
error=str(e),
))
return
@@ -687,7 +993,6 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte
# Confirm to admin
await message.answer(_(
"admin_user_message_sent_success",
default="✅ Сообщение отправлено пользователю {user_id}",
user_id=target_user_id
))
@@ -697,19 +1002,25 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte
subscription_service = SubscriptionService(settings, panel_service)
referral_service = ReferralService(settings, subscription_service, bot, i18n)
user_card_text = await format_user_card(target_user, session, subscription_service, i18n, current_lang, referral_service)
keyboard = get_user_card_keyboard(target_user.user_id, i18n, current_lang)
keyboard = get_user_card_keyboard(
target_user.user_id,
i18n,
current_lang,
target_user.referred_by_id
)
await message.answer(
user_card_text,
reply_markup=keyboard.as_markup(),
await _send_with_profile_link_fallback(
message.answer,
text=user_card_text,
markup=keyboard.as_markup(),
user_id=target_user.user_id,
parse_mode="HTML"
)
except Exception as e:
logging.error(f"Error sending direct message to user {target_user_id}: {e}")
await message.answer(_(
"admin_user_message_sent_error",
default="❌ Ошибка отправки сообщения"
"admin_user_message_sent_error"
))
await state.clear()
@@ -727,8 +1038,7 @@ async def ban_user_prompt_handler(callback: types.CallbackQuery,
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
prompt_text = _(
"admin_ban_user_prompt",
default="🚫 Блокировка пользователя\n\nВведите ID пользователя или @username для блокировки:"
"admin_ban_user_prompt"
)
try:
@@ -759,8 +1069,7 @@ async def unban_user_prompt_handler(callback: types.CallbackQuery,
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
prompt_text = _(
"admin_unban_user_prompt",
default="✅ Разблокировка пользователя\n\nВведите ID пользователя или @username для разблокировки:"
"admin_unban_user_prompt"
)
try:
@@ -796,8 +1105,7 @@ async def view_banned_users_handler(callback: types.CallbackQuery,
if not banned_users:
message_text = _(
"admin_banned_users_empty",
default="📋 Заблокированные пользователи\n\nСписок пуст"
"admin_banned_users_empty"
)
else:
user_list = []
@@ -809,7 +1117,6 @@ async def view_banned_users_handler(callback: types.CallbackQuery,
message_text = _(
"admin_banned_users_list",
default="📋 Заблокированные пользователи ({count}):\n\n{users}",
count=len(banned_users),
users="\n".join(user_list)
)
@@ -854,7 +1161,6 @@ async def process_ban_user_handler(message: types.Message, state: FSMContext,
if not user_model:
await message.answer(_(
"admin_user_not_found",
default="❌ Пользователь не найден: {input}",
input=hcode(input_text)
))
return
@@ -863,8 +1169,7 @@ async def process_ban_user_handler(message: types.Message, state: FSMContext,
# Check if user is already banned
if user_model.is_banned:
await message.answer(_(
"admin_user_already_banned",
default="⚠️ Пользователь уже заблокирован"
"admin_user_already_banned"
))
await state.clear()
return
@@ -880,7 +1185,6 @@ async def process_ban_user_handler(message: types.Message, state: FSMContext,
await message.answer(_(
"admin_user_ban_success",
default="✅ Пользователь {input} заблокирован",
input=hcode(input_text)
))
@@ -888,8 +1192,7 @@ async def process_ban_user_handler(message: types.Message, state: FSMContext,
logging.error(f"Error banning user {user_model.user_id}: {e}")
await session.rollback()
await message.answer(_(
"admin_user_ban_error",
default="❌ Ошибка блокировки пользователя"
"admin_user_ban_error"
))
await state.clear()
@@ -897,9 +1200,9 @@ async def process_ban_user_handler(message: types.Message, state: FSMContext,
@router.message(AdminStates.waiting_for_user_id_to_unban, F.text)
async def process_unban_user_handler(message: types.Message, state: FSMContext,
settings: Settings, i18n_data: dict,
panel_service: PanelApiService,
session: AsyncSession):
settings: Settings, i18n_data: dict,
panel_service: PanelApiService,
session: AsyncSession):
"""Process user unban input"""
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -925,7 +1228,6 @@ async def process_unban_user_handler(message: types.Message, state: FSMContext,
if not user_model:
await message.answer(_(
"admin_user_not_found",
default="❌ Пользователь не найден: {input}",
input=hcode(input_text)
))
return
@@ -934,8 +1236,7 @@ async def process_unban_user_handler(message: types.Message, state: FSMContext,
# Check if user is not banned
if not user_model.is_banned:
await message.answer(_(
"admin_user_not_banned",
default="⚠️ Пользователь не заблокирован"
"admin_user_not_banned"
))
await state.clear()
return
@@ -951,7 +1252,6 @@ async def process_unban_user_handler(message: types.Message, state: FSMContext,
await message.answer(_(
"admin_user_unban_success",
default="✅ Пользователь {input} разблокирован",
input=hcode(input_text)
))
@@ -959,8 +1259,71 @@ async def process_unban_user_handler(message: types.Message, state: FSMContext,
logging.error(f"Error unbanning user {user_model.user_id}: {e}")
await session.rollback()
await message.answer(_(
"admin_user_unban_error",
default="❌ Ошибка разблокировки пользователя"
"admin_user_unban_error"
))
await state.clear()
@router.callback_query(F.data.startswith("admin_user_card_from_list:"))
async def user_card_from_list_handler(callback: types.CallbackQuery,
state: FSMContext, i18n_data: dict,
settings: Settings, bot: Bot,
subscription_service: SubscriptionService,
panel_service: PanelApiService,
session: AsyncSession):
"""Display user card when clicked from user list"""
try:
parts = callback.data.split(":")
user_id = int(parts[1])
page = int(parts[2])
except (IndexError, ValueError):
await callback.answer("Invalid user data", show_alert=True)
return
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await callback.answer("Language service error", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
# Get user from database
user = await user_dal.get_user_by_id(session, user_id)
if not user:
await callback.answer("User not found", show_alert=True)
return
# Create keyboard with back to list button
keyboard = get_user_card_keyboard(
user_id,
i18n,
current_lang,
user.referred_by_id
)
keyboard.button(
text=_("admin_user_back_to_list_button"),
callback_data=f"admin_action:users_list:{page}"
)
quick_links_width = 2 if user.referred_by_id else 1
keyboard.adjust(2, 2, 2, quick_links_width, 1, 2, 1)
# Format user card
try:
from bot.services.referral_service import ReferralService
referral_service = ReferralService(settings, subscription_service, bot, i18n)
user_card_text = await format_user_card(user, session, subscription_service, i18n, current_lang, referral_service)
markup = keyboard.as_markup()
await _send_with_profile_link_fallback(
callback.message.edit_text,
text=user_card_text,
markup=markup,
user_id=user.user_id,
parse_mode="HTML"
)
await callback.answer()
except Exception as e:
logging.error(f"Error displaying user card: {e}")
await callback.answer("Error displaying user card", show_alert=True)
+32 -51
View File
@@ -38,7 +38,13 @@ async def inline_query_handler(inline_query: InlineQuery,
# For all users: referral functionality
if not query or "реф" in query or "ref" in query or "друг" in query or "friend" in query:
referral_result = await create_referral_result(
inline_query, bot, referral_service, i18n, current_lang, settings
inline_query,
bot,
referral_service,
i18n,
current_lang,
settings,
session,
)
if referral_result:
results.append(referral_result)
@@ -67,9 +73,15 @@ async def inline_query_handler(inline_query: InlineQuery,
await inline_query.answer(results=[], cache_time=10)
async def create_referral_result(inline_query: InlineQuery, bot: Bot,
referral_service: ReferralService,
i18n_instance, lang: str, settings: Settings) -> Optional[InlineQueryResultArticle]:
async def create_referral_result(
inline_query: InlineQuery,
bot: Bot,
referral_service: ReferralService,
i18n_instance,
lang: str,
settings: Settings,
session: AsyncSession,
) -> Optional[InlineQueryResultArticle]:
"""Create referral link result for inline query"""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
@@ -80,25 +92,27 @@ async def create_referral_result(inline_query: InlineQuery, bot: Bot,
return None
user_id = inline_query.from_user.id
referral_link = referral_service.generate_referral_link(bot_username, user_id)
referral_link = await referral_service.generate_referral_link(
session, bot_username, user_id
)
if not referral_link:
logging.warning("Could not produce referral link for inline user %s", user_id)
return None
# Create message content (use same text as friend message)
message_text = _(
"referral_friend_message",
default="🚀 Привет! Попробуй этот VPN - быстрый, надёжный и доступный!\n\n"
"🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}",
referral_link=referral_link
)
return InlineQueryResultArticle(
id="referral_link",
title=_(
"inline_referral_title",
default="🎁 Пригласить друга"
"inline_referral_title"
),
description=_(
"inline_referral_description",
default="Поделиться реферальной ссылкой для получения бонусов"
"inline_referral_description"
),
input_message_content=InputTextMessageContent(
message_text=message_text,
@@ -149,13 +163,6 @@ async def create_user_stats_result(session: AsyncSession, i18n_instance, lang: s
stats_text = _(
"inline_user_stats_message",
default="📊 <b>Статистика Бота</b>\n👥 Пользователи\n\n"
"📊 Всего: <b>{total}</b>\n"
"💳 С платной подпиской: <b>{paid}</b>\n"
"🆓 На пробном периоде: <b>{trial}</b>\n"
"😴 Неактивных: <b>{inactive}</b>\n"
"🚫 Заблокированных: <b>{banned}</b>\n"
"🎁 Привлечено по реферальной программе: <b>{referral}</b>",
total=user_stats['total_users'],
active_today=user_stats['active_today'],
paid=user_stats['paid_subscriptions'],
@@ -168,12 +175,10 @@ async def create_user_stats_result(session: AsyncSession, i18n_instance, lang: s
return InlineQueryResultArticle(
id="admin_user_stats",
title=_(
"inline_admin_user_stats_title",
default="📊 Статистика пользователей"
"inline_admin_user_stats_title"
),
description=_(
"inline_user_stats_description",
default="Всего: {total}, Платных: {active}",
total=user_stats['total_users'],
active=user_stats['paid_subscriptions']
),
@@ -199,12 +204,6 @@ async def create_financial_stats_result(session: AsyncSession, i18n_instance, la
stats_text = _(
"inline_financial_stats_message",
default="💰 <b>Финансовая статистика</b>\n\n"
"📅 За сегодня: <b>{today:.2f} RUB</b>\n"
" ({today_count} платежей)\n"
"📅 За неделю: <b>{week:.2f} RUB</b>\n"
"📅 За месяц: <b>{month:.2f} RUB</b>\n"
"🏆 За все время: <b>{all_time:.2f} RUB</b>",
today=financial_stats['today_revenue'],
today_count=financial_stats['today_payments_count'],
week=financial_stats['week_revenue'],
@@ -215,12 +214,10 @@ async def create_financial_stats_result(session: AsyncSession, i18n_instance, la
return InlineQueryResultArticle(
id="admin_financial_stats",
title=_(
"inline_admin_financial_stats_title",
default="💰 Финансовая статистика"
"inline_admin_financial_stats_title"
),
description=_(
"inline_financial_description",
default="Сегодня: {today} RUB",
today=f"{financial_stats['today_revenue']:.2f}"
),
input_message_content=InputTextMessageContent(
@@ -293,17 +290,6 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang:
stats_text = _(
"inline_system_stats_message",
default="🖥 <b>Статистика панели</b>\n\n"
"🟢 Онлайн: <b>{online}</b>\n"
"📊 Активных: <b>{active}</b>\n"
"🔴 Отключенных: <b>{disabled}</b>\n"
"⏰ Истекшие: <b>{expired}</b>\n"
"⚠️ Ограниченные: <b>{limited}</b>\n"
"👥 Всего пользователей: <b>{total}</b>\n"
"💾 Использование RAM: <b>{memory:.1f}%</b>\n"
"📊 Трафик за неделю: <b>{week_traffic}</b>\n"
"📊 Трафик за месяц: <b>{month_traffic}</b>\n"
"🔗 Активных нод: <b>{active_nodes}/{total_nodes}</b>",
online=online_now,
active=active_users,
disabled=disabled_users,
@@ -317,17 +303,15 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang:
total_nodes=total_nodes
)
else:
stats_text = _("inline_panel_stats_error", default="❌ Ошибка получения данных с панели")
stats_text = _("inline_panel_stats_error")
return InlineQueryResultArticle(
id="admin_system_stats",
title=_(
"inline_admin_system_stats_title",
default="🖥 Системная статистика"
"inline_admin_system_stats_title"
),
description=_(
"inline_system_description",
default="🟢 Онлайн: {online}, 📊 Активных: {active}",
online=online_now,
active=active_users
),
@@ -341,15 +325,14 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang:
except Exception as e:
logging.error(f"Error creating system stats result: {e}")
# Fallback error message
error_text = _("inline_panel_stats_error", default="❌ Ошибка получения данных с панели")
error_text = _("inline_panel_stats_error")
return InlineQueryResultArticle(
id="admin_system_stats",
title=_(
"inline_admin_system_stats_title",
default="🖥 Системная статистика"
"inline_admin_system_stats_title"
),
description=_("inline_system_error", default="Ошибка получения данных"),
description=_("inline_system_error"),
input_message_content=InputTextMessageContent(
message_text=error_text,
parse_mode="HTML"
@@ -357,5 +340,3 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang:
thumbnail_url=settings.INLINE_SYSTEM_STATS_THUMBNAIL_URL
)
return None
+166 -70
View File
@@ -18,11 +18,13 @@ from bot.services.subscription_service import SubscriptionService
from bot.services.referral_service import ReferralService
from bot.services.panel_api_service import PanelApiService
from bot.services.yookassa_service import YooKassaService
from bot.services.lknpd_service import LknpdService
from bot.middlewares.i18n import JsonI18n
from config.settings import Settings
from bot.services.notification_service import NotificationService
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from bot.utils.config_link import prepare_config_links
payment_processing_lock = asyncio.Lock()
@@ -36,10 +38,13 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
i18n: JsonI18n, settings: Settings,
panel_service: PanelApiService,
subscription_service: SubscriptionService,
referral_service: ReferralService):
referral_service: ReferralService,
lknpd_service: Optional[LknpdService] = None):
metadata = payment_info_from_webhook.get("metadata", {})
user_id_str = metadata.get("user_id")
subscription_months_str = metadata.get("subscription_months")
traffic_gb_str = metadata.get("traffic_gb")
sale_mode = metadata.get("sale_mode") or ("traffic" if settings.traffic_sale_mode else "subscription")
promo_code_id_str = metadata.get("promo_code_id")
payment_db_id_str = metadata.get("payment_db_id")
auto_renew_subscription_id_str = metadata.get(
@@ -47,8 +52,11 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
# For auto-renew payments, payment_db_id may be absent. In that case,
# we will create/ensure a payment record idempotently using provider payment id.
if (not user_id_str or not subscription_months_str
or (not payment_db_id_str and not auto_renew_subscription_id_str)):
if (
not user_id_str
or (not subscription_months_str and not traffic_gb_str)
or (not payment_db_id_str and not auto_renew_subscription_id_str)
):
logging.error(
f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}"
)
@@ -57,54 +65,65 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
db_user = None
try:
user_id = int(user_id_str)
subscription_months = int(subscription_months_str)
subscription_months = float(subscription_months_str or 0)
traffic_amount_gb = float(traffic_gb_str) if traffic_gb_str else subscription_months
payment_db_id = int(
payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None
is_auto_renew = bool(auto_renew_subscription_id_str and not payment_db_id)
is_auto_renew = bool(auto_renew_subscription_id_str and not payment_db_id and sale_mode != "traffic")
promo_code_id = int(
promo_code_id_str
) if promo_code_id_str and promo_code_id_str.isdigit() else None
amount_data = payment_info_from_webhook.get("amount", {})
months_for_record = int(subscription_months) if sale_mode != "traffic" else 0
payment_value = float(amount_data.get("value", 0.0))
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
payment_record = None
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists
if payment_db_id is None and auto_renew_subscription_id_str:
try:
# Create/ensure provider payment by YooKassa payment id for idempotency
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
if not yk_payment_id_from_hook:
logging.error(
"Auto-renew webhook missing YooKassa payment id; cannot ensure payment record."
)
return
from db.dal import payment_dal as _payment_dal
ensured_payment = await _payment_dal.ensure_payment_with_provider_id(
session,
user_id=user_id,
amount=payment_value,
currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL),
months=subscription_months,
description=payment_info_from_webhook.get(
"description") or f"Auto-renewal for {subscription_months} months",
provider="yookassa",
provider_payment_id=yk_payment_id_from_hook,
payment_record = await _payment_dal.get_payment_by_provider_payment_id(
session, yk_payment_id_from_hook
)
payment_db_id = ensured_payment.payment_id
# Also persist yookassa_payment_id field if not set yet
try:
await _payment_dal.update_payment_status_by_db_id(
if not payment_record:
payment_record = await _payment_dal.ensure_payment_with_provider_id(
session,
payment_db_id,
payment_info_from_webhook.get("status", "succeeded"),
yk_payment_id_from_hook,
)
except Exception:
# Non-fatal; continue processing
logging.exception(
"Failed to backfill yookassa_payment_id for ensured auto-renew payment"
user_id=user_id,
amount=payment_value,
currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL),
months=months_for_record or 1,
description=payment_info_from_webhook.get(
"description") or f"Auto-renewal for {months_for_record or subscription_months} months",
provider="yookassa",
provider_payment_id=yk_payment_id_from_hook,
)
payment_db_id = payment_record.payment_id
except Exception as e_ensure:
logging.error(
f"Failed to ensure payment record for auto-renew webhook (YK {payment_info_from_webhook.get('id')}): {e_ensure}",
exc_info=True,
)
return
elif payment_db_id is not None:
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment_record:
logging.error(
f"Payment record {payment_db_id} not found for YK ID {yk_payment_id_from_hook}."
)
return
if payment_record and payment_record.status == "succeeded":
logging.info(
f"Skipping duplicate YooKassa webhook for payment {payment_db_id} (YK: {yk_payment_id_from_hook})."
)
return
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user:
@@ -136,10 +155,24 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
try:
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
payment_before_update = None
if payment_db_id is not None:
payment_before_update = await payment_dal.get_payment_by_db_id(
session,
payment_db_id,
)
should_send_lknpd_receipt = bool(
lknpd_service
and lknpd_service.configured
and payment_info_from_webhook.get("paid") is True
and payment_info_from_webhook.get("status") == "succeeded"
and payment_before_update
and payment_before_update.status != "succeeded"
)
# Try to capture and save payment method for future charges if available
try:
payment_method = payment_info_from_webhook.get("payment_method")
if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False) and isinstance(payment_method, dict) and payment_method.get("saved", False):
if settings.yookassa_autopayments_active and isinstance(payment_method, dict) and payment_method.get("saved", False):
pm_id = payment_method.get("id")
pm_type = payment_method.get("type")
title = payment_method.get("title")
@@ -170,8 +203,40 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
card_last4=display_last4,
card_network=display_network,
)
try:
await user_billing_dal.upsert_user_payment_method(
session,
user_id=user_id,
provider_payment_method_id=pm_id,
provider="yookassa",
card_last4=display_last4,
card_network=display_network,
set_default=True,
)
except Exception:
logging.exception("Failed to persist multi-card YooKassa method from webhook")
except Exception:
logging.exception("Failed to persist YooKassa payment method from webhook")
months_for_activation = int(subscription_months) if sale_mode != "traffic" else 0
activation_details = await subscription_service.activate_subscription(
session,
user_id,
months_for_activation,
payment_value,
payment_db_id,
promo_code_id_from_payment=promo_code_id,
provider="yookassa",
sale_mode=sale_mode,
traffic_gb=traffic_amount_gb if sale_mode == "traffic" else None,
)
if not activation_details or not activation_details.get('end_date'):
logging.error(
f"Failed to activate subscription for user {user_id} after payment {yk_payment_id_from_hook}"
)
raise Exception(
f"Subscription Error: Failed to activate for user {user_id}")
updated_payment_record = await payment_dal.update_payment_status_by_db_id(
session,
payment_db_id=payment_db_id,
@@ -184,34 +249,20 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
raise Exception(
f"DB Error: Could not update payment record {payment_db_id}")
activation_details = await subscription_service.activate_subscription(
session,
user_id,
subscription_months,
payment_value,
payment_db_id,
promo_code_id_from_payment=promo_code_id,
provider="yookassa")
if not activation_details or not activation_details.get('end_date'):
logging.error(
f"Failed to activate subscription for user {user_id} after payment {yk_payment_id_from_hook}"
)
raise Exception(
f"Subscription Error: Failed to activate for user {user_id}")
base_subscription_end_date = activation_details['end_date']
final_end_date_for_user = base_subscription_end_date
applied_promo_bonus_days = activation_details.get(
"applied_promo_bonus_days", 0)
referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment(
session,
user_id,
subscription_months,
current_payment_db_id=payment_db_id,
skip_if_active_before_payment=False,
)
referral_bonus_info = None
if sale_mode != "traffic":
referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment(
session,
user_id,
months_for_activation or int(subscription_months) or 1,
current_payment_db_id=payment_db_id,
skip_if_active_before_payment=False,
)
applied_referee_bonus_days_from_referral: Optional[int] = None
if referral_bonus_info and referral_bonus_info.get(
"referee_new_end_date"):
@@ -224,19 +275,56 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
user_lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
traffic_label = (
str(int(traffic_amount_gb)) if float(traffic_amount_gb).is_integer() else f"{traffic_amount_gb:g}"
)
if should_send_lknpd_receipt:
receipt_item_name = payment_info_from_webhook.get("description")
if not receipt_item_name:
if sale_mode == "traffic":
receipt_item_name = settings.LKNPD_RECEIPT_NAME_TRAFFIC.format(gb=traffic_label)
else:
receipt_item_name = settings.LKNPD_RECEIPT_NAME_SUBSCRIPTION.format(months=int(subscription_months))
try:
await lknpd_service.create_income_receipt(
item_name=receipt_item_name,
amount=payment_value,
quantity=1.0,
operation_time=datetime.now(timezone.utc),
)
except Exception:
logging.exception(
"Failed to send LKNPD receipt for payment %s",
yk_payment_id_from_hook,
)
config_link_display, connect_button_url = await prepare_config_links(
settings, activation_details.get("subscription_url") if activation_details else None
)
config_link_text = config_link_display or _("config_link_not_available")
# For auto-renew charges, avoid re-sending config link; send concise message
if is_auto_renew and final_end_date_for_user:
if sale_mode != "traffic" and is_auto_renew and final_end_date_for_user:
details_message = _(
"yookassa_auto_renewal",
months=subscription_months,
months=int(subscription_months),
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
)
details_markup = None
else:
config_link = activation_details.get("subscription_url") or _(
"config_link_not_available"
elif sale_mode == "traffic":
details_message = _(
"payment_successful_traffic_full",
traffic_gb=traffic_label,
end_date=final_end_date_for_user.strftime('%Y-%m-%d') if final_end_date_for_user else "",
config_link=config_link_text,
)
details_markup = get_connect_and_main_keyboard(
user_lang,
i18n,
settings,
config_link_display,
connect_button_url=connect_button_url,
preserve_message=True,
)
else:
if applied_referee_bonus_days_from_referral and final_end_date_for_user:
inviter_name_display = _("friend_placeholder")
if db_user and db_user.referred_by_id:
@@ -251,27 +339,27 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
details_message = _(
"payment_successful_with_referral_bonus_full",
months=subscription_months,
months=int(subscription_months),
base_end_date=base_subscription_end_date.strftime('%Y-%m-%d'),
bonus_days=applied_referee_bonus_days_from_referral,
final_end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
inviter_name=inviter_name_display,
config_link=config_link,
config_link=config_link_text,
)
elif applied_promo_bonus_days > 0 and final_end_date_for_user:
details_message = _(
"payment_successful_with_promo_full",
months=subscription_months,
months=int(subscription_months),
bonus_days=applied_promo_bonus_days,
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
config_link=config_link,
config_link=config_link_text,
)
elif final_end_date_for_user:
details_message = _(
"payment_successful_full",
months=subscription_months,
months=int(subscription_months),
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
config_link=config_link,
config_link=config_link_text,
)
else:
logging.error(
@@ -280,7 +368,12 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
details_message = _("payment_successful_error_details")
details_markup = get_connect_and_main_keyboard(
user_lang, i18n, settings, config_link, preserve_message=True
user_lang,
i18n,
settings,
config_link_display,
connect_button_url=connect_button_url,
preserve_message=True,
)
try:
await bot.send_message(
@@ -303,9 +396,10 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
user_id=user_id,
amount=payment_value,
currency=settings.DEFAULT_CURRENCY_SYMBOL,
months=subscription_months,
months=int(subscription_months) if sale_mode != "traffic" else 0,
payment_provider="yookassa", # This is specifically for YooKassa webhook
username=user.username if user else None
username=user.username if user else None,
traffic_gb=traffic_amount_gb if sale_mode == "traffic" else None,
)
except Exception as e:
logging.error(f"Failed to send payment notification: {e}")
@@ -379,6 +473,7 @@ async def yookassa_webhook_route(request: web.Request):
subscription_service: SubscriptionService = request.app[
'subscription_service']
referral_service: ReferralService = request.app['referral_service']
lknpd_service: Optional[LknpdService] = request.app.get('lknpd_service')
async_session_factory: sessionmaker = request.app[
'async_session_factory']
except KeyError as e_app_ctx:
@@ -471,7 +566,8 @@ async def yookassa_webhook_route(request: web.Request):
await process_successful_payment(
session, bot, payment_dict_for_processing,
i18n_instance, settings, panel_service,
subscription_service, referral_service)
subscription_service, referral_service,
lknpd_service)
await session.commit()
else:
logging.warning(
@@ -487,7 +583,7 @@ async def yookassa_webhook_route(request: web.Request):
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE:
# Bind-only flow: save method and cancel auth if metadata has bind_only
metadata = payment_dict_for_processing.get("metadata", {}) or {}
if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False) and metadata.get("bind_only") == "1":
if settings.yookassa_autopayments_active and metadata.get("bind_only") == "1":
try:
user_id_str = metadata.get("user_id")
if user_id_str and user_id_str.isdigit():
+55 -13
View File
@@ -123,38 +123,80 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
code=hcode(code_input.upper()))
reply_markup = get_back_to_main_menu_markup(current_lang, i18n)
else:
# Try as BONUS code first (existing behavior)
success, result = await promo_code_service.apply_promo_code(
session, user.id, code_input, current_lang)
if success:
# Bonus code success
await session.commit()
logging.info(
f"Promo code '{code_input}' successfully applied for user {user.id}."
f"Bonus promo code '{code_input}' successfully applied for user {user.id}."
)
new_end_date = result if isinstance(result, datetime) else None
active = await subscription_service.get_active_subscription_details(session, user.id)
config_link = active.get("config_link") if active else None
config_link = config_link or _("config_link_not_available")
config_link_display = active.get("config_link") if active else None
connect_button_url = active.get("connect_button_url") if active else None
config_link_text = config_link_display or _("config_link_not_available")
response_to_user_text = _(
"promo_code_applied_success_full",
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
config_link=config_link,
config_link=config_link_text,
)
reply_markup = get_connect_and_main_keyboard(
current_lang, i18n, settings, config_link
current_lang,
i18n,
settings,
config_link_display,
connect_button_url=connect_button_url,
)
else:
await session.rollback()
logging.info(
f"Promo code '{code_input}' application failed for user {user.id}. Reason: {result}"
)
response_to_user_text = result
reply_markup = get_back_to_main_menu_markup(
current_lang, i18n
# Bonus code failed, try as DISCOUNT code
success_discount, result_discount = await promo_code_service.apply_discount_promo_code(
session, user.id, code_input, current_lang
)
if success_discount:
# Discount code success
await session.commit()
logging.info(
f"Discount promo code '{code_input}' successfully applied for user {user.id}."
)
discount_pct = result_discount # Returns percentage
# Send notification about discount promo activation
if settings.LOG_PROMO_ACTIVATIONS:
try:
from bot.services.notification_service import NotificationService
notification_service = NotificationService(bot, settings, i18n)
await notification_service.notify_discount_promo_activation(
user_id=user.id,
promo_code=code_input.upper(),
discount_percentage=discount_pct,
username=user.username
)
except Exception as e:
logging.error(f"Failed to send discount promo activation notification: {e}")
response_to_user_text = _(
"discount_promo_code_applied_success",
code=hcode(code_input.upper()),
discount=discount_pct
)
reply_markup = get_back_to_main_menu_markup(current_lang, i18n)
else:
# Both failed
await session.rollback()
logging.info(
f"Promo code '{code_input}' application failed for user {user.id}. Reason: {result}"
)
response_to_user_text = result # Original error message from bonus code attempt
reply_markup = get_back_to_main_menu_markup(
current_lang, i18n
)
await message.answer(
response_to_user_text,
reply_markup=reply_markup,
+40 -19
View File
@@ -60,28 +60,40 @@ async def referral_command_handler(event: Union[types.Message,
return
inviter_user_id = event.from_user.id
referral_link = referral_service.generate_referral_link(
bot_username, inviter_user_id)
referral_link = await referral_service.generate_referral_link(
session, bot_username, inviter_user_id)
if not referral_link:
logging.error(
"Failed to generate referral link for user %s (probably missing DB record).",
inviter_user_id,
)
await target_message_obj.answer(_("error_generating_referral_link"))
if isinstance(event, types.CallbackQuery):
await event.answer()
return
bonus_info_parts = []
if settings.subscription_options:
if getattr(settings, "traffic_sale_mode", False):
bonus_details_str = _("referral_not_available_for_traffic")
else:
if settings.subscription_options:
for months_period_key, _price in sorted(
settings.subscription_options.items()):
for months_period_key, _price in sorted(
settings.subscription_options.items()):
inv_bonus = settings.referral_bonus_inviter.get(months_period_key)
ref_bonus = settings.referral_bonus_referee.get(months_period_key)
if inv_bonus is not None or ref_bonus is not None:
bonus_info_parts.append(
_("referral_bonus_per_period",
months=months_period_key,
inviter_bonus_days=inv_bonus
if inv_bonus is not None else _("no_bonus_placeholder"),
referee_bonus_days=ref_bonus
if ref_bonus is not None else _("no_bonus_placeholder")))
inv_bonus = settings.referral_bonus_inviter.get(months_period_key)
ref_bonus = settings.referral_bonus_referee.get(months_period_key)
if inv_bonus is not None or ref_bonus is not None:
bonus_info_parts.append(
_("referral_bonus_per_period",
months=months_period_key,
inviter_bonus_days=inv_bonus
if inv_bonus is not None else _("no_bonus_placeholder"),
referee_bonus_days=ref_bonus
if ref_bonus is not None else _("no_bonus_placeholder")))
bonus_details_str = "\n".join(bonus_info_parts) if bonus_info_parts else _(
"referral_no_bonuses_configured")
bonus_details_str = "\n".join(bonus_info_parts) if bonus_info_parts else _(
"referral_no_bonuses_configured")
# Get referral statistics
referral_stats = await referral_service.get_referral_stats(session, inviter_user_id)
@@ -132,7 +144,16 @@ async def referral_action_handler(callback: types.CallbackQuery, settings: Setti
return
inviter_user_id = callback.from_user.id
referral_link = referral_service.generate_referral_link(bot_username, inviter_user_id)
referral_link = await referral_service.generate_referral_link(
session, bot_username, inviter_user_id)
if not referral_link:
logging.error(
"Failed to generate referral link for user %s via inline button.",
inviter_user_id,
)
await callback.answer(_("error_generating_referral_link"), show_alert=True)
return
friend_message = _("referral_friend_message", referral_link=referral_link)
+40 -8
View File
@@ -302,7 +302,7 @@ async def ensure_required_channel_subscription(
@router.message(CommandStart())
@router.message(CommandStart(magic=F.args.regexp(r"^ref_(\d+)$").as_("ref_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^ref_((?:[uU][A-Za-z0-9]{9})|(?:[A-Za-z0-9]{9})|\d+)$").as_("ref_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^(?!ref_|promo_)([A-Za-z0-9_\-]{2,64})$").as_("ad_param_match")))
async def start_command_handler(message: types.Message,
@@ -328,9 +328,23 @@ async def start_command_handler(message: types.Message,
ad_start_param: Optional[str] = None
if ref_match:
potential_referrer_id = int(ref_match.group(1))
if await user_dal.get_user_by_id(session, potential_referrer_id):
referred_by_user_id = potential_referrer_id
raw_ref_value = ref_match.group(1)
if raw_ref_value.isdigit():
if settings.LEGACY_REFS:
potential_referrer_id = int(raw_ref_value)
if potential_referrer_id != user_id and await user_dal.get_user_by_id(
session, potential_referrer_id):
referred_by_user_id = potential_referrer_id
else:
normalized_code = raw_ref_value.strip()
if normalized_code and normalized_code[0].lower() == "u":
normalized_code = normalized_code[1:]
ref_user = None
if normalized_code:
ref_user = await user_dal.get_user_by_referral_code(
session, normalized_code)
if ref_user and ref_user.user_id != user_id:
referred_by_user_id = ref_user.user_id
elif promo_match:
promo_code_to_apply = promo_match.group(1)
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
@@ -357,6 +371,17 @@ async def start_command_handler(message: types.Message,
db_user, created = await user_dal.create_user(session, user_data_to_create)
if created:
try:
await session.commit()
except Exception as commit_error:
await session.rollback()
logging.error(
f"Failed to commit new user {user_id}: {commit_error}",
exc_info=True,
)
await message.answer(_("error_occurred_processing_request"))
return
logging.info(
f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}."
)
@@ -453,21 +478,28 @@ async def start_command_handler(message: types.Message,
# Get updated subscription details
active = await subscription_service.get_active_subscription_details(session, user_id)
config_link = active.get("config_link") if active else None
config_link = config_link or _("config_link_not_available")
config_link_display = active.get("config_link") if active else None
connect_button_url = active.get("connect_button_url") if active else None
config_link_text = config_link_display or _("config_link_not_available")
new_end_date = result if isinstance(result, datetime) else None
promo_success_text = _(
"promo_code_applied_success_full",
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
config_link=config_link,
config_link=config_link_text,
)
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
await message.answer(
promo_success_text,
reply_markup=get_connect_and_main_keyboard(current_lang, i18n, settings, config_link),
reply_markup=get_connect_and_main_keyboard(
current_lang,
i18n,
settings,
config_link_display,
connect_button_url=connect_button_url,
),
parse_mode="HTML"
)
+194 -53
View File
@@ -1,3 +1,4 @@
import hashlib
import logging
from aiogram import Router, F, types, Bot
from aiogram.filters import Command
@@ -16,12 +17,28 @@ from bot.keyboards.inline.user_keyboards import (
from bot.services.subscription_service import SubscriptionService
from bot.services.panel_api_service import PanelApiService
from bot.middlewares.i18n import JsonI18n
from db.dal import subscription_dal
from db.dal import subscription_dal, user_billing_dal
from db.models import Subscription
router = Router(name="user_subscription_core_router")
def _shorten_hwid_for_display(hwid: Optional[str], max_length: int = 24) -> str:
"""Trim HWID for button text to keep within Telegram limits."""
if not hwid:
return "-"
hwid_str = str(hwid)
if len(hwid_str) <= max_length:
return hwid_str
return f"{hwid_str[:8]}...{hwid_str[-6:]}"
def _hwid_callback_token(hwid: Optional[str]) -> str:
"""Stable short token for callback_data; avoids 64b limit with raw HWID."""
hwid_str = str(hwid or "")
return hashlib.sha256(hwid_str.encode()).hexdigest()[:32]
async def display_subscription_options(event: Union[types.Message, types.CallbackQuery], i18n_data: dict, settings: Settings, session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -40,13 +57,29 @@ async def display_subscription_options(event: Union[types.Message, types.Callbac
return
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
text_content = get_text("select_subscription_period") if settings.subscription_options else get_text("no_subscription_options_available")
traffic_packages = getattr(settings, "traffic_packages", {}) or {}
stars_traffic_packages = getattr(settings, "stars_traffic_packages", {}) or {}
traffic_mode = bool(getattr(settings, "traffic_sale_mode", False) or stars_traffic_packages)
reply_markup = (
get_subscription_options_keyboard(settings.subscription_options, currency_symbol_val, current_lang, i18n)
if settings.subscription_options
else get_back_to_main_menu_markup(current_lang, i18n)
)
if traffic_mode:
if traffic_packages:
options = traffic_packages
elif stars_traffic_packages:
options = stars_traffic_packages
currency_symbol_val = ""
else:
options = {}
else:
options = settings.subscription_options
if options:
text_content = get_text("select_traffic_package") if traffic_mode else get_text("select_subscription_period")
reply_markup = get_subscription_options_keyboard(
options, currency_symbol_val, current_lang, i18n, traffic_mode=traffic_mode
)
else:
text_content = get_text("no_subscription_options_available")
reply_markup = get_back_to_main_menu_markup(current_lang, i18n)
target_message_obj = event.message if isinstance(event, types.CallbackQuery) else event
if not target_message_obj:
@@ -104,7 +137,7 @@ async def my_subscription_command_handler(
text = get_text("subscription_not_active")
buy_button = InlineKeyboardButton(
text=get_text("menu_subscribe_inline", default="Купить"), callback_data="main_action:subscribe"
text=get_text("menu_subscribe_inline"), callback_data="main_action:subscribe"
)
back_markup = get_back_to_main_menu_markup(current_lang, i18n)
@@ -125,28 +158,53 @@ async def my_subscription_command_handler(
end_date = active.get("end_date")
days_left = (end_date.date() - datetime.now().date()).days if end_date else 0
tribute_hint = ""
if active.get("status_from_panel", "").lower() == "active":
local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id)
if local_sub:
if local_sub.provider == "tribute":
link = None
link = settings.tribute_payment_links.get(local_sub.duration_months or 1) if hasattr(settings, "tribute_payment_links") else None
tribute_hint = "\n\n" + (
get_text("subscription_tribute_notice_with_link", link=link) if link else get_text("subscription_tribute_notice")
)
traffic_mode = bool(getattr(settings, "traffic_sale_mode", False))
config_link_display = active.get("config_link")
connect_button_url = active.get("connect_button_url")
config_link_value = config_link_display or get_text("config_link_not_available")
def _fmt_gb(val: Optional[float]) -> str:
if val is None:
return get_text("traffic_na")
try:
if isinstance(val, (int, float)):
val_gb = float(val) / (2**30)
return f"{val_gb:.2f} GB"
except Exception:
pass
return str(val)
text = get_text(
"my_subscription_details",
end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A",
days_left=max(0, days_left),
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
config_link=active.get("config_link") or get_text("config_link_not_available"),
traffic_limit=(f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" if active.get("traffic_limit_bytes") else get_text("traffic_unlimited")),
traffic_used=(
f"{active['traffic_used_bytes'] / 2**30:.2f} GB" if active.get("traffic_used_bytes") is not None else get_text("traffic_na")
),
)
if traffic_mode:
limit_display = _fmt_gb(active.get("traffic_limit_bytes"))
used_display = _fmt_gb(active.get("traffic_used_bytes"))
remaining_display = get_text("traffic_na")
try:
limit_val = active.get("traffic_limit_bytes") or 0
used_val = active.get("traffic_used_bytes") or 0
remaining_val = max(0, float(limit_val) - float(used_val))
remaining_display = _fmt_gb(remaining_val)
except Exception:
pass
text = get_text(
"my_traffic_details",
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
end_date=end_date.strftime("%Y-%m-%d") if end_date else get_text("traffic_no_expiry"),
traffic_limit=limit_display,
traffic_used=used_display,
traffic_left=remaining_display,
config_link=config_link_value,
)
else:
text = get_text(
"my_subscription_details",
end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A",
days_left=max(0, days_left),
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
config_link=config_link_value,
traffic_limit=(f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" if active.get("traffic_limit_bytes") else get_text("traffic_unlimited")),
traffic_used=(
f"{active['traffic_used_bytes'] / 2**30:.2f} GB" if active.get("traffic_used_bytes") is not None else get_text("traffic_na")
),
)
base_markup = get_back_to_main_menu_markup(current_lang, i18n)
kb = base_markup.inline_keyboard
@@ -164,7 +222,7 @@ async def my_subscription_command_handler(
)
])
else:
cfg_link_val = (active or {}).get("config_link")
cfg_link_val = connect_button_url or config_link_display
if cfg_link_val:
prepend_rows.append([
InlineKeyboardButton(
@@ -183,9 +241,38 @@ async def my_subscription_command_handler(
max_devices_display = str(max_devices_int)
except (TypeError, ValueError):
max_devices_display = str(max_devices_value)
current_devices_display = "?"
user_uuid = active.get("user_id")
devices_response = None
if user_uuid:
try:
devices_response = await panel_service.get_user_devices(user_uuid)
except Exception:
logging.exception("Failed to load devices for user %s", user_uuid)
if devices_response:
devices_count: Optional[int] = None
if isinstance(devices_response, dict):
devices_list = devices_response.get("devices")
if isinstance(devices_list, list):
devices_count = len(devices_list)
elif isinstance(devices_list, int):
devices_count = devices_list
else:
try:
devices_count = len(devices_list) # type: ignore[arg-type]
except Exception:
devices_count = None
if devices_count is None:
total_value = devices_response.get("total")
if isinstance(total_value, int):
devices_count = total_value
elif isinstance(devices_response, list):
devices_count = len(devices_response)
if devices_count is not None:
current_devices_display = str(devices_count)
devices_button_text = get_text(
"devices_button",
current_devices="?",
current_devices=current_devices_display,
max_devices=max_devices_display,
)
prepend_rows.append([
@@ -195,8 +282,8 @@ async def my_subscription_command_handler(
)
])
# 2) Auto-renew toggle (if supported and not tribute)
if local_sub and local_sub.provider != "tribute" and getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
# 2) Auto-renew toggle (YooKassa only)
if not traffic_mode and local_sub and local_sub.provider == "yookassa" and settings.yookassa_autopayments_active:
toggle_text = (
get_text("autorenew_disable_button") if local_sub.auto_renew_enabled else get_text("autorenew_enable_button")
)
@@ -208,7 +295,7 @@ async def my_subscription_command_handler(
])
# 3) Payment methods management (when autopayments enabled)
if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
if not traffic_mode and settings.yookassa_autopayments_active:
prepend_rows.append([
InlineKeyboardButton(text=get_text("payment_methods_manage_button"), callback_data="pm:manage")
])
@@ -225,17 +312,17 @@ async def my_subscription_command_handler(
except Exception:
pass
try:
await event.message.edit_text(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
await event.message.edit_text(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
except Exception:
await bot.send_message(
chat_id=target.chat.id,
text=text + tribute_hint,
text=text,
reply_markup=markup,
parse_mode="HTML",
disable_web_page_preview=True,
)
else:
await target.answer(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
await target.answer(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
@router.callback_query(F.data == "main_action:my_devices")
@@ -292,6 +379,12 @@ async def my_devices_command_handler(
await target.answer(get_text("no_devices_found"))
return
devices_list_raw = []
if isinstance(devices, dict):
devices_list_raw = devices.get("devices") or []
elif isinstance(devices, list):
devices_list_raw = devices
max_devices_value = active.get("max_devices")
max_devices_display = get_text("devices_unlimited_label")
if max_devices_value not in (None, 0):
@@ -302,19 +395,22 @@ async def my_devices_command_handler(
except (TypeError, ValueError):
max_devices_display = str(max_devices_value)
if not devices or not devices.get('devices') or len(devices.get('devices')) == 0:
if not devices_list_raw:
text = get_text("no_devices_details_found_message", max_devices=max_devices_display)
else:
devices_list = []
current_devices = len(devices.get('devices') or [])
for index, device in enumerate(devices.get('devices') or [], start=1):
current_devices = len(devices_list_raw)
for index, device in enumerate(devices_list_raw, start=1):
device_model = device.get('deviceModel') or None
platform = device.get('platform') or None
user_agent = device.get('userAgent') or None
os_version = device.get('osVersion') or None
created_at = device.get('createdAt')
hwid = device.get('hwid')
created_at_str = datetime.fromisoformat(created_at).strftime("%d.%m.%Y %H:%M")
try:
created_at_str = datetime.fromisoformat(created_at).strftime("%d.%m.%Y %H:%M") if created_at else "-"
except Exception:
created_at_str = str(created_at)
device_details = get_text("device_details", index=index, device_model=device_model, platform=platform, os_version=os_version, created_at_str=created_at_str, user_agent=user_agent, hwid=hwid)
devices_list.append(device_details)
@@ -325,11 +421,14 @@ async def my_devices_command_handler(
kb = base_markup.inline_keyboard
devices_kb = []
for index, device in enumerate(devices.get('devices') or [], start=1):
for index, device in enumerate(devices_list_raw, start=1):
hwid = device.get('hwid')
device_button_text = get_text("disconnect_device_button", hwid=hwid, index=index)
if not hwid:
continue
device_button_text = get_text("disconnect_device_button", hwid=_shorten_hwid_for_display(hwid), index=index)
hwid_token = _hwid_callback_token(hwid)
devices_kb.append([InlineKeyboardButton(text=device_button_text, callback_data=f"disconnect_device:{hwid}")])
devices_kb.append([InlineKeyboardButton(text=device_button_text, callback_data=f"disconnect_device:{hwid_token}")])
kb = devices_kb + kb
markup = InlineKeyboardMarkup(inline_keyboard=kb)
@@ -368,7 +467,7 @@ async def disconnect_device_handler(
return
try:
_, hwid = callback.data.split(":", 1)
_, hwid_token = callback.data.split(":", 1)
except Exception:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
@@ -377,10 +476,32 @@ async def disconnect_device_handler(
return
active = await subscription_service.get_active_subscription_details(session, callback.from_user.id)
if not active:
if not active or not active.get("user_id"):
await callback.answer(get_text("subscription_not_active"), show_alert=True)
return
devices = await panel_service.get_user_devices(active.get("user_id"))
if not devices:
await callback.answer(get_text("no_devices_found"), show_alert=True)
return
devices_list_raw = []
if isinstance(devices, dict):
devices_list_raw = devices.get("devices") or []
elif isinstance(devices, list):
devices_list_raw = devices
hwid = None
for device in devices_list_raw:
hwid_candidate = device.get("hwid")
if hwid_candidate and _hwid_callback_token(hwid_candidate) == hwid_token:
hwid = hwid_candidate
break
if not hwid:
await callback.answer(get_text("error_try_again"), show_alert=True)
return
success = await panel_service.disconnect_device(active.get("user_id"), hwid)
if not success:
await callback.answer(get_text("error_try_again"), show_alert=True)
@@ -423,9 +544,17 @@ async def toggle_autorenew_handler(
if not sub or sub.user_id != callback.from_user.id:
await callback.answer(get_text("error_try_again"), show_alert=True)
return
if sub.provider == "tribute":
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
if sub.provider != "yookassa":
await callback.answer(get_text("error_try_again"), show_alert=True)
return
if enable:
has_saved_card = await user_billing_dal.user_has_saved_payment_method(session, callback.from_user.id)
if not has_saved_card:
try:
await callback.answer(get_text("autorenew_enable_requires_card"), show_alert=True)
except Exception:
pass
return
# Show confirmation popup and inline buttons
confirm_text = get_text("autorenew_confirm_enable") if enable else get_text("autorenew_confirm_disable")
@@ -473,9 +602,21 @@ async def confirm_autorenew_handler(
if not sub or sub.user_id != callback.from_user.id:
await callback.answer(get_text("error_try_again"), show_alert=True)
return
if sub.provider == "tribute":
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
if sub.provider != "yookassa":
await callback.answer(get_text("error_try_again"), show_alert=True)
return
if enable:
has_saved_card = await user_billing_dal.user_has_saved_payment_method(session, callback.from_user.id)
if not has_saved_card:
try:
await callback.answer(get_text("autorenew_enable_requires_card"), show_alert=True)
except Exception:
pass
try:
await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
except Exception:
pass
return
await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": enable})
await session.commit()
@@ -500,7 +641,7 @@ async def autorenew_cancel_from_webhook_button(
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
# Disable auto-renew on the active subscription (non-tribute)
# Disable auto-renew on the active subscription
from db.dal import subscription_dal
sub = await subscription_dal.get_active_subscription_by_user_id(session, callback.from_user.id)
if not sub:
@@ -509,9 +650,9 @@ async def autorenew_cancel_from_webhook_button(
except Exception:
pass
return
if sub.provider == "tribute":
if sub.provider != "yookassa":
try:
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
@@ -0,0 +1,40 @@
"""
Helper функция для применения скидок к платежам
Используется всеми платежными обработчиками
"""
import logging
from typing import Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from db.dal import active_discount_dal
async def apply_discount_to_payment(
session: AsyncSession,
user_id: int,
original_price: float,
promo_code_service=None
) -> Tuple[float, Optional[float], Optional[int]]:
"""
Apply active discount to payment if exists.
Returns:
(final_price, discount_amount, promo_code_id)
"""
if not promo_code_service:
return original_price, None, None
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if not active_discount:
return original_price, None, None
# Calculate discounted price
final_price, discount_amount = promo_code_service.calculate_discounted_price(
original_price, active_discount.discount_percentage
)
logging.info(
f"Applying {active_discount.discount_percentage}% discount to payment for user {user_id}: "
f"{original_price} -> {final_price}"
)
return final_price, discount_amount, active_discount.promo_code_id
@@ -22,7 +22,7 @@ router = Router(name="user_subscription_payment_methods_router")
async def payment_methods_manage(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
if not settings.yookassa_autopayments_active:
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
@@ -51,9 +51,9 @@ async def payment_methods_manage(callback: types.CallbackQuery, settings: Settin
return get_text("payment_method_wallet_title", last4=l4)
return get_text("payment_method_wallet_title", last4="****")
if last4:
network_name = network or get_text("payment_network_card", default="Card")
network_name = network or get_text("payment_network_card")
return get_text("payment_method_card_title", network=network_name, last4=last4)
network_name = network or get_text("payment_network_generic", default="Payment method")
network_name = network or get_text("payment_network_generic")
return get_text("payment_method_generic_title", network=network_name)
for m in methods:
@@ -75,7 +75,7 @@ async def payment_methods_manage(callback: types.CallbackQuery, settings: Settin
async def payment_method_bind(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
if not settings.yookassa_autopayments_active:
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
@@ -109,7 +109,7 @@ async def payment_method_bind(callback: types.CallbackQuery, settings: Settings,
async def payment_method_delete_confirm(callback: types.CallbackQuery, settings: Settings, i18n_data: dict):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
if not settings.yookassa_autopayments_active:
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
@@ -130,7 +130,7 @@ async def payment_method_delete_confirm(callback: types.CallbackQuery, settings:
async def payment_method_delete(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
if not settings.yookassa_autopayments_active:
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
@@ -177,9 +177,9 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting
return _("payment_method_wallet_title", last4=l4)
return _("payment_method_wallet_title", last4="****")
if last4:
network_name = network or _("payment_network_card", default="Card")
network_name = network or _("payment_network_card")
return _("payment_method_card_title", network=network_name, last4=last4)
network_name = network or _("payment_network_generic", default="Payment method")
network_name = network or _("payment_network_generic")
return _("payment_method_generic_title", network=network_name)
title = _format_pm_title(m.card_network, m.card_last4)
cards.append((str(m.method_id), title if not m.is_default else f"{title}"))
@@ -204,7 +204,7 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting
async def payment_method_view(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
if not settings.yookassa_autopayments_active:
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
@@ -239,9 +239,9 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
return _("payment_method_wallet_title", last4=l4)
return _("payment_method_wallet_title", last4="****")
if last4:
network_name = network or _("payment_network_card", default="Card")
network_name = network or _("payment_network_card")
return _("payment_method_card_title", network=network_name, last4=last4)
network_name = network or _("payment_network_generic", default="Payment method")
network_name = network or _("payment_network_generic")
return _("payment_method_generic_title", network=network_name)
title = _format_pm_title(sel.card_network, sel.card_last4)
@@ -307,9 +307,9 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
return _("payment_method_wallet_title", last4=l4)
return _("payment_method_wallet_title", last4="****")
if last4:
network_name = network or _("payment_network_card", default="Card")
network_name = network or _("payment_network_card")
return _("payment_method_card_title", network=network_name, last4=last4)
network_name = network or _("payment_network_generic", default="Payment method")
network_name = network or _("payment_network_generic")
return _("payment_method_generic_title", network=network_name)
title = _format_pm_title(billing.card_network, billing.card_last4)
@@ -325,7 +325,7 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
async def payment_method_history(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
if not settings.yookassa_autopayments_active:
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
@@ -390,8 +390,15 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin
await callback.message.edit_text(_("payment_method_no_history"), reply_markup=back_markup)
return
traffic_mode = getattr(settings, "traffic_sale_mode", False)
def _format_item(p: Payment) -> str:
title = p.description or _("subscription_purchase_title", months=p.subscription_duration_months or 1)
if traffic_mode:
units_val = p.subscription_duration_months or 0
units_display = str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}"
title = p.description or _("traffic_purchase_title", traffic_gb=units_display)
else:
title = p.description or _("subscription_purchase_title", months=p.subscription_duration_months or 1)
date_str = p.created_at.strftime('%Y-%m-%d') if p.created_at else "N/A"
return f"{date_str}{title}{p.amount:.2f} {p.currency}"
@@ -433,9 +440,9 @@ async def payment_methods_list(callback: types.CallbackQuery, settings: Settings
return get_text("payment_method_wallet_title", last4=l4)
return get_text("payment_method_wallet_title", last4="****")
if last4:
network_name = network or get_text("payment_network_card", default="Card")
network_name = network or get_text("payment_network_card")
return get_text("payment_method_card_title", network=network_name, last4=last4)
network_name = network or get_text("payment_network_generic", default="Payment method")
network_name = network or get_text("payment_network_generic")
return get_text("payment_method_generic_title", network=network_name)
title = _format_pm_title(m.card_network, m.card_last4)
cards.append((str(m.method_id), title if not m.is_default else f"{title}"))
@@ -455,4 +462,3 @@ async def payment_methods_list(callback: types.CallbackQuery, settings: Settings
except Exception:
pass
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,117 @@
from typing import Optional
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.services.crypto_pay_service import CryptoPayService
from config.settings import Settings
router = Router(name="user_subscription_payments_crypto_router")
@router.callback_query(F.data.startswith("pay_crypto:"))
async def pay_crypto_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
cryptopay_service: CryptoPayService,
promo_code_service=None,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = (lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key)
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not cryptopay_service or not getattr(cryptopay_service, "configured", False):
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
parts = data_payload.split(":")
months = float(parts[0])
price_amount = float(parts[1])
sale_mode = parts[2] if len(parts) > 2 else "subscription"
except (ValueError, IndexError):
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_mode == "traffic"
else get_text("payment_description_subscription", months=int(months))
)
invoice_url = await cryptopay_service.create_invoice(
session=session,
user_id=user_id,
months=months,
amount=price_amount,
description=payment_description,
sale_mode=sale_mode,
promo_code_service=promo_code_service,
)
if invoice_url:
try:
await callback.message.edit_text(
get_text(
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
invoice_url,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception:
try:
await callback.message.answer(
get_text(
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
invoice_url,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
return
try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception:
pass
@@ -0,0 +1,222 @@
import logging
from datetime import datetime
from typing import Optional
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.services.freekassa_service import FreeKassaService
from config.settings import Settings
from db.dal import payment_dal
router = Router(name="user_subscription_payments_freekassa_router")
@router.callback_query(F.data.startswith("pay_fk:"))
async def pay_fk_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
freekassa_service: FreeKassaService,
session: AsyncSession,
promo_code_service=None,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not freekassa_service or not freekassa_service.configured:
logging.error("FreeKassa service is not configured or unavailable.")
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
try:
await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
parts = data_payload.split(":")
months = float(parts[0])
price_rub = float(parts[1])
sale_mode = parts[2] if len(parts) > 2 else "subscription"
except (ValueError, IndexError):
logging.error(f"Invalid pay_fk data in callback: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_mode == "traffic"
else get_text("payment_description_subscription", months=int(months))
)
currency_code = getattr(freekassa_service, "default_currency", None) or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
# Price is already discounted at payments_subscription.py stage
# Service will handle discount metadata if needed
payment_record_payload = {
"user_id": user_id,
"amount": price_rub,
"original_amount": None,
"discount_applied": None,
"currency": currency_code,
"status": "pending_freekassa",
"description": payment_description,
"subscription_duration_months": int(months),
"provider": "freekassa",
"promo_code_id": None,
}
try:
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
await session.commit()
except Exception as e_db_create:
await session.rollback()
logging.error(
f"FreeKassa: failed to create payment record for user {user_id}: {e_db_create}",
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception:
pass
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
success, response_data = await freekassa_service.create_order(
payment_db_id=payment_record.payment_id,
user_id=payment_record.user_id,
months=months,
amount=price_rub,
currency=freekassa_service.default_currency,
payment_method_id=freekassa_service.payment_method_id,
ip_address=freekassa_service.server_ip,
extra_params={
"us_method": freekassa_service.payment_method_id,
},
promo_code_service=promo_code_service,
session=session,
)
if success:
location = response_data.get("location")
order_hash = response_data.get("orderHash")
order_id_api = response_data.get("orderId")
provider_identifier = order_hash or order_id_api
if provider_identifier:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment_record.payment_id,
str(provider_identifier),
payment_record.status,
)
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(
f"FreeKassa: failed to store provider order id for payment {payment_record.payment_id}: {e_status}",
exc_info=True,
)
if location:
order_identifier_display = str(order_id_api or provider_identifier or payment_record.payment_id)
order_info_text = get_text(
"free_kassa_order_info",
order_id=order_identifier_display,
date=datetime.now().strftime("%Y-%m-%d"),
)
try:
await callback.message.edit_text(
f"{order_info_text}\n\n" + get_text(
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
location,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception as e_edit:
logging.warning(f"FreeKassa: failed to display payment link ({e_edit}), sending new message.")
try:
await callback.message.answer(
f"{order_info_text}\n\n" + get_text(
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
location,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
return
logging.error(
"FreeKassa: create_order succeeded but no payment link returned for payment %s. Response: %s",
payment_record.payment_id,
response_data,
)
else:
logging.error(
"FreeKassa: create_order failed for payment %s with response %s",
payment_record.payment_id,
response_data,
)
try:
await payment_dal.update_payment_status_by_db_id(
session,
payment_record.payment_id,
"failed_creation",
)
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(f"FreeKassa: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True)
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception:
pass
try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception:
pass
@@ -0,0 +1,218 @@
import json
import logging
from typing import Optional
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.services.platega_service import PlategaService
from config.settings import Settings
from db.dal import payment_dal
router = Router(name="user_subscription_payments_platega_router")
@router.callback_query(F.data.startswith("pay_platega:"))
async def pay_platega_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
platega_service: PlategaService,
session: AsyncSession,
promo_code_service=None,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not platega_service or not platega_service.configured:
logging.error("Platega service is not configured or unavailable.")
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
try:
await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
parts = data_payload.split(":")
months = float(parts[0])
price_rub = float(parts[1])
sale_mode = parts[2] if len(parts) > 2 else "subscription"
except (ValueError, IndexError):
logging.error(f"Invalid pay_platega data in callback: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_mode == "traffic"
else get_text("payment_description_subscription", months=int(months))
)
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
# Price is already discounted at payments_subscription.py stage
# Service will handle discount metadata if needed
payment_record_payload = {
"user_id": user_id,
"amount": price_rub,
"original_amount": None,
"discount_applied": None,
"currency": currency_code,
"status": "pending_platega",
"description": payment_description,
"subscription_duration_months": int(months),
"provider": "platega",
"promo_code_id": None,
}
try:
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
await session.commit()
except Exception as e_db_create:
await session.rollback()
logging.error(
f"Platega: failed to create payment record for user {user_id}: {e_db_create}",
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception:
pass
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
payload_meta = json.dumps(
{
"payment_db_id": payment_record.payment_id,
"user_id": user_id,
"months": months,
"sale_mode": sale_mode,
}
)
success, response_data = await platega_service.create_transaction(
payment_db_id=payment_record.payment_id,
user_id=user_id,
months=months,
amount=price_rub,
currency=currency_code,
description=payment_description,
payload=payload_meta,
promo_code_service=promo_code_service,
session=session,
)
if success:
transaction_id = response_data.get("transactionId") or response_data.get("id")
redirect_url = (
response_data.get("redirect")
or response_data.get("url")
or response_data.get("paymentUrl")
)
provider_status = response_data.get("status", payment_record.status)
if transaction_id and redirect_url:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment_record.payment_id,
str(transaction_id),
str(provider_status),
)
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(
f"Platega: failed to store transaction id for payment {payment_record.payment_id}: {e_status}",
exc_info=True,
)
try:
await callback.message.edit_text(
get_text(
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
redirect_url,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception as e_edit:
logging.warning(f"Platega: failed to display payment link ({e_edit}), sending new message.")
try:
await callback.message.answer(
get_text(
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
redirect_url,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
return
logging.error(
"Platega: transaction created but missing transaction id or payment link for payment %s. Response: %s",
payment_record.payment_id,
response_data,
)
try:
await payment_dal.update_payment_status_by_db_id(
session,
payment_record.payment_id,
"failed_creation",
)
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(f"Platega: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True)
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception:
pass
try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception:
pass
@@ -0,0 +1,207 @@
import logging
from typing import Optional
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.services.severpay_service import SeverPayService
from config.settings import Settings
from db.dal import payment_dal
router = Router(name="user_subscription_payments_severpay_router")
@router.callback_query(F.data.startswith("pay_severpay:"))
async def pay_severpay_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
severpay_service: SeverPayService,
session: AsyncSession,
promo_code_service=None,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not severpay_service or not severpay_service.configured:
logging.error("SeverPay service is not configured or unavailable.")
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
try:
await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
parts = data_payload.split(":")
months = float(parts[0])
price_rub = float(parts[1])
sale_mode = parts[2] if len(parts) > 2 else "subscription"
except (ValueError, IndexError):
logging.error(f"Invalid pay_severpay data in callback: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_mode == "traffic"
else get_text("payment_description_subscription", months=int(months))
)
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
# Price is already discounted at payments_subscription.py stage
# Service will handle discount metadata if needed
payment_record_payload = {
"user_id": user_id,
"amount": price_rub,
"original_amount": None,
"discount_applied": None,
"currency": currency_code,
"status": "pending_severpay",
"description": payment_description,
"subscription_duration_months": int(months),
"provider": "severpay",
"promo_code_id": None,
}
try:
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
await session.commit()
except Exception as e_db_create:
await session.rollback()
logging.error(
f"SeverPay: failed to create payment record for user {user_id}: {e_db_create}",
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception:
pass
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
success, response_data = await severpay_service.create_payment(
payment_db_id=payment_record.payment_id,
user_id=user_id,
months=months,
amount=price_rub,
currency=currency_code,
description=payment_description,
promo_code_service=promo_code_service,
session=session,
)
if success:
payment_link = (
response_data.get("url")
or response_data.get("payment_url")
or response_data.get("paymentUrl")
)
provider_identifier = response_data.get("id") or response_data.get("uid")
if provider_identifier:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment_record.payment_id,
str(provider_identifier),
payment_record.status,
)
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(
f"SeverPay: failed to store provider payment id for payment {payment_record.payment_id}: {e_status}",
exc_info=True,
)
if payment_link:
try:
await callback.message.edit_text(
get_text(
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
payment_link,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception as e_edit:
logging.warning(f"SeverPay: failed to display payment link ({e_edit}), sending new message.")
try:
await callback.message.answer(
get_text(
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
payment_link,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
return
logging.error(
"SeverPay: payment created but missing payment link for payment %s. Response: %s",
payment_record.payment_id,
response_data,
)
try:
await payment_dal.update_payment_status_by_db_id(
session,
payment_record.payment_id,
"failed_creation",
)
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(f"SeverPay: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True)
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception:
pass
try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception:
pass
@@ -0,0 +1,138 @@
import logging
from typing import Optional
from aiogram import F, Router, types
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n
from bot.services.stars_service import StarsService
from config.settings import Settings
router = Router(name="user_subscription_payments_stars_router")
@router.callback_query(F.data.startswith("pay_stars:"))
async def pay_stars_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
stars_service: StarsService,
promo_code_service=None,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = (lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key)
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not settings.STARS_ENABLED:
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
parts = data_payload.split(":")
months = float(parts[0])
stars_price = int(float(parts[1]))
sale_mode = parts[2] if len(parts) > 2 else "subscription"
except (ValueError, IndexError):
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_mode == "traffic"
else get_text("payment_description_subscription", months=int(months))
)
payment_db_id = await stars_service.create_invoice(
session=session,
user_id=user_id,
months=months,
stars_price=stars_price,
description=payment_description,
sale_mode=sale_mode,
promo_code_service=promo_code_service,
)
if payment_db_id:
try:
await callback.message.edit_text(
get_text(
"payment_invoice_sent_message_traffic" if sale_mode == "traffic" else "payment_invoice_sent_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text=get_text("back_to_payment_methods_button"),
callback_data=f"subscribe_period:{human_value}",
)]
]),
)
except Exception as e_edit:
logging.warning(f"Stars payment: failed to show invoice info message ({e_edit})")
try:
await callback.answer()
except Exception:
pass
return
try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception:
pass
@router.pre_checkout_query()
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
try:
await query.answer(ok=True)
except Exception:
# Nothing else to do here; Telegram will show an error if not answered
pass
@router.message(F.successful_payment)
async def handle_successful_stars_payment(
message: types.Message,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
stars_service: StarsService,
):
payload = (message.successful_payment.invoice_payload
if message and message.successful_payment else "")
try:
parts = (payload or "").split(":")
payment_db_id = int(parts[0])
months = float(parts[1]) if len(parts) > 1 else 0
sale_mode = parts[2] if len(parts) > 2 else "subscription"
except Exception:
return
stars_amount = int(message.successful_payment.total_amount) if message.successful_payment else 0
await stars_service.process_successful_payment(
session=session,
message=message,
payment_db_id=payment_db_id,
months=months,
stars_amount=stars_amount,
i18n_data=i18n_data,
sale_mode=sale_mode,
)
@@ -0,0 +1,134 @@
import logging
from typing import Optional
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_payment_method_keyboard
from bot.middlewares.i18n import JsonI18n
from config.settings import Settings
router = Router(name="user_subscription_payments_selection_router")
@router.callback_query(F.data.startswith("subscribe_period:"))
async def select_subscription_period_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
promo_code_service=None, # Injected from dispatcher
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
traffic_packages = getattr(settings, "traffic_packages", {}) or {}
stars_traffic_packages = getattr(settings, "stars_traffic_packages", {}) or {}
traffic_mode = bool(getattr(settings, "traffic_sale_mode", False) or stars_traffic_packages)
try:
months = float(callback.data.split(":")[-1])
except (ValueError, IndexError):
logging.error(f"Invalid subscription period in callback_data: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
price_source = traffic_packages if traffic_mode else settings.subscription_options
stars_price_source = stars_traffic_packages if traffic_mode else settings.stars_subscription_options
price_rub = price_source.get(months)
stars_price = stars_price_source.get(months)
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
# Check for active discount and apply if exists
discount_text = ""
if promo_code_service and price_rub:
active_discount_info = await promo_code_service.get_user_active_discount(
session, callback.from_user.id
)
if active_discount_info:
discount_pct, promo_code = active_discount_info
original_price_rub = price_rub
price_rub, discount_amt = promo_code_service.calculate_discounted_price(
price_rub, discount_pct
)
discount_text = get_text(
"active_discount_notice",
code=promo_code,
discount_pct=discount_pct,
original_price=original_price_rub,
discounted_price=price_rub,
discount_amount=discount_amt
)
# Note: Stars prices typically don't get discounts (can be added if needed)
if price_rub is None:
if traffic_mode and not price_source and stars_price is not None:
currency_methods_enabled = any(
[
settings.FREEKASSA_ENABLED,
settings.PLATEGA_ENABLED,
settings.SEVERPAY_ENABLED,
settings.YOOKASSA_ENABLED,
settings.CRYPTOPAY_ENABLED,
]
)
if currency_methods_enabled:
logging.error(
"Currency price missing for traffic option %s while fiat providers are enabled.",
months,
)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
price_rub = 0.0
currency_symbol_val = ""
else:
logging.error(
f"Price not found for option {months} using {'traffic_packages' if traffic_mode else 'subscription_options'}."
)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
text_content = get_text("choose_payment_method_traffic") if traffic_mode else get_text("choose_payment_method")
if discount_text:
text_content = f"{discount_text}\n\n{text_content}"
reply_markup = get_payment_method_keyboard(
months,
price_rub,
stars_price,
currency_symbol_val,
current_lang,
i18n,
settings,
sale_mode="traffic" if traffic_mode else "subscription",
)
try:
await callback.message.edit_text(text_content, reply_markup=reply_markup)
except Exception as e_edit:
logging.warning(
f"Edit message for payment method selection failed: {e_edit}. Sending new one."
)
await callback.message.answer(text_content, reply_markup=reply_markup)
try:
await callback.answer()
except Exception:
pass
@@ -0,0 +1,796 @@
import logging
from typing import List, Optional, Tuple
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import (
get_back_to_main_menu_markup,
get_payment_url_keyboard,
get_yk_autopay_choice_keyboard,
get_yk_saved_cards_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.services.yookassa_service import YooKassaService
from config.settings import Settings
from db.dal import payment_dal, user_billing_dal, active_discount_dal
router = Router(name="user_subscription_payments_yookassa_router")
def _format_value(val: float) -> str:
return str(int(val)) if float(val).is_integer() else f"{val:g}"
def _parse_offer_payload(payload: str) -> Optional[Tuple[float, float, str]]:
try:
parts = payload.split(":")
value = float(parts[0])
price = float(parts[1])
sale_mode = parts[2] if len(parts) > 2 else "subscription"
return value, price, sale_mode
except (ValueError, IndexError):
return None
def _format_saved_payment_method_title(get_text, network: Optional[str], last4: Optional[str], is_default: bool) -> str:
def _is_yoomoney_network(name: Optional[str]) -> bool:
s = (name or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
def _extract_last4(text: str) -> Optional[str]:
digits = "".join(ch for ch in text if ch.isdigit())
return digits[-4:] if len(digits) >= 4 else None
if _is_yoomoney_network(network):
inferred_last4 = last4 or (_extract_last4(network or "") or "****")
title = get_text("payment_method_wallet_title", last4=inferred_last4)
elif last4:
network_name = network or get_text("payment_network_card")
title = get_text("payment_method_card_title", network=network_name, last4=last4)
else:
network_name = network or get_text("payment_network_generic")
title = get_text("payment_method_generic_title", network=network_name)
return f"{title}" if is_default else title
async def _initiate_yk_payment(
callback: types.CallbackQuery,
*,
settings: Settings,
session: AsyncSession,
yookassa_service: YooKassaService,
promo_code_service, # NEW: Added promo_code_service
i18n: Optional[JsonI18n],
current_lang: str,
get_text,
user_id: int,
months: int,
price_rub: float,
currency_code_for_yk: str,
save_payment_method: bool,
back_callback: str,
payment_method_id: Optional[str] = None,
selected_method_internal_id: Optional[int] = None,
sale_mode: str = "subscription",
) -> bool:
"""Create payment record and initiate YooKassa payment (new card or saved card)."""
if not callback.message:
return False
# Check for active discount to save metadata (price already discounted from previous step)
original_price = None
discount_amount = None
active_promo_code_id = None
if promo_code_service:
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if active_discount:
# Price is already discounted, calculate original price backwards
discount_pct = active_discount.discount_percentage
original_price = price_rub / (1 - discount_pct / 100)
discount_amount = original_price - price_rub
active_promo_code_id = active_discount.promo_code_id
logging.info(
f"Recording {discount_pct}% discount for YooKassa payment: "
f"original {original_price:.2f} -> final {price_rub}"
)
payment_description = (
get_text("payment_description_traffic", traffic_gb=_format_value(months))
if sale_mode == "traffic"
else get_text("payment_description_subscription", months=int(months))
)
payment_record_data = {
"user_id": user_id,
"amount": price_rub, # Discounted amount
"original_amount": original_price if discount_amount else None, # NEW
"discount_applied": discount_amount, # NEW
"currency": currency_code_for_yk,
"status": "pending_yookassa",
"description": payment_description,
"subscription_duration_months": int(months),
"promo_code_id": active_promo_code_id, # NEW: Link to promo code
}
db_payment_record = None
try:
db_payment_record = await payment_dal.create_payment_record(session, payment_record_data)
await session.commit()
logging.info(
f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'."
)
except Exception as e_db_payment:
await session.rollback()
logging.error(
f"Failed to create payment record in DB for user {user_id}: {e_db_payment}",
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception:
pass
return False
if not db_payment_record:
try:
await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception:
pass
return False
yookassa_metadata = {
"user_id": str(user_id),
"subscription_months": str(months),
"payment_db_id": str(db_payment_record.payment_id),
"sale_mode": sale_mode,
}
if sale_mode == "traffic":
yookassa_metadata["traffic_gb"] = str(months)
if payment_method_id:
yookassa_metadata["used_saved_payment_method_id"] = payment_method_id
receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
payment_response_yk = await yookassa_service.create_payment(
amount=price_rub,
currency=currency_code_for_yk,
description=payment_description,
metadata=yookassa_metadata,
receipt_email=receipt_email_for_yk,
save_payment_method=save_payment_method,
payment_method_id=payment_method_id,
)
if payment_response_yk and payment_response_yk.get("confirmation_url"):
pm = payment_response_yk.get("payment_method")
try:
if pm and pm.get("id"):
pm_type = pm.get("type")
title = pm.get("title")
card = pm.get("card") or {}
account_number = pm.get("account_number") or pm.get("account")
if isinstance(card, dict) and (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
display_network = card.get("card_type") or title or "Card"
display_last4 = card.get("last4")
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
display_network = "YooMoney"
display_last4 = (
account_number[-4:]
if isinstance(account_number, str) and len(account_number) >= 4
else None
)
else:
display_network = title or (pm_type.upper() if pm_type else "Payment method")
display_last4 = None
await user_billing_dal.upsert_yk_payment_method(
session,
user_id=user_id,
payment_method_id=pm["id"],
card_last4=display_last4,
card_network=display_network,
)
try:
await user_billing_dal.upsert_user_payment_method(
session,
user_id=user_id,
provider_payment_method_id=pm["id"],
provider="yookassa",
card_last4=display_last4,
card_network=display_network,
set_default=save_payment_method,
)
except Exception:
pass
await session.commit()
except Exception:
await session.rollback()
logging.exception("Failed to save YooKassa payment method preliminarily")
try:
await payment_dal.update_payment_status_by_db_id(
session,
payment_db_id=db_payment_record.payment_id,
new_status=payment_response_yk.get("status", "pending"),
yk_payment_id=payment_response_yk.get("id"),
)
if selected_method_internal_id is not None:
try:
await user_billing_dal.set_user_default_payment_method(
session, user_id, selected_method_internal_id
)
except Exception:
logging.exception("Failed to set default payment method after initiating payment")
await session.commit()
except Exception as e_db_update_ykid:
await session.rollback()
logging.error(
f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}",
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_payment_gateway_link_failed"))
except Exception:
pass
return False
try:
await callback.message.edit_text(
get_text(
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
months=int(months),
traffic_gb=_format_value(months),
),
reply_markup=get_payment_url_keyboard(
payment_response_yk["confirmation_url"],
current_lang,
i18n,
back_callback=back_callback,
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception as e_edit:
logging.warning(
f"Edit message for payment link failed: {e_edit}. Sending new one."
)
try:
await callback.message.answer(
get_text(
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
months=int(months),
traffic_gb=_format_value(months),
),
reply_markup=get_payment_url_keyboard(
payment_response_yk["confirmation_url"],
current_lang,
i18n,
back_callback=back_callback,
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception:
pass
return True
if payment_response_yk and payment_method_id:
status_to_store = payment_response_yk.get("status", "pending")
try:
await payment_dal.update_payment_status_by_db_id(
session,
payment_db_id=db_payment_record.payment_id,
new_status=status_to_store,
yk_payment_id=payment_response_yk.get("id"),
)
if selected_method_internal_id is not None:
try:
await user_billing_dal.set_user_default_payment_method(
session, user_id, selected_method_internal_id
)
except Exception:
logging.exception("Failed to set default payment method after saved-card payment start")
await session.commit()
except Exception as e_db_update_saved:
await session.rollback()
logging.error(
f"Failed to update saved-card payment record {db_payment_record.payment_id}: {e_db_update_saved}",
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception:
pass
return False
message_text = get_text("yookassa_autopay_charge_initiated")
try:
await callback.message.edit_text(
message_text,
reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
)
except Exception as e_edit:
logging.warning(f"Failed to notify about saved-card charge start: {e_edit}")
try:
await callback.message.answer(
message_text,
reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
)
except Exception:
pass
return True
try:
await payment_dal.update_payment_status_by_db_id(
session, db_payment_record.payment_id, "failed_creation"
)
await session.commit()
except Exception as e_db_fail_create:
await session.rollback()
logging.error(
f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}",
exc_info=True,
)
logging.error(
f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}"
)
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception:
pass
return False
@router.callback_query(F.data.startswith("pay_yk:"))
async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession, promo_code_service=None):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not yookassa_service or not yookassa_service.configured:
logging.error("YooKassa service is not configured or unavailable.")
target_msg_edit = callback.message
await target_msg_edit.edit_text(get_text("payment_service_unavailable"))
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
except ValueError:
logging.error(f"Invalid pay_yk data in callback: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
parsed = _parse_offer_payload(data_payload)
if not parsed:
logging.error(f"Invalid pay_yk payload structure: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
months, price_rub, sale_mode = parsed
user_id = callback.from_user.id
currency_code_for_yk = "RUB"
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
autopay_require_binding = bool(
getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True)
)
saved_methods: List = []
if autopay_enabled:
try:
saved_methods = await user_billing_dal.list_user_payment_methods(
session, user_id, provider="yookassa"
)
except Exception as e_list:
logging.exception(f"Failed to load saved payment methods for user {user_id}: {e_list}")
saved_methods = []
if autopay_enabled and saved_methods:
try:
await callback.message.edit_text(
get_text("yookassa_autopay_flow_prompt"),
reply_markup=get_yk_autopay_choice_keyboard(
months,
price_rub,
current_lang,
i18n,
has_saved_cards=True,
sale_mode=sale_mode,
),
)
except Exception as e_edit:
logging.warning(f"Failed to show autopay choice: {e_edit}. Sending new message.")
try:
await callback.message.answer(
get_text("yookassa_autopay_flow_prompt"),
reply_markup=get_yk_autopay_choice_keyboard(
months,
price_rub,
current_lang,
i18n,
has_saved_cards=True,
sale_mode=sale_mode,
),
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
return
await _initiate_yk_payment(
callback,
settings=settings,
session=session,
yookassa_service=yookassa_service,
promo_code_service=promo_code_service,
i18n=i18n,
current_lang=current_lang,
get_text=get_text,
user_id=user_id,
months=months,
price_rub=price_rub,
currency_code_for_yk=currency_code_for_yk,
save_payment_method=autopay_enabled and autopay_require_binding,
back_callback=f"subscribe_period:{_format_value(months)}",
sale_mode=sale_mode,
)
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pay_yk_new:"))
async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession, promo_code_service=None):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not yookassa_service or not yookassa_service.configured:
logging.error("YooKassa service unavailable for pay_yk_new.")
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
try:
await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
except ValueError:
logging.error(f"Invalid pay_yk_new data in callback: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
parsed = _parse_offer_payload(data_payload)
if not parsed:
logging.error(f"Invalid pay_yk_new payload structure: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
months, price_rub, sale_mode = parsed
user_id = callback.from_user.id
currency_code_for_yk = "RUB"
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
autopay_require_binding = bool(
getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True)
)
await _initiate_yk_payment(
callback,
settings=settings,
session=session,
yookassa_service=yookassa_service,
promo_code_service=promo_code_service,
i18n=i18n,
current_lang=current_lang,
get_text=get_text,
user_id=user_id,
months=months,
price_rub=price_rub,
currency_code_for_yk=currency_code_for_yk,
save_payment_method=autopay_enabled and autopay_require_binding,
back_callback=f"subscribe_period:{_format_value(months)}",
sale_mode=sale_mode,
)
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pay_yk_saved_list:"))
async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
except ValueError:
logging.error(f"Invalid pay_yk_saved_list data: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
parts = data_payload.split(":")
if len(parts) < 2:
logging.error(f"pay_yk_saved_list payload missing components: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
try:
months = float(parts[0])
price_rub = float(parts[1])
page = int(parts[2]) if len(parts) > 2 else 0
sale_mode = parts[3] if len(parts) > 3 else "subscription"
except (ValueError, IndexError):
logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
if not autopay_enabled:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
try:
saved_methods = await user_billing_dal.list_user_payment_methods(
session, user_id, provider="yookassa"
)
except Exception as e_list:
logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}")
saved_methods = []
if not saved_methods:
try:
await callback.message.edit_text(
get_text("yookassa_autopay_no_saved_cards"),
reply_markup=get_yk_autopay_choice_keyboard(
months,
price_rub,
current_lang,
i18n,
has_saved_cards=False,
sale_mode=sale_mode,
),
)
except Exception as e_edit:
logging.warning(f"Failed to display no-saved-card notice: {e_edit}")
try:
await callback.message.answer(
get_text("yookassa_autopay_no_saved_cards"),
reply_markup=get_yk_autopay_choice_keyboard(
months,
price_rub,
current_lang,
i18n,
has_saved_cards=False,
sale_mode=sale_mode,
),
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
return
cards: List[Tuple[str, str]] = []
for method in saved_methods:
title = _format_saved_payment_method_title(
get_text, method.card_network, method.card_last4, method.is_default
)
cards.append((str(method.method_id), title))
per_page = 5
max_page = max(0, (len(cards) - 1) // per_page)
page = max(0, min(page, max_page))
try:
await callback.message.edit_text(
get_text("yookassa_autopay_choose_saved_card"),
reply_markup=get_yk_saved_cards_keyboard(
cards,
months,
price_rub,
current_lang,
i18n,
page=page,
sale_mode=sale_mode,
),
)
except Exception as e_edit:
logging.warning(f"Failed to display saved card list: {e_edit}")
try:
await callback.message.answer(
get_text("yookassa_autopay_choose_saved_card"),
reply_markup=get_yk_saved_cards_keyboard(
cards,
months,
price_rub,
current_lang,
i18n,
page=page,
sale_mode=sale_mode,
),
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pay_yk_use_saved:"))
async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession, promo_code_service=None):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not yookassa_service or not yookassa_service.configured:
logging.error("YooKassa service unavailable for pay_yk_use_saved.")
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
try:
await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
except ValueError:
logging.error(f"Invalid pay_yk_use_saved data: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
parts = data_payload.split(":")
if len(parts) < 3:
logging.error(f"pay_yk_use_saved payload missing components: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
try:
months = float(parts[0])
price_rub = float(parts[1])
sale_mode = parts[3] if len(parts) > 3 else "subscription"
except (ValueError, IndexError):
logging.error(f"pay_yk_use_saved months/price parsing error: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
if not autopay_enabled:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
method_identifier = parts[2]
user_id = callback.from_user.id
try:
saved_methods = await user_billing_dal.list_user_payment_methods(
session, user_id, provider="yookassa"
)
except Exception as e_list:
logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}")
saved_methods = []
selected_method = None
for method in saved_methods:
if method_identifier.isdigit():
if method.method_id == int(method_identifier):
selected_method = method
break
if method.provider_payment_method_id == method_identifier:
selected_method = method
break
if not selected_method:
logging.warning(f"Selected payment method not found for user {user_id}: {method_identifier}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
currency_code_for_yk = "RUB"
await _initiate_yk_payment(
callback,
settings=settings,
session=session,
yookassa_service=yookassa_service,
promo_code_service=promo_code_service,
i18n=i18n,
current_lang=current_lang,
get_text=get_text,
user_id=user_id,
months=months,
price_rub=price_rub,
currency_code_for_yk=currency_code_for_yk,
save_payment_method=False,
back_callback=f"pay_yk_saved_list:{_format_value(months)}:{price_rub}:{sale_mode}",
payment_method_id=selected_method.provider_payment_method_id,
selected_method_internal_id=selected_method.method_id,
sale_mode=sale_mode,
)
try:
await callback.answer()
except Exception:
pass
+21 -6
View File
@@ -13,6 +13,7 @@ from bot.keyboards.inline.user_keyboards import (
get_main_menu_inline_keyboard,
get_connect_and_main_keyboard,
)
from bot.utils.config_link import prepare_config_links
from bot.middlewares.i18n import JsonI18n
from .start import send_main_menu
@@ -76,7 +77,9 @@ async def request_trial_confirmation_handler(
final_message_text_in_chat = ""
show_trial_button_after_action = False
config_link_display_for_trial = None
config_link_for_trial = None
connect_button_url_for_trial = None
if activation_result and activation_result.get("activated"):
try:
@@ -85,9 +88,10 @@ async def request_trial_confirmation_handler(
pass
end_date_obj = activation_result.get("end_date")
config_link_for_trial = activation_result.get("subscription_url") or _(
"config_link_not_available"
config_link_display_for_trial, connect_button_url_for_trial = await prepare_config_links(
settings, activation_result.get("subscription_url")
)
config_link_for_trial = config_link_display_for_trial or _("config_link_not_available")
traffic_gb_val = activation_result.get(
"traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB
@@ -142,7 +146,11 @@ async def request_trial_confirmation_handler(
reply_markup = (
get_connect_and_main_keyboard(
current_lang, i18n, settings, config_link_for_trial
current_lang,
i18n,
settings,
config_link_display_for_trial,
connect_button_url=connect_button_url_for_trial,
)
if activation_result and activation_result.get("activated")
else get_main_menu_inline_keyboard(
@@ -221,7 +229,9 @@ async def confirm_activate_trial_handler(
final_message_text_in_chat = ""
show_trial_button_after_action = False
config_link_display_for_trial = None
config_link_for_trial = None
connect_button_url_for_trial = None
if activation_result and activation_result.get("activated"):
try:
@@ -230,9 +240,10 @@ async def confirm_activate_trial_handler(
pass
end_date_obj = activation_result.get("end_date")
config_link_for_trial = activation_result.get("subscription_url") or _(
"config_link_not_available"
config_link_display_for_trial, connect_button_url_for_trial = await prepare_config_links(
settings, activation_result.get("subscription_url")
)
config_link_for_trial = config_link_display_for_trial or _("config_link_not_available")
traffic_gb_val = activation_result.get(
"traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB
@@ -275,7 +286,11 @@ async def confirm_activate_trial_handler(
reply_markup = (
get_connect_and_main_keyboard(
current_lang, i18n, settings, config_link_for_trial
current_lang,
i18n,
settings,
config_link_display_for_trial,
connect_button_url=connect_button_url_for_trial,
)
if activation_result and activation_result.get("activated")
else get_main_menu_inline_keyboard(
+87 -20
View File
@@ -26,7 +26,7 @@ def get_admin_panel_keyboard(i18n_instance, lang: str,
callback_data="admin_section:promo_marketing")
# Реклама
builder.button(text=_(key="admin_ads_section", default="📈 Реклама"),
builder.button(text=_(key="admin_ads_section"),
callback_data="admin_action:ads")
# Системные функции
@@ -43,7 +43,7 @@ def get_stats_monitoring_keyboard(i18n_instance, lang: str) -> InlineKeyboardMar
builder.button(text=_(key="admin_stats_button"),
callback_data="admin_action:stats")
builder.button(text=_(key="admin_view_payments_button", default="💰 Платежи"),
builder.button(text=_(key="admin_view_payments_button"),
callback_data="admin_action:view_payments")
builder.button(text=_(key="admin_view_logs_menu_button"),
callback_data="admin_action:view_logs_menu")
@@ -59,13 +59,15 @@ def get_user_management_keyboard(i18n_instance, lang: str) -> InlineKeyboardMark
builder = InlineKeyboardBuilder()
builder.button(text=_(key="admin_users_management_button"),
callback_data="admin_action:users_management")
callback_data="admin_action:users_list:0")
builder.button(text=_(key="admin_users_search_button"),
callback_data="admin_action:users_search_prompt")
builder.button(text=_(key="admin_ban_management_section"),
callback_data="admin_section:ban_management")
builder.button(text=_(key="back_to_admin_panel_button"),
callback_data="admin_action:main")
builder.adjust(2, 1)
builder.adjust(2, 1, 1)
return builder.as_markup()
@@ -123,7 +125,7 @@ def get_system_functions_keyboard(i18n_instance, lang: str) -> InlineKeyboardMar
def get_ads_menu_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.button(text=_(key="admin_ads_create_button", default=" Создать кампанию"),
builder.button(text=_(key="admin_ads_create_button"),
callback_data="admin_action:ads_create")
builder.button(text=_(key="back_to_admin_panel_button"),
callback_data="admin_action:main")
@@ -154,7 +156,7 @@ def get_ads_list_keyboard(
if current_page > 0:
row.append(
InlineKeyboardButton(
text="⬅️ " + _("prev_page_button", default="Prev"),
text="⬅️ " + _("prev_page_button"),
callback_data=f"admin_ads:page:{current_page - 1}",
)
)
@@ -167,14 +169,14 @@ def get_ads_list_keyboard(
if current_page < total_pages - 1:
row.append(
InlineKeyboardButton(
text=_("next_page_button", default="Next") + " ➡️",
text=_("next_page_button") + " ➡️",
callback_data=f"admin_ads:page:{current_page + 1}",
)
)
if row:
builder.row(*row)
builder.button(text=_(key="admin_ads_create_button", default=" Создать кампанию"),
builder.button(text=_(key="admin_ads_create_button"),
callback_data="admin_action:ads_create")
builder.button(text=_(key="back_to_admin_panel_button"),
callback_data="admin_action:main")
@@ -186,9 +188,9 @@ def get_ad_card_keyboard(i18n_instance, lang: str, campaign_id: int, back_page:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
# Dangerous action: Delete campaign
builder.button(text=_(key="admin_ads_delete_button", default="🗑 Удалить кампанию"),
builder.button(text=_(key="admin_ads_delete_button"),
callback_data=f"admin_ads:delete:{campaign_id}:{back_page}")
builder.button(text=_(key="back_to_ads_list_button", default="⬅️ К списку"),
builder.button(text=_(key="back_to_ads_list_button"),
callback_data=f"admin_ads:page:{back_page}")
builder.button(text=_(key="back_to_admin_panel_button"),
callback_data="admin_action:main")
@@ -225,12 +227,12 @@ def get_logs_pagination_keyboard(
if current_page > 0:
row_buttons.append(
InlineKeyboardButton(
text="⬅️ " + _("prev_page_button", default="Prev"),
text="⬅️ " + _("prev_page_button"),
callback_data=f"{base_callback_data}:{current_page - 1}"))
if current_page < total_pages - 1:
row_buttons.append(
InlineKeyboardButton(
text=_("next_page_button", default="Next") + " ➡️",
text=_("next_page_button") + " ➡️",
callback_data=f"{base_callback_data}:{current_page + 1}"))
if row_buttons: builder.row(*row_buttons)
@@ -305,6 +307,68 @@ def get_banned_users_keyboard(banned_users: List[User], current_page: int,
return builder.as_markup()
def get_users_list_keyboard(users: List[User], current_page: int,
total_users: int, i18n_instance, lang: str,
page_size: int = 15) -> InlineKeyboardMarkup:
"""Generate keyboard for paginated user list"""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
# Add user buttons
for user in users:
user_display_parts = []
if user.username:
user_display_parts.append(f"@{user.username}")
user_display_parts.append(f"ID: {user.user_id}")
if user.first_name:
user_display_parts.append(f"- {user.first_name}")
button_text = " ".join(user_display_parts)
builder.row(
InlineKeyboardButton(
text=button_text,
callback_data=f"admin_user_card_from_list:{user.user_id}:{current_page}"
)
)
# Pagination buttons
if total_users > page_size:
total_pages = math.ceil(total_users / page_size)
pagination_buttons = []
if current_page > 0:
pagination_buttons.append(
InlineKeyboardButton(
text=_("prev_page_button"),
callback_data=f"admin_action:users_list:{current_page - 1}"
)
)
pagination_buttons.append(
InlineKeyboardButton(
text=f"{current_page + 1}/{total_pages}",
callback_data="stub_page_display"
)
)
if current_page < total_pages - 1:
pagination_buttons.append(
InlineKeyboardButton(
text=_("next_page_button"),
callback_data=f"admin_action:users_list:{current_page + 1}"
)
)
if pagination_buttons:
builder.row(*pagination_buttons)
# Back button
builder.row(
InlineKeyboardButton(
text=_("back_to_user_management_button"),
callback_data="admin_section:user_management"
)
)
return builder.as_markup()
def get_user_card_keyboard(user_id: int,
is_banned: bool,
i18n_instance,
@@ -320,6 +384,12 @@ def get_user_card_keyboard(user_id: int,
builder.button(
text=_(key="user_card_ban_button"),
callback_data=f"admin_ban_confirm:{user_id}:{banned_list_page}")
builder.button(
text=_(
key="user_card_open_profile_button"
),
url=f"tg://user?id={user_id}"
)
builder.button(
text=_(key="user_card_back_to_banned_list_button"),
callback_data=f"admin_action:view_banned:{banned_list_page}")
@@ -347,16 +417,13 @@ def get_broadcast_confirmation_keyboard(lang: str,
# Row: target selection (all / active / inactive)
target_all_label = _(
key="broadcast_target_all_button",
default="👥 Все"
key="broadcast_target_all_button"
)
target_active_label = _(
key="broadcast_target_active_button",
default="✅ Активные"
key="broadcast_target_active_button"
)
target_inactive_label = _(
key="broadcast_target_inactive_button",
default="⌛ Неактивные"
key="broadcast_target_inactive_button"
)
# Highlight current selection with a prefix
@@ -378,9 +445,9 @@ def get_broadcast_confirmation_keyboard(lang: str,
builder.adjust(3)
# Row: confirmation
builder.button(text=_(key="confirm_broadcast_send_button", default="🚀 Отправить"),
builder.button(text=_(key="confirm_broadcast_send_button"),
callback_data="broadcast_final_action:send")
builder.button(text=_(key="cancel_broadcast_button", default="❌ Отмена"),
builder.button(text=_(key="cancel_broadcast_button"),
callback_data="broadcast_final_action:cancel")
builder.adjust(2)
return builder.as_markup()
+77 -33
View File
@@ -91,19 +91,31 @@ def get_trial_confirmation_keyboard(lang: str,
def get_subscription_options_keyboard(subscription_options: Dict[
int, Optional[int]], currency_symbol_val: str, lang: str,
i18n_instance) -> InlineKeyboardMarkup:
float, Optional[float]], currency_symbol_val: str, lang: str,
i18n_instance, traffic_mode: bool = False) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
def _format_gb(val: float) -> str:
return str(int(val)) if float(val).is_integer() else f"{val:g}"
if subscription_options:
for months, price in subscription_options.items():
if price is not None:
button_text = _("subscribe_for_months_button",
months=months,
price=price,
currency_symbol=currency_symbol_val)
if traffic_mode:
button_text = _(
"buy_traffic_package_button",
traffic_gb=_format_gb(months),
price=price,
currency_symbol=currency_symbol_val,
)
callback_data = f"subscribe_period:{_format_gb(months)}"
else:
button_text = _("subscribe_for_months_button",
months=months,
price=price,
currency_symbol=currency_symbol_val)
callback_data = f"subscribe_period:{months}"
builder.button(text=button_text,
callback_data=f"subscribe_period:{months}")
callback_data=callback_data)
builder.adjust(1)
builder.row(
InlineKeyboardButton(text=_(key="back_to_main_menu_button"),
@@ -112,26 +124,46 @@ def get_subscription_options_keyboard(subscription_options: Dict[
def get_payment_method_keyboard(months: int, price: float,
tribute_url: Optional[str],
stars_price: Optional[int],
currency_symbol_val: str, lang: str,
i18n_instance, settings: Settings) -> InlineKeyboardMarkup:
i18n_instance, settings: Settings, sale_mode: str = "subscription") -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
if settings.FREEKASSA_ENABLED:
builder.button(text=_("pay_with_sbp_button"),
callback_data=f"pay_fk:{months}:{price}")
if settings.YOOKASSA_ENABLED:
builder.button(text=_("pay_with_yookassa_button"),
callback_data=f"pay_yk:{months}:{price}")
if settings.TRIBUTE_ENABLED and tribute_url:
builder.button(text=_("pay_with_tribute_button"), url=tribute_url)
if settings.STARS_ENABLED and stars_price is not None:
builder.button(text=_("pay_with_stars_button"),
callback_data=f"pay_stars:{months}:{stars_price}")
if settings.CRYPTOPAY_ENABLED:
builder.button(text=_("pay_with_cryptopay_button"),
callback_data=f"pay_crypto:{months}:{price}")
def _format_value(val: float) -> str:
return str(int(val)) if float(val).is_integer() else f"{val:g}"
value_str = _format_value(months)
mode_suffix = f":{sale_mode}"
for method in settings.payment_methods_order:
if method == "severpay" and getattr(settings, "SEVERPAY_ENABLED", False):
builder.button(
text=_("pay_with_severpay_button"),
callback_data=f"pay_severpay:{value_str}:{price}{mode_suffix}",
)
elif method == "freekassa" and settings.FREEKASSA_ENABLED:
builder.button(
text=_("pay_with_sbp_button"),
callback_data=f"pay_fk:{value_str}:{price}{mode_suffix}",
)
elif method == "platega" and settings.PLATEGA_ENABLED:
builder.button(
text=_("pay_with_platega_button"),
callback_data=f"pay_platega:{value_str}:{price}{mode_suffix}",
)
elif method == "yookassa" and settings.YOOKASSA_ENABLED:
builder.button(
text=_("pay_with_yookassa_button"),
callback_data=f"pay_yk:{value_str}:{price}{mode_suffix}",
)
elif method == "stars" and settings.STARS_ENABLED and stars_price is not None:
builder.button(
text=_("pay_with_stars_button"),
callback_data=f"pay_stars:{value_str}:{stars_price}{mode_suffix}",
)
elif method == "cryptopay" and settings.CRYPTOPAY_ENABLED:
builder.button(
text=_("pay_with_cryptopay_button"),
callback_data=f"pay_crypto:{value_str}:{price}{mode_suffix}",
)
builder.button(text=_(key="cancel_button"),
callback_data="main_action:subscribe")
builder.adjust(1)
@@ -162,28 +194,33 @@ def get_yk_autopay_choice_keyboard(
lang: str,
i18n_instance,
has_saved_cards: bool = True,
sale_mode: str = "subscription",
) -> InlineKeyboardMarkup:
"""Keyboard for choosing between saved card charge or new card payment when auto-renew is enabled."""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
price_str = str(price)
def _format_value(val: float) -> str:
return str(int(val)) if float(val).is_integer() else f"{val:g}"
value_str = _format_value(months)
suffix = f":{sale_mode}"
if has_saved_cards:
builder.row(
InlineKeyboardButton(
text=_(key="yookassa_autopay_pay_saved_card_button"),
callback_data=f"pay_yk_saved_list:{months}:{price_str}",
callback_data=f"pay_yk_saved_list:{value_str}:{price_str}{suffix}",
)
)
builder.row(
InlineKeyboardButton(
text=_(key="yookassa_autopay_pay_new_card_button"),
callback_data=f"pay_yk_new:{months}:{price_str}",
callback_data=f"pay_yk_new:{value_str}:{price_str}{suffix}",
)
)
builder.row(
InlineKeyboardButton(
text=_(key="back_to_payment_methods_button"),
callback_data=f"subscribe_period:{months}",
callback_data=f"subscribe_period:{value_str}",
)
)
return builder.as_markup()
@@ -196,6 +233,7 @@ def get_yk_saved_cards_keyboard(
lang: str,
i18n_instance,
page: int = 0,
sale_mode: str = "subscription",
) -> InlineKeyboardMarkup:
"""Paginated keyboard for selecting a saved YooKassa card."""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
@@ -205,12 +243,16 @@ def get_yk_saved_cards_keyboard(
start = page * per_page
end = min(total, start + per_page)
price_str = str(price)
def _format_value(val: float) -> str:
return str(int(val)) if float(val).is_integer() else f"{val:g}"
value_str = _format_value(months)
suffix = f":{sale_mode}"
for method_id, title in cards[start:end]:
builder.row(
InlineKeyboardButton(
text=title,
callback_data=f"pay_yk_use_saved:{months}:{price_str}:{method_id}",
callback_data=f"pay_yk_use_saved:{value_str}:{price_str}:{method_id}{suffix}",
)
)
@@ -219,14 +261,14 @@ def get_yk_saved_cards_keyboard(
nav_buttons.append(
InlineKeyboardButton(
text="⬅️",
callback_data=f"pay_yk_saved_list:{months}:{price_str}:{page-1}",
callback_data=f"pay_yk_saved_list:{value_str}:{price_str}:{page-1}{suffix}",
)
)
if end < total:
nav_buttons.append(
InlineKeyboardButton(
text="➡️",
callback_data=f"pay_yk_saved_list:{months}:{price_str}:{page+1}",
callback_data=f"pay_yk_saved_list:{value_str}:{price_str}:{page+1}{suffix}",
)
)
if nav_buttons:
@@ -235,13 +277,13 @@ def get_yk_saved_cards_keyboard(
builder.row(
InlineKeyboardButton(
text=_(key="yookassa_autopay_pay_new_card_button"),
callback_data=f"pay_yk_new:{months}:{price_str}",
callback_data=f"pay_yk_new:{value_str}:{price_str}{suffix}",
)
)
builder.row(
InlineKeyboardButton(
text=_(key="back_to_autopay_method_choice_button"),
callback_data=f"pay_yk:{months}:{price_str}",
callback_data=f"pay_yk:{value_str}:{price_str}{suffix}",
)
)
return builder.as_markup()
@@ -333,10 +375,12 @@ def get_connect_and_main_keyboard(
i18n_instance,
settings: Settings,
config_link: Optional[str],
connect_button_url: Optional[str] = None,
preserve_message: bool = False) -> InlineKeyboardMarkup:
"""Keyboard with a connect button and a back to main menu button."""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
button_target = connect_button_url or config_link
if settings.SUBSCRIPTION_MINI_APP_URL:
builder.row(
@@ -345,9 +389,9 @@ def get_connect_and_main_keyboard(
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
)
)
elif config_link:
elif button_target:
builder.row(
InlineKeyboardButton(text=_("connect_button"), url=config_link)
InlineKeyboardButton(text=_("connect_button"), url=button_target)
)
else:
builder.row(
+3 -2
View File
@@ -33,7 +33,6 @@ from bot.services.subscription_service import SubscriptionService
from bot.services.referral_service import ReferralService
from bot.services.promo_code_service import PromoCodeService
from bot.services.stars_service import StarsService
from bot.services.tribute_service import TributeService, tribute_webhook_route
from bot.services.crypto_pay_service import CryptoPayService, cryptopay_webhook_route
from bot.handlers.user import payment as user_payment_webhook_module
@@ -200,13 +199,15 @@ async def on_shutdown_configured(dispatcher: Dispatcher):
"panel_service",
"cryptopay_service",
"freekassa_service",
"tribute_service",
"panel_webhook_service",
"yookassa_service",
"lknpd_service",
"promo_code_service",
"stars_service",
"subscription_service",
"referral_service",
"platega_service",
"severpay_service",
):
await close_service(service_key)
+8
View File
@@ -40,6 +40,14 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
if not event_user or event_user.id in self.settings.ADMIN_IDS:
return await handler(event, data)
callback_query = event.callback_query
if (
callback_query
and callback_query.data
and callback_query.data == "channel_subscription:verify"
):
return await handler(event, data)
# Allow /start to reach the handler so the check can be re-run.
message_object: Optional[Message] = event.message
if (
+73 -20
View File
@@ -17,6 +17,7 @@ from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.services.notification_service import NotificationService
from db.dal import payment_dal, user_dal
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from bot.utils.config_link import prepare_config_links
class CryptoPayService:
@@ -63,23 +64,47 @@ class CryptoPayService:
months: int,
amount: float,
description: str,
sale_mode: str = "subscription",
promo_code_service=None,
) -> Optional[str]:
if not self.configured or not self.client:
logging.error("CryptoPayService not configured")
return None
# Check for active discount to save metadata (price already discounted from previous step)
original_amount = None
discount_amount = None
promo_code_id = None
if promo_code_service:
from db.dal import active_discount_dal
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if active_discount:
# Price is already discounted, calculate original price backwards
discount_pct = active_discount.discount_percentage
original_amount = amount / (1 - discount_pct / 100)
discount_amount = original_amount - amount
promo_code_id = active_discount.promo_code_id
logging.info(
f"Recording {discount_pct}% discount for CryptoPay payment: "
f"original {original_amount:.2f} -> final {amount}"
)
# Create pending payment in DB and commit to persist
try:
payment_record = await payment_dal.create_payment_record(
session,
{
"user_id": user_id,
"amount": float(amount),
"amount": amount,
"original_amount": original_amount,
"discount_applied": discount_amount,
"currency": self.settings.CRYPTOPAY_ASSET,
"status": "pending_cryptopay",
"description": description,
"subscription_duration_months": months,
"subscription_duration_months": int(months),
"provider": "cryptopay",
"promo_code_id": promo_code_id,
},
)
await session.commit()
@@ -94,6 +119,8 @@ class CryptoPayService:
"user_id": str(user_id),
"subscription_months": str(months),
"payment_db_id": str(payment_record.payment_id),
"sale_mode": sale_mode,
"traffic_gb": str(months) if sale_mode == "traffic" else None,
})
try:
invoice = await self.client.create_invoice(
@@ -132,8 +159,10 @@ class CryptoPayService:
try:
meta = json.loads(invoice.payload)
user_id = int(meta["user_id"])
months = int(meta["subscription_months"])
months = float(meta.get("subscription_months") or 0)
payment_db_id = int(meta["payment_db_id"])
sale_mode = meta.get("sale_mode") or ("traffic" if self.settings.traffic_sale_mode else "subscription")
traffic_gb = float(meta.get("traffic_gb")) if meta.get("traffic_gb") else months
except Exception as e:
logging.error(f"Failed to parse CryptoPay payload: {e}")
return
@@ -147,6 +176,12 @@ class CryptoPayService:
async with async_session_factory() as session:
try:
# Fetch payment record to get promo_code_id
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment_record:
logging.error(f"CryptoPay: Payment record {payment_db_id} not found")
return
await payment_dal.update_provider_payment_and_status(
session,
payment_db_id,
@@ -156,18 +191,23 @@ class CryptoPayService:
activation = await subscription_service.activate_subscription(
session,
user_id,
months,
int(months) if sale_mode != "traffic" else 0,
float(invoice.amount),
payment_db_id,
promo_code_id_from_payment=payment_record.promo_code_id,
provider="cryptopay",
sale_mode=sale_mode,
traffic_gb=traffic_gb if sale_mode == "traffic" else None,
)
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
session,
user_id,
months,
current_payment_db_id=payment_db_id,
skip_if_active_before_payment=False,
)
referral_bonus = None
if sale_mode != "traffic":
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
session,
user_id,
int(months) or 1,
current_payment_db_id=payment_db_id,
skip_if_active_before_payment=False,
)
await session.commit()
except Exception as e:
await session.rollback()
@@ -179,14 +219,21 @@ class CryptoPayService:
lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
config_link = activation.get("subscription_url") or _("config_link_not_available")
raw_config_link = activation.get("subscription_url") if activation else None
display_link, button_link = await prepare_config_links(settings, raw_config_link)
config_link_text = display_link or _("config_link_not_available")
final_end = activation.get("end_date")
applied_days = 0
if referral_bonus and referral_bonus.get("referee_new_end_date"):
final_end = referral_bonus["referee_new_end_date"]
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
if applied_days:
if sale_mode == "traffic":
text = _("payment_successful_traffic_full",
traffic_gb=str(int(traffic_gb)) if float(traffic_gb).is_integer() else f"{traffic_gb:g}",
end_date=final_end.strftime('%Y-%m-%d') if final_end else "",
config_link=config_link_text)
elif applied_days:
inviter_name_display = _("friend_placeholder")
if db_user and db_user.referred_by_id:
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
@@ -197,20 +244,25 @@ class CryptoPayService:
elif inviter.username:
inviter_name_display = username_for_display(inviter.username, with_at=False)
text = _("payment_successful_with_referral_bonus_full",
months=months,
months=int(months),
base_end_date=activation["end_date"].strftime('%Y-%m-%d'),
bonus_days=applied_days,
final_end_date=final_end.strftime('%Y-%m-%d'),
inviter_name=inviter_name_display,
config_link=config_link)
config_link=config_link_text)
else:
text = _("payment_successful_full",
months=months,
end_date=final_end.strftime('%Y-%m-%d'),
config_link=config_link)
months=int(months),
end_date=final_end.strftime('%Y-%m-%d') if final_end else "",
config_link=config_link_text)
markup = get_connect_and_main_keyboard(
lang, i18n, settings, config_link, preserve_message=True
lang,
i18n,
settings,
display_link,
connect_button_url=button_link,
preserve_message=True,
)
try:
await bot.send_message(
@@ -231,7 +283,8 @@ class CryptoPayService:
user_id=user_id,
amount=float(invoice.amount),
currency=invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL,
months=months,
months=int(months) if sale_mode != "traffic" else 0,
traffic_gb=traffic_gb if sale_mode == "traffic" else None,
payment_provider="crypto_pay",
username=user.username if user else None
)
+73 -22
View File
@@ -20,6 +20,7 @@ from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.services.notification_service import NotificationService
from db.dal import payment_dal, user_dal
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from bot.utils.config_link import prepare_config_links
class FreeKassaService:
@@ -45,6 +46,7 @@ class FreeKassaService:
self.second_secret: Optional[str] = settings.FREEKASSA_SECOND_SECRET
self.default_currency: str = (settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
self.server_ip: Optional[str] = settings.FREEKASSA_PAYMENT_IP
self.payment_method_id: Optional[int] = settings.FREEKASSA_PAYMENT_METHOD_ID
self.api_base_url: str = "https://api.fk.life/v1"
self._timeout = ClientTimeout(total=15)
@@ -72,15 +74,51 @@ class FreeKassaService:
months: int,
amount: float,
currency: Optional[str],
method_code: int,
email: Optional[str] = None,
ip_address: Optional[str] = None,
payment_method_id: Optional[int] = None,
extra_params: Optional[Dict[str, Any]] = None,
promo_code_service=None,
session=None,
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
logging.error("FreeKassaService is not configured. Cannot create order.")
return False, {"message": "service_not_configured"}
# Check for active discount to save metadata (price already discounted from previous step)
original_amount = None
discount_amount = None
promo_code_id = None
if promo_code_service and session:
from db.dal import active_discount_dal
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if active_discount:
# Price is already discounted, calculate original price backwards
discount_pct = active_discount.discount_percentage
original_amount = amount / (1 - discount_pct / 100)
discount_amount = original_amount - amount
promo_code_id = active_discount.promo_code_id
logging.info(
f"Recording {discount_pct}% discount for FreeKassa payment: "
f"original {original_amount:.2f} -> final {amount}"
)
# Update payment record with discount metadata
try:
await payment_dal.update_payment_discount_info(
session,
payment_db_id,
original_amount,
discount_amount,
promo_code_id,
)
await session.commit()
except Exception as e_update:
logging.warning(
f"FreeKassa: failed to update discount metadata for payment {payment_db_id}: {e_update}"
)
ip_address = ip_address or self.server_ip
if not ip_address:
logging.error("FreeKassaService: payment IP is required but not configured.")
@@ -94,7 +132,7 @@ class FreeKassaService:
"shopId": int(self.shop_id),
"nonce": await self._generate_nonce(),
"paymentId": str(payment_db_id),
"i": int(method_code),
"i": int(payment_method_id),
"amount": amount_str,
"currency": currency_code,
"email": email,
@@ -283,23 +321,29 @@ class FreeKassaService:
)
months = payment.subscription_duration_months or 1
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
activation = await self.subscription_service.activate_subscription(
session,
payment.user_id,
months,
int(months) if sale_mode != "traffic" else 0,
float(payment.amount),
payment.payment_id,
promo_code_id_from_payment=payment.promo_code_id,
provider="freekassa",
sale_mode=sale_mode,
traffic_gb=months if sale_mode == "traffic" else None,
)
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
session,
payment.user_id,
months,
current_payment_db_id=payment.payment_id,
skip_if_active_before_payment=False,
)
referral_bonus = None
if sale_mode != "traffic":
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
session,
payment.user_id,
int(months),
current_payment_db_id=payment.payment_id,
skip_if_active_before_payment=False,
)
await session.commit()
except Exception as e:
@@ -311,12 +355,12 @@ class FreeKassaService:
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
config_link = None
final_end = None
raw_config_link = activation.get("subscription_url") if activation else None
config_link_display, connect_button_url = await prepare_config_links(self.settings, raw_config_link)
config_link_text = config_link_display or _("config_link_not_available")
final_end = activation.get("end_date") if activation else None
months = payment.subscription_duration_months or 1
if activation:
config_link = activation.get("subscription_url")
final_end = activation.get("end_date")
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
applied_days = 0
if referral_bonus and referral_bonus.get("referee_new_end_date"):
@@ -326,14 +370,19 @@ class FreeKassaService:
if not final_end and activation and activation.get("end_date"):
final_end = activation["end_date"]
if not config_link:
config_link = _("config_link_not_available")
if final_end:
end_date_str = final_end.strftime("%Y-%m-%d")
else:
end_date_str = _("config_link_not_available")
if applied_days:
traffic_label = str(int(months)) if float(months).is_integer() else f"{months:g}"
if sale_mode == "traffic":
text = _("payment_successful_traffic_full",
traffic_gb=traffic_label,
end_date=end_date_str if final_end else "",
config_link=config_link_text)
elif applied_days:
inviter_name_display = _("friend_placeholder")
if db_user and db_user.referred_by_id:
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
@@ -350,14 +399,14 @@ class FreeKassaService:
bonus_days=applied_days,
final_end_date=end_date_str,
inviter_name=inviter_name_display,
config_link=config_link,
config_link=config_link_text,
)
else:
text = _(
"payment_successful_full",
months=months,
end_date=end_date_str,
config_link=config_link,
config_link=config_link_text,
)
if provider_payment_id:
order_info_text = _(
@@ -371,7 +420,8 @@ class FreeKassaService:
lang,
self.i18n,
self.settings,
config_link,
config_link_display,
connect_button_url=connect_button_url,
preserve_message=True,
)
try:
@@ -391,7 +441,8 @@ class FreeKassaService:
user_id=payment.user_id,
amount=float(payment.amount),
currency=self.default_currency,
months=months,
months=int(months) if sale_mode != "traffic" else 0,
traffic_gb=months if sale_mode == "traffic" else None,
payment_provider="freekassa",
username=db_user.username if db_user else None,
)
+321
View File
@@ -0,0 +1,321 @@
"""
LKNPD API client for self-employed (NPD) tax receipts.
Custom implementation for lknpd.nalog.ru API.
"""
import asyncio
import logging
import uuid
from datetime import UTC, datetime
from decimal import Decimal
from enum import Enum
from typing import Any
import httpx
logger = logging.getLogger(__name__)
class PaymentType(str, Enum):
"""Payment type for income registration."""
CASH = "CASH"
WIRE = "WIRE"
class IncomeType(str, Enum):
"""Income source type."""
FROM_INDIVIDUAL = "FROM_INDIVIDUAL"
FROM_LEGAL_ENTITY = "FROM_LEGAL_ENTITY"
FROM_FOREIGN_AGENCY = "FROM_FOREIGN_AGENCY"
class LknpdApiError(Exception):
"""Base exception for LKNPD API errors."""
def __init__(self, message: str, status_code: int | None = None):
super().__init__(message)
self.status_code = status_code
class LknpdAuthError(LknpdApiError):
"""Authentication error (401)."""
pass
class LknpdValidationError(LknpdApiError):
"""Validation error (400)."""
pass
def _generate_device_id() -> str:
"""Generate device ID for API requests."""
return str(uuid.uuid4()).replace("-", "")[:21].lower()
def _format_datetime(dt: datetime) -> str:
"""Format datetime to ISO/ATOM format with Z suffix."""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
elif dt.tzinfo != UTC:
dt = dt.astimezone(UTC)
return dt.isoformat().replace("+00:00", "Z")
class LknpdClient:
"""
Async client for LKNPD (lknpd.nalog.ru) self-employed API.
Supports:
- INN + password authentication
- Token refresh
- Income registration with proper payment types (CASH/WIRE)
"""
DEFAULT_HEADERS = {
"Content-Type": "application/json",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
"Referrer": "https://lknpd.nalog.ru/auth/login",
}
DEVICE_INFO_TEMPLATE = {
"sourceType": "WEB",
"appVersion": "1.0.0",
"metaDetails": {
"userAgent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_2) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/88.0.4324.192 Safari/537.36"
)
},
}
def __init__(
self,
base_url: str = "https://lknpd.nalog.ru/api",
timeout: float = 10.0,
):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.device_id = _generate_device_id()
self._token_data: dict[str, Any] | None = None
self._refresh_lock = asyncio.Lock()
def _get_device_info(self) -> dict[str, Any]:
"""Get device info with current device ID."""
info = self.DEVICE_INFO_TEMPLATE.copy()
info["sourceDeviceId"] = self.device_id
return info
async def authenticate(self, inn: str, password: str) -> bool:
"""
Authenticate with INN and password.
Returns True if authentication was successful.
"""
request_data = {
"username": inn,
"password": password,
"deviceInfo": self._get_device_info(),
}
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/v1/auth/lkfl",
json=request_data,
headers=self.DEFAULT_HEADERS,
)
if response.status_code == 401:
raise LknpdAuthError("Invalid credentials", 401)
if response.status_code >= 400:
raise LknpdApiError(
f"Authentication failed: {response.text}",
response.status_code,
)
self._token_data = response.json()
logger.info("LKNPD authentication successful")
return True
except httpx.RequestError as e:
logger.exception("Network error during authentication")
raise LknpdApiError(f"Network error: {e}")
async def _refresh_token(self) -> bool:
"""Refresh access token using refresh token."""
async with self._refresh_lock:
if not self._token_data or "refreshToken" not in self._token_data:
return False
request_data = {
"deviceInfo": self._get_device_info(),
"refreshToken": self._token_data["refreshToken"],
}
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/v1/auth/token",
json=request_data,
headers=self.DEFAULT_HEADERS,
)
if response.status_code != 200:
return False
self._token_data = response.json()
logger.info("LKNPD token refreshed")
return True
except Exception:
logger.exception("Token refresh failed")
return False
def _get_auth_headers(self) -> dict[str, str]:
"""Get authorization headers from current token."""
if not self._token_data or "token" not in self._token_data:
return {}
return {"Authorization": f"Bearer {self._token_data['token']}"}
async def _request(
self,
method: str,
path: str,
json_data: dict[str, Any] | None = None,
retry_on_401: bool = True,
) -> httpx.Response:
"""Make authenticated API request with auto-retry on 401."""
headers = {**self.DEFAULT_HEADERS, **self._get_auth_headers()}
url = f"{self.base_url}/v1{path}"
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.request(
method,
url,
json=json_data,
headers=headers,
)
# Handle 401 with token refresh
if response.status_code == 401 and retry_on_401:
if await self._refresh_token():
headers = {**self.DEFAULT_HEADERS, **self._get_auth_headers()}
response = await client.request(
method,
url,
json=json_data,
headers=headers,
)
return response
@property
def is_authenticated(self) -> bool:
"""Check if client has valid token data."""
return self._token_data is not None and "token" in self._token_data
async def create_income(
self,
*,
name: str,
amount: Decimal | float,
quantity: Decimal | float | int = 1,
payment_type: PaymentType = PaymentType.WIRE,
income_type: IncomeType = IncomeType.FROM_INDIVIDUAL,
client_inn: str | None = None,
client_name: str | None = None,
client_phone: str | None = None,
operation_time: datetime | None = None,
) -> str | None:
"""
Register income and create receipt.
Args:
name: Service/item description
amount: Price per unit
quantity: Number of units
payment_type: CASH or WIRE (for card/bank payments)
income_type: Source type (individual, legal entity, foreign)
client_inn: Client's INN (required for legal entities)
client_name: Client's display name
client_phone: Client's phone number
operation_time: Time of operation (defaults to now)
Returns:
Receipt UUID if successful, None otherwise
"""
if not self.is_authenticated:
raise LknpdAuthError("Not authenticated")
# Prepare times
now = datetime.now(UTC)
op_time = operation_time or now
# Calculate total
amount_decimal = Decimal(str(amount))
qty_decimal = Decimal(str(quantity))
total = amount_decimal * qty_decimal
# API expects quantity as integer when it's a whole number
qty_value: int | str
if qty_decimal == qty_decimal.to_integral_value():
qty_value = int(qty_decimal)
else:
qty_value = str(qty_decimal)
# Build request
request_data = {
"operationTime": _format_datetime(op_time),
"requestTime": _format_datetime(now),
"services": [
{
"name": name,
"amount": str(amount_decimal),
"quantity": qty_value,
}
],
"totalAmount": str(total),
"client": {
"contactPhone": client_phone,
"displayName": client_name,
"incomeType": income_type.value,
"inn": client_inn,
},
"paymentType": payment_type.value,
"ignoreMaxTotalIncomeRestriction": False,
}
try:
response = await self._request("POST", "/income", json_data=request_data)
if response.status_code == 400:
logger.error("LKNPD validation error: %s", response.text)
raise LknpdValidationError(response.text, 400)
if response.status_code == 401:
raise LknpdAuthError("Authentication expired", 401)
if response.status_code >= 400:
logger.error(
"LKNPD API error: status=%d body=%s",
response.status_code,
response.text,
)
raise LknpdApiError(response.text, response.status_code)
payload = response.json()
receipt_uuid = (
payload.get("approvedReceiptUuid")
or payload.get("receiptUuid")
or payload.get("receipt_uuid")
)
if receipt_uuid:
logger.info("LKNPD receipt created: %s", receipt_uuid)
return receipt_uuid
except httpx.RequestError as e:
logger.exception("Network error creating income")
raise LknpdApiError(f"Network error: {e}")
+69
View File
@@ -0,0 +1,69 @@
import asyncio
import logging
from datetime import datetime
from typing import Optional
from .lknpd_client import LknpdClient, PaymentType, LknpdApiError
class LknpdService:
def __init__(
self,
inn: Optional[str],
password: Optional[str],
api_url: str = "https://lknpd.nalog.ru/api",
) -> None:
self.inn = inn.strip() if inn else None
self.password = password
self.configured = bool(self.inn and self.password)
self._client = LknpdClient(base_url=api_url) if self.configured else None
self._auth_lock = asyncio.Lock()
if not self.configured:
logging.warning("LKNPD credentials are missing. Receipt sending disabled.")
async def _ensure_authenticated(self) -> bool:
if not self._client:
return False
async with self._auth_lock:
if self._client.is_authenticated:
return True
try:
await self._client.authenticate(self.inn, self.password)
return True
except LknpdApiError:
logging.exception("LKNPD authentication failed.")
return False
async def create_income_receipt(
self,
*,
item_name: str,
amount: float,
quantity: float = 1.0,
operation_time: Optional[datetime] = None,
) -> Optional[str]:
if not self.configured:
return None
if not await self._ensure_authenticated():
return None
try:
receipt_uuid = await self._client.create_income(
name=item_name,
amount=amount,
quantity=quantity,
payment_type=PaymentType.WIRE,
operation_time=operation_time,
)
if not receipt_uuid:
logging.info("LKNPD receipt created without a UUID in response.")
return receipt_uuid
except LknpdApiError:
logging.exception("Failed to create LKNPD receipt.")
return None
async def close(self) -> None:
return None
+155 -64
View File
@@ -1,10 +1,11 @@
import logging
import asyncio
from aiogram import Bot
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.text_decorations import html_decoration as hd
from aiogram.exceptions import TelegramRetryAfter
from aiogram.exceptions import TelegramBadRequest
from datetime import datetime, timezone
from typing import Optional, Union, Dict, Any
from typing import Optional, Union, Dict, Any, Callable
from config.settings import Settings
from sqlalchemy.orm import sessionmaker
@@ -14,6 +15,10 @@ from bot.utils.text_sanitizer import (
display_name_or_fallback,
username_for_display,
)
from bot.utils.telegram_markup import (
is_profile_link_error,
remove_profile_link_buttons,
)
class NotificationService:
@@ -34,8 +39,43 @@ class NotificationService:
if username:
base_display = f"{base_display} ({username_for_display(username)})"
return base_display
@staticmethod
def _build_profile_keyboard(
translate: Callable[..., str],
user_id: int,
referrer_id: Optional[int] = None,
) -> InlineKeyboardMarkup:
"""Create inline keyboard with links to user (and referrer) profiles."""
buttons = [
[
InlineKeyboardButton(
text=translate(
"log_open_profile_link",
),
url=f"tg://user?id={user_id}",
)
]
]
if referrer_id:
buttons.append([
InlineKeyboardButton(
text=translate(
"log_open_referrer_profile_button",
),
url=f"tg://user?id={referrer_id}",
)
])
return InlineKeyboardMarkup(inline_keyboard=buttons)
async def _send_to_log_channel(self, message: str, thread_id: Optional[int] = None):
async def _send_to_log_channel(
self,
message: str,
thread_id: Optional[int] = None,
reply_markup: Optional[InlineKeyboardMarkup] = None,
):
"""Send message to configured log channel/group using message queue"""
if not self.settings.LOG_CHAT_ID:
return
@@ -43,13 +83,42 @@ class NotificationService:
queue_manager = get_queue_manager()
if not queue_manager:
logging.warning("Message queue manager not available, falling back to direct send")
final_thread_id = thread_id or self.settings.LOG_THREAD_ID
def _build_kwargs(markup: Optional[InlineKeyboardMarkup]) -> Dict[str, Any]:
kwargs: Dict[str, Any] = {
"chat_id": self.settings.LOG_CHAT_ID,
"text": message,
"parse_mode": "HTML",
"disable_web_page_preview": True,
}
if markup:
kwargs["reply_markup"] = markup
if final_thread_id:
kwargs["message_thread_id"] = final_thread_id
return kwargs
try:
await self.bot.send_message(
chat_id=self.settings.LOG_CHAT_ID,
text=message,
parse_mode="HTML",
disable_web_page_preview=True,
message_thread_id=thread_id or self.settings.LOG_THREAD_ID
await self.bot.send_message(**_build_kwargs(reply_markup))
except TelegramBadRequest as exc:
if is_profile_link_error(exc):
fallback_markup = remove_profile_link_buttons(reply_markup)
logging.warning(
"Telegram rejected profile buttons for log chat %s: %s. "
"Retrying without tg:// links.",
self.settings.LOG_CHAT_ID,
getattr(exc, "message", "") or str(exc),
)
try:
await self.bot.send_message(**_build_kwargs(fallback_markup))
except Exception as retry_exc:
logging.error(
"Failed to send notification without profile buttons to log "
f"channel {self.settings.LOG_CHAT_ID}: {retry_exc}"
)
return
logging.error(
f"Failed to send notification to log channel {self.settings.LOG_CHAT_ID}: {exc}"
)
except Exception as e:
logging.error(f"Failed to send notification to log channel {self.settings.LOG_CHAT_ID}: {e}")
@@ -64,6 +133,8 @@ class NotificationService:
"parse_mode": "HTML",
"disable_web_page_preview": True
}
if reply_markup:
kwargs["reply_markup"] = reply_markup
# Add thread ID for supergroups if specified
if final_thread_id:
@@ -124,26 +195,28 @@ class NotificationService:
referral_text = ""
if referred_by_id:
referral_text = _("log_referral_suffix", default=" (реферал от {referrer_id})", referrer_id=referred_by_id)
referrer_link = hd.link(str(referred_by_id), f"tg://user?id={referred_by_id}")
referral_text = _(
"log_referral_suffix",
referrer_link=referrer_link,
)
message = _(
"log_new_user_registration",
default="👤 <b>Новый пользователь</b>\n\n"
"🆔 ID: <code>{user_id}</code>\n"
"👤 Имя: {user_display}{referral_text}\n"
"📅 Время: {timestamp}",
user_id=user_id,
user_display=user_display,
referral_text=referral_text,
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
)
# Send to log channel
await self._send_to_log_channel(message)
profile_keyboard = self._build_profile_keyboard(_, user_id, referred_by_id)
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
async def notify_payment_received(self, user_id: int, amount: float, currency: str,
months: int, payment_provider: str,
username: Optional[str] = None):
username: Optional[str] = None,
traffic_gb: Optional[float] = None):
"""Send notification about successful payment"""
if not self.settings.LOG_PAYMENTS:
return
@@ -161,58 +234,89 @@ class NotificationService:
"freekassa": "💳",
"cryptopay": "",
"stars": "",
"tribute": "💎"
"platega": "💳",
"severpay": "💳",
}.get(payment_provider.lower(), "💰")
message = _(
"log_payment_received",
default="{provider_emoji} <b>Получен платеж</b>\n\n"
"👤 Пользователь: {user_display}\n"
"💰 Сумма: <b>{amount} {currency}</b>\n"
"📅 Период: <b>{months} мес.</b>\n"
"🏦 Провайдер: {payment_provider}\n"
"🕐 Время: {timestamp}",
provider_emoji=provider_emoji,
user_display=user_display,
amount=amount,
currency=currency,
months=months,
payment_provider=payment_provider,
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
)
if traffic_gb is not None:
traffic_label = str(int(traffic_gb)) if float(traffic_gb).is_integer() else f"{traffic_gb:g}"
message = _(
"log_payment_received_traffic",
provider_emoji=provider_emoji,
user_display=user_display,
amount=amount,
currency=currency,
traffic_gb=traffic_label,
payment_provider=payment_provider,
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
)
else:
message = _(
"log_payment_received",
provider_emoji=provider_emoji,
user_display=user_display,
amount=amount,
currency=currency,
months=months,
payment_provider=payment_provider,
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
)
# Send to log channel
await self._send_to_log_channel(message)
profile_keyboard = self._build_profile_keyboard(_, user_id)
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
async def notify_promo_activation(self, user_id: int, promo_code: str, bonus_days: int,
username: Optional[str] = None):
"""Send notification about promo code activation"""
if not self.settings.LOG_PROMO_ACTIVATIONS:
return
admin_lang = self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
user_display = self._format_user_display(
user_id=user_id,
username=username,
)
message = _(
"log_promo_activation",
default="🎁 <b>Активирован промокод</b>\n\n"
"👤 Пользователь: {user_display}\n"
"🏷 Код: <code>{promo_code}</code>\n"
"🎯 Бонус: <b>+{bonus_days} дн.</b>\n"
"🕐 Время: {timestamp}",
user_display=user_display,
promo_code=promo_code,
bonus_days=bonus_days,
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
)
# Send to log channel
await self._send_to_log_channel(message)
profile_keyboard = self._build_profile_keyboard(_, user_id)
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
async def notify_discount_promo_activation(self, user_id: int, promo_code: str, discount_percentage: int,
username: Optional[str] = None):
"""Send notification about discount promo code activation"""
if not self.settings.LOG_PROMO_ACTIVATIONS:
return
admin_lang = self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
user_display = self._format_user_display(
user_id=user_id,
username=username,
)
message = _(
"log_promo_discount_activation",
user_display=user_display,
promo_code=promo_code,
discount_percentage=discount_percentage,
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
)
# Send to log channel
profile_keyboard = self._build_profile_keyboard(_, user_id)
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
async def notify_trial_activation(self, user_id: int, end_date: datetime,
username: Optional[str] = None):
@@ -230,17 +334,14 @@ class NotificationService:
message = _(
"log_trial_activation",
default="🆓 <b>Активирован триал</b>\n\n"
"👤 Пользователь: {user_display}\n"
"⏰ Действует до: <b>{end_date}</b>\n"
"🕐 Время: {timestamp}",
user_display=user_display,
end_date=end_date.strftime("%Y-%m-%d %H:%M"),
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
)
# Send to log channel
await self._send_to_log_channel(message)
profile_keyboard = self._build_profile_keyboard(_, user_id)
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
async def notify_panel_sync(self, status: str, details: str,
users_processed: int, subs_synced: int,
@@ -261,12 +362,6 @@ class NotificationService:
message = _(
"log_panel_sync",
default="{status_emoji} <b>Синхронизация с панелью</b>\n\n"
"📊 Статус: <b>{status}</b>\n"
"👥 Обработано пользователей: <b>{users_processed}</b>\n"
"📋 Синхронизировано подписок: <b>{subs_synced}</b>\n"
"🕐 Время: {timestamp}\n\n"
"📝 Детали:\n{details}",
status_emoji=status_emoji,
status=status,
users_processed=users_processed,
@@ -275,7 +370,7 @@ class NotificationService:
details=details
)
# Send to log channel
# Send to log channel
await self._send_to_log_channel(message)
async def notify_suspicious_promo_attempt(
@@ -297,18 +392,14 @@ class NotificationService:
message = _(
"log_suspicious_promo",
default="⚠️ <b>Подозрительная попытка ввода промокода</b>\n\n"
"👤 Пользователь: {user_display}\n"
"🆔 ID: <code>{user_id}</code>\n"
"📝 Ввод: <pre>{suspicious_input}</pre>\n"
"🕐 Время: {timestamp}",
user_display=hd.quote(user_display),
user_id=user_id,
suspicious_input=hd.quote(suspicious_input),
timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z"))
# Send to log channel
await self._send_to_log_channel(message)
profile_keyboard = self._build_profile_keyboard(_, user_id)
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
async def send_custom_notification(self, message: str, to_admins: bool = False,
to_log_channel: bool = True, thread_id: Optional[int] = None):
+65 -12
View File
@@ -1,6 +1,7 @@
import aiohttp
import logging
import json
import re
from typing import Optional, List, Dict, Any
from datetime import datetime, timedelta, timezone
import asyncio
@@ -339,23 +340,25 @@ class PanelApiService:
default_traffic_limit_strategy: str = "NO_RESET",
hwid_device_limit: Optional[int] = None,
specific_squad_uuids: Optional[List[str]] = None,
external_squad_uuid: Optional[str] = None,
description: Optional[str] = None,
tag: Optional[str] = None,
status: str = "ACTIVE",
log_response: bool = True) -> Optional[Dict[str, Any]]:
if not (6 <= len(username_on_panel) <= 34 and
username_on_panel.replace('_', '').replace('-', '').isalnum()):
if not (username_on_panel.startswith("tg_")
and username_on_panel.split("tg_")[-1].isdigit()):
msg = f"Panel username '{username_on_panel}' does not meet panel requirements."
logging.error(msg)
return {
"error": True,
"status_code": 400,
"message": msg,
"errorCode": "VALIDATION_ERROR_USERNAME"
}
username_is_valid = (
3 <= len(username_on_panel) <= 36
and re.match(r"^[A-Za-z0-9_-]+$", username_on_panel) is not None
)
if not username_is_valid:
msg = f"Panel username '{username_on_panel}' does not meet panel requirements."
logging.error(msg)
return {
"error": True,
"status_code": 400,
"message": msg,
"errorCode": "VALIDATION_ERROR_USERNAME"
}
now = datetime.now(timezone.utc)
expire_at_dt = now + timedelta(days=default_expire_days)
@@ -383,6 +386,8 @@ class PanelApiService:
)
if specific_squad_uuids:
payload["activeInternalSquads"] = specific_squad_uuids
if external_squad_uuid:
payload["externalSquadUuid"] = external_squad_uuid
if telegram_id is not None: payload["telegramId"] = telegram_id
if email: payload["email"] = email
if description: payload["description"] = description
@@ -455,6 +460,37 @@ class PanelApiService:
)
return False
async def delete_user_from_panel(self,
user_uuid: str,
log_response: bool = True) -> bool:
"""Delete a user from the panel. Treat not-found as already deleted."""
endpoint = f"/users/{user_uuid}"
response_data = await self._request(
"DELETE", endpoint, log_full_response=log_response
)
if not response_data:
logging.error(
f"Panel API delete_user_from_panel returned no data for user {user_uuid}."
)
return False
if response_data.get("error"):
details = response_data.get("details") or {}
error_code = details.get("errorCode") or response_data.get("errorCode")
if error_code in {"A062", "A040"}:
logging.info(
f"Panel user {user_uuid} already absent (errorCode {error_code}). Treating as deleted."
)
return True
logging.error(
f"Failed to delete user {user_uuid} on panel. Response: {response_data}"
)
return False
logging.info(f"Panel user {user_uuid} deleted successfully.")
return True
async def get_subscription_link(
self,
short_uuid_or_sub_uuid: str,
@@ -526,3 +562,20 @@ class PanelApiService:
if response_data and not response_data.get("error") and "response" in response_data:
return response_data.get("response")
return None
async def encrypt_happ_link(self, link_to_encrypt: str) -> Optional[str]:
"""Encrypt a subscription link using the panel's happ crypt4 API.
Returns the encrypted link string or None if encryption failed.
"""
payload = {"linkToEncrypt": link_to_encrypt}
response_data = await self._request(
"POST",
"/system/tools/happ/encrypt",
json=payload,
log_full_response=False
)
if response_data and not response_data.get("error") and "response" in response_data:
return response_data.get("response", {}).get("encryptedLink")
logging.error(f"Failed to encrypt happ link. Response: {response_data}")
return None
+4 -131
View File
@@ -12,7 +12,6 @@ from .panel_api_service import PanelApiService
from bot.middlewares.i18n import JsonI18n
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup, get_autorenew_cancel_keyboard
from db.dal import user_dal
from bot.utils.date_utils import add_months
EVENT_MAP = {
"user.expires_in_72_hours": (3, "subscription_72h_notification"),
@@ -44,128 +43,6 @@ class PanelWebhookService:
except Exception as e:
logging.error(f"Failed to send notification to {user_id}: {e}")
async def _handle_expired_subscription(self, session, user_id: int, user_payload: dict,
lang: str, markup, first_name: str) -> bool:
"""Handle expired subscription - auto-renew tribute users if no cancellation was received.
Returns True if an auto-renewal was performed (and renewal message sent), False otherwise.
"""
from db.dal import subscription_dal, payment_dal
from datetime import datetime, timezone
try:
auto_renewed = False
# Check if user has tribute subscriptions that weren't cancelled
user_subs = await subscription_dal.get_active_subscriptions_for_user(session, user_id)
for sub in user_subs:
# Check if this subscription was marked as cancelled (from tribute cancellation webhook)
if sub.status_from_panel == 'CANCELLED':
logging.info(f"Subscription {sub.subscription_id} for user {user_id} was cancelled, skipping auto-renewal")
continue
# Check if this user has tribute payments
last_tribute_duration = await payment_dal.get_last_tribute_payment_duration(session, user_id)
if last_tribute_duration is not None:
# This user has tribute payments, auto-renew for the same duration
logging.info(f"Auto-renewing tribute subscription for user {user_id} for {last_tribute_duration} months")
# Extend subscription by the last payment duration (calendar months)
new_end_date = add_months(datetime.now(timezone.utc), last_tribute_duration)
# Update local DB subscription
await subscription_dal.update_subscription(
session,
sub.subscription_id,
{
'end_date': new_end_date,
'status_from_panel': 'ACTIVE',
'is_active': True
}
)
# Update panel expiry to ensure actual service access is extended
try:
panel_payload = {
"uuid": sub.panel_user_uuid,
"expireAt": new_end_date.isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
"status": "ACTIVE",
}
panel_update_resp = await self.panel_service.update_user_details_on_panel(
sub.panel_user_uuid,
panel_payload,
log_response=True,
)
if panel_update_resp:
logging.info(
f"Panel expiry updated for user {user_id} (panel_uuid {sub.panel_user_uuid}) to {new_end_date}"
)
except Exception as e_panel:
logging.error(
f"Failed to update panel expiry for user {user_id} (panel_uuid {sub.panel_user_uuid}): {e_panel}")
# Create a succeeded payment record in DB with the same amount/currency as last tribute payment
try:
last_payment = await payment_dal.get_last_tribute_payment(session, user_id)
if last_payment and last_payment.amount and last_payment.currency:
provider_payment_id = (
f"tribute_auto_{user_id}_{sub.subscription_id}_"
f"{new_end_date.strftime('%Y%m%d')}"
)
created_payment = await payment_dal.ensure_payment_with_provider_id(
session,
user_id=user_id,
amount=float(last_payment.amount),
currency=last_payment.currency,
months=last_tribute_duration,
description="Auto-renewal (panel webhook)",
provider="tribute",
provider_payment_id=provider_payment_id,
)
if created_payment:
logging.info(
f"Auto-renew payment recorded (id={created_payment.payment_id}) for user {user_id} amount={created_payment.amount} {created_payment.currency} months={last_tribute_duration}"
)
else:
logging.warning(
f"Could not create auto-renew payment for user {user_id}: previous tribute payment not found or missing amount/currency")
except Exception as e_pay:
logging.error(
f"Failed to create auto-renew payment record for user {user_id}: {e_pay}",
exc_info=True,
)
# Send auto-renewal notification
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
auto_renewal_msg = _(
"tribute_auto_renewal",
default="🔄 <b>Подписка автоматически продлена</b>\n\n"
"Ваша подписка Tribute была автоматически продлена на {months} мес.\n"
"Новая дата окончания: {end_date}",
user_name=first_name,
months=last_tribute_duration,
end_date=new_end_date.strftime('%Y-%m-%d')
)
try:
await self.bot.send_message(
user_id,
auto_renewal_msg,
reply_markup=markup,
parse_mode="HTML"
)
auto_renewed = True
except Exception as e:
logging.error(f"Failed to send auto-renewal notification to user {user_id}: {e}")
await session.commit()
return auto_renewed
except Exception as e:
logging.error(f"Error handling expired subscription for user {user_id}: {e}")
await session.rollback()
return False
async def handle_event(self, event_name: str, user_payload: dict):
telegram_id = user_payload.get("telegramId")
if not telegram_id:
@@ -193,7 +70,7 @@ class PanelWebhookService:
async with self.async_session_factory() as session:
from db.dal import subscription_dal
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
if sub and sub.auto_renew_enabled and sub.provider != 'tribute':
if sub and sub.auto_renew_enabled and sub.provider == 'yookassa':
try:
ok = await subscription_service.charge_subscription_renewal(session, sub)
# If initiation succeeded, suppress the 24h reminder by returning early
@@ -208,7 +85,7 @@ class PanelWebhookService:
except Exception:
logging.exception("Auto-renew trigger (24h) failed pre-check")
if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE:
# For 48h event, if auto-renew is enabled and not tribute, show special notice with cancel button
# For 48h event, if auto-renew is enabled, show special notice with cancel button
if days_left == 2:
async with self.async_session_factory() as session:
from db.dal import subscription_dal
@@ -220,7 +97,7 @@ class PanelWebhookService:
getattr(sub, 'auto_renew_enabled', None) if sub else None,
getattr(sub, 'provider', None) if sub else None,
)
if sub and sub.auto_renew_enabled and sub.provider != 'tribute':
if sub and sub.auto_renew_enabled and sub.provider == 'yookassa':
cancel_kb = get_autorenew_cancel_keyboard(lang, self.i18n)
await self._send_message(
user_id,
@@ -239,11 +116,7 @@ class PanelWebhookService:
end_date=user_payload.get("expireAt", "")[:10],
)
elif event_name == "user.expired":
# Check if this is a tribute user that should be auto-renewed (regardless of notification settings)
auto_renewed = await self._handle_expired_subscription(session, user_id, user_payload, lang, markup, first_name)
# If auto-renewed via Tribute, suppress expiration notification. Otherwise, send it if enabled.
if not auto_renewed and self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
await self._send_message(
user_id,
lang,
+374
View File
@@ -0,0 +1,374 @@
import json
import logging
from decimal import Decimal, ROUND_HALF_UP
from typing import Optional, Dict, Any, Tuple
from aiohttp import ClientSession, ClientTimeout, web
from aiogram import Bot
from sqlalchemy.orm import sessionmaker
from config.settings import Settings
from bot.middlewares.i18n import JsonI18n
from bot.services.subscription_service import SubscriptionService
from bot.services.referral_service import ReferralService
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.services.notification_service import NotificationService
from db.dal import payment_dal, user_dal
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from bot.utils.config_link import prepare_config_links
class PlategaService:
def __init__(
self,
*,
bot: Bot,
settings: Settings,
i18n: JsonI18n,
async_session_factory: sessionmaker,
subscription_service: SubscriptionService,
referral_service: ReferralService,
default_return_url: str,
):
self.bot = bot
self.settings = settings
self.i18n = i18n
self.async_session_factory = async_session_factory
self.subscription_service = subscription_service
self.referral_service = referral_service
self.base_url = (settings.PLATEGA_BASE_URL or "https://app.platega.io").rstrip("/")
self.merchant_id = settings.PLATEGA_MERCHANT_ID
self.secret = settings.PLATEGA_SECRET
self.payment_method = settings.PLATEGA_PAYMENT_METHOD
self.return_url = settings.PLATEGA_RETURN_URL or f"https://t.me/{default_return_url}"
self.failed_url = settings.PLATEGA_FAILED_URL or self.return_url
self._timeout = ClientTimeout(total=20)
self._session: Optional[ClientSession] = None
self._auth_headers = {
"X-MerchantId": self.merchant_id or "",
"X-Secret": self.secret or "",
"Content-Type": "application/json",
}
self.configured: bool = bool(
settings.PLATEGA_ENABLED and self.merchant_id and self.secret
)
if not self.configured:
logging.warning("PlategaService initialized but not fully configured. Payments disabled.")
async def _get_session(self) -> ClientSession:
if self._session is None or self._session.closed:
self._session = ClientSession(timeout=self._timeout)
return self._session
async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
async def create_transaction(
self,
*,
payment_db_id: int,
user_id: int,
months: int,
amount: float,
currency: Optional[str],
description: str,
payload: Optional[str] = None,
promo_code_service=None,
session=None,
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
logging.error("PlategaService is not configured. Cannot create transaction.")
return False, {"message": "service_not_configured"}
# Check for active discount to save metadata (price already discounted from previous step)
original_amount = None
discount_amount = None
promo_code_id = None
if promo_code_service and session:
from db.dal import active_discount_dal
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if active_discount:
# Price is already discounted, calculate original price backwards
discount_pct = active_discount.discount_percentage
original_amount = amount / (1 - discount_pct / 100)
discount_amount = original_amount - amount
promo_code_id = active_discount.promo_code_id
logging.info(
f"Recording {discount_pct}% discount for Platega payment: "
f"original {original_amount:.2f} -> final {amount}"
)
# Update payment record with discount metadata
try:
await payment_dal.update_payment_discount_info(
session,
payment_db_id,
original_amount,
discount_amount,
promo_code_id,
)
await session.commit()
except Exception as e_update:
logging.warning(
f"Platega: failed to update discount metadata for payment {payment_db_id}: {e_update}"
)
http_session = await self._get_session()
url = f"{self.base_url}/transaction/process"
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
body: Dict[str, Any] = {
"paymentMethod": int(self.payment_method),
"paymentDetails": {"amount": float(amount), "currency": currency_code},
"description": description,
"return": self.return_url,
"failedUrl": self.failed_url,
"payload": payload,
}
# Remove optional keys with falsy values to avoid validation errors
clean_body = {k: v for k, v in body.items() if v not in (None, "")}
try:
async with http_session.post(url, json=clean_body, headers=self._auth_headers) as response:
response_text = await response.text()
try:
response_data = json.loads(response_text) if response_text else {}
except json.JSONDecodeError:
logging.error("Platega create_transaction: invalid JSON response: %s", response_text)
return False, {
"status": response.status,
"message": "invalid_json",
"raw": response_text,
}
if response.status != 200:
logging.error(
"Platega create_transaction: API returned error (status=%s, body=%s)",
response.status,
response_data,
)
return False, {"status": response.status, "message": response_data}
return True, response_data
except Exception as exc:
logging.error("Platega create_transaction: request failed: %s", exc, exc_info=True)
return False, {"message": str(exc)}
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured:
return web.Response(status=503, text="platega_disabled")
try:
data = await request.json()
except Exception as exc:
logging.error("Platega webhook: failed to parse JSON: %s", exc)
return web.Response(status=400, text="bad_request")
header_merchant = request.headers.get("X-MerchantId")
header_secret = request.headers.get("X-Secret")
if header_merchant != self.merchant_id or header_secret != self.secret:
logging.error("Platega webhook: invalid auth headers")
return web.Response(status=403, text="forbidden")
transaction_id = str(data.get("id") or data.get("transactionId") or "").strip()
status = str(data.get("status") or "").upper()
amount_raw = data.get("amount")
currency = data.get("currency") or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
if not transaction_id or not status:
logging.error("Platega webhook: missing transaction id or status in payload: %s", data)
return web.Response(status=400, text="missing_fields")
async with self.async_session_factory() as session:
payment = await payment_dal.get_payment_by_provider_payment_id(session, transaction_id)
if not payment:
logging.error("Platega webhook: payment not found for transaction %s", transaction_id)
return web.Response(status=404, text="payment_not_found")
if payment.status == "succeeded" and status == "CONFIRMED":
return web.Response(text="ok")
payment_months = payment.subscription_duration_months or 1
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
if status == "CONFIRMED":
if amount_raw is not None:
try:
incoming_amount = Decimal(str(amount_raw)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
expected_amount = Decimal(str(payment.amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
if incoming_amount != expected_amount:
logging.warning(
"Platega webhook: amount mismatch for payment %s (expected %s, got %s)",
payment.payment_id,
expected_amount,
incoming_amount,
)
except Exception as exc:
logging.warning("Platega webhook: failed to compare amounts for %s: %s", payment.payment_id, exc)
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
transaction_id,
"succeeded",
)
activation = await self.subscription_service.activate_subscription(
session,
payment.user_id,
int(payment_months) if sale_mode != "traffic" else 0,
float(payment.amount),
payment.payment_id,
promo_code_id_from_payment=payment.promo_code_id,
provider="platega",
sale_mode=sale_mode,
traffic_gb=payment_months if sale_mode == "traffic" else None,
)
referral_bonus = None
if sale_mode != "traffic":
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
session,
payment.user_id,
int(payment_months),
current_payment_db_id=payment.payment_id,
skip_if_active_before_payment=False,
)
await session.commit()
except Exception as exc:
await session.rollback()
logging.error("Platega webhook: failed to process payment %s: %s", transaction_id, exc, exc_info=True)
return web.Response(status=500, text="processing_error")
db_user = await user_dal.get_user_by_id(session, payment.user_id)
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
raw_config_link = activation.get("subscription_url") if activation else None
config_link_display, connect_button_url = await prepare_config_links(self.settings, raw_config_link)
config_link_text = config_link_display or _("config_link_not_available")
final_end = activation.get("end_date") if activation else None
applied_days = 0
applied_promo_days = activation.get("applied_promo_bonus_days", 0) if activation else 0
if referral_bonus and referral_bonus.get("referee_new_end_date"):
final_end = referral_bonus["referee_new_end_date"]
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
traffic_label = str(int(payment_months)) if float(payment_months).is_integer() else f"{payment_months:g}"
if sale_mode == "traffic":
text = _(
"payment_successful_traffic_full",
traffic_gb=traffic_label,
end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
config_link=config_link_text,
)
elif applied_days:
inviter_name_display = _("friend_placeholder")
if db_user and db_user.referred_by_id:
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
if inviter:
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
if safe_name:
inviter_name_display = safe_name
elif inviter.username:
inviter_name_display = username_for_display(inviter.username, with_at=False)
text = _(
"payment_successful_with_referral_bonus_full",
months=payment_months,
base_end_date=activation["end_date"].strftime("%Y-%m-%d") if activation and activation.get("end_date") else final_end.strftime("%Y-%m-%d") if final_end else "",
bonus_days=applied_days,
final_end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
inviter_name=inviter_name_display,
config_link=config_link_text,
)
elif applied_promo_days and final_end:
text = _(
"payment_successful_with_promo_full",
months=payment_months,
bonus_days=applied_promo_days,
end_date=final_end.strftime("%Y-%m-%d"),
config_link=config_link_text,
)
else:
text = _(
"payment_successful_full",
months=payment_months,
end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
config_link=config_link_text,
)
markup = get_connect_and_main_keyboard(
lang,
self.i18n,
self.settings,
config_link_display,
connect_button_url=connect_button_url,
preserve_message=True,
)
try:
await self.bot.send_message(
payment.user_id,
text,
reply_markup=markup,
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as exc:
logging.error("Platega webhook: failed to notify user %s: %s", payment.user_id, exc)
try:
notification_service = NotificationService(self.bot, self.settings, self.i18n)
await notification_service.notify_payment_received(
user_id=payment.user_id,
amount=float(payment.amount),
currency=currency,
months=int(payment_months) if sale_mode != "traffic" else 0,
traffic_gb=payment_months if sale_mode == "traffic" else None,
payment_provider="platega",
username=db_user.username if db_user else None,
)
except Exception as exc:
logging.error("Platega webhook: failed to notify admins: %s", exc)
return web.Response(text="ok")
if status in {"CANCELED", "CANCELLED", "CHARGEBACKED"}:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
transaction_id,
"canceled",
)
await session.commit()
except Exception as exc:
await session.rollback()
logging.error("Platega webhook: failed to cancel payment %s: %s", transaction_id, exc)
return web.Response(status=500, text="processing_error")
db_user = await user_dal.get_user_by_id(session, payment.user_id)
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
try:
await self.bot.send_message(payment.user_id, _("payment_failed"))
except Exception:
pass
return web.Response(text="ok_canceled")
logging.warning("Platega webhook: unhandled status '%s' for transaction %s", status, transaction_id)
return web.Response(status=202, text="status_ignored")
async def platega_webhook_route(request: web.Request) -> web.Response:
service: PlategaService = request.app["platega_service"]
return await service.webhook_route(request)
+163 -3
View File
@@ -1,12 +1,12 @@
import logging
from datetime import datetime
from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional, Tuple, Dict
from aiogram import Bot
from config.settings import Settings
from db.dal import promo_code_dal, user_dal
from db.dal import promo_code_dal, user_dal, active_discount_dal
from db.models import PromoCode, User
from .subscription_service import SubscriptionService
@@ -34,7 +34,7 @@ class PromoCodeService:
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
code_input_upper = code_input.strip().upper()
promo_data = await promo_code_dal.get_active_promo_code_by_code_str(
promo_data = await promo_code_dal.get_active_bonus_promo_code_by_code_str(
session, code_input_upper)
if not promo_data:
@@ -83,3 +83,163 @@ class PromoCodeService:
return False, _("error_applying_promo_bonus")
else:
return False, _("error_applying_promo_bonus")
async def apply_discount_promo_code(
self,
session: AsyncSession,
user_id: int,
code_input: str,
user_lang: str,
) -> Tuple[bool, int | str]:
"""
Apply a discount promo code (sets active discount for user).
Returns: (success: bool, discount_percentage or error_message)
"""
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
code_input_upper = code_input.strip().upper()
# Check if user already has an active discount
existing_discount = await active_discount_dal.get_active_discount(session, user_id)
if existing_discount:
# Get the promo code for the existing discount
existing_promo = await promo_code_dal.get_promo_code_by_id(
session, existing_discount.promo_code_id
)
if existing_promo:
return False, _("discount_promo_already_active",
code=existing_promo.code,
discount_pct=existing_discount.discount_percentage)
else:
# Existing discount but promo not found - clear it and continue
await active_discount_dal.clear_active_discount(session, user_id)
# Get discount promo code
promo_data = await promo_code_dal.get_active_discount_promo_code_by_code_str(
session, code_input_upper
)
if not promo_data:
return False, _("promo_code_not_found_or_not_discount", code=code_input_upper)
# Check if user already used this code
existing_activation = await promo_code_dal.get_user_activation_for_promo(
session, promo_data.promo_code_id, user_id
)
if existing_activation:
return False, _("promo_code_already_used_by_user", code=code_input_upper)
# Set active discount
active_discount = await active_discount_dal.set_active_discount(
session,
user_id=user_id,
promo_code_id=promo_data.promo_code_id,
discount_percentage=promo_data.discount_percentage
)
if not active_discount:
# This shouldn't happen since we checked above, but just in case
return False, _("error_applying_promo_discount")
logging.info(
f"Discount promo code {code_input_upper} activated for user {user_id}: "
f"{promo_data.discount_percentage}% off"
)
return True, promo_data.discount_percentage
async def get_user_active_discount(
self,
session: AsyncSession,
user_id: int
) -> Optional[Tuple[int, str]]:
"""
Get user's active discount if any.
Returns: (discount_percentage, promo_code) or None
"""
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if not active_discount:
return None
# Fetch promo code for code string
promo = await promo_code_dal.get_promo_code_by_id(
session, active_discount.promo_code_id
)
if not promo:
# Discount exists but promo not found - clear it
await active_discount_dal.clear_active_discount(session, user_id)
return None
# Check if promo code has expired
if promo.valid_until and promo.valid_until <= datetime.now(timezone.utc):
# Promo code expired - clear the discount
logging.info(
f"Promo code {promo.code} expired (valid_until: {promo.valid_until}). "
f"Clearing active discount for user {user_id}"
)
await active_discount_dal.clear_active_discount(session, user_id)
return None
return (active_discount.discount_percentage, promo.code)
def calculate_discounted_price(
self,
original_price: float,
discount_percentage: int
) -> Tuple[float, float]:
"""
Calculate discounted price and discount amount.
Returns: (final_price, discount_amount)
"""
discount_amount = round(original_price * (discount_percentage / 100), 2)
final_price = round(original_price - discount_amount, 2)
# Ensure price doesn't go negative
if final_price < 0:
final_price = 0
discount_amount = original_price
return final_price, discount_amount
async def consume_discount(
self,
session: AsyncSession,
user_id: int,
payment_id: int
) -> bool:
"""
Consume active discount: record activation, increment usage, clear active discount.
Call this AFTER successful payment.
"""
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if not active_discount:
return False
# Record activation
activation_recorded = await promo_code_dal.record_promo_activation(
session,
active_discount.promo_code_id,
user_id,
payment_id=payment_id
)
# Increment usage
promo_incremented = await promo_code_dal.increment_promo_code_usage(
session,
active_discount.promo_code_id
)
# Clear active discount
await active_discount_dal.clear_active_discount(session, user_id)
if activation_recorded and promo_incremented:
await session.flush()
logging.info(
f"Discount consumed for user {user_id}, promo {active_discount.promo_code_id}, "
f"payment {payment_id}"
)
return True
else:
logging.error(
f"Failed to consume discount for user {user_id}, "
f"promo {active_discount.promo_code_id}"
)
return False
+31 -3
View File
@@ -177,6 +177,8 @@ class ReferralService:
"ACTIVE_BONUS",
"traffic_limit_bytes":
self.settings.user_traffic_limit_bytes,
"auto_renew_enabled":
False,
}
try:
await subscription_dal.deactivate_other_active_subscriptions(
@@ -255,9 +257,35 @@ class ReferralService:
raise
def generate_referral_link(self, bot_username: str,
inviter_user_id: int) -> str:
return f"https://t.me/{bot_username}?start=ref_{inviter_user_id}"
async def generate_referral_link(self, session: AsyncSession,
bot_username: str,
inviter_user_id: int) -> Optional[str]:
try:
user = await user_dal.get_user_by_id(session, inviter_user_id)
if not user:
logging.warning(
"Unable to generate referral link: user %s not found.",
inviter_user_id,
)
return None
referral_code = await user_dal.ensure_referral_code(session, user)
if not referral_code:
logging.warning(
"User %s has no referral code even after regeneration attempt.",
inviter_user_id,
)
return None
return f"https://t.me/{bot_username}?start=ref_u{referral_code}"
except Exception as exc:
logging.error(
"Failed to generate referral link for user %s: %s",
inviter_user_id,
exc,
exc_info=True,
)
return None
async def get_referral_stats(self, session: AsyncSession, user_id: int) -> dict:
"""Get referral statistics for a user"""
+406
View File
@@ -0,0 +1,406 @@
import json
import logging
import secrets
import hmac
import hashlib
from decimal import Decimal, ROUND_HALF_UP
from typing import Optional, Dict, Any, Tuple
from aiohttp import ClientSession, ClientTimeout, web
from aiogram import Bot
from sqlalchemy.orm import sessionmaker
from config.settings import Settings
from bot.middlewares.i18n import JsonI18n
from bot.services.subscription_service import SubscriptionService
from bot.services.referral_service import ReferralService
from bot.services.notification_service import NotificationService
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from db.dal import payment_dal, user_dal
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from bot.utils.config_link import prepare_config_links
class SeverPayService:
def __init__(
self,
*,
bot: Bot,
settings: Settings,
i18n: JsonI18n,
async_session_factory: sessionmaker,
subscription_service: SubscriptionService,
referral_service: ReferralService,
default_return_url: str,
):
self.bot = bot
self.settings = settings
self.i18n = i18n
self.async_session_factory = async_session_factory
self.subscription_service = subscription_service
self.referral_service = referral_service
self.base_url = (settings.SEVERPAY_BASE_URL or "https://severpay.io/api/merchant").rstrip("/")
self.mid = settings.SEVERPAY_MID
self.token = settings.SEVERPAY_TOKEN or ""
self.return_url = settings.SEVERPAY_RETURN_URL or f"https://t.me/{default_return_url}"
self.lifetime_minutes = settings.SEVERPAY_LIFETIME_MINUTES
self._timeout = ClientTimeout(total=15)
self._session: Optional[ClientSession] = None
self.configured: bool = bool(settings.SEVERPAY_ENABLED and self.mid and self.token)
if not self.configured:
logging.warning("SeverPayService initialized but not fully configured. Payments disabled.")
async def _get_session(self) -> ClientSession:
if self._session is None or self._session.closed:
self._session = ClientSession(timeout=self._timeout)
return self._session
async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
@staticmethod
def _format_amount(amount: float) -> str:
quantized = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
return f"{quantized:.2f}"
def _sign_payload(self, payload: Dict[str, Any]) -> str:
message = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
return hmac.new(self.token.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
def _build_signed_body(self, extra: Dict[str, Any]) -> Dict[str, Any]:
body: Dict[str, Any] = {
"mid": self.mid,
"salt": secrets.token_hex(8),
}
body.update(extra)
sorted_body = dict(sorted(body.items()))
sorted_body["sign"] = self._sign_payload(sorted_body)
return sorted_body
def _validate_signature(self, payload: Dict[str, Any]) -> bool:
provided_sign = str(payload.get("sign") or "")
if not provided_sign or not self.token:
return False
# Webhook signatures are calculated on the original payload order (without sorting).
data = {k: v for k, v in payload.items() if k != "sign"}
expected_sign = self._sign_payload(data)
return hmac.compare_digest(provided_sign, expected_sign)
async def create_payment(
self,
*,
payment_db_id: int,
user_id: int,
months: int,
amount: float,
currency: Optional[str],
description: str,
promo_code_service=None,
session=None,
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
logging.error("SeverPayService is not configured. Cannot create payment.")
return False, {"message": "service_not_configured"}
# Check for active discount to save metadata (price already discounted from previous step)
original_amount = None
discount_amount = None
promo_code_id = None
if promo_code_service and session:
from db.dal import active_discount_dal
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if active_discount:
# Price is already discounted, calculate original price backwards
discount_pct = active_discount.discount_percentage
original_amount = amount / (1 - discount_pct / 100)
discount_amount = original_amount - amount
promo_code_id = active_discount.promo_code_id
logging.info(
f"Recording {discount_pct}% discount for SeverPay payment: "
f"original {original_amount:.2f} -> final {amount}"
)
# Update payment record with discount metadata
try:
await payment_dal.update_payment_discount_info(
session,
payment_db_id,
original_amount,
discount_amount,
promo_code_id,
)
await session.commit()
except Exception as e_update:
logging.warning(
f"SeverPay: failed to update discount metadata for payment {payment_db_id}: {e_update}"
)
http_session = await self._get_session()
url = f"{self.base_url}/payin/create"
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
amount_str = self._format_amount(amount)
body = {
"order_id": str(payment_db_id),
"amount": amount_str,
"currency": currency_code,
"client_email": f"{user_id}@telegram.org",
"client_id": str(user_id),
"url_return": self.return_url,
}
if self.lifetime_minutes:
body["lifetime"] = int(self.lifetime_minutes)
signed_body = self._build_signed_body(body)
try:
async with http_session.post(url, json=signed_body) as response:
response_text = await response.text()
try:
response_data = json.loads(response_text) if response_text else {}
except json.JSONDecodeError:
logging.error("SeverPay create_payment: invalid JSON response: %s", response_text)
return False, {"status": response.status, "message": "invalid_json", "raw": response_text}
if response.status != 200 or not response_data.get("status"):
logging.error(
"SeverPay create_payment: API returned error (status=%s, body=%s)",
response.status,
response_data,
)
return False, {"status": response.status, "message": response_data}
return True, response_data.get("data") or response_data
except Exception as exc:
logging.error("SeverPay create_payment: request failed: %s", exc, exc_info=True)
return False, {"message": str(exc)}
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured:
return web.json_response({"status": False, "msg": "severpay_disabled"}, status=503)
try:
payload = await request.json()
except Exception as exc:
logging.error("SeverPay webhook: failed to parse JSON: %s", exc)
return web.json_response({"status": False, "msg": "bad_request"}, status=400)
if not isinstance(payload, dict) or not self._validate_signature(payload):
logging.error("SeverPay webhook: invalid signature or payload.")
return web.json_response({"status": False, "msg": "invalid_signature"}, status=403)
event_type = str(payload.get("type") or "").lower()
data = payload.get("data") or {}
if event_type != "payin" or not isinstance(data, dict):
logging.warning("SeverPay webhook: unsupported event type '%s'", event_type)
return web.json_response({"status": True})
provider_payment_id = str(data.get("id") or data.get("uid") or "")
order_id_raw = data.get("order_id")
status = str(data.get("status") or "").lower()
payment_db_id: Optional[int] = None
try:
if isinstance(order_id_raw, int):
payment_db_id = order_id_raw
elif isinstance(order_id_raw, str) and order_id_raw.isdigit():
payment_db_id = int(order_id_raw)
except Exception:
payment_db_id = None
async with self.async_session_factory() as session:
payment = None
if payment_db_id is not None:
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment and provider_payment_id:
payment = await payment_dal.get_payment_by_provider_payment_id(session, provider_payment_id)
if not payment:
logging.error("SeverPay webhook: payment not found (order_id=%s, provider_id=%s)", order_id_raw, provider_payment_id)
return web.json_response({"status": False, "msg": "payment_not_found"}, status=404)
payment_months = payment.subscription_duration_months or 1
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
if status == "success":
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
provider_payment_id or str(payment.payment_id),
"succeeded",
)
activation = await self.subscription_service.activate_subscription(
session,
payment.user_id,
int(payment_months) if sale_mode != "traffic" else 0,
float(payment.amount),
payment.payment_id,
promo_code_id_from_payment=payment.promo_code_id,
provider="severpay",
sale_mode=sale_mode,
traffic_gb=payment_months if sale_mode == "traffic" else None,
)
referral_bonus = None
if sale_mode != "traffic":
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
session,
payment.user_id,
int(payment_months),
current_payment_db_id=payment.payment_id,
skip_if_active_before_payment=False,
)
await session.commit()
except Exception as exc:
await session.rollback()
logging.error("SeverPay webhook: failed to process payment %s: %s", provider_payment_id, exc, exc_info=True)
return web.json_response({"status": False, "msg": "processing_error"}, status=500)
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
raw_config_link = activation.get("subscription_url") if activation else None
config_link_display, connect_button_url = await prepare_config_links(self.settings, raw_config_link)
config_link_text = config_link_display or _("config_link_not_available")
final_end = activation.get("end_date") if activation else None
applied_days = 0
applied_promo_days = activation.get("applied_promo_bonus_days", 0) if activation else 0
if referral_bonus and referral_bonus.get("referee_new_end_date"):
final_end = referral_bonus["referee_new_end_date"]
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
traffic_label = str(int(payment_months)) if float(payment_months).is_integer() else f"{payment_months:g}"
if sale_mode == "traffic":
text = _(
"payment_successful_traffic_full",
traffic_gb=traffic_label,
end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
config_link=config_link_text,
)
elif applied_days:
inviter_name_display = _("friend_placeholder")
if db_user and db_user.referred_by_id:
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
if inviter:
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
if safe_name:
inviter_name_display = safe_name
elif inviter.username:
inviter_name_display = username_for_display(inviter.username, with_at=False)
text = _(
"payment_successful_with_referral_bonus_full",
months=payment_months,
base_end_date=activation["end_date"].strftime("%Y-%m-%d") if activation and activation.get("end_date") else final_end.strftime("%Y-%m-%d") if final_end else "",
bonus_days=applied_days,
final_end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
inviter_name=inviter_name_display,
config_link=config_link_text,
)
elif applied_promo_days and final_end:
text = _(
"payment_successful_with_promo_full",
months=payment_months,
bonus_days=applied_promo_days,
end_date=final_end.strftime("%Y-%m-%d"),
config_link=config_link_text,
)
else:
text = _(
"payment_successful_full",
months=payment_months,
end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
config_link=config_link_text,
)
markup = get_connect_and_main_keyboard(
lang,
self.i18n,
self.settings,
config_link_display,
connect_button_url=connect_button_url,
preserve_message=True,
)
try:
await self.bot.send_message(
payment.user_id,
text,
reply_markup=markup,
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as exc:
logging.error("SeverPay webhook: failed to notify user %s: %s", payment.user_id, exc)
try:
notification_service = NotificationService(self.bot, self.settings, self.i18n)
await notification_service.notify_payment_received(
user_id=payment.user_id,
amount=float(payment.amount),
currency=payment.currency,
months=int(payment_months) if sale_mode != "traffic" else 0,
traffic_gb=payment_months if sale_mode == "traffic" else None,
payment_provider="severpay",
username=db_user.username if db_user else None,
)
except Exception as exc:
logging.error("SeverPay webhook: failed to notify admins: %s", exc)
return web.json_response({"status": True})
if status in {"fail", "decline"}:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
provider_payment_id or str(payment.payment_id),
"failed",
)
await session.commit()
except Exception as exc:
await session.rollback()
logging.error("SeverPay webhook: failed to mark payment %s as failed: %s", provider_payment_id, exc)
return web.json_response({"status": False, "msg": "processing_error"}, status=500)
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
try:
await self.bot.send_message(payment.user_id, _("payment_failed"))
except Exception:
pass
return web.json_response({"status": True})
if status in {"process", "new"}:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
provider_payment_id or str(payment.payment_id),
"pending_severpay",
)
await session.commit()
except Exception as exc:
await session.rollback()
logging.error("SeverPay webhook: failed to update pending status for %s: %s", provider_payment_id, exc)
return web.json_response({"status": True})
logging.warning("SeverPay webhook: unhandled status '%s' for payment %s", status, provider_payment_id)
return web.json_response({"status": True})
async def severpay_webhook_route(request: web.Request) -> web.Response:
service: SeverPayService = request.app["severpay_service"]
return await service.webhook_route(request)
+71 -21
View File
@@ -1,4 +1,5 @@
import logging
import math
from typing import Optional
from aiogram import Bot, types
@@ -13,6 +14,7 @@ from bot.middlewares.i18n import JsonI18n
from .notification_service import NotificationService
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from bot.utils.config_link import prepare_config_links
class StarsService:
@@ -26,15 +28,40 @@ class StarsService:
self.referral_service = referral_service
async def create_invoice(self, session: AsyncSession, user_id: int, months: int,
stars_price: int, description: str) -> Optional[int]:
stars_price: int, description: str, sale_mode: str = "subscription",
promo_code_service=None) -> Optional[int]:
# Apply active discount if exists (Stars use ceiling rounding)
original_stars_price = stars_price
discount_amount_stars = None
promo_code_id = None
if promo_code_service:
# Import here to avoid circular import
from bot.handlers.user.subscription.payment_discount_helper import apply_discount_to_payment
# Apply discount and round up using ceiling
final_price_float, discount_float, promo_code_id = await apply_discount_to_payment(
session, user_id, float(stars_price), promo_code_service
)
if discount_float:
# Apply ceiling rounding for fractional Stars amounts
stars_price = math.ceil(final_price_float)
discount_amount_stars = original_stars_price - stars_price
logging.info(
f"Stars discount applied: {original_stars_price} -> {final_price_float:.2f} -> {stars_price} (ceiling)"
)
payment_record_data = {
"user_id": user_id,
"amount": float(stars_price),
"original_amount": float(original_stars_price) if discount_amount_stars else None,
"discount_applied": float(discount_amount_stars) if discount_amount_stars else None,
"currency": "XTR",
"status": "pending_stars",
"description": description,
"subscription_duration_months": months,
"subscription_duration_months": int(months),
"provider": "telegram_stars",
"promo_code_id": promo_code_id,
}
try:
db_payment_record = await payment_dal.create_payment_record(
@@ -46,7 +73,7 @@ class StarsService:
exc_info=True)
return None
payload = f"{db_payment_record.payment_id}:{months}"
payload = f"{db_payment_record.payment_id}:{months}:{sale_mode}"
prices = [LabeledPrice(label=description, amount=stars_price)]
try:
await self.bot.send_invoice(
@@ -69,7 +96,12 @@ class StarsService:
payment_db_id: int,
months: int,
stars_amount: int,
i18n_data: dict) -> None:
i18n_data: dict,
sale_mode: str = "subscription") -> None:
# Fetch payment record to get promo_code_id
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
promo_code_id_from_payment = payment_record.promo_code_id if payment_record else None
try:
await payment_dal.update_provider_payment_and_status(
session, payment_db_id,
@@ -86,23 +118,28 @@ class StarsService:
activation_details = await self.subscription_service.activate_subscription(
session,
message.from_user.id,
months,
int(months) if sale_mode != "traffic" else 0,
float(stars_amount),
payment_db_id,
promo_code_id_from_payment=promo_code_id_from_payment,
provider="telegram_stars",
sale_mode=sale_mode,
traffic_gb=months if sale_mode == "traffic" else None,
)
if not activation_details or not activation_details.get("end_date"):
logging.error(
f"Failed to activate subscription after stars payment for user {message.from_user.id}")
return
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
session,
message.from_user.id,
months,
current_payment_db_id=payment_db_id,
skip_if_active_before_payment=False,
)
referral_bonus = None
if sale_mode != "traffic":
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
session,
message.from_user.id,
int(months) or 1,
current_payment_db_id=payment_db_id,
skip_if_active_before_payment=False,
)
await session.commit()
applied_days = referral_bonus.get("referee_bonus_applied_days") if referral_bonus else None
@@ -116,11 +153,18 @@ class StarsService:
i18n: JsonI18n = i18n_data.get("i18n_instance")
_ = lambda k, **kw: i18n.gettext(current_lang, k, **kw) if i18n else k
config_link = activation_details.get("subscription_url") or _(
"config_link_not_available"
)
raw_config_link = activation_details.get("subscription_url") if activation_details else None
config_link_display, connect_button_url = await prepare_config_links(self.settings, raw_config_link)
config_link_text = config_link_display or _("config_link_not_available")
if applied_days:
if sale_mode == "traffic":
success_msg = _(
"payment_successful_traffic_full",
traffic_gb=str(int(months)) if float(months).is_integer() else f"{months:g}",
end_date=final_end.strftime('%Y-%m-%d'),
config_link=config_link_text,
)
elif applied_days:
inviter_name_display = _("friend_placeholder")
db_user = await user_dal.get_user_by_id(session, message.from_user.id)
if db_user and db_user.referred_by_id:
@@ -138,17 +182,22 @@ class StarsService:
bonus_days=applied_days,
final_end_date=final_end.strftime('%Y-%m-%d'),
inviter_name=inviter_name_display,
config_link=config_link,
config_link=config_link_text,
)
else:
success_msg = _(
"payment_successful_full",
months=months,
end_date=final_end.strftime('%Y-%m-%d'),
config_link=config_link,
config_link=config_link_text,
)
markup = get_connect_and_main_keyboard(
current_lang, i18n, self.settings, config_link, preserve_message=True
current_lang,
i18n,
self.settings,
config_link_display,
connect_button_url=connect_button_url,
preserve_message=True,
)
try:
await self.bot.send_message(
@@ -170,9 +219,10 @@ class StarsService:
user_id=message.from_user.id,
amount=float(stars_amount),
currency="XTR",
months=months,
months=int(months) if sale_mode != "traffic" else 0,
payment_provider="stars",
username=user.username if user else None
username=user.username if user else None,
traffic_gb=months if sale_mode == "traffic" else None,
)
except Exception as e:
logging.error(f"Failed to send stars payment notification: {e}")
+215 -15
View File
@@ -5,8 +5,9 @@ from typing import Optional, Dict, Any, List, Tuple
from aiogram import Bot
from bot.middlewares.i18n import JsonI18n
from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal, user_billing_dal
from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal, user_billing_dal, active_discount_dal
from bot.utils.date_utils import add_months
from bot.utils.config_link import prepare_config_links
from db.models import User, Subscription
from config.settings import Settings
@@ -125,6 +126,7 @@ class SubscriptionService:
(db_user.last_name or "") if db_user else "",
]),
specific_squad_uuids=self.settings.parsed_user_squad_uuids,
external_squad_uuid=self.settings.parsed_user_external_squad_uuid,
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
default_traffic_limit_strategy=self.settings.USER_TRAFFIC_STRATEGY,
)
@@ -153,6 +155,7 @@ class SubscriptionService:
(db_user.last_name or "") if db_user else "",
]),
specific_squad_uuids=self.settings.parsed_user_squad_uuids,
external_squad_uuid=self.settings.parsed_user_external_squad_uuid,
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
default_traffic_limit_strategy=self.settings.USER_TRAFFIC_STRATEGY,
)
@@ -417,6 +420,119 @@ class SubscriptionService:
"subscription_url": final_subscription_url,
}
async def _activate_traffic_package(
self,
session: AsyncSession,
user_id: int,
traffic_gb: float,
payment_amount: float,
payment_db_id: int,
provider: str = "yookassa",
) -> Optional[Dict[str, Any]]:
"""Activate or extend a traffic-based package instead of a time-based subscription."""
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user:
logging.error("User %s not found for traffic package activation", user_id)
return None
panel_user_uuid, panel_sub_link_id, panel_short_uuid, _ = (
await self._get_or_create_panel_user_link_details(session, user_id, db_user)
)
if not panel_user_uuid or not panel_sub_link_id:
logging.error("Failed to ensure panel linkage for user %s during traffic activation", user_id)
return None
panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid) or {}
traffic_info = panel_user_data.get("userTraffic") or {}
current_limit = panel_user_data.get("trafficLimitBytes")
current_used = traffic_info.get("usedTrafficBytes")
active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, panel_user_uuid
)
if current_limit is None and active_sub:
current_limit = active_sub.traffic_limit_bytes
if current_used is None and active_sub:
current_used = active_sub.traffic_used_bytes
purchase_bytes = int(float(traffic_gb) * (1024**3))
new_limit = (current_limit or 0) + purchase_bytes
start_date = datetime.now(timezone.utc)
# Set a far-future expiry to satisfy panel requirements; keep the latest known expiry if it's further.
far_future = datetime(2099, 1, 1, tzinfo=timezone.utc)
final_end_date = far_future
if active_sub and active_sub.end_date and active_sub.end_date > final_end_date:
final_end_date = active_sub.end_date
await subscription_dal.deactivate_other_active_subscriptions(
session, panel_user_uuid, panel_sub_link_id
)
sub_payload = {
"user_id": user_id,
"panel_user_uuid": panel_user_uuid,
"panel_subscription_uuid": panel_sub_link_id,
"start_date": start_date,
"end_date": final_end_date,
"duration_months": 0,
"is_active": True,
"status_from_panel": "ACTIVE",
"traffic_limit_bytes": new_limit,
"traffic_used_bytes": current_used,
"provider": provider,
"skip_notifications": True,
"auto_renew_enabled": False,
}
try:
new_or_updated_sub = await subscription_dal.upsert_subscription(session, sub_payload)
except Exception as exc:
logging.error("Failed to upsert traffic subscription for user %s: %s", user_id, exc, exc_info=True)
return None
panel_update_payload = self._build_panel_update_payload(
panel_user_uuid=panel_user_uuid,
expire_at=final_end_date,
status="ACTIVE",
traffic_limit_bytes=new_limit,
traffic_limit_strategy="NO_RESET",
)
panel_update_payload["description"] = "\n".join(
[
(db_user.username or "") if db_user else "",
(db_user.first_name or "") if db_user else "",
(db_user.last_name or "") if db_user else "",
]
)
updated_panel_user = await self.panel_service.update_user_details_on_panel(
panel_user_uuid, panel_update_payload
)
if not updated_panel_user or updated_panel_user.get("error"):
logging.warning(
"Panel user details update FAILED for traffic package user %s. Response: %s",
panel_user_uuid,
updated_panel_user,
)
return None
final_subscription_url = updated_panel_user.get("subscriptionUrl")
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
return {
"subscription_id": new_or_updated_sub.subscription_id,
"end_date": final_end_date,
"is_active": True,
"panel_user_uuid": panel_user_uuid,
"panel_short_uuid": final_panel_short_uuid,
"subscription_url": final_subscription_url,
"applied_promo_bonus_days": 0,
"traffic_limit_bytes": new_limit,
}
async def activate_subscription(
self,
session: AsyncSession,
@@ -426,8 +542,21 @@ class SubscriptionService:
payment_db_id: int,
promo_code_id_from_payment: Optional[int] = None,
provider: str = "yookassa",
sale_mode: str = "subscription",
traffic_gb: Optional[float] = None,
) -> Optional[Dict[str, Any]]:
if sale_mode == "traffic" or getattr(self.settings, "traffic_sale_mode", False):
target_gb = traffic_gb if traffic_gb is not None else float(months)
return await self._activate_traffic_package(
session=session,
user_id=user_id,
traffic_gb=target_gb,
payment_amount=payment_amount,
payment_db_id=payment_db_id,
provider=provider,
)
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user:
logging.error(
@@ -445,6 +574,11 @@ class SubscriptionService:
)
return None
try:
months_int = int(months)
except Exception:
months_int = 1
current_active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, panel_user_uuid
)
@@ -457,7 +591,7 @@ class SubscriptionService:
start_date = current_active_sub.end_date
# base duration by months
end_after_months = add_months(start_date, months)
end_after_months = add_months(start_date, months_int)
duration_days_total = (end_after_months - start_date).days
applied_promo_bonus_days = 0
@@ -498,19 +632,25 @@ class SubscriptionService:
session, panel_user_uuid, panel_sub_link_id
)
auto_renew_should_enable = False
if provider == "yookassa" and self.settings.yookassa_autopayments_active:
auto_renew_should_enable = await user_billing_dal.user_has_saved_payment_method(
session, user_id
)
sub_payload = {
"user_id": user_id,
"panel_user_uuid": panel_user_uuid,
"panel_subscription_uuid": panel_sub_link_id,
"start_date": start_date,
"end_date": final_end_date,
"duration_months": months,
"duration_months": months_int,
"is_active": True,
"status_from_panel": "ACTIVE",
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
"provider": provider,
"skip_notifications": provider == "tribute" and self.settings.TRIBUTE_SKIP_NOTIFICATIONS,
"auto_renew_enabled": True,
"skip_notifications": False,
"auto_renew_enabled": auto_renew_should_enable,
}
try:
new_or_updated_sub = await subscription_dal.upsert_subscription(
@@ -551,6 +691,35 @@ class SubscriptionService:
final_subscription_url = updated_panel_user.get("subscriptionUrl")
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
# NEW: Consume discount promo code if payment had one
try:
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if payment_record and payment_record.discount_applied:
# This payment had a discount applied - consume it
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if active_discount:
# Record promo activation
await promo_code_dal.record_promo_activation(
session,
active_discount.promo_code_id,
user_id,
payment_id=payment_db_id
)
# Increment usage
await promo_code_dal.increment_promo_code_usage(
session,
active_discount.promo_code_id
)
# Clear active discount
await active_discount_dal.clear_active_discount(session, user_id)
logging.info(
f"Discount consumed for user {user_id}, promo {active_discount.promo_code_id}, "
f"payment {payment_db_id}"
)
except Exception as e:
logging.error(f"Failed to consume discount for user {user_id}, payment {payment_db_id}: {e}")
# Don't fail the subscription activation if discount consumption fails
return {
"subscription_id": new_or_updated_sub.subscription_id,
"end_date": final_end_date,
@@ -568,6 +737,11 @@ class SubscriptionService:
bonus_days: int,
reason: str = "bonus",
) -> Optional[datetime]:
reason_lower = (reason or "").lower()
apply_main_traffic_limit = any(
keyword in reason_lower for keyword in ("admin", "promo code", "referral", "bonus")
)
user = await user_dal.get_user_by_id(session, user_id)
if not user:
logging.warning(
@@ -594,8 +768,12 @@ class SubscriptionService:
start_date = datetime.now(timezone.utc)
new_end_date_obj = start_date + timedelta(days=bonus_days)
# For promo code activations, use the configured user traffic limit
traffic_limit = self.settings.user_traffic_limit_bytes if "promo code" in reason.lower() else self.settings.trial_traffic_limit_bytes
# Apply main traffic limit for admin/referral/promo bonuses, fallback to trial limit otherwise
traffic_limit = (
self.settings.user_traffic_limit_bytes
if apply_main_traffic_limit
else self.settings.trial_traffic_limit_bytes
)
bonus_sub_payload = {
"user_id": user_id,
@@ -607,6 +785,7 @@ class SubscriptionService:
"is_active": True,
"status_from_panel": "ACTIVE_BONUS",
"traffic_limit_bytes": traffic_limit,
"auto_renew_enabled": False,
}
await subscription_dal.deactivate_other_active_subscriptions(
session, panel_uuid, panel_sub_uuid
@@ -626,12 +805,23 @@ class SubscriptionService:
session, active_sub.subscription_id, new_end_date_obj
)
if (
apply_main_traffic_limit
and updated_sub_model
and updated_sub_model.traffic_limit_bytes != self.settings.user_traffic_limit_bytes
):
updated_sub_model = await subscription_dal.update_subscription(
session,
updated_sub_model.subscription_id,
{"traffic_limit_bytes": self.settings.user_traffic_limit_bytes},
)
if updated_sub_model:
# Prepare panel update payload
panel_update_payload = self._build_panel_update_payload(
expire_at=new_end_date_obj,
traffic_limit_bytes=(
self.settings.user_traffic_limit_bytes if "promo code" in reason.lower() else None
self.settings.user_traffic_limit_bytes if apply_main_traffic_limit else None
),
include_uuid=False,
)
@@ -685,7 +875,8 @@ class SubscriptionService:
update_payload_local = {}
panel_status = panel_user_data.get("status", "UNKNOWN").upper()
panel_expire_at_str = panel_user_data.get("expireAt")
panel_traffic_used = panel_user_data.get("usedTrafficBytes")
traffic_stats = panel_user_data.get("userTraffic") or {}
panel_traffic_used = traffic_stats.get("usedTrafficBytes")
panel_traffic_limit = panel_user_data.get("trafficLimitBytes")
panel_sub_uuid_from_panel = panel_user_data.get(
"subscriptionUuid"
@@ -739,6 +930,8 @@ class SubscriptionService:
if panel_user_data.get("expireAt")
else None
)
config_link_raw = panel_user_data.get("subscriptionUrl")
display_link, connect_button_url = await prepare_config_links(self.settings, config_link_raw)
hwid_limit = panel_user_data.get("hwidDeviceLimit")
if hwid_limit is None:
hwid_limit = self.settings.USER_HWID_DEVICE_LIMIT
@@ -747,9 +940,10 @@ class SubscriptionService:
"user_id": panel_user_data.get("uuid"),
"end_date": panel_end_date,
"status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(),
"config_link": panel_user_data.get("subscriptionUrl"),
"config_link": display_link,
"connect_button_url": connect_button_url,
"traffic_limit_bytes": panel_user_data.get("trafficLimitBytes"),
"traffic_used_bytes": panel_user_data.get("usedTrafficBytes"),
"traffic_used_bytes": (panel_user_data.get("userTraffic") or {}).get("usedTrafficBytes"),
"user_bot_username": db_user.username,
"is_panel_data": True,
"max_devices": hwid_limit,
@@ -793,13 +987,16 @@ class SubscriptionService:
sub: Subscription,
) -> bool:
"""Attempt to charge user using saved payment method. Return True on initiated/handled, False on failure."""
if getattr(self.settings, "traffic_sale_mode", False):
logging.info("Auto-renew skipped: traffic sale mode enabled")
return True
if not sub.auto_renew_enabled:
return True
# If autopayments are disabled globally, skip charging attempts
if not getattr(self.settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
if not self.settings.yookassa_autopayments_active:
return True
if sub.provider == "tribute":
# Tribute is paid externally; we do not auto-charge here
if sub.provider != "yookassa":
logging.info("Auto-renew skipped: provider %s does not support auto-renew", sub.provider)
return True
from db.dal.user_billing_dal import get_user_default_payment_method
@@ -872,6 +1069,7 @@ class SubscriptionService:
status: Optional[str] = None,
traffic_limit_bytes: Optional[int] = None,
include_uuid: bool = True,
traffic_limit_strategy: Optional[str] = None,
) -> Dict[str, Any]:
payload: Dict[str, Any] = {}
if include_uuid and panel_user_uuid:
@@ -882,7 +1080,9 @@ class SubscriptionService:
payload["status"] = status
if traffic_limit_bytes is not None:
payload["trafficLimitBytes"] = traffic_limit_bytes
payload["trafficLimitStrategy"] = self.settings.USER_TRAFFIC_STRATEGY
payload["trafficLimitStrategy"] = traffic_limit_strategy or self.settings.USER_TRAFFIC_STRATEGY
if self.settings.parsed_user_squad_uuids:
payload["activeInternalSquads"] = self.settings.parsed_user_squad_uuids
if self.settings.parsed_user_external_squad_uuid:
payload["externalSquadUuid"] = self.settings.parsed_user_external_squad_uuid
return payload
-335
View File
@@ -1,335 +0,0 @@
import logging
import hmac
import hashlib
import json
from typing import Optional
from aiohttp import web
from aiogram import Bot
from sqlalchemy.orm import sessionmaker
from config.settings import Settings
from bot.middlewares.i18n import JsonI18n
from bot.services.subscription_service import SubscriptionService
from bot.services.panel_api_service import PanelApiService
from bot.services.referral_service import ReferralService
from .notification_service import NotificationService
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from db.dal import payment_dal, user_dal, subscription_dal
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
def convert_period_to_months(period: Optional[str]) -> int:
"""Map Tribute subscription period strings to months."""
if not period:
return 1
mapping = {
"monthly": 1,
"quarterly": 3,
"3-month": 3,
"3months": 3,
"3-months": 3,
"q": 3,
"halfyearly": 6,
"yearly": 12,
"annual": 12,
"y": 12,
}
return mapping.get(period.lower(), 1)
class TributeService:
def __init__(
self,
bot: Bot,
settings: Settings,
i18n: JsonI18n,
async_session_factory: sessionmaker,
panel_service: PanelApiService,
subscription_service: SubscriptionService,
referral_service: ReferralService,
):
self.bot = bot
self.settings = settings
self.i18n = i18n
self.async_session_factory = async_session_factory
self.panel_service = panel_service
self.subscription_service = subscription_service
self.referral_service = referral_service
async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response:
settings = self.settings
bot = self.bot
i18n = self.i18n
async_session_factory = self.async_session_factory
subscription_service = self.subscription_service
referral_service = self.referral_service
def ok(data: Optional[dict] = None) -> web.Response:
payload = {"status": "ok"}
if data:
payload.update(data)
return web.json_response(payload, status=200)
def ignored(reason: str) -> web.Response:
return web.json_response({"status": "ignored", "reason": reason}, status=200)
def bad_request(reason: str) -> web.Response:
return web.json_response({"status": "error", "reason": reason}, status=400)
if settings.TRIBUTE_API_KEY:
if not signature_header:
return web.json_response({"status": "error", "reason": "no_signature"}, status=403)
expected_sig = hmac.new(settings.TRIBUTE_API_KEY.encode(), raw_body,
hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected_sig, signature_header):
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=403)
try:
payload = json.loads(raw_body.decode())
except Exception:
return bad_request("invalid_json")
logging.info(
"Tribute webhook data: %s",
json.dumps(payload, ensure_ascii=False),
)
# Tribute webhook spec: only two events are sent
# name: new_subscription | cancelled_subscription
event_name = payload.get("name")
data = payload.get("payload", {})
# Mandatory routing fields
user_id = data.get("telegram_user_id")
if not user_id:
# Permanent format issue — acknowledge to avoid retries
return ignored("missing_telegram_user_id")
period_val = data.get("period")
months = convert_period_to_months(period_val)
# Tribute sends amount in minor units (kopecks/cents). Convert to major units before persisting.
amount_value = data.get("amount") or data.get("price")
currency = (data.get("currency") or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
if amount_value is not None:
try:
amount_minor_units = float(amount_value)
except (TypeError, ValueError):
amount_minor_units = 0.0
amount_float = round(amount_minor_units / 100.0, 2)
else:
amount_float = 0.0
async with async_session_factory() as session:
if event_name == "new_subscription":
# Use a unique, idempotent provider payment id per webhook event
# Prefer explicit event/payment identifiers if present; otherwise fall back to payload hash suffix
candidate_event_id = (
str(data.get("event_id") or data.get("payment_id") or data.get("purchase_id") or data.get("invoice_id") or "")
)
if candidate_event_id:
provider_payment_id = candidate_event_id
else:
# Combine subscription_id (if any) with a stable hash of the raw payload to ensure uniqueness per event
sub_id_part = str(data.get("subscription_id") or "sub")
payload_hash = hashlib.sha256(raw_body).hexdigest()[:16]
provider_payment_id = f"{sub_id_part}:{payload_hash}"
# Idempotent ensure payment
payment_record = await payment_dal.ensure_payment_with_provider_id(
session,
user_id=int(user_id),
amount=amount_float,
currency=currency,
months=months,
description="Tribute subscription",
provider="tribute",
provider_payment_id=provider_payment_id,
)
activation_details = await subscription_service.activate_subscription(
session,
int(user_id),
months,
float(amount_float),
payment_record.payment_id,
provider="tribute",
)
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
session,
int(user_id),
months,
current_payment_db_id=payment_record.payment_id,
skip_if_active_before_payment=False,
)
await session.commit()
db_user = await user_dal.get_user_by_id(session, int(user_id))
lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
applied_ref_days = referral_bonus.get('referee_bonus_applied_days') if referral_bonus else None
final_end = (referral_bonus.get('referee_new_end_date')
if referral_bonus else None)
if not final_end:
final_end = activation_details.get('end_date')
if final_end:
config_link = activation_details.get("subscription_url") or _(
"config_link_not_available"
)
if applied_ref_days:
inviter_name_display = _('friend_placeholder')
if db_user and db_user.referred_by_id:
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
if inviter:
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
if safe_name:
inviter_name_display = safe_name
elif inviter.username:
inviter_name_display = username_for_display(inviter.username, with_at=False)
success_msg = _(
"payment_successful_with_referral_bonus_full",
months=months,
base_end_date=activation_details["end_date"].strftime('%Y-%m-%d'),
bonus_days=applied_ref_days,
final_end_date=final_end.strftime('%Y-%m-%d'),
inviter_name=inviter_name_display,
config_link=config_link,
)
else:
success_msg = _(
"payment_successful_full",
months=months,
end_date=final_end.strftime('%Y-%m-%d'),
config_link=config_link,
)
markup = get_connect_and_main_keyboard(
lang,
i18n,
settings,
config_link,
preserve_message=True,
)
try:
# Use user's DB language in success messages prepared above
await bot.send_message(
int(user_id),
success_msg,
reply_markup=markup,
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as e:
logging.error(
f"Failed to send Tribute payment success message to user {user_id}: {e}")
# Send notification about payment
try:
notification_service = NotificationService(bot, settings, i18n)
user = await user_dal.get_user_by_id(session, int(user_id))
await notification_service.notify_payment_received(
user_id=int(user_id),
amount=float(amount_float),
currency=currency,
months=months,
payment_provider="tribute",
username=user.username if user else None
)
except Exception as e:
logging.error(f"Failed to send tribute payment notification: {e}")
elif event_name == "cancelled_subscription":
await self._handle_tribute_cancellation(session, int(user_id), bot, i18n)
else:
await session.commit()
# Acknowledge to Tribute that webhook was received and processed/accepted
return ok({"event": event_name or "unknown"})
async def _handle_tribute_cancellation(self, session, user_id: int, bot: Bot, i18n: JsonI18n):
"""Handle tribute subscription cancellation - set subscription to 1 day grace period"""
from datetime import datetime, timezone, timedelta
from db.dal import subscription_dal, user_dal
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
try:
grace_days = 1
grace_end = datetime.now(timezone.utc) + timedelta(days=grace_days)
active_subscriptions = await subscription_dal.get_active_subscriptions_for_user(session, user_id)
panel_users_updated: set[str] = set()
for sub in active_subscriptions:
updated_sub = await subscription_dal.update_subscription(
session,
sub.subscription_id,
{
"end_date": grace_end,
"status_from_panel": "CANCELLED",
"skip_notifications": True,
},
)
panel_uuid = updated_sub.panel_user_uuid if updated_sub else None
if panel_uuid and panel_uuid not in panel_users_updated:
panel_users_updated.add(panel_uuid)
panel_payload = {
"expireAt": grace_end.isoformat(timespec="milliseconds").replace("+00:00", "Z"),
}
try:
await self.panel_service.update_user_details_on_panel(
panel_uuid,
panel_payload,
log_response=False,
)
except Exception as panel_err:
logging.error(
f"Failed to update panel expiry for user {user_id} (panel_uuid {panel_uuid}) during Tribute cancellation: {panel_err}")
await session.commit()
# Send notification about cancellation if enabled
if not self.settings.TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS:
db_user = await user_dal.get_user_by_id(session, user_id)
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}"
_ = lambda k, **kw: i18n.gettext(lang, k, **kw) if i18n else k
markup = get_subscribe_only_markup(lang, i18n)
cancellation_msg = _(
"tribute_subscription_cancelled",
default="🚨 <b>Подписка отменена</b>\n\n"
"Ваша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, "
"после чего подписка будет заблокирована.\n\n"
"Для продления подписки нажмите кнопку ниже.",
user_name=first_name
)
try:
await bot.send_message(
int(user_id),
cancellation_msg,
reply_markup=markup,
parse_mode="HTML"
)
except Exception as e:
logging.error(f"Failed to send tribute cancellation notification to user {user_id}: {e}")
logging.info(f"Tribute subscription cancelled for user {user_id}, grace period set to 1 day")
except Exception as e:
logging.error(f"Error handling tribute cancellation for user {user_id}: {e}")
await session.rollback()
async def tribute_webhook_route(request: web.Request):
"""AIOHTTP route handler for Tribute webhook calls."""
tribute_service: TributeService = request.app['tribute_service']
raw_body = await request.read()
signature_header = request.headers.get('trbt-signature')
return await tribute_service.handle_webhook(raw_body, signature_header)
+10 -5
View File
@@ -21,7 +21,10 @@ class YooKassaService:
self.settings = settings_obj
if not shop_id or not secret_key:
if self.settings and not self.settings.YOOKASSA_ENABLED:
logging.warning("YooKassa is disabled via YOOKASSA_ENABLED flag. Payment functionality will be DISABLED.")
self.configured = False
elif not shop_id or not secret_key:
logging.warning(
"YooKassa SHOP_ID or SECRET_KEY not configured in settings. "
"Payment functionality will be DISABLED.")
@@ -111,10 +114,12 @@ class YooKassaService:
capture = False
amount = max(amount, 1.00)
builder.set_capture(capture)
builder.set_confirmation({
"type": ConfirmationType.REDIRECT,
"return_url": self.return_url
})
if not payment_method_id:
# Saved payment_method_id charges must omit confirmation per YooKassa API
builder.set_confirmation({
"type": ConfirmationType.REDIRECT,
"return_url": self.return_url
})
builder.set_description(description)
builder.set_metadata(metadata)
if save_payment_method:
+3
View File
@@ -6,8 +6,10 @@ class AdminStates(StatesGroup):
waiting_for_broadcast_message = State()
confirming_broadcast = State()
waiting_for_promo_details = State()
waiting_for_promo_type_selection = State()
waiting_for_promo_code = State()
waiting_for_promo_bonus_days = State()
waiting_for_promo_discount_percentage = State()
waiting_for_promo_max_activations = State()
waiting_for_promo_validity_days = State()
waiting_for_promo_edit_details = State()
@@ -28,6 +30,7 @@ class AdminStates(StatesGroup):
waiting_for_user_search = State()
waiting_for_subscription_days_to_add = State()
waiting_for_direct_message_to_user = State()
waiting_for_user_delete_confirmation = State()
# Ads campaigns
waiting_for_ad_source = State()
+47
View File
@@ -0,0 +1,47 @@
import logging
from typing import Optional, Tuple
from config.settings import Settings
from bot.services.panel_api_service import PanelApiService
async def _encrypt_raw_link(settings: Settings, raw_link: str) -> Optional[str]:
"""Encrypt the raw subscription URL using the panel's happ crypt4 API."""
async with PanelApiService(settings) as panel_service:
encrypted_link = await panel_service.encrypt_happ_link(raw_link)
if encrypted_link:
return encrypted_link
return None
async def prepare_config_links(settings: Settings, raw_link: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
"""
Build the user-facing connection key and the URL for the connect button.
Returns (display_link, button_link). When CRYPT4 is enabled the display link
is encrypted and prefixed with happ://crypt4/ by panel API, and the button link is wrapped
with CRYPT4_REDIRECT_URL if provided.
"""
if not raw_link:
return None, None
cleaned = raw_link.strip()
if not cleaned:
return None, None
display_link = cleaned
button_link = cleaned
if settings.CRYPT4_ENABLED:
encrypted_payload = await _encrypt_raw_link(settings, cleaned)
if encrypted_payload:
display_link = encrypted_payload
button_link = display_link
else:
logging.error("CRYPT4_ENABLED is set but encryption failed; using raw link as fallback.")
redirect_base = (settings.CRYPT4_REDIRECT_URL or "").strip()
if redirect_base and settings.CRYPT4_ENABLED and display_link:
button_link = f"{redirect_base}{display_link}"
return display_link, button_link
+62 -7
View File
@@ -5,6 +5,12 @@ from dataclasses import dataclass
from datetime import datetime, timedelta
from collections import deque
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest
from bot.utils.telegram_markup import (
is_profile_link_error,
remove_profile_link_buttons,
)
@dataclass
@@ -51,14 +57,31 @@ class MessageQueue:
message = self.queue.popleft()
try:
await self._send_message(message)
self.last_send_times.append(datetime.now())
self.total_sent += 1
self._record_send_time()
except TelegramBadRequest as exc:
fallback_message = self._build_profile_link_fallback(message, exc)
if fallback_message:
logging.warning(
"Telegram rejected profile buttons for chat %s: %s. "
"Retrying without tg:// links.",
message.chat_id,
getattr(exc, "message", "") or str(exc),
)
try:
await self._send_message(fallback_message)
self._record_send_time()
continue
except Exception as retry_exc:
self.total_failed += 1
logging.error(
f"Failed to send fallback message to {message.chat_id}: {retry_exc}"
)
continue
self.total_failed += 1
logging.error(f"Failed to send queued message to {message.chat_id}: {exc}")
# Keep only recent send times (last minute)
cutoff_time = datetime.now() - timedelta(seconds=60)
while self.last_send_times and self.last_send_times[0] < cutoff_time:
self.last_send_times.popleft()
except Exception as e:
self.total_failed += 1
logging.error(f"Failed to send queued message to {message.chat_id}: {e}")
@@ -77,6 +100,38 @@ class MessageQueue:
if time_since_last < self.delay_between_messages:
wait_time = self.delay_between_messages - time_since_last
await asyncio.sleep(wait_time)
def _record_send_time(self) -> None:
"""Track sent message timestamps and purge old entries for rate limiting."""
now = datetime.now()
self.last_send_times.append(now)
self.total_sent += 1
cutoff_time = now - timedelta(seconds=60)
while self.last_send_times and self.last_send_times[0] < cutoff_time:
self.last_send_times.popleft()
def _build_profile_link_fallback(
self, message: QueuedMessage, exc: Exception
) -> Optional[QueuedMessage]:
"""Create a fallback message without tg://user buttons when Telegram rejects them."""
if not is_profile_link_error(exc):
return None
markup = message.kwargs.get("reply_markup")
if markup is None:
return None
safe_markup = remove_profile_link_buttons(markup)
fallback_kwargs = dict(message.kwargs)
fallback_kwargs["reply_markup"] = safe_markup
return QueuedMessage(
chat_id=message.chat_id,
method_name=message.method_name,
kwargs=fallback_kwargs,
callback=message.callback,
)
async def _send_message(self, message: QueuedMessage) -> Any:
"""Send a single message - to be implemented by subclass"""
+39
View File
@@ -0,0 +1,39 @@
from typing import Optional
from aiogram import types
PROFILE_BUTTON_ERROR_CODES = ("BUTTON_USER_INVALID", "BUTTON_USER_PRIVACY_RESTRICTED")
TG_USER_LINK_PREFIX = "tg://user?id="
def remove_profile_link_buttons(
markup: Optional[types.InlineKeyboardMarkup],
) -> Optional[types.InlineKeyboardMarkup]:
"""Remove buttons that point to tg://user links to avoid privacy-related errors."""
inline_keyboard = getattr(markup, "inline_keyboard", None)
if not markup or not inline_keyboard:
return None
cleaned_rows = []
for row in inline_keyboard:
filtered_row = [
button
for button in row
if not (
getattr(button, "url", None)
and str(button.url).startswith(TG_USER_LINK_PREFIX)
)
]
if filtered_row:
cleaned_rows.append(filtered_row)
if not cleaned_rows:
return None
return types.InlineKeyboardMarkup(inline_keyboard=cleaned_rows)
def is_profile_link_error(exc: BaseException) -> bool:
"""Return True if Telegram rejected markup because of profile link buttons."""
message = getattr(exc, "message", "") or str(exc)
return any(code in message for code in PROFILE_BUTTON_ERROR_CODES)
+238 -37
View File
@@ -41,6 +41,36 @@ class Settings(BaseSettings):
YOOKASSA_PAYMENT_SUBJECT: str = Field(default="service")
# Single toggle to enable recurring payments (saving cards, managing payment methods, auto-renew)
YOOKASSA_AUTOPAYMENTS_ENABLED: bool = Field(default=False)
YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING: bool = Field(
default=True,
description="When true, new YooKassa payments in autopay mode force card binding without a user checkbox."
)
LKNPD_INN: Optional[str] = Field(
default=None,
alias="NALOGO_INN",
description="INN for lknpd.nalog.ru (self-employed) authentication"
)
LKNPD_PASSWORD: Optional[str] = Field(
default=None,
alias="NALOGO_PASSWORD",
description="Password for lknpd.nalog.ru (self-employed) authentication"
)
LKNPD_API_URL: str = Field(
default="https://lknpd.nalog.ru/api",
alias="NALOGO_API_URL",
description="Base URL for LKNPD API (can be overridden for proxies)"
)
LKNPD_RECEIPT_NAME_SUBSCRIPTION: str = Field(
default="subscription {months} months",
alias="NALOGO_RECEIPT_NAME_SUBSCRIPTION",
description="Receipt item name for time-based subscriptions. Use {months} placeholder for duration."
)
LKNPD_RECEIPT_NAME_TRAFFIC: str = Field(
default="traffic package {gb} GB",
alias="NALOGO_RECEIPT_NAME_TRAFFIC",
description="Receipt item name for traffic packages. Use {gb} placeholder for traffic amount."
)
WEBHOOK_BASE_URL: Optional[str] = None
@@ -49,6 +79,15 @@ class Settings(BaseSettings):
CRYPTOPAY_CURRENCY_TYPE: str = Field(default="fiat")
CRYPTOPAY_ASSET: str = Field(default="RUB")
CRYPTOPAY_ENABLED: bool = Field(default=True)
PLATEGA_ENABLED: bool = Field(default=False)
PLATEGA_BASE_URL: str = Field(default="https://app.platega.io")
PLATEGA_MERCHANT_ID: Optional[str] = None
PLATEGA_SECRET: Optional[str] = None
PLATEGA_PAYMENT_METHOD: int = Field(
default=2, description="Platega payment method ID (e.g., 2 for SBP QR)"
)
PLATEGA_RETURN_URL: Optional[str] = Field(default=None)
PLATEGA_FAILED_URL: Optional[str] = Field(default=None)
FREEKASSA_ENABLED: bool = Field(default=False)
FREEKASSA_MERCHANT_ID: Optional[str] = None
@@ -57,10 +96,24 @@ class Settings(BaseSettings):
FREEKASSA_PAYMENT_URL: str = Field(default="https://pay.freekassa.ru/")
FREEKASSA_API_KEY: Optional[str] = None
FREEKASSA_PAYMENT_IP: Optional[str] = None
FREEKASSA_PAYMENT_METHOD_ID: Optional[int] = None
SEVERPAY_ENABLED: bool = Field(default=False)
SEVERPAY_MID: Optional[int] = None
SEVERPAY_TOKEN: Optional[str] = None
SEVERPAY_RETURN_URL: Optional[str] = None
SEVERPAY_BASE_URL: str = Field(default="https://severpay.io/api/merchant")
SEVERPAY_LIFETIME_MINUTES: Optional[int] = Field(
default=None,
description="Lifetime of the payment link in minutes (30-4320, defaults to provider value)",
)
YOOKASSA_ENABLED: bool = Field(default=True)
STARS_ENABLED: bool = Field(default=True)
TRIBUTE_ENABLED: bool = Field(default=True)
PAYMENT_METHODS_ORDER: Optional[str] = Field(
default=None,
description="Comma-separated list of payment methods to show (e.g., severpay,freekassa,yookassa,platega,stars,cryptopay)",
)
MONTH_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED")
MONTH_3_ENABLED: bool = Field(default=True, alias="3_MONTHS_ENABLED")
@@ -76,17 +129,17 @@ class Settings(BaseSettings):
STARS_PRICE_3_MONTHS: Optional[int] = Field(default=None)
STARS_PRICE_6_MONTHS: Optional[int] = Field(default=None)
STARS_PRICE_12_MONTHS: Optional[int] = Field(default=None)
TRIBUTE_LINK_1_MONTH: Optional[str] = Field(default=None)
TRIBUTE_LINK_3_MONTHS: Optional[str] = Field(default=None)
TRIBUTE_LINK_6_MONTHS: Optional[str] = Field(default=None)
TRIBUTE_LINK_12_MONTHS: Optional[str] = Field(default=None)
TRIBUTE_API_KEY: Optional[str] = Field(default=None)
TRIBUTE_SKIP_NOTIFICATIONS: bool = Field(default=True, description="Skip renewal notifications for Tribute payments")
TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS: bool = Field(default=False, description="Skip cancellation notifications for Tribute payments")
PANEL_WEBHOOK_SECRET: Optional[str] = Field(default=None)
TRAFFIC_PACKAGES: Optional[str] = Field(
default=None,
description="Comma-separated list of traffic packages in the format '<GB>:<price>', e.g. '10:199,50:799'",
)
STARS_TRAFFIC_PACKAGES: Optional[str] = Field(
default=None,
description="Comma-separated list of traffic packages priced in Stars, e.g. '5:500,20:1500'",
)
SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True)
SUBSCRIPTION_NOTIFY_ON_EXPIRE: bool = Field(default=True)
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE: bool = Field(default=True)
@@ -113,7 +166,11 @@ class Settings(BaseSettings):
# Referral program configuration
REFERRAL_ONE_BONUS_PER_REFEREE: bool = Field(
default=True,
description="When true, referral bonuses (for inviter and referee) are applied only once per invited user on their first successful payment."
description="When true, referral bonuses (for inviter and referee) are applied only once per invited user - on their first successful payment."
)
LEGACY_REFS: bool = Field(
default=True,
description="Allow legacy referral links like ref_<telegram_id> to continue working. Defaults to True when unset."
)
PANEL_API_URL: Optional[str] = None
@@ -124,11 +181,18 @@ class Settings(BaseSettings):
default=None,
description=
"Comma-separated UUIDs of internal squads to assign to new panel users")
USER_EXTERNAL_SQUAD_UUID: Optional[str] = Field(
default=None,
description=
"UUID of the external squad to assign to new panel users (optional)")
TRIAL_ENABLED: bool = Field(default=True)
TRIAL_DURATION_DAYS: int = Field(default=3)
TRIAL_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=5.0)
CRYPT4_ENABLED: bool = Field(default=False, description="Enable happ crypt4 encryption for subscription URLs")
CRYPT4_REDIRECT_URL: Optional[str] = Field(default=None, description="Base redirect URL used for the connect button when crypt4 is enabled")
WEB_SERVER_HOST: str = Field(default="0.0.0.0")
WEB_SERVER_PORT: int = Field(default=8080)
LOGS_PAGE_SIZE: int = Field(default=10)
@@ -206,6 +270,15 @@ class Settings(BaseSettings):
]
return None
@computed_field
@property
def parsed_user_external_squad_uuid(self) -> Optional[str]:
if self.USER_EXTERNAL_SQUAD_UUID:
cleaned = self.USER_EXTERNAL_SQUAD_UUID.strip()
if cleaned:
return cleaned
return None
@computed_field
@property
def yookassa_webhook_path(self) -> str:
@@ -220,19 +293,6 @@ class Settings(BaseSettings):
return f"{base.rstrip('/')}{self.yookassa_webhook_path}"
return None
@computed_field
@property
def tribute_webhook_path(self) -> str:
return "/webhook/tribute"
@computed_field
@property
def tribute_full_webhook_url(self) -> Optional[str]:
base = self.WEBHOOK_BASE_URL
if base:
return f"{base.rstrip('/')}{self.tribute_webhook_path}"
return None
@computed_field
@property
def panel_webhook_path(self) -> str:
@@ -272,6 +332,32 @@ class Settings(BaseSettings):
return f"{base.rstrip('/')}{self.freekassa_webhook_path}"
return None
@computed_field
@property
def severpay_webhook_path(self) -> str:
return "/webhook/severpay"
@computed_field
@property
def severpay_full_webhook_url(self) -> Optional[str]:
base = self.WEBHOOK_BASE_URL
if base:
return f"{base.rstrip('/')}{self.severpay_webhook_path}"
return None
@computed_field
@property
def platega_webhook_path(self) -> str:
return "/webhook/platega"
@computed_field
@property
def platega_full_webhook_url(self) -> Optional[str]:
base = self.WEBHOOK_BASE_URL
if base:
return f"{base.rstrip('/')}{self.platega_webhook_path}"
return None
# Computed YooKassa receipt fields based on recurring toggle
@computed_field
@property
@@ -316,17 +402,59 @@ class Settings(BaseSettings):
@computed_field
@property
def tribute_payment_links(self) -> Dict[int, str]:
links: Dict[int, str] = {}
if self.TRIBUTE_ENABLED and self.MONTH_1_ENABLED and self.TRIBUTE_LINK_1_MONTH:
links[1] = self.TRIBUTE_LINK_1_MONTH
if self.TRIBUTE_ENABLED and self.MONTH_3_ENABLED and self.TRIBUTE_LINK_3_MONTHS:
links[3] = self.TRIBUTE_LINK_3_MONTHS
if self.TRIBUTE_ENABLED and self.MONTH_6_ENABLED and self.TRIBUTE_LINK_6_MONTHS:
links[6] = self.TRIBUTE_LINK_6_MONTHS
if self.TRIBUTE_ENABLED and self.MONTH_12_ENABLED and self.TRIBUTE_LINK_12_MONTHS:
links[12] = self.TRIBUTE_LINK_12_MONTHS
return links
def traffic_packages(self) -> Dict[float, float]:
"""
Mapping of traffic size in GB to price in the default currency.
"""
packages: Dict[float, float] = {}
raw = (self.TRAFFIC_PACKAGES or "").strip()
if not raw:
return packages
for part in raw.split(","):
chunk = part.strip()
if not chunk or ":" not in chunk:
continue
size_str, price_str = chunk.split(":", 1)
try:
size_gb = float(size_str.strip())
price_val = float(price_str.strip())
if size_gb > 0 and price_val >= 0:
packages[size_gb] = price_val
except ValueError:
logging.warning("Invalid TRAFFIC_PACKAGES entry skipped: %s", chunk)
continue
return packages
@computed_field
@property
def stars_traffic_packages(self) -> Dict[float, int]:
"""
Mapping of traffic size in GB to price in Telegram Stars.
"""
packages: Dict[float, int] = {}
raw = (self.STARS_TRAFFIC_PACKAGES or "").strip()
if not raw:
return packages
for part in raw.split(","):
chunk = part.strip()
if not chunk or ":" not in chunk:
continue
size_str, price_str = chunk.split(":", 1)
try:
size_gb = float(size_str.strip())
price_val = int(float(price_str.strip()))
if size_gb > 0 and price_val >= 0:
packages[size_gb] = price_val
except ValueError:
logging.warning("Invalid STARS_TRAFFIC_PACKAGES entry skipped: %s", chunk)
continue
return packages
@computed_field
@property
def traffic_sale_mode(self) -> bool:
"""When true, the bot sells traffic packages instead of time-based subscriptions."""
return bool(self.traffic_packages or self.stars_traffic_packages)
@computed_field
@property
@@ -355,11 +483,53 @@ class Settings(BaseSettings):
if self.REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS is not None:
bonuses[12] = self.REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS
return bonuses
@computed_field
@property
def yookassa_autopayments_active(self) -> bool:
"""Autopay features are available only when YooKassa itself is enabled."""
return bool(self.YOOKASSA_ENABLED and self.YOOKASSA_AUTOPAYMENTS_ENABLED)
@computed_field
@property
def payment_methods_order(self) -> List[str]:
"""
Ordered list of payment providers to show in the subscription payment keyboard.
"""
default_order = [
"freekassa",
"platega",
"severpay",
"yookassa",
"stars",
"cryptopay",
]
if not self.PAYMENT_METHODS_ORDER:
return default_order
methods = []
for item in self.PAYMENT_METHODS_ORDER.split(","):
slug = item.strip().lower()
if slug:
methods.append(slug)
return methods or default_order
# Logging Configuration
LOG_LEVEL: str = Field(
default="INFO",
description="Global log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)",
)
LOG_CHAT_ID: Optional[int] = Field(default=None, description="Telegram chat/group ID for sending notifications")
LOG_THREAD_ID: Optional[int] = Field(default=None, description="Thread ID for supergroup messages (optional)")
@field_validator('LOG_LEVEL', mode='before')
@classmethod
def normalize_log_level(cls, v):
if isinstance(v, str):
v = v.strip().upper()
if not v:
return "INFO"
return v
@field_validator('LOG_CHAT_ID', 'LOG_THREAD_ID', mode='before')
@classmethod
def validate_optional_int_fields(cls, v):
@@ -368,14 +538,21 @@ class Settings(BaseSettings):
return None
return v
@field_validator('REQUIRED_CHANNEL_LINK', mode='before')
@field_validator(
'REQUIRED_CHANNEL_LINK',
'PLATEGA_RETURN_URL',
'PLATEGA_FAILED_URL',
'SEVERPAY_RETURN_URL',
'CRYPT4_REDIRECT_URL',
mode='before',
)
@classmethod
def sanitize_optional_link(cls, v):
if isinstance(v, str) and not v.strip():
return None
return v
@field_validator('USER_HWID_DEVICE_LIMIT', mode='before')
@field_validator('USER_HWID_DEVICE_LIMIT', 'SEVERPAY_MID', 'SEVERPAY_LIFETIME_MINUTES', mode='before')
@classmethod
def validate_optional_int(cls, v):
if isinstance(v, str):
@@ -418,6 +595,16 @@ def get_settings() -> Settings:
logging.warning(
"CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work."
)
if (
_settings_instance.LKNPD_INN
or _settings_instance.LKNPD_PASSWORD
) and not (
_settings_instance.LKNPD_INN
and _settings_instance.LKNPD_PASSWORD
):
logging.warning(
"WARNING: LKNPD credentials are incomplete. Receipt sending will be disabled."
)
if _settings_instance.FREEKASSA_ENABLED:
if (
not _settings_instance.FREEKASSA_MERCHANT_ID
@@ -435,6 +622,20 @@ def get_settings() -> Settings:
"CRITICAL: FreeKassa is enabled but no subscription prices are configured (RUB_PRICE_*). Users will not see payment buttons."
)
if _settings_instance.PLATEGA_ENABLED:
if (
not _settings_instance.PLATEGA_MERCHANT_ID
or not _settings_instance.PLATEGA_SECRET
):
logging.warning(
"CRITICAL: Platega is enabled but merchant credentials (PLATEGA_MERCHANT_ID/PLATEGA_SECRET) are missing. Platega payments will not work."
)
if _settings_instance.SEVERPAY_ENABLED:
if not _settings_instance.SEVERPAY_MID or not _settings_instance.SEVERPAY_TOKEN:
logging.warning(
"CRITICAL: SeverPay is enabled but MID or TOKEN is missing. SeverPay payments will not work."
)
except ValidationError as e:
logging.critical(
f"Pydantic validation error while loading settings: {e}")
+88
View File
@@ -0,0 +1,88 @@
import logging
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy import delete
from datetime import datetime, timezone
from db.models import ActiveDiscount, PromoCode
async def set_active_discount(
session: AsyncSession,
user_id: int,
promo_code_id: int,
discount_percentage: int
) -> Optional[ActiveDiscount]:
"""
Set active discount for user.
Returns None if user already has an active discount (enforce one-at-a-time rule).
"""
# Check if user already has an active discount
existing = await get_active_discount(session, user_id)
if existing:
logging.warning(
f"User {user_id} already has active discount (promo_code_id: {existing.promo_code_id}). "
f"Cannot activate new discount {promo_code_id}."
)
return None
# Create new active discount
new_discount = ActiveDiscount(
user_id=user_id,
promo_code_id=promo_code_id,
discount_percentage=discount_percentage,
activated_at=datetime.now(timezone.utc)
)
session.add(new_discount)
await session.flush()
await session.refresh(new_discount)
logging.info(
f"Active discount set for user {user_id}: promo_code_id={promo_code_id}, "
f"discount={discount_percentage}%"
)
return new_discount
async def get_active_discount(
session: AsyncSession,
user_id: int
) -> Optional[ActiveDiscount]:
"""Get active discount for user if exists."""
stmt = select(ActiveDiscount).where(ActiveDiscount.user_id == user_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def clear_active_discount(
session: AsyncSession,
user_id: int
) -> bool:
"""
Clear active discount for user.
Returns True if discount was cleared, False if no discount was found.
"""
stmt = delete(ActiveDiscount).where(ActiveDiscount.user_id == user_id)
result = await session.execute(stmt)
await session.flush()
cleared = result.rowcount > 0
if cleared:
logging.info(f"Active discount cleared for user {user_id}")
return cleared
async def clear_active_discounts_by_promo_code(
session: AsyncSession,
promo_code_id: int
) -> int:
"""
Clear all active discounts associated with a specific promo code.
Returns the number of discounts cleared.
"""
stmt = delete(ActiveDiscount).where(ActiveDiscount.promo_code_id == promo_code_id)
result = await session.execute(stmt)
await session.flush()
count = result.rowcount
if count > 0:
logging.info(f"Cleared {count} active discount(s) for promo_code_id={promo_code_id}")
return count
+42 -18
View File
@@ -98,23 +98,37 @@ async def get_campaign_stats(session: AsyncSession, campaign_id: int) -> Dict[st
trials = (await session.execute(trials_stmt)).scalar() or 0
# Payers (unique users with succeeded payments)
payers_stmt = select(func.count(func.distinct(Payment.user_id))).select_from(Payment).where(
and_(
Payment.status == "succeeded",
Payment.user_id.in_(
select(AdAttribution.user_id).where(AdAttribution.ad_campaign_id == campaign_id)
),
attrib_subq = (
select(
AdAttribution.user_id.label("user_id"),
AdAttribution.first_start_at.label("first_start_at"),
)
.where(AdAttribution.ad_campaign_id == campaign_id)
.subquery()
)
payers_stmt = (
select(func.count(func.distinct(Payment.user_id)))
.select_from(Payment)
.join(attrib_subq, Payment.user_id == attrib_subq.c.user_id)
.where(
and_(
Payment.status == "succeeded",
Payment.created_at >= attrib_subq.c.first_start_at,
)
)
)
payers = (await session.execute(payers_stmt)).scalar() or 0
# Revenue sum
revenue_stmt = select(func.coalesce(func.sum(Payment.amount), 0.0)).select_from(Payment).where(
and_(
Payment.status == "succeeded",
Payment.user_id.in_(
select(AdAttribution.user_id).where(AdAttribution.ad_campaign_id == campaign_id)
),
revenue_stmt = (
select(func.coalesce(func.sum(Payment.amount), 0.0))
.select_from(Payment)
.join(attrib_subq, Payment.user_id == attrib_subq.c.user_id)
.where(
and_(
Payment.status == "succeeded",
Payment.created_at >= attrib_subq.c.first_start_at,
)
)
)
revenue = float((await session.execute(revenue_stmt)).scalar() or 0.0)
@@ -151,10 +165,22 @@ async def get_totals(session: AsyncSession) -> Dict[str, float]:
total_cost = float((await session.execute(total_cost_stmt)).scalar() or 0.0)
# Total revenue from all attributed users (unique users counted across all campaigns)
revenue_stmt = select(func.coalesce(func.sum(Payment.amount), 0.0)).select_from(Payment).where(
and_(
Payment.status == "succeeded",
Payment.user_id.in_(select(AdAttribution.user_id)),
attrib_subq = (
select(
AdAttribution.user_id.label("user_id"),
AdAttribution.first_start_at.label("first_start_at"),
)
.subquery()
)
revenue_stmt = (
select(func.coalesce(func.sum(Payment.amount), 0.0))
.select_from(Payment)
.join(attrib_subq, Payment.user_id == attrib_subq.c.user_id)
.where(
and_(
Payment.status == "succeeded",
Payment.created_at >= attrib_subq.c.first_start_at,
)
)
)
total_revenue = float((await session.execute(revenue_stmt)).scalar() or 0.0)
@@ -178,5 +204,3 @@ async def delete_campaign(session: AsyncSession, campaign_id: int) -> bool:
except Exception as e:
logging.error(f"Failed to delete AdCampaign id={campaign_id}: {e}", exc_info=True)
raise
+55 -19
View File
@@ -60,17 +60,18 @@ async def ensure_payment_with_provider_id(
"""Idempotently create a payment record for a provider event.
If a payment with the same provider_payment_id already exists, returns it.
Otherwise creates a new succeeded payment with provided data.
Otherwise creates a new pending payment with provided data.
"""
existing = await get_payment_by_provider_payment_id(session, provider_payment_id)
if existing:
return existing
pending_status = f"pending_{provider}" if provider else "pending"
payment_payload: Dict[str, Any] = {
"user_id": user_id,
"amount": float(amount),
"currency": currency,
"status": "succeeded",
"status": pending_status,
"description": description,
"subscription_duration_months": months,
"provider_payment_id": provider_payment_id,
@@ -175,6 +176,32 @@ async def update_provider_payment_and_status(
return payment
async def update_payment_discount_info(
session: AsyncSession,
payment_db_id: int,
original_amount: Optional[float],
discount_applied: Optional[float],
promo_code_id: Optional[int]) -> Optional[Payment]:
"""Update payment record with discount metadata."""
payment = await get_payment_by_db_id(session, payment_db_id)
if payment:
payment.original_amount = original_amount
payment.discount_applied = discount_applied
payment.promo_code_id = promo_code_id
payment.updated_at = func.now()
await session.flush()
await session.refresh(payment)
logging.info(
f"Payment record {payment.payment_id} updated with discount info: "
f"original {original_amount}, discount {discount_applied}, promo {promo_code_id}"
)
else:
logging.warning(
f"Payment record with DB ID {payment_db_id} not found for discount info update."
)
return payment
async def get_financial_statistics(session: AsyncSession) -> Dict[str, Any]:
"""Get comprehensive financial statistics."""
from datetime import datetime, timedelta
@@ -239,26 +266,35 @@ async def get_financial_statistics(session: AsyncSession) -> Dict[str, Any]:
}
async def get_last_tribute_payment_duration(session: AsyncSession, user_id: int) -> Optional[int]:
"""Get duration in months from the last successful tribute payment for a user."""
stmt = select(Payment.subscription_duration_months).where(
async def get_user_total_paid(session: AsyncSession, user_id: int) -> float:
"""Get total amount paid by a specific user (sum of all succeeded payments)."""
stmt = select(func.sum(Payment.amount)).where(
and_(
Payment.user_id == user_id,
Payment.provider == 'tribute',
Payment.status == 'succeeded'
)
).order_by(Payment.created_at.desc()).limit(1)
)
result = await session.execute(stmt)
total = result.scalar()
return float(total or 0)
async def get_referral_revenue(session: AsyncSession, referrer_id: int) -> float:
"""Get total revenue generated from referred users' payments.
This calculates the sum of all succeeded payments made by users
where referred_by_id equals the referrer_id.
"""
from db.models import User
stmt = select(func.sum(Payment.amount)).join(
User, Payment.user_id == User.user_id
).where(
and_(
User.referred_by_id == referrer_id,
Payment.status == 'succeeded'
)
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_last_tribute_payment(
session: AsyncSession, user_id: int) -> Optional[Payment]:
"""Return the most recent succeeded Tribute payment for the user."""
stmt = (select(Payment).where(
and_(Payment.user_id == user_id, Payment.provider == 'tribute',
Payment.status == 'succeeded')).order_by(
Payment.created_at.desc()).limit(1))
result = await session.execute(stmt)
return result.scalar_one_or_none()
total = result.scalar()
return float(total or 0)
+43 -2
View File
@@ -43,6 +43,34 @@ async def get_active_promo_code_by_code_str(
return result.scalar_one_or_none()
async def get_active_bonus_promo_code_by_code_str(
session: AsyncSession, code_str: str) -> Optional[PromoCode]:
"""Get active bonus_days-type promo code by code string"""
stmt = select(PromoCode).where(
PromoCode.code == code_str.upper(),
PromoCode.promo_type == "bonus_days",
PromoCode.is_active == True,
PromoCode.current_activations < PromoCode.max_activations,
or_(PromoCode.valid_until == None, PromoCode.valid_until
> datetime.now(timezone.utc)))
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_active_discount_promo_code_by_code_str(
session: AsyncSession, code_str: str) -> Optional[PromoCode]:
"""Get active discount-type promo code by code string"""
stmt = select(PromoCode).where(
PromoCode.code == code_str.upper(),
PromoCode.promo_type == "discount",
PromoCode.is_active == True,
PromoCode.current_activations < PromoCode.max_activations,
or_(PromoCode.valid_until == None, PromoCode.valid_until
> datetime.now(timezone.utc)))
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_all_active_promo_codes(session: AsyncSession,
limit: int = 20,
offset: int = 0) -> List[PromoCode]:
@@ -104,16 +132,29 @@ async def update_promo_code(session: AsyncSession, promo_id: int,
async def delete_promo_code(session: AsyncSession, promo_id: int) -> Optional[PromoCode]:
from db.dal import active_discount_dal
promo = await get_promo_code_by_id(session, promo_id)
if not promo:
return None
# First, delete related activations due to foreign key constraint
# 1. Clear all active discounts referencing this promo code
await active_discount_dal.clear_active_discounts_by_promo_code(session, promo_id)
# 2. Set promo_code_id to NULL in payments table to avoid FK violation
stmt = update(Payment).where(Payment.promo_code_id == promo_id).values(promo_code_id=None)
await session.execute(stmt)
# 3. Delete related activations
activations = await get_promo_activations_by_code_id(session, promo_id)
for activation in activations:
await session.delete(activation)
# 4. Delete the promo code itself
await session.delete(promo)
await session.flush()
logging.info(f"Promo code '{promo.code}' (ID: {promo_id}) deleted successfully")
return promo
+16
View File
@@ -162,3 +162,19 @@ async def delete_user_payment_method_by_provider_id(
await session.delete(method)
await session.flush()
return True
async def user_has_saved_payment_method(
session: AsyncSession,
user_id: int,
provider: str = "yookassa",
) -> bool:
"""Return True if the user has at least one saved payment method."""
try:
methods = await list_user_payment_methods(session, user_id, provider)
if methods:
return True
billing = await get_user_billing(session, user_id)
return bool(billing and billing.yookassa_payment_method_id)
except Exception:
return False
+135 -2
View File
@@ -1,13 +1,71 @@
import logging
import secrets
import string
from typing import Optional, List, Dict, Any, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from sqlalchemy import update, delete, func, and_
from sqlalchemy import update, delete, func, and_, or_
from datetime import datetime, timezone
from sqlalchemy.dialects.postgresql import insert as pg_insert
from ..models import User, Subscription
from ..models import (
User,
Subscription,
Payment,
PromoCodeActivation,
MessageLog,
UserBilling,
UserPaymentMethod,
AdAttribution,
)
REFERRAL_CODE_ALPHABET = string.ascii_uppercase + string.digits
REFERRAL_CODE_LENGTH = 9
MAX_REFERRAL_CODE_ATTEMPTS = 25
def _generate_referral_code_candidate() -> str:
return "".join(
secrets.choice(REFERRAL_CODE_ALPHABET) for _ in range(REFERRAL_CODE_LENGTH)
)
async def _referral_code_exists(session: AsyncSession, code: str) -> bool:
stmt = select(User.user_id).where(User.referral_code == code)
result = await session.execute(stmt)
return result.scalar_one_or_none() is not None
async def generate_unique_referral_code(session: AsyncSession) -> str:
"""
Generate a unique referral code consisting of uppercase alphanumeric characters.
Retries until a free code is found or raises RuntimeError after exceeding attempts.
"""
for _ in range(MAX_REFERRAL_CODE_ATTEMPTS):
candidate = _generate_referral_code_candidate()
if not await _referral_code_exists(session, candidate):
return candidate
raise RuntimeError("Failed to generate a unique referral code after several attempts.")
async def ensure_referral_code(session: AsyncSession, user: User) -> str:
"""
Ensure the provided user has a referral code, generating and persisting it if missing.
Returns the existing or newly generated code.
"""
if user.referral_code:
normalized = user.referral_code.strip().upper()
if normalized != user.referral_code:
user.referral_code = normalized
await session.flush()
await session.refresh(user)
return user.referral_code
user.referral_code = await generate_unique_referral_code(session)
await session.flush()
await session.refresh(user)
return user.referral_code
async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
@@ -43,6 +101,11 @@ async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> Tuple
if "registration_date" not in user_data:
user_data["registration_date"] = datetime.now(timezone.utc)
if not user_data.get("referral_code"):
user_data["referral_code"] = await generate_unique_referral_code(session)
else:
user_data["referral_code"] = user_data["referral_code"].strip().upper()
# Use PostgreSQL upsert to avoid IntegrityError on concurrent inserts
stmt = (
pg_insert(User)
@@ -71,6 +134,15 @@ async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> Tuple
return user, created
async def get_user_by_referral_code(session: AsyncSession, referral_code: str) -> Optional[User]:
normalized = referral_code.strip().upper()
if not normalized:
return None
stmt = select(User).where(User.referral_code == normalized)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def update_user(
session: AsyncSession, user_id: int, update_data: Dict[str, Any]
) -> Optional[User]:
@@ -102,6 +174,29 @@ async def get_banned_users(session: AsyncSession) -> List[User]:
return result.scalars().all()
async def get_all_users_paginated(
session: AsyncSession, *, page: int = 0, page_size: int = 15
) -> List[User]:
"""Return a slice of users ordered by newest registration first."""
safe_page = max(page, 0)
safe_page_size = max(page_size, 1)
stmt = (
select(User)
.order_by(User.registration_date.desc())
.offset(safe_page * safe_page_size)
.limit(safe_page_size)
)
result = await session.execute(stmt)
return result.scalars().all()
async def count_all_users(session: AsyncSession) -> int:
"""Count total number of users."""
result = await session.execute(select(func.count(User.user_id)))
return result.scalar_one()
async def get_all_active_user_ids_for_broadcast(session: AsyncSession) -> List[int]:
stmt = select(User.user_id).where(User.is_banned == False)
result = await session.execute(stmt)
@@ -227,3 +322,41 @@ async def get_user_ids_without_active_subscription(session: AsyncSession) -> Lis
)
result = await session.execute(stmt)
return result.scalars().all()
async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool:
"""Completely remove a user and all dependent records from the database.
This helper ensures we do not leave dangling foreign keys or orphaned data.
"""
user = await get_user_by_id(session, user_id)
if not user:
return False
# Ensure referral pointers do not block deletion
await session.execute(
update(User).where(User.referred_by_id == user_id).values(referred_by_id=None)
)
# Clean up dependent tables that do not cascade automatically
await session.execute(
delete(MessageLog).where(
or_(MessageLog.user_id == user_id, MessageLog.target_user_id == user_id)
)
)
await session.execute(delete(Payment).where(Payment.user_id == user_id))
await session.execute(
delete(Subscription).where(Subscription.user_id == user_id)
)
await session.execute(
delete(PromoCodeActivation).where(PromoCodeActivation.user_id == user_id)
)
await session.execute(
delete(UserPaymentMethod).where(UserPaymentMethod.user_id == user_id)
)
await session.execute(delete(UserBilling).where(UserBilling.user_id == user_id))
await session.execute(delete(AdAttribution).where(AdAttribution.user_id == user_id))
await session.delete(user)
await session.flush()
return True
+135
View File
@@ -48,12 +48,147 @@ def _migration_0001_add_channel_subscription_fields(connection: Connection) -> N
connection.execute(text(stmt))
def _migration_0002_add_referral_code(connection: Connection) -> None:
inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
if "referral_code" not in columns:
connection.execute(
text("ALTER TABLE users ADD COLUMN referral_code VARCHAR(16)")
)
connection.execute(
text(
"""
WITH generated_codes AS (
SELECT
user_id,
UPPER(
SUBSTRING(
md5(
user_id::text
|| clock_timestamp()::text
|| random()::text
)
FROM 1 FOR 9
)
) AS referral_code
FROM users
WHERE referral_code IS NULL OR referral_code = ''
)
UPDATE users AS u
SET referral_code = g.referral_code
FROM generated_codes AS g
WHERE u.user_id = g.user_id
"""
)
)
connection.execute(
text(
"""
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_referral_code
ON users (referral_code)
WHERE referral_code IS NOT NULL
"""
)
)
def _migration_0003_normalize_referral_codes(connection: Connection) -> None:
inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
if "referral_code" not in columns:
return
connection.execute(
text(
"""
UPDATE users
SET referral_code = UPPER(referral_code)
WHERE referral_code IS NOT NULL
AND referral_code <> UPPER(referral_code)
"""
)
)
def _migration_0004_add_discount_promo_codes(connection: Connection) -> None:
inspector = inspect(connection)
# 1. Добавить поля в payments
payment_columns: Set[str] = {col["name"] for col in inspector.get_columns("payments")}
if "original_amount" not in payment_columns:
connection.execute(text("ALTER TABLE payments ADD COLUMN original_amount FLOAT"))
if "discount_applied" not in payment_columns:
connection.execute(text("ALTER TABLE payments ADD COLUMN discount_applied FLOAT"))
# 2. Модифицировать promo_codes
promo_columns: Set[str] = {col["name"] for col in inspector.get_columns("promo_codes")}
if "promo_type" not in promo_columns:
connection.execute(
text(
"ALTER TABLE promo_codes ADD COLUMN promo_type VARCHAR NOT NULL DEFAULT 'bonus_days'"
)
)
if "discount_percentage" not in promo_columns:
connection.execute(
text("ALTER TABLE promo_codes ADD COLUMN discount_percentage INTEGER")
)
# Изменить bonus_days на nullable (если еще не nullable)
connection.execute(
text("ALTER TABLE promo_codes ALTER COLUMN bonus_days DROP NOT NULL")
)
# Создать индекс на promo_type
connection.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_promo_codes_promo_type ON promo_codes (promo_type)"
)
)
# 3. Создать таблицу active_discounts
connection.execute(
text(
"""
CREATE TABLE IF NOT EXISTS active_discounts (
user_id BIGINT PRIMARY KEY,
promo_code_id INTEGER NOT NULL,
discount_percentage INTEGER NOT NULL,
activated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT fk_active_discounts_user
FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE,
CONSTRAINT fk_active_discounts_promo_code
FOREIGN KEY (promo_code_id) REFERENCES promo_codes (promo_code_id) ON DELETE CASCADE
)
"""
)
)
MIGRATIONS: List[Migration] = [
Migration(
id="0001_add_channel_subscription_fields",
description="Add columns to track required channel subscription verification",
upgrade=_migration_0001_add_channel_subscription_fields,
),
Migration(
id="0002_add_referral_code",
description="Store short referral codes for users and backfill existing rows",
upgrade=_migration_0002_add_referral_code,
),
Migration(
id="0003_normalize_referral_codes",
description="Normalize referral codes to uppercase for consistent lookups",
upgrade=_migration_0003_normalize_referral_codes,
),
Migration(
id="0004_add_discount_promo_codes",
description="Add support for percentage discount promo codes",
upgrade=_migration_0004_add_discount_promo_codes,
),
]
+31 -2
View File
@@ -21,6 +21,7 @@ class User(Base):
server_default=func.now())
is_banned = Column(Boolean, default=False)
panel_user_uuid = Column(String, nullable=True, unique=True, index=True)
referral_code = Column(String(16), nullable=True, unique=True, index=True)
referred_by_id = Column(BigInteger,
ForeignKey("users.user_id"),
nullable=True)
@@ -99,7 +100,12 @@ class Payment(Base):
provider_payment_id = Column(String, unique=True, nullable=True)
provider = Column(String, nullable=False, default="yookassa", index=True)
idempotence_key = Column(String, unique=True, nullable=True)
amount = Column(Float, nullable=False)
amount = Column(Float, nullable=False) # Final amount paid (after discount if any)
# Discount tracking fields
original_amount = Column(Float, nullable=True) # Amount before discount
discount_applied = Column(Float, nullable=True) # Discount amount (not percentage)
currency = Column(String, nullable=False)
status = Column(String, nullable=False, index=True)
description = Column(String, nullable=True)
@@ -153,7 +159,17 @@ class PromoCode(Base):
promo_code_id = Column(Integer, primary_key=True, autoincrement=True)
code = Column(String, unique=True, nullable=False, index=True)
bonus_days = Column(Integer, nullable=False)
# Type field to distinguish promo code types
promo_type = Column(String, nullable=False, default="bonus_days", index=True)
# Values: "bonus_days" or "discount"
# For bonus_days type: number of days to add to subscription
bonus_days = Column(Integer, nullable=True)
# For discount type: percentage discount (1-100)
discount_percentage = Column(Integer, nullable=True)
max_activations = Column(Integer, nullable=False)
current_activations = Column(Integer, default=0)
is_active = Column(Boolean, default=True)
@@ -190,6 +206,19 @@ class PromoCodeActivation(Base):
name='uq_promo_user_activation'), )
class ActiveDiscount(Base):
"""Tracks pending discount promo codes awaiting payment (permanent until used)"""
__tablename__ = "active_discounts"
user_id = Column(BigInteger, ForeignKey("users.user_id"), primary_key=True)
promo_code_id = Column(Integer, ForeignKey("promo_codes.promo_code_id"), nullable=False)
discount_percentage = Column(Integer, nullable=False)
activated_at = Column(DateTime(timezone=True), server_default=func.now())
promo_code = relationship("PromoCode")
user = relationship("User")
class MessageLog(Base):
__tablename__ = "message_logs"
+41
View File
@@ -0,0 +1,41 @@
services:
remnawave-tg-shop:
image: kavore/remnawave-tg-shop:latest
container_name: remnawave-tg-shop
hostname: remnawave-tg-shop
env_file:
- .env
ports:
- '127.0.0.1:8080:8080'
networks:
- remnawave-network
environment:
- TZ=UTC
volumes:
- ./locales:/app/locales
restart: unless-stopped
depends_on:
- remnawave-tg-shop-db
remnawave-tg-shop-db:
image: postgres:17
container_name: remnawave-tg-shop-db
hostname: remnawave-tg-shop-db
env_file:
- .env
environment:
- TZ=UTC
volumes:
- remnawave-tg-shop-db-data:/var/lib/postgresql/data
networks:
- remnawave-network
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 20
networks:
remnawave-network: null
volumes:
remnawave-tg-shop-db-data:
name: remnawave-tg-shop-db-data
+1 -1
View File
@@ -1,6 +1,6 @@
services:
remnawave-tg-shop:
image: ghcr.io/machka-pasla/remnawave-tg-shop:latest
image: kavore/remnawave-tg-shop:latest
# build: .
container_name: remnawave-tg-shop
hostname: remnawave-tg-shop
+56 -8
View File
@@ -25,8 +25,11 @@
"error_displaying_menu": "Error displaying menu.",
"main_menu_unknown_action": "Unknown action.",
"select_subscription_period": "Select subscription period:",
"select_traffic_package": "Select a traffic package:",
"subscribe_for_months_button": "{months} mo. - {price} {currency_symbol}",
"buy_traffic_package_button": "{traffic_gb} GB - {price} {currency_symbol}",
"choose_payment_method": "Choose payment method:",
"choose_payment_method_traffic": "Choose how to pay for the traffic package:",
"pay_button": "💳 Pay",
"pay_with_yookassa_button": "💳 YooKassa",
"yookassa_autopay_flow_prompt": "Auto-renew is enabled. Choose how you'd like to pay:",
@@ -37,9 +40,10 @@
"back_to_autopay_method_choice_button": "⬅️ Back",
"yookassa_autopay_charge_initiated": "Charge request sent to the selected card. We'll notify you once the payment completes.",
"pay_with_sbp_button": "📱 SBP",
"pay_with_platega_button": "💳 Platega (SBP/Cards)",
"pay_with_severpay_button": "💳 SeverPay",
"back_to_payment_methods_button": "⬅️ Back",
"pay_with_cryptopay_button": "💎 CryptoBot",
"pay_with_tribute_button": "❤️ Tribute",
"pay_with_stars_button": "🌟 Telegram Stars",
"connect_button": "🔗 Connect",
"cancel_button": "❌ Cancel",
@@ -55,11 +59,15 @@
"my_devices_feature_disabled": "The My Devices section is currently unavailable.",
"payment_description_subscription": "Subscription payment for {months} mo.",
"payment_description_traffic": "Traffic package {traffic_gb} GB",
"payment_link_message": "To pay for {months} mo. subscription, click the button below:",
"payment_link_message_traffic": "To pay for a {traffic_gb} GB package, tap the button below:",
"free_kassa_order_info": "Order #{order_id} from {date}",
"payment_invoice_sent_message": "Telegram has sent the invoice above. Complete the payment or pick another method below.",
"payment_invoice_sent_message_traffic": "Invoice for {traffic_gb} GB sent above. Complete the payment or pick another method below.",
"payment_successful_error_details": "✅ Payment succeeded, but an error occurred displaying details. Your subscription is active. Contact support if anything is wrong.",
"payment_successful_full": "✅ Payment successful!\nYour {months}-month subscription is active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
"payment_successful_traffic_full": "✅ Payment successful!\nYour {traffic_gb} GB package is active.\nValidity: {end_date}\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
"payment_successful_with_referral_bonus_full": "✅ Payment successful!\nYour {months}-month subscription (base end date: {base_end_date}) has been extended by {bonus_days} bonus days for referral from {inviter_name} and is now active until {final_end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
"payment_failed": "❌ Payment failed or was cancelled. Please try again or contact support.",
"config_link_not_available": "not available, contact support",
@@ -68,6 +76,10 @@
"promo_code_not_found": "Promo code <code>{code}</code> not found, expired, or already used the maximum number of times.",
"promo_code_already_used_by_user": "You have already used promo code <code>{code}</code>.",
"promo_code_applied_success_full": "✅ Promo code applied successfully!\nSubscription active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
"discount_promo_code_applied_success": "✅ Promo code <code>{code}</code> activated!\n\n💰 A {discount}% discount will be applied to your next purchase.\n\nSelect a plan for payment.",
"discount_promo_already_active": "❌ You already have an active discount promo code (<code>{code}</code>, -{discount_pct}%). Use it first or wait until the payment is complete.",
"promo_code_not_found_or_not_discount": "❌ Promo code <code>{code}</code> not found or is invalid.",
"active_discount_notice": "🎁 Active discount: <code>{code}</code> (-{discount_pct}%)\n💵 Price: <s>{original_price}</s> ➔ <b>{discounted_price}</b>\n💰 Savings: {discount_amount}",
"error_applying_promo_bonus": "Failed to apply promo bonus. Please try again later or contact support.",
"promo_input_cancelled_short": "Promo code entry cancelled.",
"trial_feature_disabled": "Free trial is currently unavailable.",
@@ -79,6 +91,7 @@
"no_button": "No",
"referral_program_info_new": "🎁 <b>Referral Program</b>\n\n📊 <b>Your stats:</b>\n👥 Friends invited: <b>{invited_count}</b>\n💳 Purchased subscription: <b>{purchased_count}</b>\n\n🔗 Your link:\n<code>{referral_link}</code>\n\n💰 <b>Invitation bonuses:</b>\n{bonus_details}\n\n📢 Share the link with friends and get bonuses!",
"referral_bonus_per_period": "\n\n🎁 For a friend's {months}-month subscription:\n ➢ You: <b>{inviter_bonus_days} days</b>\n ➢ Friend: <b>{referee_bonus_days} days</b>",
"referral_not_available_for_traffic": "Referral bonuses are not available for traffic packages.",
"referral_share_message_button": "📩 Message for friend",
"referral_friend_message": "🚀 Hey! Try this VPN - it's fast, reliable and affordable!\n\n🎁 Use my link to get bonus days with your subscription!\n\n{referral_link}",
"friend_placeholder": "friend",
@@ -101,7 +114,10 @@
"admin_promo_marketing_section": "🎁 Promos",
"admin_system_functions_section": "⚙️ System",
"admin_ban_management_section": "🚫 Bans",
"admin_users_management_button": "👤 Management",
"admin_users_search_button": "🔍 Search",
"admin_users_management_button": "👥 User List",
"admin_users_list_header": "👥 <b>User List</b>\n\nPage {current}/{total} ({total_users} users)",
"admin_user_back_to_list_button": "⬅️ Back to List",
"back_to_user_management_button": "⬅️ To Users",
"back_to_admin_panel_button": "⬅️ To Admin",
"admin_stats_header": "📊 Bot Statistics",
@@ -127,6 +143,7 @@
"admin_csv_provider": "Provider",
"admin_csv_status": "Status",
"admin_csv_description": "Description",
"admin_csv_units": "Months/GB",
"admin_csv_months": "Months",
"admin_csv_created_at": "Created At",
"admin_csv_provider_payment_id": "Provider Payment ID",
@@ -156,6 +173,7 @@
"admin_promo_invalid_max_activations": "Max activations must be a positive number.",
"admin_promo_invalid_validity_days": "Validity period (in days) must be a positive number.",
"admin_promo_created_success": "✅ Promo code <code>{code}</code> created successfully!\nBonus: {bonus_days} days\nMax uses: {max_activations}\nValid until: {valid_until_str}.",
"admin_promo_created_success_discount": "✅ Promo code <code>{code}</code> created successfully!\nDiscount: {discount_percentage}%\nMax uses: {max_activations}\nValid until: {valid_until_str}.",
"admin_promo_set_validity_days": "⏰ Set validity (days)",
"admin_back_to_panel": "⬅️ Back to panel",
"admin_promo_unlimited": "♾️ Unlimited",
@@ -190,11 +208,13 @@
"csv_no": "No",
"admin_promo_edit_select_field": "Select a field to edit:",
"admin_promo_prompt_bonus_days": "Enter the new number of bonus days:",
"admin_promo_prompt_discount_percentage": "Enter the new discount percentage (1-100):",
"admin_promo_prompt_max_activations": "Enter the new maximum number of activations:",
"admin_promo_prompt_validity_days": "Enter the new validity period in days (0 for indefinite):",
"admin_promo_edit_success": "Promo code updated successfully.",
"admin_promo_invalid_input": "Invalid input, please try again.",
"admin_promo_edit_bonus_days": "🎁 Bonus Days",
"admin_promo_edit_discount_percentage": "💰 Discount %",
"admin_promo_edit_max_activations": "🔢 Max Activations",
"admin_promo_edit_validity": "⏰ Validity",
"admin_ban_user_prompt": "Enter user ID or @username to ban:",
@@ -207,6 +227,8 @@
"admin_user_card_title": "User Card",
"user_card_ban_button": "🚫 Ban",
"user_card_unban_button": "✅ Unban",
"user_card_open_profile_button": "👤 Open profile",
"user_card_open_referrer_profile_button": "👤 Referrer profile",
"user_card_back_to_banned_list_button": "⬅️ Back to Ban List",
"admin_logs_menu_title": "Logs Menu:",
"admin_view_all_logs_button": "📜 All Message Logs",
@@ -233,8 +255,6 @@
"autorenew_48h_charge_tomorrow_notice": "🔔 Reminder\n\nTomorrow an automatic charge will occur to renew your subscription. If you don't want auto-renew, disable it using the button below.",
"autorenew_confirm_enable": "🔄 Enable auto-renew? An automatic charge will be attempted before your subscription ends.",
"autorenew_confirm_disable": "🛑 Disable auto-renew? No further automatic charges will occur.",
"tribute_subscription_cancelled": "🚨 <b>Subscription Cancelled</b>\n\nYour Tribute subscription has been cancelled. You have 24 hours to restore access, after which the subscription will be blocked.\n\nTo renew your subscription, press the button below.",
"tribute_auto_renewal": "🔄 <b>Subscription Auto-Renewed</b>\n\nYour Tribute subscription has been automatically renewed for {months} months.\nNew expiration date: {end_date}",
"yookassa_auto_renewal": "🔄 <b>Subscription Auto-Renewed</b>\n\nYour subscription was automatically renewed for {months} month(s).\nNew expiration date: {end_date}",
"admin_user_management_prompt": "👤 User Management\n\nEnter user ID or @username to search:",
"admin_user_subscription_info": "Subscription Information:",
@@ -244,6 +264,16 @@
"admin_user_send_message_button": "✉️ Send Message",
"admin_user_view_logs_button": "📜 User Actions",
"admin_user_refresh_button": "🔄 Refresh",
"admin_user_delete_button": "❌ Delete User",
"admin_user_delete_not_allowed": "❌ You are not permitted to delete users.",
"admin_user_delete_confirmation_prompt": "⚠️ You are about to delete user {user_id} completely.\n\nSend the exact Telegram ID of this user to confirm.\nAny other reply will cancel the operation.",
"admin_user_delete_state_missing": "⚠️ No active delete operation. Start again.",
"admin_user_delete_cancelled": "Deletion cancelled.",
"admin_user_delete_mismatch": "⚠️ ID mismatch. Deletion aborted.",
"admin_user_delete_already_removed": "️ The user is already removed.",
"admin_user_delete_panel_error": "❌ Failed to remove the user from the panel. Operation aborted.",
"admin_user_delete_success": "✅ User {user_id} was removed from the bot and the panel.",
"admin_user_delete_error": "❌ Unable to delete the user. Please try again later.",
"admin_user_search_new_button": "🔍 Find Another",
"admin_user_view_all_logs_button": "📋 All Actions",
"admin_user_back_to_card_button": "🔙 Back to Card",
@@ -257,6 +287,7 @@
"admin_user_invalid_days": "❌ Invalid number of days. Enter number from 1 to 3650.",
"admin_user_subscription_added_success": "✅ Successfully added {days} days to user {user_id}",
"admin_user_subscription_added_error": "❌ Error adding subscription days",
"admin_panel_user_creation_failed": "❌ Failed to create or link panel user for ID {user_id}. Manual intervention required.",
"admin_user_ban_toggle_success": "✅ User {status}",
"admin_user_ban_toggle_error": "❌ Error changing ban status",
"admin_user_ban_success": "✅ User {input} has been banned",
@@ -277,10 +308,14 @@
"inline_admin_financial_stats_title": "💰 Financial Statistics",
"inline_system_stats_message": "🖥 <b>Panel Statistics</b>\n\n🟢 Online: <b>{online}</b>\n📊 Active: <b>{active}</b>\n🔴 Disabled: <b>{disabled}</b>\n⏰ Expired: <b>{expired}</b>\n⚠️ Limited: <b>{limited}</b>\n👥 Total users: <b>{total}</b>\n💾 RAM Usage: <b>{memory:.1f}%</b>\n📊 Week traffic: <b>{week_traffic}</b>\n📊 Month traffic: <b>{month_traffic}</b>\n🔗 Active nodes: <b>{active_nodes}/{total_nodes}</b>",
"inline_admin_system_stats_title": "🖥 System Statistics",
"log_referral_suffix": " (referral from {referrer_id})",
"log_referral_suffix": " (referral from {referrer_link})",
"log_open_profile_link": "👤 Open profile",
"log_open_referrer_profile_button": "👤 Referrer profile",
"log_new_user_registration": "👤 <b>New User</b>\n\n🆔 ID: <code>{user_id}</code>\n👤 Name: {user_display}{referral_text}\n📅 Time: {timestamp}",
"log_payment_received": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n📅 Period: <b>{months} mo.</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
"log_payment_received_traffic": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n🗂 Traffic: <b>{traffic_gb} GB</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
"log_promo_activation": "🎁 <b>Promo Code Activated</b>\n\n👤 User: {user_display}\n🏷 Code: <code>{promo_code}</code>\n🎯 Bonus: <b>+{bonus_days}d</b>\n🕐 Time: {timestamp}",
"log_promo_discount_activation": "💰 <b>Discount Promo Code Activated</b>\n\n👤 User: {user_display}\n🏷 Code: <code>{promo_code}</code>\n💵 Discount: <b>{discount_percentage}%</b>\n🕐 Time: {timestamp}",
"log_trial_activation": "🆓 <b>Trial Activated</b>\n\n👤 User: {user_display}\n⏰ Valid until: <b>{end_date}</b>\n🕐 Time: {timestamp}",
"log_panel_sync": "{status_emoji} <b>Panel Synchronization</b>\n\n📊 Status: <b>{status}</b>\n👥 Users processed: <b>{users_processed}</b>\n📋 Subscriptions synced: <b>{subs_synced}</b>\n🕐 Time: {timestamp}\n\n📝 Details:\n{details}",
"log_suspicious_promo": "⚠️ <b>Suspicious Promo Code Attempt</b>\n\n👤 User: {user_display}\n🆔 ID: <code>{user_id}</code>\n📝 Input: <pre>{suspicious_input}</pre>\n🕐 Time: {timestamp}",
@@ -308,10 +343,16 @@
"admin_bulk_promo_invalid_quantity": "❌ Quantity must be between 1 and 1000",
"admin_bulk_promo_enter_validity_days": "⏰ Enter the number of validity days for promo codes (1-365):",
"admin_bulk_promo_creating": "⏳ Creating {quantity} promo codes...",
"admin_promo_step0_type": "Choose promo code type:",
"admin_promo_type_bonus_days": "🎁 Bonus Days (subscription extension)",
"admin_promo_type_discount": "💰 Purchase Discount (%)",
"admin_promo_step1_code": "🎟 <b>Create Promo Code</b>\n\n<b>Step 1 of 4:</b> Promo Code\n\nEnter promo code (3-30 characters, letters and numbers only):",
"admin_promo_step2_bonus_days": "🎟 <b>Create Promo Code</b>\n\n<b>Step 2 of 4:</b> Bonus Days\n\nCode: <b>{code}</b>\n\nEnter the number of bonus days (1-365):",
"admin_promo_step2_discount_percentage": "🎟 <b>Create Promo Code</b>\n\n<b>Step 2 of 4:</b> Discount Percentage\n\nCode: <b>{code}</b>\n\nEnter the discount percentage for the promo code (1-100):",
"admin_promo_step3_max_activations": "🎟 <b>Create Promo Code</b>\n\n<b>Step 3 of 4:</b> Max Activations\n\nCode: <b>{code}</b>\nBonus days: <b>{bonus_days}</b>\n\nEnter the maximum number of activations (1-10000):",
"admin_promo_step3_max_activations_discount": "🎟 <b>Create Promo Code</b>\n\n<b>Step 3 of 4:</b> Max Activations\n\nCode: <b>{code}</b>\nDiscount: <b>{discount_percentage}%</b>\n\nEnter the maximum number of activations (1-10000):",
"admin_promo_step4_validity": "🎟 <b>Create Promo Code</b>\n\n<b>Step 4 of 4:</b> Validity Period\n\nCode: <b>{code}</b>\nBonus days: <b>{bonus_days}</b>\nMax activations: <b>{max_activations}</b>\n\nChoose the validity period for the promo code:",
"admin_promo_step4_validity_discount": "🎟 <b>Create Promo Code</b>\n\n<b>Step 4 of 4:</b> Validity Period\n\nCode: <b>{code}</b>\nDiscount: <b>{discount_percentage}%</b>\nMax activations: <b>{max_activations}</b>\n\nChoose the validity period for the promo code:",
"admin_promo_code_already_exists": "❌ A promo code with this code already exists",
"admin_promo_unlimited_validity": "♾️ Unlimited",
"admin_promo_enter_validity_days": "⏰ Enter the number of validity days for the promo code (1-365):",
@@ -336,6 +377,8 @@
"admin_user_na_value": "N/A",
"admin_user_subscription_none": "No active subscription",
"admin_user_actions_count_label": "📜 <b>Total actions:</b>",
"admin_user_total_paid_label": "💰 <b>Total Paid:</b>",
"admin_user_referral_revenue_label": "💸 <b>Referral Revenue:</b>",
"admin_user_invited_friends_label": "👥 <b>Friends invited:</b>",
"admin_user_ref_purchased_label": "💳 <b>Purchased subscription:</b>",
"admin_user_subscription_active_until": "⏰ <b>Active until:</b>",
@@ -344,7 +387,9 @@
"admin_promo_management_title": "🎟 <b>Promo Code Management</b>\n\nSelect a promo code for detailed view:",
"admin_promo_management_empty": "📭 No promo codes available",
"admin_promo_card_title": "🎟 <b>Promo Code: {code}</b>",
"admin_promo_card_type": "📌 Type: {type}",
"admin_promo_card_bonus_days": "🎁 Bonus days: <b>{days}</b>",
"admin_promo_card_discount_percentage": "💰 Discount: <b>{percentage}%</b>",
"admin_promo_card_activations": "🔢 Activations: <b>{current}/{max}</b>",
"admin_promo_card_validity": "⏰ Valid until: <b>{validity}</b>",
"admin_promo_card_status": "📊 Status: <b>{status}</b>",
@@ -397,7 +442,11 @@
"admin_sync_no_telegram_id": "\n⚠️ Records without telegramId: {count}",
"admin_sync_not_found_in_db": "\n❌ Not found in DB: {count}",
"admin_payments_pagination_info": "📊 Showing {shown} of {total} payments (page {current_page}/{total_pages})",
"admin_payment_traffic_label": "🗂 Traffic: <b>{traffic_gb} GB</b>",
"admin_payment_months_label": "📅 Period: <b>{months} mo.</b>",
"my_subscription_details": "🔐 <b>My Subscription</b>\n\n⏰ Status: <b>{status}</b>\n📅 Active until: <b>{end_date}</b>\n📆 Days left: <b>{days_left}</b>\n\n🔗 Configuration link:\n<code>{config_link}</code>\n\n📊 Traffic:\nLimit: <b>{traffic_limit}</b>\nUsed: <b>{traffic_used}</b>",
"my_traffic_details": "🔐 <b>My Traffic</b>\n\n⏰ Status: <b>{status}</b>\n📅 Valid until: <b>{end_date}</b>\n\n🔗 Configuration link:\n<code>{config_link}</code>\n\n📊 Traffic:\nLimit: <b>{traffic_limit}</b>\nUsed: <b>{traffic_used}</b>\nLeft: <b>{traffic_left}</b>",
"traffic_no_expiry": "no limit",
"autorenew_enable_button": "🔄 Enable auto-renew",
"autorenew_disable_button": "🛑 Disable auto-renew",
"subscription_autorenew_updated": "Auto-renew settings updated.",
@@ -420,9 +469,8 @@
"payment_method_tx_history_title": "📜 Transactions history",
"payment_method_no_history": "No transactions history.",
"subscription_purchase_title": "Subscription purchase for {months} mo.",
"subscription_tribute_notice": "Paid via Tribute. Renew using your Tribute link.",
"subscription_tribute_notice_with_link": "Paid via Tribute. Renew: {link}",
"subscription_autorenew_not_supported_for_tribute": "Auto-renew is handled by Tribute. Manage renewal in the Tribute app/link.",
"traffic_purchase_title": "Traffic purchase {traffic_gb} GB",
"autorenew_enable_requires_card": "Link a payment card in Payment Methods before enabling auto-renew.",
"subscription_not_active": "You don't have an active subscription.",
"error_service_unavailable": "Service unavailable. Please try again later.",
"error_payment_gateway": "Payment service error. Please try again later.",
+59 -9
View File
@@ -9,7 +9,7 @@
"menu_activate_trial_button": "🆓 Пробный период",
"menu_subscribe_inline": "🚀 Купить",
"menu_my_subscription_inline": "🔐 Моя подписка",
"no_subscription_options_available": "Выдача подписки не натроена администратором бота",
"no_subscription_options_available": "Выдача подписки не настроена администратором бота",
"menu_referral_inline": "🎁 Рефералы",
"referral_no_bonuses_configured": "Извините, реферальная программа в данный момент отключена",
"menu_apply_promo_button": "🎟 Промокод",
@@ -25,8 +25,11 @@
"error_displaying_menu": "Ошибка отображения меню.",
"main_menu_unknown_action": "Неизвестное действие.",
"select_subscription_period": "Выберите срок подписки:",
"select_traffic_package": "Выберите пакет трафика:",
"subscribe_for_months_button": "{months} мес. - {price} {currency_symbol}",
"buy_traffic_package_button": "{traffic_gb} ГБ - {price} {currency_symbol}",
"choose_payment_method": "Выберите способ оплаты:",
"choose_payment_method_traffic": "Выберите способ оплаты пакета трафика:",
"pay_button": "💳 Оплатить",
"pay_with_yookassa_button": "💳 ЮKassa",
"yookassa_autopay_flow_prompt": "Автопродление включено. Выберите, как оплатить подписку:",
@@ -37,9 +40,10 @@
"back_to_autopay_method_choice_button": "⬅️ Назад",
"yookassa_autopay_charge_initiated": "Запрос на списание с выбранной карты отправлен. Сообщим, как только платёж завершится.",
"pay_with_sbp_button": "📱 СБП",
"pay_with_platega_button": "💳 Platega (СБП/карты)",
"pay_with_severpay_button": "💳 SeverPay",
"back_to_payment_methods_button": "⬅️ Назад",
"pay_with_cryptopay_button": "💎 CryptoBot",
"pay_with_tribute_button": "❤️ Tribute",
"pay_with_stars_button": "🌟 Звезды Telegram",
"connect_button": "🔗 Подключиться",
@@ -55,11 +59,15 @@
"cancel_button": "❌ Отмена",
"payment_description_subscription": "Оплата подписки на {months} мес.",
"payment_description_traffic": "Пакет трафика {traffic_gb} ГБ",
"payment_link_message": "Для оплаты подписки на {months} мес., нажмите кнопку ниже:",
"payment_link_message_traffic": "Для оплаты пакета {traffic_gb} ГБ нажмите кнопку ниже:",
"free_kassa_order_info": "Заказ №{order_id} от {date}",
"payment_invoice_sent_message": "Счёт Telegram Stars отправлен выше. Нажмите «Оплатить» или вернитесь к выбору способа ниже.",
"payment_invoice_sent_message_traffic": "Счет на пакет {traffic_gb} ГБ отправлен выше. Нажмите «Оплатить» или вернитесь к выбору способа ниже.",
"payment_successful_error_details": "✅ Оплата прошла успешно, но возникла ошибка при отображении деталей. Ваша подписка активна. Свяжитесь с поддержкой, если что-то не так.",
"payment_successful_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
"payment_successful_traffic_full": "✅ Оплата прошла успешно!\nВаш пакет {traffic_gb} ГБ активирован.\nДата действия: {end_date}\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
"payment_successful_with_referral_bonus_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (базовая дата окончания: {base_end_date}) продлена на {bonus_days} бонусных дней за приглашение от {inviter_name} и теперь активна до {final_end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
"payment_failed": "❌ Оплата не удалась или была отменена. Пожалуйста, попробуйте еще раз или свяжитесь с поддержкой.",
"config_link_not_available": "недоступна, обратитесь в поддержку",
@@ -68,7 +76,12 @@
"promo_code_not_found": "Промокод <code>{code}</code> не найден, истек или уже использован максимальное количество раз.",
"promo_code_already_used_by_user": "Вы уже активировали промокод <code>{code}</code>.",
"promo_code_applied_success_full": "✅ Промокод успешно применен!\nПодписка активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
"discount_promo_code_applied_success": "✅ Промокод <code>{code}</code> активирован!\n\n💰 Скидка {discount}% будет применена к вашей следующей покупке.\n\nВыберите тариф для оплаты.",
"discount_promo_already_active": "❌ У вас уже есть активированный промокод на скидку (<code>{code}</code>, -{discount_pct}%). Используйте его сначала или дождитесь окончания платежа.",
"promo_code_not_found_or_not_discount": "❌ Промокод <code>{code}</code> не найден или недействителен.",
"active_discount_notice": "🎁 Активна скидка: <code>{code}</code> (-{discount_pct}%)\n💵 Цена: <s>{original_price}</s> ➔ <b>{discounted_price}</b>\n💰 Экономия: {discount_amount}",
"error_applying_promo_bonus": "Не удалось применить бонус по промокоду. Пожалуйста, попробуйте позже или свяжитесь с поддержкой.",
"error_applying_promo_discount": "❌ Ошибка при активации промокода. Попробуйте позже.",
"promo_input_cancelled_short": "Ввод промокода отменен.",
"trial_feature_disabled": "Пробный период в данный момент недоступен.",
"trial_already_had_subscription_or_trial": "Вы уже использовали пробный период или у вас была платная подписка. Пробный период доступен только один раз для новых пользователей.",
@@ -79,6 +92,7 @@
"no_button": "Нет",
"referral_program_info_new": "🎁 <b>Реферальная программа</b>\n\n📊 <b>Твоя статистика:</b>\n👥 Приглашено друзей: <b>{invited_count}</b>\n💳 Купили подписку: <b>{purchased_count}</b>\n\n🔗 Твоя ссылка:\n<code>{referral_link}</code>\n\n💰 <b>Бонусы за приглашения:</b>\n{bonus_details}\n\n📢 Поделись ссылкой с друзьями и получай бонусы!",
"referral_bonus_per_period": "\n\n🎁 За {months}-мес. подписку друга:\n ➢ Вы: <b>{inviter_bonus_days} дн.</b>\n ➢ Друг: <b>{referee_bonus_days} дн.</b>",
"referral_not_available_for_traffic": "Для пакетов трафика реферальные бонусы не начисляются.",
"referral_share_message_button": "📩 Сообщение для друга",
"referral_friend_message": "🚀 Привет! Попробуй этот VPN - быстрый, надёжный и доступный!\n\n🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}",
"friend_placeholder": "друг",
@@ -101,7 +115,10 @@
"admin_promo_marketing_section": "🎁 Промокоды",
"admin_system_functions_section": "⚙️ Система",
"admin_ban_management_section": "🚫 Блокировки",
"admin_users_management_button": "👤 Управление",
"admin_users_search_button": "🔍 Поиск",
"admin_users_management_button": "👥 Список пользователей",
"admin_users_list_header": "👥 <b>Список пользователей</b>\n\nСтраница {current}/{total} ({total_users} пользователей)",
"admin_user_back_to_list_button": "⬅️ К списку",
"back_to_user_management_button": "⬅️ К пользователям",
"back_to_admin_panel_button": "⬅️ В админку",
"admin_stats_header": "📊 Статистика Бота",
@@ -127,6 +144,7 @@
"admin_csv_provider": "Платежная система",
"admin_csv_status": "Статус",
"admin_csv_description": "Описание",
"admin_csv_units": "Месяцы/ГБ",
"admin_csv_months": "Месяцев",
"admin_csv_created_at": "Дата создания",
"admin_csv_provider_payment_id": "ID платежа в системе",
@@ -156,6 +174,7 @@
"admin_promo_invalid_max_activations": "Максимальное количество активаций должно быть положительным числом.",
"admin_promo_invalid_validity_days": "Срок действия промокода (в днях) должен быть положительным числом.",
"admin_promo_created_success": "✅ Промокод <code>{code}</code> успешно создан!\nБонус: {bonus_days} дней\nМакс. активаций: {max_activations}\nДействителен: {valid_until_str}",
"admin_promo_created_success_discount": "✅ Промокод <code>{code}</code> успешно создан!\nСкидка: {discount_percentage}%\nМакс. активаций: {max_activations}\nДействителен: {valid_until_str}",
"subscription_72h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 3 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
"subscription_48h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 2 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
"subscription_24h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 1 день — {end_date}.\n\nПродлите её по кнопке ниже.",
@@ -164,7 +183,6 @@
"autorenew_48h_charge_tomorrow_notice": "🔔 Напоминание\n\nЗавтра будет автоматическое списание за продление подписки. Если вы не хотите автопродление — отключите его кнопкой ниже.",
"autorenew_confirm_enable": "🔄 Включить автопродление? Перед окончанием подписки будет выполняться автосписание.",
"autorenew_confirm_disable": "🛑 Отключить автопродление? Автосписаний больше не будет.",
"tribute_subscription_cancelled": "🚨 <b>Подписка отменена</b>\n\nВаша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, после чего подписка будет заблокирована.\n\nДля продления подписки нажмите кнопку ниже.",
"yookassa_auto_renewal": "🔄 <b>Подписка автоматически продлена</b>\n\nВаша подписка была автоматически продлена на {months} мес.\nНовая дата окончания: {end_date}",
"admin_promo_set_validity_days": "⏰ Установить срок (дни)",
"admin_back_to_panel": "⬅️ В панель",
@@ -200,11 +218,13 @@
"csv_no": "Нет",
"admin_promo_edit_select_field": "Выберите поле для редактирования:",
"admin_promo_prompt_bonus_days": "Введите новое количество бонусных дней:",
"admin_promo_prompt_discount_percentage": "Введите новый процент скидки (от 1 до 100):",
"admin_promo_prompt_max_activations": "Введите новое максимальное количество активаций:",
"admin_promo_prompt_validity_days": "Введите новый срок действия в днях (0 для бессрочного):",
"admin_promo_edit_success": "Промокод успешно обновлен.",
"admin_promo_invalid_input": "Неверный ввод, попробуйте еще раз.",
"admin_promo_edit_bonus_days": "🎁 Бонусные дни",
"admin_promo_edit_discount_percentage": "💰 Процент скидки",
"admin_promo_edit_max_activations": "🔢 Макс. активации",
"admin_promo_edit_validity": "⏰ Срок действия",
"admin_ban_user_prompt": "Введите ID или @username пользователя для блокировки:",
@@ -217,6 +237,8 @@
"admin_user_card_title": "Карточка пользователя",
"user_card_ban_button": "🚫 Заблокировать",
"user_card_unban_button": "✅ Разблокировать",
"user_card_open_profile_button": "👤 Открыть профиль",
"user_card_open_referrer_profile_button": "👤 Профиль пригласившего",
"user_card_back_to_banned_list_button": "⬅️ К списку забаненных",
"admin_logs_menu_title": "Меню логов:",
"admin_view_all_logs_button": "📜 Все логи сообщений",
@@ -235,7 +257,6 @@
"admin_broadcast_invalid_html": "❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.",
"error_displaying_logs_too_long": "Ошибка: логи слишком длинные для отображения одним сообщением. Попробуйте найти логи по конкретному пользователю.",
"error_displaying_statistics": "Ошибка отображения статистики.",
"tribute_auto_renewal": "🔄 <b>Подписка автоматически продлена</b>\n\nВаша подписка Tribute была автоматически продлена на {months} мес.\nНовая дата окончания: {end_date}",
"admin_user_management_prompt": "👤 Управление пользователями\n\nВведите ID пользователя или @username для поиска:",
"admin_user_subscription_info": "Информация о подписке:",
"admin_user_reset_trial_button": "🔄 Сбросить триал",
@@ -244,6 +265,16 @@
"admin_user_send_message_button": "✉️ Сообщение",
"admin_user_view_logs_button": "📜 Логи",
"admin_user_refresh_button": "🔄 Обновить",
"admin_user_delete_button": "❌ Удалить пользователя",
"admin_user_delete_not_allowed": "❌ У вас нет прав для удаления пользователей.",
"admin_user_delete_confirmation_prompt": "⚠️ Вы хотите полностью удалить пользователя {user_id}.\n\nОтправьте точный Telegram ID этого пользователя, чтобы подтвердить удаление.\nЛюбой другой ответ отменит операцию.",
"admin_user_delete_state_missing": "⚠️ Нет активной операции удаления. Начните заново.",
"admin_user_delete_cancelled": "Удаление отменено.",
"admin_user_delete_mismatch": "⚠️ ID не совпадает. Удаление отменено.",
"admin_user_delete_already_removed": "ℹ️ Пользователь уже удалён.",
"admin_user_delete_panel_error": "❌ Не удалось удалить пользователя на панели. Операция прервана.",
"admin_user_delete_success": "✅ Пользователь {user_id} удалён из бота и панели.",
"admin_user_delete_error": "❌ Не удалось удалить пользователя. Попробуйте позже.",
"admin_user_search_new_button": "🔍 Новый поиск",
"admin_user_view_all_logs_button": "📋 Все логи",
"admin_user_back_to_card_button": "🔙 К карточке",
@@ -257,6 +288,7 @@
"admin_user_invalid_days": "❌ Неверное количество дней. Введите число от 1 до 3650.",
"admin_user_subscription_added_success": "✅ Успешно добавлено {days} дней подписки пользователю {user_id}",
"admin_user_subscription_added_error": "❌ Ошибка добавления дней подписки",
"admin_panel_user_creation_failed": "❌ Не удалось создать или привязать пользователя в панели для ID {user_id}. Требуется ручная проверка.",
"admin_user_ban_toggle_success": "✅ Пользователь {status}",
"admin_user_ban_toggle_error": "❌ Ошибка изменения статуса блокировки",
"admin_user_ban_success": "✅ Пользователь {input} заблокирован",
@@ -277,10 +309,14 @@
"inline_admin_financial_stats_title": "💰 Финансовая статистика",
"inline_system_stats_message": "🖥 <b>Статистика панели</b>\n\n🟢 Онлайн: <b>{online}</b>\n📊 Активных: <b>{active}</b>\n🔴 Отключенных: <b>{disabled}</b>\n⏰ Истекшие: <b>{expired}</b>\n⚠️ Ограниченные: <b>{limited}</b>\n👥 Всего пользователей: <b>{total}</b>\n💾 Использование RAM: <b>{memory:.1f}%</b>\n📊 Трафик за неделю: <b>{week_traffic}</b>\n📊 Трафик за месяц: <b>{month_traffic}</b>\n🔗 Активных нод: <b>{active_nodes}/{total_nodes}</b>",
"inline_admin_system_stats_title": "🖥 Системная статистика",
"log_referral_suffix": " (реферал от {referrer_id})",
"log_referral_suffix": " (реферал от {referrer_link})",
"log_open_profile_link": "👤 Открыть профиль",
"log_open_referrer_profile_button": "👤 Профиль пригласившего",
"log_new_user_registration": "👤 <b>Новый пользователь</b>\n\n🆔 ID: <code>{user_id}</code>\n👤 Имя: {user_display}{referral_text}\n📅 Время: {timestamp}",
"log_payment_received": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n📅 Период: <b>{months} мес.</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
"log_payment_received_traffic": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n🗂 Трафик: <b>{traffic_gb} ГБ</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
"log_promo_activation": "🎁 <b>Активирован промокод</b>\n\n👤 Пользователь: {user_display}\n🏷 Код: <code>{promo_code}</code>\n🎯 Бонус: <b>+{bonus_days} дн.</b>\n🕐 Время: {timestamp}",
"log_promo_discount_activation": "💰 <b>Активирован промокод на скидку</b>\n\n👤 Пользователь: {user_display}\n🏷 Код: <code>{promo_code}</code>\n💵 Скидка: <b>{discount_percentage}%</b>\n🕐 Время: {timestamp}",
"log_trial_activation": "🆓 <b>Активирован триал</b>\n\n👤 Пользователь: {user_display}\n⏰ Действует до: <b>{end_date}</b>\n🕐 Время: {timestamp}",
"log_panel_sync": "{status_emoji} <b>Синхронизация с панелью</b>\n\n📊 Статус: <b>{status}</b>\n👥 Обработано пользователей: <b>{users_processed}</b>\n📋 Синхронизировано подписок: <b>{subs_synced}</b>\n🕐 Время: {timestamp}\n\n📝 Детали:\n{details}",
"log_suspicious_promo": "⚠️ <b>Подозрительная попытка ввода промокода</b>\n\n👤 Пользователь: {user_display}\n🆔 ID: <code>{user_id}</code>\n📝 Ввод: <pre>{suspicious_input}</pre>\n🕐 Время: {timestamp}",
@@ -308,10 +344,17 @@
"admin_bulk_promo_invalid_quantity": "❌ Количество должно быть от 1 до 1000",
"admin_bulk_promo_enter_validity_days": "⏰ Введите количество дней действия промокодов (1-365):",
"admin_bulk_promo_creating": "⏳ Создаю {quantity} промокодов...",
"admin_promo_step0_type": "Выберите тип промокода:",
"admin_promo_type_bonus_days": "🎁 Бонусные дни (продление подписки)",
"admin_promo_type_discount": "💰 Скидка на покупку (%)",
"admin_promo_step1_code": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 1 из 4:</b> Код промокода\n\nВведите код промокода (3-30 символов, только буквы и цифры):",
"admin_promo_step2_bonus_days": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 2 из 4:</b> Бонусные дни\n\nКод: <b>{code}</b>\n\nВведите количество бонусных дней (1-365):",
"admin_promo_step2_discount_percentage": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 2 из 4:</b> Процент скидки\n\nКод: <b>{code}</b>\n\nВведите процент скидки для промокода <code>{code}</code> (от 1 до 100):",
"admin_promo_invalid_discount_percentage": "❌ Процент скидки должен быть от 1 до 100.",
"admin_promo_step3_max_activations": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 3 из 4:</b> Максимальные активации\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\n\nВведите максимальное количество активаций (1-10000):",
"admin_promo_step3_max_activations_discount": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 3 из 4:</b> Максимальные активации\n\nКод: <b>{code}</b>\nСкидка: <b>{discount_percentage}%</b>\n\nВведите максимальное количество активаций (1-10000):",
"admin_promo_step4_validity": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активации: <b>{max_activations}</b>\n\nВыберите срок действия промокода:",
"admin_promo_step4_validity_discount": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nСкидка: <b>{discount_percentage}%</b>\nМакс. активации: <b>{max_activations}</b>\n\nВыберите срок действия промокода:",
"admin_promo_code_already_exists": "❌ Промокод с таким кодом уже существует",
"admin_promo_unlimited_validity": "♾️ Неограниченно",
"admin_promo_enter_validity_days": "⏰ Введите количество дней действия промокода (1-365):",
@@ -336,6 +379,8 @@
"admin_user_na_value": "N/A",
"admin_user_subscription_none": "Нет активной подписки",
"admin_user_actions_count_label": "📜 <b>Всего действий:</b>",
"admin_user_total_paid_label": "💰 <b>Всего оплачено:</b>",
"admin_user_referral_revenue_label": "💸 <b>Доход по рефералам:</b>",
"admin_user_invited_friends_label": "👥 <b>Приглашено друзей:</b>",
"admin_user_ref_purchased_label": "💳 <b>Купили подписку:</b>",
"admin_user_subscription_active_until": "⏰ <b>Действует до:</b>",
@@ -344,7 +389,9 @@
"admin_promo_management_title": "🎟 <b>Управление промокодами</b>\n\nВыберите промокод для детального просмотра:",
"admin_promo_management_empty": "📭 Промокоды отсутствуют",
"admin_promo_card_title": "🎟 <b>Промокод: {code}</b>",
"admin_promo_card_type": "📌 Тип: {type}",
"admin_promo_card_bonus_days": "🎁 Бонусные дни: <b>{days}</b>",
"admin_promo_card_discount_percentage": "💰 Скидка: <b>{percentage}%</b>",
"admin_promo_card_activations": "🔢 Активации: <b>{current}/{max}</b>",
"admin_promo_card_validity": "⏰ Действует до: <b>{validity}</b>",
"admin_promo_card_status": "📊 Статус: <b>{status}</b>",
@@ -397,7 +444,11 @@
"admin_sync_no_telegram_id": "\n⚠️ Записей без telegramId: {count}",
"admin_sync_not_found_in_db": "\n❌ Не найдено в БД: {count}",
"admin_payments_pagination_info": "📊 Показано {shown} из {total} платежей (стр. {current_page}/{total_pages})",
"admin_payment_traffic_label": "🗂 Трафик: <b>{traffic_gb} ГБ</b>",
"admin_payment_months_label": "📅 Период: <b>{months} мес.</b>",
"my_subscription_details": "🔐 <b>Моя подписка</b>\n\n⏰ Статус: <b>{status}</b>\n📅 Действует до: <b>{end_date}</b>\n📆 Осталось дней: <b>{days_left}</b>\n\n🔗 Ссылка на конфигурацию:\n<code>{config_link}</code>\n\n📊 Трафик:\nЛимит: <b>{traffic_limit}</b>\nИспользовано: <b>{traffic_used}</b>",
"my_traffic_details": "🔐 <b>Мой трафик</b>\n\n⏰ Статус: <b>{status}</b>\n📅 Действует до: <b>{end_date}</b>\n\n🔗 Ссылка на конфигурацию:\n<code>{config_link}</code>\n\n📊 Трафик:\nЛимит: <b>{traffic_limit}</b>\nИспользовано: <b>{traffic_used}</b>\nОсталось: <b>{traffic_left}</b>",
"traffic_no_expiry": "без ограничения",
"autorenew_enable_button": "🔄 Включить автопродление",
"autorenew_disable_button": "🛑 Отключить автопродление",
"subscription_autorenew_updated": "Настройки автопродления обновлены.",
@@ -420,9 +471,8 @@
"payment_method_tx_history_title": "📜 История операций",
"payment_method_no_history": "История операций отсутствует.",
"subscription_purchase_title": "Покупка подписки на {months} мес.",
"subscription_tribute_notice": "Оплачено через Tribute. Продление делайте по ссылке Tribute.",
"subscription_tribute_notice_with_link": "Оплачено через Tribute. Продлить: {link}",
"subscription_autorenew_not_supported_for_tribute": "Автопродление управляется Tribute. Управляйте продлением в приложении/ссылке Tribute.",
"traffic_purchase_title": "Покупка {traffic_gb} ГБ",
"autorenew_enable_requires_card": "Прежде чем включать автоплатёж, привяжите карту в разделе «Способы оплаты».",
"subscription_not_active": "У вас нет активной подписки.",
"error_service_unavailable": "Сервис недоступен. Попробуйте позже.",
"error_payment_gateway": "Ошибка платежного сервиса. Попробуйте позже.",
+18 -1
View File
@@ -1,5 +1,6 @@
import asyncio
import logging
import os
import sys
from dotenv import load_dotenv
@@ -9,6 +10,21 @@ from config.settings import get_settings, Settings
from db.database_setup import init_db, init_db_connection
def _resolve_log_level(value: str) -> int:
if not value:
return logging.INFO
if isinstance(value, str):
normalized = value.strip()
if not normalized:
return logging.INFO
if normalized.isdigit():
return int(normalized)
level = getattr(logging, normalized.upper(), None)
if isinstance(level, int):
return level
return logging.INFO
async def main():
load_dotenv()
settings = get_settings()
@@ -25,8 +41,9 @@ async def main():
if __name__ == "__main__":
load_dotenv()
logging.basicConfig(
level=logging.INFO,
level=_resolve_log_level(os.getenv("LOG_LEVEL", "INFO")),
stream=sys.stdout,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
try:
+9 -10
View File
@@ -1,11 +1,10 @@
aiogram==3.21.0
python-dotenv==1.0.1
aiohttp==3.12.14
pydantic==2.7.1
yookassa==3.5.0
pycountry==23.12.11
pydantic_settings
sqlalchemy[asyncio]==2.0.29
asyncpg==0.29.0
alembic==1.13.1
aiogram==3.24.0
python-dotenv==1.2.1
aiohttp==3.13.3
pydantic==2.12.5
yookassa==3.9.0
httpx>=0.27.0
pydantic_settings==2.12.0
sqlalchemy[asyncio]==2.0.45
asyncpg==0.31.0
aiocryptopay==0.4.8