Compare commits

..
58 Commits
Author SHA1 Message Date
kavoreandGitHub 1c121957aa Merge pull request #163 from kavore/dev
chore(release): migrate DB to alembic and harden payments/webhooks
2026-02-20 12:33:49 +03:00
kavore 5d00124ca0 fix(users): update referral code handling to ensure uniqueness and proper formatting
Implement logic to clean and standardize referral codes in the users table. This includes setting referral codes to NULL if they are empty or consist only of whitespace, and generating unique referral codes for users with duplicates. The changes enhance data integrity and ensure consistent formatting of referral codes across the database.
2026-02-12 16:44:14 +03:00
kavore b164fbd9bb refactor(payments): enhance payment processing logic and error handling
Refactor the `process_successful_payment` function to improve the handling of payment statuses during processing. Introduce a new method to atomically claim payments for processing and rollback payment statuses in case of activation failures. Enhance logging for various scenarios, including payment status checks and rollback actions, to provide clearer insights into payment processing flows. This improves the robustness and reliability of the payment handling logic.
2026-02-12 15:58:16 +03:00
kavore f1620a784b refactor(promo): streamline discount consumption logic
Simplify the discount consumption process in the PromoCodeService by removing unnecessary checks for active discounts and improving logging for various scenarios. Ensure that successful payments are always accounted for, even if the associated reservation has expired. This enhances clarity and reliability in the discount application workflow.
2026-02-12 15:47:52 +03:00
kavore e546ba3515 fix(payments): improve YooKassa webhook processing for terminal failures
Refactor the YooKassa webhook handling to better manage terminal payment failures. Introduce checks for payment status based on metadata, ensuring that only terminal failures are committed to the database. Enhance error handling and logging for non-terminal failures, providing clearer responses for retry scenarios. This improves the robustness of payment processing and reduces unnecessary retries.
2026-02-12 15:23:12 +03:00
kavore d39e781203 feat(payments): implement server-side price resolution for subscription payments
Add a new function `resolve_fiat_offer_price_for_user` to determine the correct offer price for users based on their subscription choices and any applicable discounts. Update payment handlers for CryptoPay, FreeKassa, Platega, SeverPay, and YooKassa to utilize this function, ensuring callback price validation and improving error handling for price mismatches. This enhances security by preventing callback payload tampering and ensures accurate pricing for users.
2026-02-12 15:09:04 +03:00
kavoreandGitHub b5dfaf8188 Merge pull request #165 from Snowy-Fluffy/fix-alembic
alembic migration fix
2026-02-12 10:50:28 +03:00
kavore 0bd3d70e12 feat(logging): add LOG_ADMIN_HIDE setting and update logging functionality
Introduce a new configuration option `LOG_ADMIN_HIDE` to control the visibility of admin-generated events in the logs. Update the logging retrieval functions to respect this setting, ensuring that admin actions can be hidden from the "All message logs" UI and CSV exports. Additionally, enhance the README with detailed logging configuration options for better clarity.
2026-02-12 10:48:48 +03:00
Snowy-Fluffy bce3d1a046 alembic migration fix 2026-02-12 10:17:17 +03:00
kavore 826b036724 fix(payments): enhance payment processing return values
Refactor the `process_successful_payment` function to ensure consistent return values, replacing `return` statements with `return False` for error cases. This improves error handling and clarity in payment processing logic. Additionally, update the promo code consumption logic to handle missing discounts more gracefully, ensuring proper logging and flow control.
2026-02-12 09:27:44 +03:00
kavore 799bd01a79 fix(payments): enhance YooKassa webhook processing and notification handling
Improve error handling for YooKassa payment events by adding verification for the payment service configuration. Refactor notification sending for expired discounts to batch messages before sending, ensuring better performance and error logging.
2026-02-11 23:49:58 +03:00
kavore 814663a528 fix(payments): improve payment activation error handling
Add validation for provider payment IDs in YooKassa webhook processing and ensure activation details are checked for null values in CryptoPay, FreeKassa, Platega, and Stars services. This prevents potential runtime errors during payment processing and enhances logging for better debugging.
2026-02-11 23:20:40 +03:00
kavore ee9d1a3ad1 fix(promo): auto-apply discount code from start link 2026-02-11 19:00:48 +03:00
kavore 11a4d08b45 fix(promo): avoid double stars discount application 2026-02-11 01:24:20 +03:00
kavore 1eb2165b28 fix(users): avoid promo activation FK conflict on user deletion 2026-02-10 22:14:13 +03:00
kavore 3f22c3a531 fix(payments): enforce strict YooKassa webhook binding
Require payment_db_id for successful webhook processing and validate ownership. Precreate payment records for auto-renew and include payment_db_id in provider metadata. Ignore replayed succeeded webhooks. Also normalize empty Telegram webhook secret before set_webhook and request handler usage.
2026-02-09 10:17:37 +03:00
kavore 2c618c7a6a fix(payments): hardcode RUB in user payment handlers 2026-02-09 00:19:59 +03:00
kavore 7622537961 fix(payments): enforce RUB as default service currency 2026-02-09 00:19:59 +03:00
kavore 5365df91f6 fix(env): remove unused default currency symbol setting 2026-02-09 00:19:59 +03:00
kavore 194295d34c fix(payments): add cancel button for Telegram Stars flow 2026-02-09 00:04:05 +03:00
kavore 5e9a08aeda feat(promo): add discount promo reservation timeout 2026-02-08 23:27:19 +03:00
kavore f870e4d8b7 feat(env): migrate database setup to alembic 2026-02-08 23:27:04 +03:00
kavore 8ba0e465b1 fix(start): keep welcome before menu after channel verify 2026-02-08 22:33:00 +03:00
kavore 0e470688a1 feat(ci): add codex PR review workflow 2026-02-08 22:33:00 +03:00
kavore 1e7152846b fix(i18n): add missing ru/en keys used in code 2026-02-08 22:33:00 +03:00
kavore e29f723486 docs(env): align .env.example and README with security changes 2026-02-08 22:33:00 +03:00
kavore 59d10184e8 chore(git): ignore .serena and AGENTS.md 2026-02-08 21:30:39 +03:00
kavore da03510a92 chore(security): replace silent exception swallowing across handlers 2026-02-08 21:30:39 +03:00
kavore 199c7797f5 fix(logs): redact sensitive payloads in storage and export 2026-02-08 21:30:39 +03:00
kavore 9a32cab718 fix(payments): enforce idempotent and verified webhook processing 2026-02-08 21:30:39 +03:00
kavore 4853a49112 fix(webhooks): harden webhook config and secret handling 2026-02-08 21:30:39 +03:00
kavore 0d637340f5 fix(env): stabilize .env parsing and add feature toggles
- Handle dotenv placeholders like 'KEY=  # comment' without validation crashes

- Add REQUIRED_CHANNEL_SUBSCRIBE_TO_USE to explicitly enable channel gate

- Add REFERRAL_ENABLED to fully disable referral flow and bonuses

- Hide referral UI/actions when disabled and ignore ref start params

- Update .env.example defaults and README docs for new flags
2026-02-06 23:49:46 +03:00
kavore 401469e276 Fix None promo bonus days in subscription activation 2026-02-06 11:16:28 +03:00
kavore 31e59f6339 Fix None promo bonus days in subscription activation 2026-02-06 11:16:23 +03:00
kavoreandGitHub 8dde585d49 Merge pull request #159 from kavore/dev
bugfix
2026-02-03 12:18:37 +03:00
kavore 3c84007f17 fix(promo): count activations on payment
Allow promo usage increments to overflow the max limit when a payment completes so counters reflect paid activations only.
2026-02-03 12:05:16 +03:00
kavore d11b0cabaa fix(users): handle active discounts on delete
Ensure active_discounts cascades on user/promo removal, clean orphan rows during migration, and rely on DB cascade for user deletion.
2026-02-03 11:25:53 +03:00
kavore 4b5908d6cc fix(promo): Improve error handling for promo code application
- Enhanced the logic for determining the response to users when a promo code application fails, ensuring clearer messaging based on the type of error encountered.
- Introduced specific error messages for cases where the promo code is not found or not applicable as a discount, improving user experience and clarity.
2026-02-02 23:55:04 +03:00
kavore 5bb8f2add0 feat(promo): Integrate promo code service into subscription options
- Enhanced the display_subscription_options and reshow_subscription_options_callback functions to accept a promo_code_service parameter for improved discount handling.
- Updated the logic to calculate and display discounted prices based on active promo codes, including support for both regular and star-based pricing.
- Modified relevant locales to include currency symbols in discount notices for better user clarity.
2026-02-02 00:35:31 +03:00
kavore 484327a032 feat(promo): Enhance promo code functionality and discount consumption logic
- Integrated promo code service into subscription service to streamline discount consumption during payment processing.
- Improved error handling and logging for promo code activation and usage increment.
- Updated payment services to handle discount calculations more robustly, including fallback mechanisms for invalid discount scenarios.
- Added new methods for managing promo code activations and usage in the database layer.
2026-02-01 19:29:31 +03:00
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
115 changed files with 5322 additions and 17900 deletions
-13
View File
@@ -5,13 +5,6 @@
.gitattributes .gitattributes
LICENSE LICENSE
README.md README.md
.env
.env.*
!.env.example
scratch_*.py
*.local.*
node_modules/
.git/
# CI # CI
@@ -24,12 +17,6 @@ docker-compose.yml
Dockerfile Dockerfile
.docker .docker
.dockerignore .dockerignore
tmp/
# WebApp build artifacts (regenerated inside Docker)
bot/app/web/templates/subscription_webapp.css
bot/app/web/templates/subscription_webapp.js
bot/app/web/templates/subscription_webapp.min.*.js
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
**/__pycache__/ **/__pycache__/
+33 -62
View File
@@ -3,72 +3,40 @@ BOT_TOKEN=your_bot_token_here #
ADMIN_IDS=comma_separated_admin_ids # Your telegram ID ADMIN_IDS=comma_separated_admin_ids # Your telegram ID
# PostgreSQL Database Connection Settings # PostgreSQL Database Connection Settings
POSTGRES_USER= # Required: database user name POSTGRES_USER=postgres # Database user name
POSTGRES_PASSWORD= # Required: database password POSTGRES_PASSWORD=postgres # Database password
POSTGRES_HOST=remnawave-minishop-db # Database container name POSTGRES_HOST=remnawave-tg-shop-db # Database container name
POSTGRES_PORT=5432 # Port POSTGRES_PORT=5432 # Port
POSTGRES_DB=postgres # Database name POSTGRES_DB=postgres # Database name
# Localization and Display # Localization and Display
DEFAULT_LANGUAGE="ru" # or "en" DEFAULT_LANGUAGE="ru" # or "en"
DEFAULT_CURRENCY_SYMBOL="RUB" # e.g., RUB, USD, EUR
# External Links # External Links
SUPPORT_LINK=https://t.me/your_support_link # Link to the support chat SUPPORT_LINK=https://t.me/your_support_link # Link to the support chat
SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service # Link to the server status page SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service # Link to the server status page
TERMS_OF_SERVICE_URL=https://example.com/tos # Link to the terms of service TERMS_OF_SERVICE_URL=https://example.com/tos # Link to the terms of service
PRIVACY_POLICY_URL=https://example.com/privacy # Link to the privacy policy SUBSCRIPTION_MINI_APP_URL="" # URL of the subscription mini-app
USER_AGREEMENT_URL=https://example.com/user-agreement # Link to the user agreement START_COMMAND_DESCRIPTION="" # Description of the /start command
SUBSCRIPTION_MINI_APP_URL= # Public URL of the subscription Mini App, e.g. https://app.yourdomain.tld/ DISABLE_WELCOME_MESSAGE=False # Disable the welcome message
START_COMMAND_DESCRIPTION= # Description of the /start command
DISABLE_WELCOME_MESSAGE= # Disable the welcome message
MY_DEVICES_SECTION_ENABLED=False # Enable the My Devices section in the subscription menu MY_DEVICES_SECTION_ENABLED=False # Enable the My Devices section in the subscription menu
USER_HWID_DEVICE_LIMIT=0 # Default HWID/device limit for panel users (0 = unlimited) USER_HWID_DEVICE_LIMIT=0 # Default HWID/device limit for panel users (0 = unlimited)
# Required channel subscription # Required channel subscription
REQUIRED_CHANNEL_ID= # Telegram channel ID (e.g. -1001234567890) the user must join REQUIRED_CHANNEL_SUBSCRIBE_TO_USE=False # Enable/disable the required channel gate
REQUIRED_CHANNEL_ID="" # Telegram channel ID (e.g. -1001234567890) the user must join
REQUIRED_CHANNEL_LINK=https://t.me/your_channel # Optional: public link/invite button text opens REQUIRED_CHANNEL_LINK=https://t.me/your_channel # Optional: public link/invite button text opens
# Webhook Base URL (used for Telegram and payment providers) # Webhook Base URL (used for Telegram and payment providers)
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
TRUSTED_PROXIES=127.0.0.1,::1 # Reverse proxies trusted for X-Forwarded-For TELEGRAM_WEBHOOK_PATH=/webhook/telegram # Relative path for Telegram updates
TELEGRAM_WEBHOOK_SECRET= # Secret token for Telegram webhook validation header
# Subscription Mini App (same container, separate port)
WEBAPP_ENABLED=True # Run Mini App HTTP server
WEBAPP_SERVER_HOST=0.0.0.0 # Internal listen host
WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port
WEBAPP_TITLE="/minishop" # Mini App title
WEBAPP_PRIMARY_COLOR="#00fe7a" # Main UI color
WEBAPP_LOGO_URL= # Optional logo URL; if empty the emoji below is used
WEBAPP_LOGO_EMOJI="🫥" # Emoji logo fallback shown in the header and login screen
WEBAPP_SESSION_SECRET= # Optional: HMAC secret for webapp sessions; generated if empty
WEBHOOK_SECRET_TOKEN= # Optional: Telegram webhook secret token; generated if empty
WEBAPP_SESSION_TTL_SECONDS=86400 # Web App session lifetime (24h)
WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age
WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600 # External browser login link lifetime
# Email login and account linking via SMTP (Brevo SMTP relay defaults)
SMTP_HOST=smtp-relay.brevo.com # SMTP server
SMTP_PORT=587 # Brevo recommends 587 with STARTTLS
SMTP_FALLBACK_PORTS=2525,465 # Tried after SMTP_PORT; 465 uses SSL automatically
SMTP_TIMEOUT_SECONDS=30 # Per SMTP connection/send attempt timeout
SMTP_USERNAME= # Brevo SMTP login
SMTP_PASSWORD= # Brevo SMTP key/password
SMTP_FROM_EMAIL= # Verified sender email
SMTP_FROM_NAME= # Optional sender name
SMTP_STARTTLS=True # Use STARTTLS on SMTP_PORT
SMTP_USE_SSL=False # Use SSL wrapper, usually only for port 465
EMAIL_CODE_TTL_SECONDS=600 # Email verification code lifetime
EMAIL_CODE_RESEND_SECONDS=60 # Minimum delay between code sends
EMAIL_CODE_MAX_ATTEMPTS=5 # Max attempts per code
BRUTE_FORCE_MAX_FAILURES=5 # Max failed code attempts in the throttle window
BRUTE_FORCE_WINDOW_SECONDS=900 # Rolling window used to count failures
BRUTE_FORCE_LOCK_SECONDS=1800 # Temporary lockout duration after too many failures
# Payment Method Toggles # Payment Method Toggles
YOOKASSA_ENABLED=True # Turn on YOOKASSA YOOKASSA_ENABLED=True # Turn on YOOKASSA
FREEKASSA_ENABLED=True # Turn on FreeKassa FREEKASSA_ENABLED=True # Turn on FreeKassa
STARS_ENABLED=True # Turn on STARS STARS_ENABLED=True # Turn on STARS
STARS_PROVIDER_TOKEN= # Leave empty for Telegram Stars (XTR)
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
PLATEGA_ENABLED=False # Turn on PLATEGA PLATEGA_ENABLED=False # Turn on PLATEGA
SEVERPAY_ENABLED=False # Turn on SeverPay SEVERPAY_ENABLED=False # Turn on SeverPay
@@ -96,7 +64,6 @@ FREEKASSA_API_KEY=your_api_key #
FREEKASSA_SECOND_SECRET=your_second_secret # Secret word #2 (used to verify notifications) FREEKASSA_SECOND_SECRET=your_second_secret # Secret word #2 (used to verify notifications)
FREEKASSA_PAYMENT_IP= # Public IP address reported to FreeKassa 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 FREEKASSA_PAYMENT_METHOD_ID=44 # Payment method ID, you can get it from https://merchant.freekassa.net/settings/currencies
FREEKASSA_TRUSTED_IPS=168.119.157.136,168.119.60.227,178.154.197.79,51.250.54.238 # FreeKassa webhook source IP allowlist
# CryptoBot Payment Gateway Configuration # CryptoBot Payment Gateway Configuration
CRYPTOPAY_TOKEN= # API token for CryptoPay CRYPTOPAY_TOKEN= # API token for CryptoPay
@@ -106,22 +73,19 @@ CRYPTOPAY_ASSET=RUB #
# Platega Payment Gateway Configuration # Platega Payment Gateway Configuration
PLATEGA_BASE_URL=https://app.platega.io # Base API URL PLATEGA_BASE_URL=https://app.platega.io # Base API URL
PLATEGA_MERCHANT_ID= # Your MerchantId from Platega PLATEGA_MERCHANT_ID="" # Your MerchantId from Platega
PLATEGA_SECRET= # API secret from Platega PLATEGA_SECRET="" # API secret from Platega
PLATEGA_PAYMENT_METHOD=2 # Legacy method ID; fallback for the SBP button when PLATEGA_SBP_METHOD stays default PLATEGA_PAYMENT_METHOD=2 # Payment method ID (2=SBP QR, 10=RU cards, 12=International, 13=Crypto)
PLATEGA_SBP_ENABLED=False # Show a separate "Pay via SBP" Platega button PLATEGA_RETURN_URL="" # Optional: redirect after successful payment (defaults to bot link)
PLATEGA_CRYPTO_ENABLED=False # Show a separate "Pay with crypto" Platega button PLATEGA_FAILED_URL="" # Optional: redirect after failed/cancelled payment (defaults to return URL)
PLATEGA_SBP_METHOD=2 # Platega method ID for SBP QR (default 2)
PLATEGA_CRYPTO_METHOD=13 # Platega method ID for crypto (default 13)
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 Payment Gateway Configuration
SEVERPAY_BASE_URL=https://severpay.io/api/merchant # Base API URL SEVERPAY_BASE_URL=https://severpay.io/api/merchant # Base API URL
SEVERPAY_MID= # Your MID from SeverPay SEVERPAY_MID="" # Your MID from SeverPay
SEVERPAY_TOKEN= # API token/secret for signing requests SEVERPAY_TOKEN="" # API token/secret for signing requests
SEVERPAY_RETURN_URL= # Optional: redirect URL after payment (defaults to bot link) 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) SEVERPAY_LIFETIME_MINUTES="" # Optional: payment link lifetime in minutes (30-4320, leave empty for default)
# Subscription Options. Specify cost parameters or payment links here. # Subscription Options. Specify cost parameters or payment links here.
1_MONTH_ENABLED=True 1_MONTH_ENABLED=True
@@ -151,8 +115,8 @@ SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True #
SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 # Days before expiration to notify SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 # Days before expiration to notify
REFERRAL_ENABLED=True # Enable/disable the referral system
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
REFERRAL_WELCOME_BONUS_DAYS=3 # Welcome bonus for newly registered user from referral link
LEGACY_REFS=true # Allow ref_<tg_id> links. Leave unset/true unless you want to disable old links LEGACY_REFS=true # Allow ref_<tg_id> links. Leave unset/true unless you want to disable old links
# Referral Bonus Days # Referral Bonus Days
# Bonus for the inviting user # Bonus for the inviting user
@@ -169,7 +133,7 @@ REFEREE_BONUS_DAYS_12_MONTHS=15
# Panel API Configuration # Panel API Configuration
PANEL_API_URL=http://your_panel_api_url/api # URL of the panel API 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_API_KEY=your_panel_api_key # Panel API key
PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures PANEL_WEBHOOK_SECRET="" # secret used to verify panel webhook signatures
# User traffic limits (applied for all users) # User traffic limits (applied for all users)
# 0 means unlimited # 0 means unlimited
@@ -179,7 +143,7 @@ USER_TRAFFIC_STRATEGY="NO_RESET" #
# Default Internal Squads for Users (Optional, comma-separated UUIDs) # Default Internal Squads for Users (Optional, comma-separated UUIDs)
USER_SQUAD_UUIDS=uuid1,uuid2,uuid3 USER_SQUAD_UUIDS=uuid1,uuid2,uuid3
# Default External Squad for Users (Optional, single UUID) # Default External Squad for Users (Optional, single UUID)
USER_EXTERNAL_SQUAD_UUID= # Optional: UUID from Remnawave External Squads to auto-link new panel users USER_EXTERNAL_SQUAD_UUID="" # Optional: UUID from Remnawave External Squads to auto-link new panel users
# Trial Settings # Trial Settings
TRIAL_ENABLED=True # Enable the trial period TRIAL_ENABLED=True # Enable the trial period
@@ -189,7 +153,7 @@ TRIAL_TRAFFIC_STRATEGY="NO_RESET" #
# Connection link handling (happ crypt4) # Connection link handling (happ crypt4)
CRYPT4_ENABLED=False # Enable happ crypt4 encryption for subscription URLs 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= 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 Settings (for handling webhooks)
WEB_SERVER_HOST="0.0.0.0" WEB_SERVER_HOST="0.0.0.0"
@@ -198,16 +162,23 @@ WEB_SERVER_PORT=8080
# Admin Panel Log Pagination # 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) LOG_LEVEL=INFO # Global log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
LOG_STORE_MESSAGE_CONTENT=False # Store message/callback content in DB logs
LOG_STORE_RAW_UPDATES=False # Store raw update payload snippets in DB logs
LOG_EXPORT_INCLUDE_SENSITIVE=False # Include content/raw update columns in admin CSV export
LOG_ADMIN_HIDE=False # Hide admin actions from "All message logs" UI and CSV export
# Admin Logging Configuration # Admin Logging Configuration
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
LOG_THREAD_ID= # Optional: Thread ID for supergroup messages LOG_THREAD_ID="" # Optional: Thread ID for supergroup messages
LOG_NEW_USERS=True # Log new user registrations LOG_NEW_USERS=True # Log new user registrations
LOG_PAYMENTS=True # Log payments LOG_PAYMENTS=True # Log payments
LOG_PROMO_ACTIVATIONS=True # Log promo code activations LOG_PROMO_ACTIVATIONS=True # Log promo code activations
LOG_TRIAL_ACTIVATIONS=True # Log trial activations LOG_TRIAL_ACTIVATIONS=True # Log trial activations
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
# Discount promo reservation timeout
DISCOUNT_PROMO_PAYMENT_TIMEOUT_MINUTES=10 # Minutes to keep discount promo reservation before it expires
# Embedded mode thumbnails. Please don't touch this if you don't know what it is. # 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 INLINE_REFERRAL_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/1077/1077114.png
INLINE_USER_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/681/681494.png INLINE_USER_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/681/681494.png
+75
View File
@@ -0,0 +1,75 @@
name: Codex PR Review
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
jobs:
codex:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
final_message: ${{ steps.run_codex.outputs.final-message }}
steps:
- uses: actions/checkout@v5
with:
ref: refs/pull/${{ github.event.pull_request.number }}/merge
- name: Pre-fetch base and head refs for the PR
run: |
git fetch --no-tags origin \
${{ github.event.pull_request.base.ref }} \
+refs/pull/${{ github.event.pull_request.number }}/head
- name: Run Codex review
id: run_codex
uses: openai/codex-action@v1
with:
openai-api-key: ${{ secrets.CODEX_API_KEY }}
responses-api-endpoint: ${{ secrets.CODEX_RESPONSES_API_ENDPOINT }}
prompt: |
This is PR #${{ github.event.pull_request.number }} for ${{ github.repository }}.
Review only changes from this PR using:
git log --oneline ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}
Focus on:
- bugs and regressions
- security issues
- risky edge cases
- missing tests where critical
Keep feedback concise and actionable.
Pull request title and body:
----
${{ github.event.pull_request.title }}
${{ github.event.pull_request.body }}
post-feedback:
runs-on: ubuntu-latest
needs: codex
if: needs.codex.outputs.final_message != ''
permissions:
issues: write
pull-requests: write
steps:
- name: Post Codex feedback to PR
uses: actions/github-script@v7
env:
CODEX_FINAL_MESSAGE: ${{ needs.codex.outputs.final_message }}
with:
github-token: ${{ github.token }}
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: process.env.CODEX_FINAL_MESSAGE,
});
+68
View File
@@ -0,0 +1,68 @@
name: Build and Push Dev Docker Image
on:
push:
branches:
- dev
pull_request:
branches:
- dev
env:
GHCR_REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
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.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
${{ env.DOCKERHUB_IMAGE }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix={{branch}}-
flavor: |
latest=false
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Image digest
run: echo ${{ steps.meta.outputs.digest }}
+56
View File
@@ -0,0 +1,56 @@
name: Build and Publish multi-arch Docker Image
on:
push:
branches:
- main
tags:
- 'v*.*.*'
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
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
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
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push multi-arch Docker image
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ env.GHCR_IMAGE }}:latest
${{ env.GHCR_IMAGE }}:${{ github.ref_name }}
${{ env.DOCKERHUB_IMAGE }}:latest
${{ env.DOCKERHUB_IMAGE }}:${{ github.ref_name }}
+2 -15
View File
@@ -3,19 +3,6 @@ bot_database.sqlite3
# Игнорировать файлы окружения # Игнорировать файлы окружения
.env .env
.env.*
!.env.example
scratch_*.py
*.local.*
node_modules/
.git/
# WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build)
bot/app/web/templates/subscription_webapp.css
bot/app/web/templates/subscription_webapp.js
bot/app/web/templates/subscription_webapp.min.*.js
tmp
.claude
# Игнорировать кэш Python # Игнорировать кэш Python
__pycache__/ __pycache__/
@@ -30,5 +17,5 @@ __pycache__/
locales/ru_backup.json locales/ru_backup.json
locales/en_backup.json locales/en_backup.json
db/models_old.py db/models_old.py
config/tariffs.json .serena/
docker-compose-dev.yml AGENTS.md
-10
View File
@@ -1,10 +0,0 @@
# Replace the example domains below with your real webhook and Mini App hostnames.
webhook.domain.com {
encode zstd gzip
reverse_proxy remnawave-minishop:{$WEB_SERVER_PORT:8080}
}
app.domain.com {
encode zstd gzip
reverse_proxy remnawave-minishop:{$WEBAPP_SERVER_PORT:8081}
}
+3 -38
View File
@@ -1,4 +1,4 @@
FROM python:3.12-slim AS python-builder FROM python:3.12-slim AS builder
WORKDIR /app WORKDIR /app
@@ -7,49 +7,14 @@ COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \ RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir -r requirements.txt pip install --no-cache-dir -r requirements.txt
FROM node:22-slim AS webapp-builder
WORKDIR /webapp
COPY package.json package-lock.json* ./
RUN --mount=type=cache,target=/root/.npm \
if [ -f package-lock.json ]; then npm ci; else npm install; fi
COPY bot/app/web/frontend ./bot/app/web/frontend
COPY bot/app/web/templates ./bot/app/web/templates
COPY scripts/build_subscription_webapp_js.mjs ./scripts/build_subscription_webapp_js.mjs
RUN npm run build:webapp
FROM python:3.12-slim FROM python:3.12-slim
WORKDIR /app WORKDIR /app
LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minishop" COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
RUN useradd -u 10001 -m appuser
COPY --from=python-builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY . . COPY . .
# Replace template assets with freshly built ones
RUN rm -f bot/app/web/templates/subscription_webapp.css \
bot/app/web/templates/subscription_webapp.js \
bot/app/web/templates/subscription_webapp.min.*.js
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.css \
bot/app/web/templates/subscription_webapp.css
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.js \
bot/app/web/templates/subscription_webapp.js
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.min.*.js \
bot/app/web/templates/
RUN rm -rf /root/.cache RUN rm -rf /root/.cache
RUN mkdir -p /app/logs /app/data && chown -R appuser:appuser /app/logs /app/data
USER appuser
CMD ["python", "main.py"] CMD ["python", "main.py"]
+91 -246
View File
@@ -1,21 +1,17 @@
# Remnawave Minishop # Telegram-бот для продажи подписок Remnawave
Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для автоматизации продажи и управления подписками панели **Remnawave**. Бот закрывает сценарий покупки, продления и работы с поддержкой прямо в чате, а Web App в едином интерфейсе показывает ссылку подключения, остаток времени, трафик, оплату и устройства, поддерживая вход через Telegram Mini Apps `initData`, Telegram Login Widget и одноразовый код по email. Под капотом — интеграция с API Remnawave для управления пользователями и подписками и набор платёжных шлюзов для приёма платежей. Этот Telegram-бот предназначен для автоматизации продажи и управления подписками для панели **Remnawave**. Он интегрируется с API Remnawave для управления пользователями и подписками, а также использует различные платежные системы для приема платежей.
> 🍴 **Это глубоко переработанный форк [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop).** Здесь добавлены полноценный Web App / Mini App, вход по email и многое другое. Возможна миграция.
## ✨ Ключевые возможности ## ✨ Ключевые возможности
### Для пользователей: ### Для пользователей:
- **Регистрация и выбор языка:** Поддержка русского и английского языков. - **Регистрация и выбор языка:** Поддержка русского и английского языков.
- **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию. - **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию.
- **Web App (Mini App):** отдельный веб-интерфейс для просмотра ссылки подключения, остатка времени и оплаты подписки.
- **Вход по email:** вход и регистрация в Web App по коду из письма, а также привязка email и Telegram к одному аккаунту.
- **Мои устройства:** Опциональный раздел для просмотра и отключения подключенных устройств (активируется через переменную `MY_DEVICES_SECTION_ENABLED`). - **Мои устройства:** Опциональный раздел для просмотра и отключения подключенных устройств (активируется через переменную `MY_DEVICES_SECTION_ENABLED`).
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке). - **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней. - **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки. - **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), Platega, SeverPay, CryptoPay и Telegram Stars. - **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), Platega, SeverPay, CryptoPay и Telegram Stars.
### Для администраторов: ### Для администраторов:
- **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`. - **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`.
@@ -32,6 +28,7 @@ Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для
- **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов. - **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов.
- **aiohttp:** Для запуска веб-сервера (вебхуки). - **aiohttp:** Для запуска веб-сервера (вебхуки).
- **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL. - **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL.
- **Alembic:** Миграции схемы базы данных.
- **YooKassa, FreeKassa API, Platega, SeverPay, aiocryptopay:** Интеграции с платежными системами. - **YooKassa, FreeKassa API, Platega, SeverPay, aiocryptopay:** Интеграции с платежными системами.
- **Pydantic:** Для управления настройками из `.env` файла. - **Pydantic:** Для управления настройками из `.env` файла.
- **Docker & Docker Compose:** Для контейнеризации и развертывания. - **Docker & Docker Compose:** Для контейнеризации и развертывания.
@@ -49,12 +46,12 @@ Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для
1. **Клонируйте репозиторий:** 1. **Клонируйте репозиторий:**
```bash ```bash
git clone https://github.com/3252a8/remnawave-minishop git clone https://github.com/kavore/remnawave-tg-shop
cd remnawave-minishop cd remnawave-tg-shop
``` ```
2. **Создайте и настройте файл `.env`:** 2. **Создайте и настройте файл `.env`:**
Скопируйте `.env.example` в `.env` и заполните своими данными. Скопируйте `env.example` в `.env` и заполните своими данными.
```bash ```bash
cp .env.example .env cp .env.example .env
nano .env nano .env
@@ -70,31 +67,12 @@ Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для
| `ADMIN_IDS` | **Обязательно.** ID администраторов в Telegram через запятую. | `12345678,98765432` | | `ADMIN_IDS` | **Обязательно.** ID администраторов в Telegram через запятую. | `12345678,98765432` |
| `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` | | `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` |
| `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` | | `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` |
| `PRIVACY_POLICY_URL` | (Опционально) Ссылка на политику конфиденциальности, показывается внизу Web App. | `https://example.com/privacy` | | `SUBSCRIPTION_MINI_APP_URL` | (Опционально) URL Mini App для показа подписки. | `https://t.me/your_bot/app` |
| `USER_AGREEMENT_URL` | (Опционально) Ссылка на пользовательское соглашение, показывается внизу Web App. | `https://example.com/agreement` |
| `SUBSCRIPTION_MINI_APP_URL` | (Опционально) Публичный URL Mini App для показа подписки. Если задан, кнопка «Моя подписка» откроет Web App. | `https://app.domain.com/` |
| `WEBAPP_ENABLED` | Включить Web App в том же контейнере, но на отдельном порту. | `true` |
| `WEBAPP_SERVER_PORT` | Внутренний порт Web App. | `8081` |
| `WEBAPP_TITLE` | Заголовок Web App. | `Моя подписка` |
| `WEBAPP_PRIMARY_COLOR` | Основной цвет Web App. | `#00fe7a` |
| `WEBAPP_LOGO_URL` | (Опционально) URL логотипа Web App. Если значение пустое, логотип не показывается вообще; если задано, он отображается в шапке и на экране логина. | `https://domain.com/logo.png` |
| `SMTP_HOST` | SMTP-сервер для кодов входа по email. Для Brevo: `smtp-relay.brevo.com`. | `smtp-relay.brevo.com` |
| `SMTP_PORT` | SMTP-порт. Для Brevo обычно используется 587 с STARTTLS. | `587` |
| `SMTP_FALLBACK_PORTS` | Дополнительные SMTP-порты через запятую. Пробуются после `SMTP_PORT`; порт `465` автоматически используется через SSL. Для Brevo удобно оставить `2525,465`. | `2525,465` |
| `SMTP_TIMEOUT_SECONDS` | Timeout для каждой SMTP-попытки подключения и отправки. | `30` |
| `SMTP_USERNAME` / `SMTP_PASSWORD` | Логин и SMTP key/password из Brevo. Если не заданы вместе с `SMTP_FROM_EMAIL`, вход по email скрывается. | `user@smtp-brevo.com` |
| `SMTP_FROM_EMAIL` / `SMTP_FROM_NAME` | Подтвержденный отправитель и отображаемое имя отправителя для писем с кодом. | `no-reply@example.com` |
| `EMAIL_CODE_TTL_SECONDS` | Срок действия кода подтверждения email. | `600` |
| `EMAIL_CODE_RESEND_SECONDS` | Минимальная пауза между отправками кода на один email. | `60` |
| `EMAIL_CODE_MAX_ATTEMPTS` | Максимум попыток на один конкретный код. | `5` |
| `BRUTE_FORCE_MAX_FAILURES` | Максимум неудачных попыток в окне защиты от перебора. | `5` |
| `BRUTE_FORCE_WINDOW_SECONDS` | Длительность окна, в котором считаются неудачные попытки. | `900` |
| `BRUTE_FORCE_LOCK_SECONDS` | Время временной блокировки после превышения лимита. | `1800` |
| `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` | | `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` |
| `WEBAPP_SESSION_SECRET` | (Опционально) HMAC-секрет для подписи сессий Web App. Если пусто — генерируется при старте, но тогда сессии станут невалидными после перезапуска контейнера. Для прода задайте явно. | `см. раздел «Генерация секретов»` | | `REQUIRED_CHANNEL_SUBSCRIBE_TO_USE` | Включить/выключить обязательную проверку подписки на канал (`true`/`false`). | `false` |
| `WEBHOOK_SECRET_TOKEN` | (Опционально) Secret token для проверки подлинности вебхуков Telegram. Если пусто — генерируется при старте. Для прода задайте явно, чтобы значение пережило рестарт. | `см. раздел «Генерация секретов»` | | `REQUIRED_CHANNEL_ID` | ID канала для проверки подписки. Используется, только если `REQUIRED_CHANNEL_SUBSCRIBE_TO_USE=true`. | `-1001234567890` |
| `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` |
| `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` | | `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` |
| `REFERRAL_ENABLED` | Включить/выключить реферальную систему полностью (`true`/`false`). | `true` |
</details> </details>
<details> <details>
@@ -102,15 +80,15 @@ Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для
| Переменная | Описание | | Переменная | Описание |
| --- | --- | | --- | --- |
| `WEBHOOK_BASE_URL` | **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. | | `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. |
| `WEB_SERVER_HOST` | Хост для веб-сервера (по умолчанию `0.0.0.0`). | | `TELEGRAM_WEBHOOK_PATH` | Относительный путь Telegram вебхука. По умолчанию `/webhook/telegram`. |
| `WEB_SERVER_PORT` | Порт для веб-сервера (по умолчанию `8080`). | | `TELEGRAM_WEBHOOK_SECRET` | (Рекомендуется) Секрет для проверки заголовка `X-Telegram-Bot-Api-Secret-Token`. |
| `WEBAPP_SERVER_HOST` | Хост отдельного веб-сервера Mini App (по умолчанию `0.0.0.0`). | | `WEB_SERVER_HOST` | Хост для веб-сервера. По умолчанию `0.0.0.0`. | `0.0.0.0` |
| `WEBAPP_SERVER_PORT` | Порт отдельного веб-сервера Mini App (по умолчанию `8081`). | | `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` |
| `PAYMENT_METHODS_ORDER` | (Опционально) Порядок отображения кнопок оплаты через запятую. Поддерживаемые ключи: `severpay`, `freekassa`, `platega`, `yookassa`, `stars`, `cryptopay`. Первый будет сверху. | | `PAYMENT_METHODS_ORDER` | (Опционально) Порядок отображения кнопок оплаты через запятую. Поддерживаемые ключи: `severpay`, `freekassa`, `platega`, `yookassa`, `stars`, `cryptopay`. Первый будет сверху. |
| `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). | | `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). |
| `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. | | `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. |
| `YOOKASSA_SECRET_KEY` | Секретный ключ магазина YooKassa. | | `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. |
| `YOOKASSA_AUTOPAYMENTS_ENABLED` | Включить автопродление (сохранение карт, автосписания, управление способами оплаты). | | `YOOKASSA_AUTOPAYMENTS_ENABLED` | Включить автопродление (сохранение карт, автосписания, управление способами оплаты). |
| `YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING` | Требовать обязательную привязку карты при оплате с автосписанием. Установите `false`, чтобы пользователю показывался чекбокс «Сохранить карту». | | `YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING` | Требовать обязательную привязку карты при оплате с автосписанием. Установите `false`, чтобы пользователю показывался чекбокс «Сохранить карту». |
| `NALOGO_INN` | ИНН для авторизации в nalog.ru (самозанятый). | | `NALOGO_INN` | ИНН для авторизации в nalog.ru (самозанятый). |
@@ -125,12 +103,13 @@ Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для
| `FREEKASSA_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. | | `FREEKASSA_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. |
| `FREEKASSA_PAYMENT_METHOD_ID` | ID метода оплаты через магазин FreeKassa. По умолчанию `44`. | | `FREEKASSA_PAYMENT_METHOD_ID` | ID метода оплаты через магазин FreeKassa. По умолчанию `44`. |
| `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). | | `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). |
| `PLATEGA_ENABLED` | Включить/выключить Platega (`true`/`false`). | | `STARS_PROVIDER_TOKEN` | Токен провайдера Telegram invoice. Для Stars (XTR) оставить пустым. |
| `PLATEGA_MERCHANT_ID` | MerchantId из личного кабинета Platega. | | `PLATEGA_ENABLED`| Включить/выключить Platega (`true`/`false`). |
| `PLATEGA_SECRET` | API секрет для запросов Platega. | | `PLATEGA_MERCHANT_ID`| MerchantId из личного кабинета Platega. |
| `PLATEGA_PAYMENT_METHOD` | ID способа оплаты (2 — SBP QR, 10 — РФ карты, 12 — международные карты, 13 — crypto). | | `PLATEGA_SECRET`| API секрет для запросов Platega. |
| `PLATEGA_RETURN_URL` | (Опционально) URL редиректа после успешной оплаты. По умолчанию ссылка на бота. | | `PLATEGA_PAYMENT_METHOD`| ID способа оплаты (2 — SBP QR, 10 — РФ карты, 12 — международные карты, 13 — crypto). |
| `PLATEGA_FAILED_URL` | (Опционально) URL редиректа при ошибке/отмене. По умолчанию как `PLATEGA_RETURN_URL`. | | `PLATEGA_RETURN_URL`| (Опционально) URL редиректа после успешной оплаты. По умолчанию ссылка на бота. |
| `PLATEGA_FAILED_URL`| (Опционально) URL редиректа при ошибке/отмене. По умолчанию как `PLATEGA_RETURN_URL`. |
| `SEVERPAY_ENABLED` | Включить/выключить SeverPay (`true`/`false`). | | `SEVERPAY_ENABLED` | Включить/выключить SeverPay (`true`/`false`). |
| `SEVERPAY_MID` | MID магазина в SeverPay. | | `SEVERPAY_MID` | MID магазина в SeverPay. |
| `SEVERPAY_TOKEN` | Секрет/токен для подписи запросов SeverPay. | | `SEVERPAY_TOKEN` | Секрет/токен для подписи запросов SeverPay. |
@@ -140,11 +119,25 @@ Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для
</details> </details>
<details> <details>
<summary><b>Настройки тарифов</b></summary> <summary><b>Настройки логирования</b></summary>
Бот умеет продавать **подписку на срок** (1/3/6/12 мес.) или **пакеты трафика** (`TRAFFIC_PACKAGES=10:199,50:799`). Эти режимы взаимоисключающие — наличие непустой `TRAFFIC_PACKAGES` (или `STARS_TRAFFIC_PACKAGES`) автоматически переключает бот в режим продажи трафика. | Переменная | Описание | Пример |
| --- | --- | --- |
| `LOGS_PAGE_SIZE` | Количество записей на странице в разделе админ-логов. | `10` |
| `LOG_STORE_MESSAGE_CONTENT` | Сохранять ли содержимое сообщений/колбэков в БД логов (`true`/`false`). | `false` |
| `LOG_STORE_RAW_UPDATES` | Сохранять ли превью сырого Telegram update в БД логов (`true`/`false`). | `false` |
| `LOG_EXPORT_INCLUDE_SENSITIVE` | Добавлять ли в CSV экспорт чувствительные поля (`content`, `raw_update_preview`). | `false` |
| `LOG_ADMIN_HIDE` | Скрывать админские события (`ADMIN_IDS`) в интерфейсе «Все логи сообщений» и в CSV экспорте (`true`/`false`). Логи продолжают записываться в БД. | `true` |
</details>
Полное описание обоих режимов, переменных, что происходит при покупке, как ведут себя автопродление, реф-бонусы и триал — вынесено в [docs/tariffs.md](docs/tariffs.md). <details>
<summary><b>Настройки подписок</b></summary>
Для каждого периода (1, 3, 6, 12 месяцев) можно настроить доступность и цены:
- `1_MONTH_ENABLED`: `true` или `false`
- `RUB_PRICE_1_MONTH`: Цена в рублях
- `STARS_PRICE_1_MONTH`: Цена в Telegram Stars
Аналогичные переменные есть для `3_MONTHS`, `6_MONTHS`, `12_MONTHS`.
</details> </details>
<details> <details>
@@ -173,142 +166,50 @@ Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для
| `TRIAL_TRAFFIC_LIMIT_GB`| Лимит трафика для пробного периода в ГБ. | | `TRIAL_TRAFFIC_LIMIT_GB`| Лимит трафика для пробного периода в ГБ. |
</details> </details>
3. **Сгенерируйте секреты (рекомендуется):** 3. **Запустите контейнеры:**
Переменные `WEBAPP_SESSION_SECRET` и `WEBHOOK_SECRET_TOKEN` могут быть пустыми — тогда они автоматически сгенерируются при каждом старте контейнера. Однако в проде это означает, что после рестарта все сессии Web App станут невалидными, а Telegram придётся перерегистрировать webhook. Поэтому для боевого окружения задайте оба значения вручную.
Сгенерировать криптостойкие значения можно одной из команд:
```bash
# вариант 1 — Python (есть в любом окружении с Python 3)
python -c "import secrets; print(secrets.token_urlsafe(32))"
# вариант 2 — openssl
openssl rand -base64 32 | tr -d '=+/' | cut -c1-43
# вариант 3 — /dev/urandom (Linux/macOS)
head -c 32 /dev/urandom | base64 | tr -d '=+/' | cut -c1-43
```
Запустите команду дважды и подставьте полученные значения в `.env`:
```env
WEBAPP_SESSION_SECRET=<первое_значение>
WEBHOOK_SECRET_TOKEN=<второе_значение>
```
> ⚠️ Не используйте одно и то же значение для обеих переменных и не коммитьте `.env` в git.
4. **Запустите контейнеры:**
```bash ```bash
docker compose up -d docker compose up -d
``` ```
Эта команда соберёт образ из `Dockerfile` (Python + сборка Web App на Node) и запустит сервис в фоновом режиме. Если нужен запуск из готового образа GHCR — используйте `docker-compose-remote-server.yml`. Эта команда скачает образ и запустит сервис в фоновом режиме.
5. **Настройка вебхуков (Обязательно):** 4. **Настройка вебхуков (Обязательно):**
Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Platega, SeverPay) и панели Remnawave. Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Platega, SeverPay) и панели Remnawave.
Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом. Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом.
**Пути для перенаправления:** **Пути для перенаправления:**
- `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-minishop:<WEB_SERVER_PORT>/webhook/yookassa` - `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/yookassa`
- `https://<ваш_домен>/webhook/freekassa` → `http://remnawave-minishop:<WEB_SERVER_PORT>/webhook/freekassa` - `https://<ваш_домен>/webhook/freekassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/freekassa`
- `https://<ваш_домен>/webhook/platega` → `http://remnawave-minishop:<WEB_SERVER_PORT>/webhook/platega` - `https://<ваш_домен>/webhook/platega` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/platega`
- `https://<ваш_домен>/webhook/severpay` → `http://remnawave-minishop:<WEB_SERVER_PORT>/webhook/severpay` - `https://<ваш_домен>/webhook/severpay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/severpay`
- `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-minishop:<WEB_SERVER_PORT>/webhook/cryptopay` - `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/cryptopay`
- `https://<ваш_домен>/webhook/panel` → `http://remnawave-minishop:<WEB_SERVER_PORT>/webhook/panel` - `https://<ваш_домен>/webhook/panel` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/panel`
- **Для Telegram:** Бот автоматически установит вебхук, если в `.env` указан `WEBHOOK_BASE_URL`. Путь будет `https://<ваш_домен>/<BOT_TOKEN>`. - **Для Telegram:** Бот автоматически установит вебхук, если в `.env` указан `WEBHOOK_BASE_URL`. Путь берётся из `TELEGRAM_WEBHOOK_PATH` (по умолчанию `https://<ваш_домен>/webhook/telegram`).
Где `remnawave-minishop` — это имя сервиса из `docker-compose.yml`, а `<WEB_SERVER_PORT>` — порт, указанный в `.env`. Где `remnawave-tg-shop` — это имя сервиса из `docker-compose.yml`, а `<WEB_SERVER_PORT>` — порт, указанный в `.env`.
**Отдельный порт Web App:** 5. **Просмотр логов:**
- `https://<домен_web_app>/` → `http://remnawave-minishop:<WEBAPP_SERVER_PORT>/`
Web App не должен проксироваться на `WEB_SERVER_PORT`: этот порт оставьте для Telegram, платежных и Remnawave webhooks.
6. **Просмотр логов:**
```bash ```bash
docker compose logs -f remnawave-minishop docker compose logs -f remnawave-tg-shop
``` ```
> 💡 Если включена проверка подписки на канал (`REQUIRED_CHANNEL_ID`), добавьте бота администратором в этот канал. Пользователь увидит кнопку «Проверить подписку», и, после первого успешного подтверждения, дальнейшие действия блокироваться не будут. > 💡 Если включена проверка подписки (`REQUIRED_CHANNEL_SUBSCRIBE_TO_USE=true`), добавьте бота администратором в канал из `REQUIRED_CHANNEL_ID`. Пользователь увидит кнопку «Проверить подписку», и после успешного подтверждения доступ продолжится.
### Настройка Web App / Mini App ### Миграции БД (Alembic)
Web App запускается в том же контейнере, что и бот, но слушает отдельный порт `WEBAPP_SERVER_PORT` (по умолчанию `8081`). Внутри Web App пользователь авторизуется через Telegram Mini Apps `initData`; если страницу открыть вне Telegram, показывается официальный Telegram Login Widget. Также доступен вход по email через одноразовый код из письма, если настроен SMTP: после отправки письма код вводится в отдельном модальном окне подтверждения. После успешного входа страница обновляет данные сразу, без сообщений боту. - При запуске `python main.py` миграции применяются автоматически до `head`.
- Для ручного запуска используйте:
1. Укажите в `.env` публичный URL Web App и порт: ```bash
alembic upgrade head
```env ```
WEBAPP_ENABLED=True
WEBAPP_SERVER_HOST=0.0.0.0
WEBAPP_SERVER_PORT=8081
SUBSCRIPTION_MINI_APP_URL=https://app.domain.com/
WEBAPP_TITLE="Моя подписка"
WEBAPP_PRIMARY_COLOR="#00fe7a"
WEBAPP_LOGO_URL=
SMTP_HOST=smtp-relay.brevo.com
SMTP_PORT=587
SMTP_FALLBACK_PORTS=2525,465
SMTP_USERNAME=<brevo-smtp-login>
SMTP_PASSWORD=<brevo-smtp-key>
SMTP_FROM_EMAIL=no-reply@domain.com
```
Если основной порт не отвечает, отправка письма автоматически пробует fallback-порты из `SMTP_FALLBACK_PORTS`. Для Brevo типичная схема: `587` с STARTTLS, затем `2525`, затем `465` через SSL.
2. Убедитесь, что `docker-compose.yml` публикует порт Web App:
```yaml
ports:
- 127.0.0.1:8080:8080
- 127.0.0.1:${WEBAPP_SERVER_PORT:-8081}:${WEBAPP_SERVER_PORT:-8081}
```
3. Проксируйте отдельный домен или location на порт Web App:
```nginx
upstream remnawave-minishop-webapp {
server remnawave-minishop:8081;
}
server {
server_name app.domain.com;
listen 443 ssl;
http2 on;
ssl_certificate "/etc/nginx/ssl/app_fullchain.pem";
ssl_certificate_key "/etc/nginx/ssl/app_privkey.key";
location / {
proxy_pass http://remnawave-minishop-webapp;
proxy_http_version 1.1;
proxy_set_header Host $host;
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;
}
}
```
4. В BotFather настройте домен Mini App для бота (`/setdomain`) и укажите домен из `SUBSCRIPTION_MINI_APP_URL`. Этот же домен используется и Telegram Login Widget.
5. Перезапустите контейнер:
```bash
docker compose up -d --build
```
После этого кнопка «Моя подписка» в меню бота откроет Web App. Web App показывает текущую ссылку подключения, остаток времени, трафик, оплату и блок аккаунта. Пользователь может привязать email к Telegram-аккаунту через код из письма или привязать Telegram к email-аккаунту через Login Widget. После привязки вход работает обоими способами.
Для email-регистраций пользователь в панели Remnawave создается с анонимным username вида `em_<referral_code>`; email добавляется в описание пользователя панели и, если API панели принимает поле email, передается отдельным полем. Для Telegram-регистраций сохраняется существующая схема `tg_<telegram_id>`.
## Подробная инструкция для развертывания на сервере с панелью Remnawave ## Подробная инструкция для развертывания на сервере с панелью Remnawave
### 1. Клонирование репозитория ### 1. Клонирование репозитория
```bash ```bash
git clone https://github.com/3252a8/remnawave-minishop && cd remnawave-minishop git clone https://github.com/kavore/remnawave-tg-shop && cd remnawave-tg-shop
``` ```
### 2. Настройка переменных окружения ### 2. Настройка переменных окружения
@@ -337,8 +238,8 @@ cd /opt/remnawave/nginx && nano nginx.conf
Добавьте в `nginx.conf` следующую конфигурацию: Добавьте в `nginx.conf` следующую конфигурацию:
```nginx ```nginx
upstream remnawave-minishop { upstream remnawave-tg-shop {
server remnawave-minishop:8080; server remnawave-tg-shop:8080;
} }
map $http_upgrade $connection_upgrade { map $http_upgrade $connection_upgrade {
@@ -370,7 +271,7 @@ server {
error_page 400 404 500 502 @redirect; error_page 400 404 500 502 @redirect;
location / { location / {
proxy_pass http://remnawave-minishop$request_uri; proxy_pass http://remnawave-tg-shop$request_uri;
} }
location @redirect { location @redirect {
@@ -440,7 +341,7 @@ networks:
Запустите бота: Запустите бота:
```bash ```bash
cd /root/remnawave-minishop && docker compose up -d && docker compose logs -f -t cd /root/remnawave-tg-shop && docker compose up -d && docker compose logs -f -t
``` ```
Перезапустите Nginx: Перезапустите Nginx:
@@ -451,96 +352,40 @@ cd /opt/remnawave/nginx && docker compose down && docker compose up -d && docker
## 🐳 Docker ## 🐳 Docker
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для локальной сборки и запуска проекта. Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки.
Если нужен запуск из готового образа, используйте `docker-compose-remote-server.yml` как шаблон и укажите свой `image:` вместо локальной сборки. По умолчанию он тянет `ghcr.io/3252a8/remnawave-minishop:latest`, а для закрепления версии можно задать `IMAGE_TAG=3.1.0`. Для автоматической публикации образов настроены GitHub Actions (`.github/workflows`). По умолчанию образы пушатся в GitHub Container Registry и Docker Hub. Добавьте в Secrets репозитория значения `DOCKERHUB_USERNAME` и `DOCKERHUB_TOKEN` (персональный access token или пароль для Docker Hub), чтобы загрузка в Docker Hub работала корректно.
В GHCR доступны теги `3.1.0` и `latest`.
Чтобы использовать сохранённый образ, можно запустить:
```bash
IMAGE_TAG=3.1.0 docker compose -f docker-compose-remote-server.yml up -d
```
### Вариант с Caddy
Если нужен reverse proxy на Caddy, используйте `docker-compose-caddy.yml` вместе с `Caddyfile`. Это удобный вариант, когда хочется, чтобы Caddy сам выпускал TLS-сертификаты и проксировал и webhook'и, и Mini App без ручной настройки Nginx.
В этой схеме:
- Caddy публикует наружу `80` и `443`.
- Бот остается доступным только внутри docker-сети.
- `WEBHOOK_BASE_URL` должен указывать на домен вебхуков, а `SUBSCRIPTION_MINI_APP_URL` - на домен Mini App.
Пример `Caddyfile`:
```caddyfile
webhook.domain.com {
encode zstd gzip
reverse_proxy remnawave-minishop:{$WEB_SERVER_PORT:8080}
}
app.domain.com {
encode zstd gzip
reverse_proxy remnawave-minishop:{$WEBAPP_SERVER_PORT:8081}
}
```
Что нужно поменять под себя:
- заменить `webhook.domain.com` и `app.domain.com` на свои домены;
- убедиться, что в `.env` заданы `WEBHOOK_BASE_URL=https://webhook.domain.com` и `SUBSCRIPTION_MINI_APP_URL=https://app.domain.com/`;
- при необходимости скорректировать `WEB_SERVER_PORT` и `WEBAPP_SERVER_PORT`, если они отличаются от стандартных `8080` и `8081`.
- в BotFather укажите домен Mini App через `/setdomain`, чтобы он совпадал с `SUBSCRIPTION_MINI_APP_URL`.
Запуск:
```bash
docker compose -f docker-compose-caddy.yml up -d --build
```
После этого Caddy сам выпустит сертификаты и будет проксировать webhook'и на порт `8080`, а Mini App - на `8081`.
## 🔄 Миграция с `remnawave-tg-shop` на `remnawave-minishop`
Короткая инструкция, автоматический запуск helper'а из `raw` и ручной вариант переноса вынесены в отдельный документ: [docs/migration-to-minishop.md](docs/migration-to-minishop.md).
## 📁 Структура проекта ## 📁 Структура проекта
``` ```
. .
├── bot/ ├── bot/
│ ├── app/ # Сборка приложения (фабрики, контроллеры, Web App) │ ├── filters/ # Пользовательские фильтры Aiogram
│ ├── controllers/ # Запуск Aiogram dispatcher ├── handlers/ # Обработчики сообщений и колбэков
│ ├── factories/ # Фабрики сервисов (платежи, панель и т.д.) ├── keyboards/ # Клавиатуры
│ └── web/ # Web App / Mini App (сервер, аутентификация, шаблоны) ├── middlewares/ # Промежуточные слои (i18n, проверка бана)
│ ├── filters/ # Пользовательские фильтры Aiogram │ ├── services/ # Бизнес-логика (платежи, API панели)
│ ├── handlers/ # Обработчики сообщений и колбэков (admin/, user/) │ ├── states/ # Состояния FSM
── keyboards/ # Клавиатуры ── main_bot.py # Основная логика бота
│ ├── middlewares/ # Промежуточные слои (i18n, проверка бана и т.д.)
│ ├── services/ # Бизнес-логика (платёжные шлюзы, API панели, email и т.д.)
│ ├── states/ # Состояния FSM (admin/user)
│ ├── utils/ # Вспомогательные утилиты
│ ├── routers.py # Регистрация всех роутеров Aiogram
│ └── main_bot.py # Основная логика бота
├── config/ ├── config/
│ └── settings.py # Настройки Pydantic │ └── settings.py # Настройки Pydantic
├── db/ ├── db/
│ ├── dal/ # Слой доступа к данным (DAL) │ ├── dal/ # Слой доступа к данным (DAL)
│ ├── database_setup.py # Настройка БД и подключения │ ├── database_setup.py # Настройка БД
── migrator.py # Миграции схемы при старте ── models.py # Модели SQLAlchemy
│ └── models.py # Модели SQLAlchemy ├── locales/ # Файлы локализации (ru, en)
├── locales/ # Файлы локализации (ru.json, en.json) ├── .env.example # Пример файла с переменными окружения
├── scripts/ # Сборка JS Web App, обновление копий Telegram JS и миграционный helper ├── Dockerfile # Инструкции для сборки Docker-образа
├── tests/ # Pytest-тесты ├── docker-compose.yml # Файл для оркестрации контейнеров
├── .env.example # Пример файла с переменными окружения ├── requirements.txt # Зависимости Python
── Caddyfile # Пример конфигурации Caddy ── main.py # Точка входа в приложение
├── Dockerfile # Multi-stage сборка (Python + Node для Web App)
├── docker-compose.yml # Локальная сборка и запуск
├── docker-compose-caddy.yml # Запуск с Caddy в качестве reverse proxy
├── docker-compose-remote-server.yml # Запуск из готового образа GHCR
├── package.json # Frontend-зависимости (Tailwind, esbuild) и сборка Web App
├── requirements.txt # Зависимости Python
└── main.py # Точка входа в приложение
``` ```
## 🔮 Планы на будущее
- Расширенные типы промокодов (например, скидки в процентах).
## ❤️ Поддержка ## ❤️ Поддержка
- Crypto: `USDT/Other ERC-20 0xeD506D44aae634fEc0E01C8835744fBedb7B2a44 (Ethereum/Polygon/Gnosis)` - Карты РФ и зарубежные: [Tribute](https://t.me/tribute/app?startapp=dqdg)
- Crypto: `USDT TRC-20 TT3SqBbfU4vYm6SUwUVNZsy278m2xbM4GE`
+38
View File
@@ -0,0 +1,38 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url =
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+94
View File
@@ -0,0 +1,94 @@
from __future__ import annotations
import asyncio
import os
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from db.models import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def _get_database_url() -> str:
configured_url = config.get_main_option("sqlalchemy.url")
if configured_url:
return configured_url
env_url = os.getenv("DATABASE_URL")
if env_url:
return env_url
user = os.getenv("POSTGRES_USER", "postgres")
password = os.getenv("POSTGRES_PASSWORD", "postgres")
host = os.getenv("POSTGRES_HOST", "localhost")
port = os.getenv("POSTGRES_PORT", "5432")
db_name = os.getenv("POSTGRES_DB", "postgres")
return f"postgresql+asyncpg://{user}:{password}@{host}:{port}/{db_name}"
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
context.configure(
url=_get_database_url(),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
configuration = config.get_section(config.config_ini_section) or {}
configuration["sqlalchemy.url"] = _get_database_url()
connectable = async_engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
connectable = config.attributes.get("connection", None)
if connectable is None:
asyncio.run(run_async_migrations())
else:
do_run_migrations(connectable)
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+26
View File
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
+280
View File
@@ -0,0 +1,280 @@
"""initial schema
Revision ID: 0001_initial_schema
Revises:
Create Date: 2026-02-08 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0001_initial_schema"
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("username", sa.String(), nullable=True),
sa.Column("first_name", sa.String(), nullable=True),
sa.Column("last_name", sa.String(), nullable=True),
sa.Column("language_code", sa.String(), nullable=True),
sa.Column("registration_date", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
sa.Column("is_banned", sa.Boolean(), nullable=True),
sa.Column("panel_user_uuid", sa.String(), nullable=True),
sa.Column("referral_code", sa.String(length=16), nullable=True),
sa.Column("referred_by_id", sa.BigInteger(), nullable=True),
sa.Column("channel_subscription_verified", sa.Boolean(), nullable=True),
sa.Column("channel_subscription_checked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("channel_subscription_verified_for", sa.BigInteger(), nullable=True),
sa.ForeignKeyConstraint(["referred_by_id"], ["users.user_id"]),
sa.PrimaryKeyConstraint("user_id"),
sa.UniqueConstraint("panel_user_uuid"),
sa.UniqueConstraint("referral_code"),
)
op.create_index("ix_users_username", "users", ["username"], unique=False)
op.create_table(
"promo_codes",
sa.Column("promo_code_id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("code", sa.String(), nullable=False),
sa.Column("promo_type", sa.String(), nullable=False),
sa.Column("bonus_days", sa.Integer(), nullable=True),
sa.Column("discount_percentage", sa.Integer(), nullable=True),
sa.Column("max_activations", sa.Integer(), nullable=False),
sa.Column("current_activations", sa.Integer(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=True),
sa.Column("created_by_admin_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("promo_code_id"),
sa.UniqueConstraint("code"),
)
op.create_index("idx_promo_codes_promo_type", "promo_codes", ["promo_type"], unique=False)
op.create_table(
"ad_campaigns",
sa.Column("ad_campaign_id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("source", sa.String(), nullable=False),
sa.Column("start_param", sa.String(), nullable=False),
sa.Column("cost", sa.Float(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
sa.PrimaryKeyConstraint("ad_campaign_id"),
sa.UniqueConstraint("start_param"),
)
op.create_index("ix_ad_campaigns_source", "ad_campaigns", ["source"], unique=False)
op.create_index("ix_ad_campaigns_is_active", "ad_campaigns", ["is_active"], unique=False)
op.create_table(
"subscriptions",
sa.Column("subscription_id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("panel_user_uuid", sa.String(), nullable=False),
sa.Column("panel_subscription_uuid", sa.String(), nullable=True),
sa.Column("start_date", sa.DateTime(timezone=True), nullable=True),
sa.Column("end_date", sa.DateTime(timezone=True), nullable=False),
sa.Column("duration_months", sa.Integer(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=True),
sa.Column("status_from_panel", sa.String(), nullable=True),
sa.Column("traffic_limit_bytes", sa.BigInteger(), nullable=True),
sa.Column("traffic_used_bytes", sa.BigInteger(), nullable=True),
sa.Column("last_notification_sent", sa.DateTime(timezone=True), nullable=True),
sa.Column("provider", sa.String(), nullable=True),
sa.Column("skip_notifications", sa.Boolean(), nullable=True),
sa.Column("auto_renew_enabled", sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["users.user_id"]),
sa.PrimaryKeyConstraint("subscription_id"),
sa.UniqueConstraint("panel_subscription_uuid"),
)
op.create_index("ix_subscriptions_user_id", "subscriptions", ["user_id"], unique=False)
op.create_index("ix_subscriptions_panel_user_uuid", "subscriptions", ["panel_user_uuid"], unique=False)
op.create_index("ix_subscriptions_end_date", "subscriptions", ["end_date"], unique=False)
op.create_index("ix_subscriptions_is_active", "subscriptions", ["is_active"], unique=False)
op.create_index("ix_subscriptions_auto_renew_enabled", "subscriptions", ["auto_renew_enabled"], unique=False)
op.create_table(
"payments",
sa.Column("payment_id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("yookassa_payment_id", sa.String(), nullable=True),
sa.Column("provider_payment_id", sa.String(), nullable=True),
sa.Column("provider", sa.String(), nullable=False),
sa.Column("idempotence_key", sa.String(), nullable=True),
sa.Column("amount", sa.Float(), nullable=False),
sa.Column("original_amount", sa.Float(), nullable=True),
sa.Column("discount_applied", sa.Float(), nullable=True),
sa.Column("currency", sa.String(), nullable=False),
sa.Column("status", sa.String(), nullable=False),
sa.Column("description", sa.String(), nullable=True),
sa.Column("subscription_duration_months", sa.Integer(), nullable=True),
sa.Column("promo_code_id", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["promo_code_id"], ["promo_codes.promo_code_id"]),
sa.ForeignKeyConstraint(["user_id"], ["users.user_id"]),
sa.PrimaryKeyConstraint("payment_id"),
sa.UniqueConstraint("idempotence_key"),
sa.UniqueConstraint("provider_payment_id"),
sa.UniqueConstraint("yookassa_payment_id"),
)
op.create_index("ix_payments_user_id", "payments", ["user_id"], unique=False)
op.create_index("ix_payments_provider", "payments", ["provider"], unique=False)
op.create_index("ix_payments_status", "payments", ["status"], unique=False)
op.create_table(
"user_billing",
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("yookassa_payment_method_id", sa.String(), nullable=True),
sa.Column("card_last4", sa.String(), nullable=True),
sa.Column("card_network", sa.String(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["users.user_id"]),
sa.PrimaryKeyConstraint("user_id"),
sa.UniqueConstraint("yookassa_payment_method_id"),
)
op.create_table(
"user_payment_methods",
sa.Column("method_id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("provider", sa.String(), nullable=False),
sa.Column("provider_payment_method_id", sa.String(), nullable=False),
sa.Column("card_last4", sa.String(), nullable=True),
sa.Column("card_network", sa.String(), nullable=True),
sa.Column("is_default", sa.Boolean(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["users.user_id"]),
sa.PrimaryKeyConstraint("method_id"),
sa.UniqueConstraint("provider_payment_method_id"),
sa.UniqueConstraint("user_id", "provider_payment_method_id", name="uq_user_provider_method"),
)
op.create_index("ix_user_payment_methods_user_id", "user_payment_methods", ["user_id"], unique=False)
op.create_index("ix_user_payment_methods_provider", "user_payment_methods", ["provider"], unique=False)
op.create_index("ix_user_payment_methods_is_default", "user_payment_methods", ["is_default"], unique=False)
op.create_table(
"promo_code_activations",
sa.Column("activation_id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("promo_code_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("activated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
sa.Column("payment_id", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(["payment_id"], ["payments.payment_id"]),
sa.ForeignKeyConstraint(["promo_code_id"], ["promo_codes.promo_code_id"]),
sa.ForeignKeyConstraint(["user_id"], ["users.user_id"]),
sa.PrimaryKeyConstraint("activation_id"),
sa.UniqueConstraint("promo_code_id", "user_id", name="uq_promo_user_activation"),
)
op.create_table(
"active_discounts",
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("promo_code_id", sa.Integer(), nullable=False),
sa.Column("discount_percentage", sa.Integer(), nullable=False),
sa.Column("activated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["promo_code_id"], ["promo_codes.promo_code_id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.user_id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("user_id"),
)
op.create_table(
"message_logs",
sa.Column("log_id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=True),
sa.Column("telegram_username", sa.String(), nullable=True),
sa.Column("telegram_first_name", sa.String(), nullable=True),
sa.Column("event_type", sa.String(), nullable=False),
sa.Column("content", sa.Text(), nullable=True),
sa.Column("raw_update_preview", sa.Text(), nullable=True),
sa.Column("timestamp", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
sa.Column("is_admin_event", sa.Boolean(), nullable=True),
sa.Column("target_user_id", sa.BigInteger(), nullable=True),
sa.ForeignKeyConstraint(["target_user_id"], ["users.user_id"]),
sa.ForeignKeyConstraint(["user_id"], ["users.user_id"]),
sa.PrimaryKeyConstraint("log_id"),
)
op.create_index("ix_message_logs_user_id", "message_logs", ["user_id"], unique=False)
op.create_index("ix_message_logs_event_type", "message_logs", ["event_type"], unique=False)
op.create_index("ix_message_logs_timestamp", "message_logs", ["timestamp"], unique=False)
op.create_index("ix_message_logs_target_user_id", "message_logs", ["target_user_id"], unique=False)
op.create_table(
"panel_sync_status",
sa.Column("id", sa.Integer(), autoincrement=False, nullable=False),
sa.Column("last_sync_time", sa.DateTime(timezone=True), nullable=True),
sa.Column("status", sa.String(), nullable=True),
sa.Column("details", sa.Text(), nullable=True),
sa.Column("users_processed_from_panel", sa.Integer(), nullable=True),
sa.Column("subscriptions_synced", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("id"),
)
op.create_table(
"ad_attributions",
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("ad_campaign_id", sa.Integer(), nullable=False),
sa.Column("first_start_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
sa.Column("trial_activated_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["ad_campaign_id"], ["ad_campaigns.ad_campaign_id"]),
sa.ForeignKeyConstraint(["user_id"], ["users.user_id"]),
sa.PrimaryKeyConstraint("user_id"),
)
op.create_index("ix_ad_attributions_ad_campaign_id", "ad_attributions", ["ad_campaign_id"], unique=False)
def downgrade() -> None:
op.drop_index("ix_ad_attributions_ad_campaign_id", table_name="ad_attributions")
op.drop_table("ad_attributions")
op.drop_table("panel_sync_status")
op.drop_index("ix_message_logs_target_user_id", table_name="message_logs")
op.drop_index("ix_message_logs_timestamp", table_name="message_logs")
op.drop_index("ix_message_logs_event_type", table_name="message_logs")
op.drop_index("ix_message_logs_user_id", table_name="message_logs")
op.drop_table("message_logs")
op.drop_table("active_discounts")
op.drop_table("promo_code_activations")
op.drop_index("ix_user_payment_methods_is_default", table_name="user_payment_methods")
op.drop_index("ix_user_payment_methods_provider", table_name="user_payment_methods")
op.drop_index("ix_user_payment_methods_user_id", table_name="user_payment_methods")
op.drop_table("user_payment_methods")
op.drop_table("user_billing")
op.drop_index("ix_payments_status", table_name="payments")
op.drop_index("ix_payments_provider", table_name="payments")
op.drop_index("ix_payments_user_id", table_name="payments")
op.drop_table("payments")
op.drop_index("ix_subscriptions_auto_renew_enabled", table_name="subscriptions")
op.drop_index("ix_subscriptions_is_active", table_name="subscriptions")
op.drop_index("ix_subscriptions_end_date", table_name="subscriptions")
op.drop_index("ix_subscriptions_panel_user_uuid", table_name="subscriptions")
op.drop_index("ix_subscriptions_user_id", table_name="subscriptions")
op.drop_table("subscriptions")
op.drop_index("ix_ad_campaigns_is_active", table_name="ad_campaigns")
op.drop_index("ix_ad_campaigns_source", table_name="ad_campaigns")
op.drop_table("ad_campaigns")
op.drop_index("idx_promo_codes_promo_type", table_name="promo_codes")
op.drop_table("promo_codes")
op.drop_index("ix_users_username", table_name="users")
op.drop_table("users")
@@ -0,0 +1,86 @@
"""add active discount expiration
Revision ID: 0002_active_discount_expires_at
Revises: 0001_initial_schema
Create Date: 2026-02-08 00:00:01.000000
"""
from typing import Sequence, Union
from alembic import op, context
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0002_active_discount_expires_at"
down_revision: Union[str, Sequence[str], None] = "0001_initial_schema"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_INDEX_NAME = "idx_active_discounts_expires_at"
def upgrade() -> None:
if context.is_offline_mode():
op.add_column(
"active_discounts",
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
)
op.execute(
sa.text(
"UPDATE active_discounts "
"SET expires_at = COALESCE(activated_at, NOW()) + INTERVAL '10 minutes' "
"WHERE expires_at IS NULL"
)
)
op.alter_column("active_discounts", "expires_at", nullable=False)
op.create_index(_INDEX_NAME, "active_discounts", ["expires_at"], unique=False)
return
bind = op.get_bind()
inspector = sa.inspect(bind)
if not inspector.has_table("active_discounts"):
return
columns = {column["name"] for column in inspector.get_columns("active_discounts")}
if "expires_at" not in columns:
op.add_column(
"active_discounts",
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
)
op.execute(
sa.text(
"UPDATE active_discounts "
"SET expires_at = COALESCE(activated_at, NOW()) + INTERVAL '10 minutes' "
"WHERE expires_at IS NULL"
)
)
op.alter_column("active_discounts", "expires_at", nullable=False)
indexes = {index["name"] for index in inspector.get_indexes("active_discounts")}
if _INDEX_NAME not in indexes:
op.create_index(_INDEX_NAME, "active_discounts", ["expires_at"], unique=False)
def downgrade() -> None:
if context.is_offline_mode():
op.drop_index(_INDEX_NAME, table_name="active_discounts")
op.drop_column("active_discounts", "expires_at")
return
bind = op.get_bind()
inspector = sa.inspect(bind)
if not inspector.has_table("active_discounts"):
return
indexes = {index["name"] for index in inspector.get_indexes("active_discounts")}
if _INDEX_NAME in indexes:
op.drop_index(_INDEX_NAME, table_name="active_discounts")
columns = {column["name"] for column in inspector.get_columns("active_discounts")}
if "expires_at" in columns:
op.drop_column("active_discounts", "expires_at")
@@ -0,0 +1,95 @@
"""harden promo current activations
Revision ID: 0003_promo_current_activations_not_null
Revises: 0002_active_discount_expires_at
Create Date: 2026-02-11 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op, context
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0003_promo_curr_act_not_null"
down_revision: Union[str, Sequence[str],
None] = "0002_active_discount_expires_at"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
if context.is_offline_mode():
op.execute(
sa.text(
"UPDATE promo_codes SET current_activations = 0 "
"WHERE current_activations IS NULL"
)
)
op.alter_column(
"promo_codes",
"current_activations",
existing_type=sa.Integer(),
nullable=False,
server_default=sa.text("0"),
)
return
bind = op.get_bind()
inspector = sa.inspect(bind)
if not inspector.has_table("promo_codes"):
return
promo_columns = {column["name"]
for column in inspector.get_columns("promo_codes")}
if "current_activations" not in promo_columns:
op.add_column(
"promo_codes",
sa.Column("current_activations", sa.Integer(),
nullable=False, server_default=sa.text("0")),
)
return
op.execute(
sa.text(
"UPDATE promo_codes SET current_activations = 0 "
"WHERE current_activations IS NULL"
)
)
op.alter_column(
"promo_codes",
"current_activations",
existing_type=sa.Integer(),
nullable=False,
server_default=sa.text("0"),
)
def downgrade() -> None:
if context.is_offline_mode():
op.alter_column(
"promo_codes",
"current_activations",
existing_type=sa.Integer(),
nullable=True,
server_default=None,
)
return
bind = op.get_bind()
inspector = sa.inspect(bind)
if not inspector.has_table("promo_codes"):
return
promo_columns = {column["name"]
for column in inspector.get_columns("promo_codes")}
if "current_activations" in promo_columns:
op.alter_column(
"promo_codes",
"current_activations",
existing_type=sa.Integer(),
nullable=True,
server_default=None,
)
+5 -2
View File
@@ -1,3 +1,4 @@
import logging
from aiogram import Bot from aiogram import Bot
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
@@ -81,12 +82,14 @@ def build_core_services(
# Wire services that depend on each other # Wire services that depend on each other
try: try:
# Allow subscription service to consume promo codes
setattr(subscription_service, "promo_code_service", promo_code_service)
# Attach YooKassa to subscription service for auto-renew charges # Attach YooKassa to subscription service for auto-renew charges
setattr(subscription_service, "yookassa_service", yookassa_service) setattr(subscription_service, "yookassa_service", yookassa_service)
# Allow panel webhook to trigger renewals through subscription service # Allow panel webhook to trigger renewals through subscription service
setattr(panel_webhook_service, "subscription_service", subscription_service) setattr(panel_webhook_service, "subscription_service", subscription_service)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/app/factories/build_services.py: %s", exc)
return { return {
"panel_service": panel_service, "panel_service": panel_service,
File diff suppressed because it is too large Load Diff
@@ -1,217 +0,0 @@
<script>
import {
ArrowRight,
CheckCircle2,
Circle,
Copy,
Crown,
Database,
Download,
Gift,
Globe2,
LockKeyhole,
Mail,
RefreshCw,
Repeat2,
Send,
Ticket,
UserRound,
Zap,
} from "lucide-svelte";
import Button from "./lib/components/ui/button.svelte";
import Card from "./lib/components/ui/card.svelte";
import BackTitle from "./preview/BackTitle.svelte";
import PhoneFrame from "./preview/PhoneFrame.svelte";
import PreviewMethods from "./preview/PreviewMethods.svelte";
import PreviewNav from "./preview/PreviewNav.svelte";
export let config = {};
export let mockData = {};
const title = config.title || "/minishop";
const logoEmoji = config.logoEmoji || "🫥";
const plans = mockData.plans || [];
const sub = mockData.subscription || {};
const methods = mockData.payment_methods || [];
const user = mockData.user || {};
const tariffs = [
["subscription", "Подписка", "Безлимитный трафик", "Идеально для постоянного использования", Zap],
["traffic", "Трафик", "Пакеты гигабайт", "Платите только за нужный объем", Database],
["premium", "Премиум", "Максимальная скорость", "Приоритетные серверы и поддержка", Crown],
];
const traffic = [
[20, 290],
[50, 590],
[100, 990],
[300, 2190],
];
const settingsRows = [
[Globe2, "Язык интерфейса", "Русский"],
[Send, "Привязка Telegram", user.telegram_linked ? `@${user.username || "username"}` : "Не привязан"],
[Mail, "Привязка почты", user.email || "Не привязана"],
[UserRound, "Выйти", "Завершить сессию"],
];
const previewTelegramName = user.first_name || (user.username ? `@${user.username}` : "Telegram не привязан");
const previewEmail = user.email || "Почта не привязана";
const previewTelegramId = user.telegram_id ? `TG ID ${user.telegram_id}` : "TG ID не привязан";
const previewAvatar = user.telegram_photo_url || "";
function money(value) {
return `${value} ₽`;
}
</script>
<div class="preview-board" style={`--accent: ${config.primaryColor || "#00fe7a"};`}>
<PhoneFrame number="1" label="Главный экран">
<main class="home-layout">
<div class="login-brand home-brand">
<div class="brand-mark brand-mark-xl"><span>{logoEmoji}</span></div>
<h1>{title}</h1>
</div>
<div class="home-bottom">
<Card class="status-card">
<div class="sub-status">
<CheckCircle2 size={23} />
<div><h2>Подписка активна</h2><p>до {sub.end_date_text}</p></div>
</div>
</Card>
<Card>
<div class="traffic-top"><span>Использовано трафика</span><strong>{sub.traffic_used} из {sub.traffic_limit}</strong></div>
<div class="progress"><span style="width: 18%"></span></div>
<div class="traffic-percent">18%</div>
</Card>
<div class="action-stack">
<Button class="wide"><Download size={17} />Установить и настроить</Button>
<Button variant="secondary" class="wide"><RefreshCw size={17} />Продлить</Button>
<Button variant="secondary" class="wide"><Repeat2 size={17} />Сменить тариф</Button>
</div>
</div>
<PreviewNav active="home" />
</main>
</PhoneFrame>
<PhoneFrame number="2" label="Выбор тарифа">
<div class="preview-header"><div class="brand-row"><div class="brand-mark"><span>{logoEmoji}</span></div><strong>{title}</strong></div></div>
<div class="tariff-list">
{#each tariffs as tariff, index}
<div class:active={index === 0} class="select-card">
<span class="select-icon"><svelte:component this={tariff[4]} size={24} /></span>
<span><strong>{tariff[1]}</strong><small>{tariff[2]}</small><em>{tariff[3]}</em></span>
{#if index === 0}<CheckCircle2 size={21} />{:else}<Circle size={21} />{/if}
</div>
{/each}
</div>
<Button class="wide bottom-action">Далее <ArrowRight size={17} /></Button>
</PhoneFrame>
<PhoneFrame number="3" label="Оплата тарифа — подписка" wide>
<BackTitle title="Подписка" subtitle="Выберите срок подписки" />
<div class="period-grid">
{#each plans as plan, index}
<div class:active={index === 1} class="period-card">
<strong>{plan.title}</strong><span>{money(plan.price)}</span><small>{money(Math.round(plan.price / plan.months))}/мес</small>
{#if index === 1}<CheckCircle2 size={18} />{/if}
</div>
{/each}
</div>
<Card class="total-card"><span>Итого<br /><small>К оплате</small></span><strong>790 ₽</strong></Card>
<PreviewMethods {methods} />
<Button class="wide bottom-action">Оплатить 790 ₽ <LockKeyhole size={16} /></Button>
</PhoneFrame>
<PhoneFrame number="4" label="Оплата тарифа — трафик" wide>
<BackTitle title="Трафик" subtitle="Выберите пакет трафика" />
<div class="period-grid">
{#each traffic as pack, index}
<div class:active={index === 2} class="period-card">
<strong>{pack[0]} ГБ</strong><span>{money(pack[1])}</span><small>{money(Math.round(pack[1] / pack[0]))}/ГБ</small>
{#if index === 2}<CheckCircle2 size={18} />{/if}
</div>
{/each}
</div>
<Card class="total-card"><span>Итого<br /><small>К оплате</small></span><strong>990 ₽</strong></Card>
<PreviewMethods {methods} />
<Button class="wide bottom-action">Оплатить 990 ₽ <LockKeyhole size={16} /></Button>
</PhoneFrame>
<PhoneFrame number="5" label="Смена тарифа">
<BackTitle title="Смена тарифа" subtitle="Остаток 12 дней будет пересчитан" />
<div class="tariff-list compact">
<div class="select-card"><span><strong>Подписка</strong><small>Безлимитный трафик</small></span><em>Доплата 190 ₽</em><Circle size={20} /></div>
<div class="select-card active"><span><strong>Трафик</strong><small>Пакеты гигабайт</small></span><em>Доплата не требуется</em><CheckCircle2 size={20} /></div>
<div class="select-card"><span><strong>Премиум</strong><small>Максимальная скорость</small></span><em>Доплата 390 ₽</em><Circle size={20} /></div>
</div>
<Button class="wide bottom-action">Далее <ArrowRight size={17} /></Button>
<div class="preview-modal">
<Repeat2 size={30} />
<strong>Сменить тариф без доплаты?</strong>
<p>Остаток 12 дней будет пересчитан по новому тарифу.</p>
<Button>Да, сменить</Button>
<Button variant="secondary">Отмена</Button>
</div>
</PhoneFrame>
<PhoneFrame number="6" label="Пригласить друга">
<div class="preview-header"><div class="brand-row"><div class="brand-mark"><span>{logoEmoji}</span></div><strong>{title}</strong></div></div>
<Card>
<div class="card-label">Ваша реферальная ссылка</div>
<div class="copy-row"><code>https://minishop.app/ref/ABCD1234</code><Button>Копировать <Copy size={16} /></Button></div>
</Card>
<Card class="bonus-card">
<Gift size={42} /><div><span>Ваш бонус</span><strong>+7 дней за каждого друга</strong><p>Друг получит +3 дня к подписке.</p></div>
</Card>
<Button variant="outline" class="wide"><Ticket size={18} />Активировать промокод</Button>
</PhoneFrame>
<PhoneFrame number="7" label="Настройки">
<div class="preview-header"><div class="brand-row"><div class="brand-mark"><span>{logoEmoji}</span></div><strong>{title}</strong></div></div>
<Card class="settings-profile">
<div class="settings-avatar">
{#if previewAvatar}
<img src={previewAvatar} alt="Аватар пользователя" />
{:else}
<UserRound size={27} />
{/if}
</div>
<div class="settings-profile-meta">
<strong>{previewTelegramName}</strong>
<small>{previewEmail}</small>
<small>{previewTelegramId}</small>
</div>
</Card> <div class="settings-list">
{#each settingsRows as row}
<div class="settings-row">
<svelte:component this={row[0]} size={20} />
<span><strong>{row[1]}</strong><small>{row[2]}</small></span>
<ArrowRight size={16} />
</div>
{/each}
</div>
</PhoneFrame>
<PhoneFrame number="8" label="Логин" wide>
<div class="login-brand small">
<div class="brand-mark brand-mark-xl"><span>{logoEmoji}</span></div>
<h1>{title}</h1><p>Войдите в свой аккаунт</p>
</div>
<Card class="auth-card">
<div class="field-label">Вход по email</div>
<div class="auth-email-stack"><div class="input muted">Email</div><Button class="wide"><Mail size={17} />Войти по почте</Button></div>
<div class="or-line"><span></span>или<span></span></div>
<Button variant="telegram" class="wide telegram-login-button">
<span class="telegram-login-text"><Send size={17} />Войти через телеграм</span>
</Button>
</Card>
</PhoneFrame>
<PhoneFrame number="9" label="Подтверждение по коду" wide>
<BackTitle title="Подтверждение по email" subtitle="Мы отправили код на user@example.com" />
<div class="otp-slots static">{#each [1, 2, 3, 4, 5, 6] as digit}<span>{digit}</span>{/each}</div>
<Button class="wide bottom-action">Подтвердить</Button>
<button class="link-button"><RefreshCw size={15} />Отправить код повторно (00:45)</button>
</PhoneFrame>
</div>
@@ -1,38 +0,0 @@
<script>
import { cn } from "../../utils.js";
export let type = "button";
export let variant = "default";
export let size = "default";
export let disabled = false;
export let href = "";
export let onclick = undefined;
let className = "";
export { className as class };
const variants = {
default: "btn btn-primary",
secondary: "btn btn-secondary",
outline: "btn btn-outline",
ghost: "btn btn-ghost",
telegram: "btn btn-telegram",
icon: "btn btn-icon",
};
const sizes = {
default: "",
sm: "btn-sm",
lg: "btn-lg",
icon: "btn-square",
};
</script>
{#if href}
<a class={cn(variants[variant], sizes[size], className)} {href} on:click={onclick} {...$$restProps}>
<slot />
</a>
{:else}
<button class={cn(variants[variant], sizes[size], className)} {type} {disabled} on:click={onclick} {...$$restProps}>
<slot />
</button>
{/if}
@@ -1,12 +0,0 @@
<script>
import { cn } from "../../utils.js";
export let active = false;
export let compact = false;
let className = "";
export { className as class };
</script>
<section class={cn("card", active && "card-active", compact && "card-compact", className)}>
<slot />
</section>
@@ -1,31 +0,0 @@
<script>
import { X } from "lucide-svelte";
import { cn } from "../../utils.js";
import Button from "./button.svelte";
export let open = false;
export let title = "";
export let description = "";
export let closeLabel = "Close";
export let onclose = () => {};
let className = "";
export { className as class };
</script>
{#if open}
<div class="dialog" role="dialog" aria-modal="true" aria-label={title}>
<button class="dialog-backdrop" type="button" aria-label={closeLabel} on:click={onclose}></button>
<section class={cn("dialog-card", className)}>
<div class="dialog-head">
<div>
{#if title}<h2>{title}</h2>{/if}
{#if description}<p>{description}</p>{/if}
</div>
<Button variant="icon" size="icon" onclick={onclose} aria-label={closeLabel}>
<X size={18} />
</Button>
</div>
<slot />
</section>
</div>
{/if}
@@ -1,29 +0,0 @@
<script>
import { cn } from "../../utils.js";
export let value = "";
export let type = "text";
export let placeholder = "";
export let inputmode = undefined;
export let maxlength = undefined;
export let autocomplete = undefined;
export let disabled = false;
let className = "";
export { className as class };
</script>
<input
bind:value
class={cn("input", className)}
on:keydown
on:input
on:focus
on:blur
{type}
{placeholder}
{inputmode}
{maxlength}
{autocomplete}
{disabled}
{...$$restProps}
/>
-6
View File
@@ -1,6 +0,0 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs) {
return twMerge(clsx(inputs));
}
-10
View File
@@ -1,10 +0,0 @@
import { mount } from "svelte";
import App from "./App.svelte";
import "./styles.css";
const target = document.getElementById("app");
if (target) {
mount(App, { target });
}
@@ -1,17 +0,0 @@
<script>
import { ArrowLeft } from "lucide-svelte";
export let title = "";
export let subtitle = "";
</script>
<header class="screen-head">
<button class="btn btn-icon btn-square" type="button" aria-label="Назад">
<ArrowLeft size={18} />
</button>
<div class="center-copy">
<h1>{title}</h1>
<p>{subtitle}</p>
</div>
<span></span>
</header>
@@ -1,12 +0,0 @@
<script>
export let number = "";
export let label = "";
export let wide = false;
</script>
<section class:wide class="preview-phone-wrap">
<h2><span>{number}.</span> {label}</h2>
<div class="preview-phone">
<slot />
</div>
</section>
@@ -1,26 +0,0 @@
<script>
import { CreditCard, Send, WalletCards } from "lucide-svelte";
export let methods = [];
const icons = [CreditCard, Send, WalletCards, WalletCards];
function note(index) {
if (index === 0) return "Visa, Mastercard";
if (index === 1) return "Быстро и удобно";
if (index === 2) return "USDT, BTC, ETH";
return "ЮMoney, СБП и др.";
}
</script>
<div class="method-grid">
{#each methods as method, index}
<div class:active={index === 1} class="method-card">
<svelte:component this={icons[index] || WalletCards} size={18} />
<span>
<strong>{method.name}</strong>
<small>{note(index)}</small>
</span>
</div>
{/each}
</div>
@@ -1,11 +0,0 @@
<script>
import { Gift, Home, Settings } from "lucide-svelte";
export let active = "home";
</script>
<nav class="bottom-nav static">
<button class:active={active === "home"} type="button"><Home size={20} /><span>Главная</span></button>
<button class:active={active === "invite"} type="button"><Gift size={20} /><span>Пригласить</span></button>
<button class:active={active === "settings"} type="button"><Settings size={20} /><span>Настройки</span></button>
</nav>
File diff suppressed because it is too large Load Diff
-36
View File
@@ -1,36 +0,0 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import { defineConfig } from "vite";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const templateDir = path.resolve(__dirname, "../templates");
export default defineConfig({
plugins: [svelte()],
build: {
outDir: templateDir,
emptyOutDir: false,
minify: false,
sourcemap: false,
cssCodeSplit: false,
lib: {
entry: path.resolve(__dirname, "src/main.js"),
name: "SubscriptionWebApp",
formats: ["iife"],
fileName: () => "subscription_webapp.js",
cssFileName: "subscription_webapp",
},
rollupOptions: {
output: {
assetFileNames: (assetInfo) => {
if (assetInfo.name && assetInfo.name.endsWith(".css")) {
return "subscription_webapp.css";
}
return "subscription_webapp.[name][extname]";
},
},
},
},
});
File diff suppressed because it is too large Load Diff
@@ -1,29 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="robots" content="noindex, nofollow">
<meta name="theme-color" content="#03070b">
<link id="app-favicon" rel="icon" href="data:," sizes="any">
<title>/minishop</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="stylesheet" href="/subscription_webapp.css">
</head>
<body>
<main id="app"></main>
<!-- WEBAPP_I18N_SCRIPT -->
<!-- WEBAPP_CONFIG_SCRIPT -->
<!-- WEBAPP_JS_SCRIPT -->
<!-- WEBAPP_DEV_MOCK_START -->
<script src="/subscription_webapp.js" defer></script>
<!-- WEBAPP_DEV_MOCK_END -->
</body>
</html>
+23 -72
View File
@@ -1,7 +1,5 @@
import hmac
import asyncio import asyncio
import logging import logging
from aiohttp import web from aiohttp import web
from aiogram import Bot, Dispatcher from aiogram import Bot, Dispatcher
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
@@ -10,24 +8,18 @@ from sqlalchemy.orm import sessionmaker
from config.settings import Settings from config.settings import Settings
class SecureSimpleRequestHandler(SimpleRequestHandler): async def build_and_start_web_app(
def verify_secret(self, telegram_secret_token: str, bot: Bot) -> bool:
if not self.secret_token:
return False
return hmac.compare_digest(telegram_secret_token, self.secret_token)
def _inject_shared_instances(
app: web.Application,
dp: Dispatcher, dp: Dispatcher,
bot: Bot, bot: Bot,
settings: Settings, settings: Settings,
async_session_factory: sessionmaker, async_session_factory: sessionmaker,
) -> None: ):
app = web.Application()
app["bot"] = bot app["bot"] = bot
app["dp"] = dp app["dp"] = dp
app["settings"] = settings app["settings"] = settings
app["async_session_factory"] = async_session_factory app["async_session_factory"] = async_session_factory
# Inject shared instances used by webhook handlers
app["i18n"] = dp.get("i18n_instance") app["i18n"] = dp.get("i18n_instance")
for key in ( for key in (
"yookassa_service", "yookassa_service",
@@ -42,37 +34,29 @@ def _inject_shared_instances(
"platega_service", "platega_service",
"severpay_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 if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore
app[key] = dp.workflow_data[key] # type: ignore app[key] = dp.workflow_data[key] # type: ignore
async def build_and_start_web_app(
dp: Dispatcher,
bot: Bot,
settings: Settings,
async_session_factory: sessionmaker,
):
app = web.Application()
_inject_shared_instances(app, dp, bot, settings, async_session_factory)
async def _healthcheck(request: web.Request) -> web.Response:
return web.json_response({"status": "ok"})
app.router.add_get("/healthz", _healthcheck)
setup_application(app, dp, bot=bot) setup_application(app, dp, bot=bot)
telegram_uses_webhook_mode = bool(settings.WEBHOOK_BASE_URL) telegram_uses_webhook_mode = bool(settings.WEBHOOK_BASE_URL)
telegram_webhook_secret = (settings.TELEGRAM_WEBHOOK_SECRET or "").strip() or None
if telegram_uses_webhook_mode: if telegram_uses_webhook_mode:
telegram_webhook_path = settings.telegram_webhook_path telegram_webhook_path = settings.telegram_webhook_path
SecureSimpleRequestHandler( app.router.add_post(
dispatcher=dp, telegram_webhook_path,
bot=bot, SimpleRequestHandler(
secret_token=settings.WEBHOOK_SECRET_TOKEN, dispatcher=dp,
).register(app, path=telegram_webhook_path) bot=bot,
secret_token=telegram_webhook_secret,
),
)
logging.info( logging.info(
f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)" "Telegram webhook route configured at: [POST] %s (secret_token=%s)",
telegram_webhook_path,
"set" if telegram_webhook_secret else "not_set",
) )
from bot.handlers.user.payment import yookassa_webhook_route from bot.handlers.user.payment import yookassa_webhook_route
@@ -85,7 +69,7 @@ async def build_and_start_web_app(
cp_path = settings.cryptopay_webhook_path cp_path = settings.cryptopay_webhook_path
if cp_path.startswith("/"): if cp_path.startswith("/"):
app.router.add_post(cp_path, cryptopay_webhook_route) app.router.add_post(cp_path, cryptopay_webhook_route)
logging.info(f"CryptoPay webhook route configured at: [POST] {cp_path}") logging.info("CryptoPay webhook route configured at: [POST] %s", cp_path)
fk_path = settings.freekassa_webhook_path fk_path = settings.freekassa_webhook_path
if fk_path.startswith("/"): if fk_path.startswith("/"):
@@ -113,13 +97,10 @@ async def build_and_start_web_app(
app.router.add_post(panel_path, panel_webhook_route) app.router.add_post(panel_path, panel_webhook_route)
logging.info(f"Panel webhook route configured at: [POST] {panel_path}") logging.info(f"Panel webhook route configured at: [POST] {panel_path}")
runners = [] web_app_runner = web.AppRunner(app)
await web_app_runner.setup()
webhooks_runner = web.AppRunner(app)
await webhooks_runner.setup()
runners.append(webhooks_runner)
site = web.TCPSite( site = web.TCPSite(
webhooks_runner, web_app_runner,
host=settings.WEB_SERVER_HOST, host=settings.WEB_SERVER_HOST,
port=settings.WEB_SERVER_PORT, port=settings.WEB_SERVER_PORT,
) )
@@ -129,35 +110,5 @@ async def build_and_start_web_app(
f"AIOHTTP server started on http://{settings.WEB_SERVER_HOST}:{settings.WEB_SERVER_PORT}" f"AIOHTTP server started on http://{settings.WEB_SERVER_HOST}:{settings.WEB_SERVER_PORT}"
) )
if settings.WEBAPP_ENABLED: # Run until cancelled
from bot.app.web.subscription_webapp import create_subscription_webapp_application await asyncio.Event().wait()
subscription_app = create_subscription_webapp_application(
dp,
bot,
settings,
async_session_factory,
)
subscription_runner = web.AppRunner(subscription_app)
await subscription_runner.setup()
runners.append(subscription_runner)
subscription_site = web.TCPSite(
subscription_runner,
host=settings.WEBAPP_SERVER_HOST,
port=settings.WEBAPP_SERVER_PORT,
)
await subscription_site.start()
logging.info(
"Subscription WebApp server started on http://%s:%s",
settings.WEBAPP_SERVER_HOST,
settings.WEBAPP_SERVER_PORT,
)
try:
await asyncio.Event().wait()
finally:
for runner in reversed(runners):
try:
await runner.cleanup()
except Exception as cleanup_error:
logging.warning("Failed to cleanup aiohttp runner: %s", cleanup_error)
-188
View File
@@ -1,188 +0,0 @@
import base64
import hashlib
import hmac
import json
import logging
import time
from typing import Any, Dict, Optional
from urllib.parse import parse_qsl
from config.settings import Settings
logger = logging.getLogger(__name__)
# 5 minutes clock skew tolerance for Telegram clients
TELEGRAM_CLOCK_SKEW_SECONDS = 300
def _urlsafe_b64encode(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _urlsafe_b64decode(raw: str) -> bytes:
padded = raw + ("=" * (-len(raw) % 4))
return base64.urlsafe_b64decode(padded.encode("ascii"))
def _session_secret(settings: Settings) -> bytes:
return hmac.new(
settings.WEBAPP_SESSION_SECRET.encode("utf-8"),
b"remnawave-tg-shop-webapp-session",
hashlib.sha256,
).digest()
def create_webapp_session_token(settings: Settings, user_id: int) -> str:
now = int(time.time())
payload = {
"sub": int(user_id),
"iat": now,
"exp": now + max(60, int(settings.WEBAPP_SESSION_TTL_SECONDS)),
}
payload_part = _urlsafe_b64encode(
json.dumps(payload, separators=(",", ":")).encode("utf-8")
)
signature = hmac.new(
_session_secret(settings),
payload_part.encode("ascii"),
hashlib.sha256,
).digest()
return f"{payload_part}.{_urlsafe_b64encode(signature)}"
def verify_webapp_session_token(settings: Settings, token: str) -> Optional[int]:
if not token or "." not in token:
return None
try:
payload_part, signature_part = token.split(".", 1)
expected_signature = hmac.new(
_session_secret(settings),
payload_part.encode("ascii"),
hashlib.sha256,
).digest()
received_signature = _urlsafe_b64decode(signature_part)
if not hmac.compare_digest(expected_signature, received_signature):
return None
payload = json.loads(_urlsafe_b64decode(payload_part).decode("utf-8"))
if int(payload.get("exp", 0)) < int(time.time()):
return None
return int(payload["sub"])
except Exception as exc:
logger.debug("Failed to verify webapp session token: %s", exc)
return None
def validate_telegram_webapp_init_data(
init_data: str,
bot_token: str,
*,
max_age_seconds: int,
) -> Optional[Dict[str, Any]]:
"""Validate Telegram Mini App initData and return the trusted user payload."""
try:
parsed_data = dict(parse_qsl(init_data or "", keep_blank_values=True))
received_hash = parsed_data.pop("hash", None)
if not received_hash:
return None
data_check_string = "\n".join(
f"{key}={value}" for key, value in sorted(parsed_data.items())
)
secret_key = hmac.new(
b"WebAppData",
bot_token.encode("utf-8"),
hashlib.sha256,
).digest()
calculated_hash = hmac.new(
secret_key,
data_check_string.encode("utf-8"),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(calculated_hash, received_hash):
logger.warning("Telegram WebApp initData hash mismatch.")
return None
auth_date_raw = parsed_data.get("auth_date")
if auth_date_raw:
auth_date = int(auth_date_raw)
now = int(time.time())
max_age = max(60, int(max_age_seconds))
if auth_date > now + TELEGRAM_CLOCK_SKEW_SECONDS or now - auth_date > max_age:
logger.warning("Telegram WebApp initData auth_date is stale.")
return None
user_json = parsed_data.get("user")
if not user_json:
return None
user_data = json.loads(user_json)
if not user_data.get("id"):
return None
if parsed_data.get("start_param"):
user_data["start_param"] = parsed_data.get("start_param")
return user_data
except Exception as exc:
logger.warning("Failed to validate Telegram WebApp initData: %s", exc)
return None
def validate_telegram_login_widget_data(
auth_data: Any,
bot_token: str,
*,
max_age_seconds: int,
) -> Optional[Dict[str, Any]]:
"""Validate Telegram Login Widget data and return the trusted user payload."""
try:
if isinstance(auth_data, str):
parsed_data = dict(parse_qsl(auth_data or "", keep_blank_values=True))
elif isinstance(auth_data, dict):
parsed_data = {
str(key): str(value)
for key, value in auth_data.items()
if value is not None
}
else:
return None
received_hash = str(parsed_data.pop("hash", "") or "")
if not received_hash:
return None
data_check_string = "\n".join(
f"{key}={value}" for key, value in sorted(parsed_data.items())
)
secret_key = hashlib.sha256(bot_token.encode("utf-8")).digest()
calculated_hash = hmac.new(
secret_key,
data_check_string.encode("utf-8"),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(calculated_hash, received_hash):
logger.warning("Telegram Login Widget hash mismatch.")
return None
auth_date_raw = parsed_data.get("auth_date")
if auth_date_raw:
auth_date = int(auth_date_raw)
now = int(time.time())
max_age = max(60, int(max_age_seconds))
if auth_date > now + TELEGRAM_CLOCK_SKEW_SECONDS or now - auth_date > max_age:
logger.warning("Telegram Login Widget auth_date is stale.")
return None
user_id_raw = parsed_data.get("id")
if not user_id_raw:
return None
int(user_id_raw)
if not parsed_data.get("first_name"):
return None
return parsed_data
except Exception as exc:
logger.warning("Failed to validate Telegram Login Widget data: %s", exc)
return None
+4 -4
View File
@@ -46,8 +46,8 @@ async def show_ads_menu(callback: types.CallbackQuery, settings: Settings, i18n_
await callback.message.edit_text(text, reply_markup=reply_markup) await callback.message.edit_text(text, reply_markup=reply_markup)
try: try:
await callback.answer() await callback.answer()
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/admin/ads.py: %s", exc)
@router.callback_query(F.data.startswith("admin_ads:page:")) @router.callback_query(F.data.startswith("admin_ads:page:"))
@@ -262,8 +262,8 @@ async def ads_create_start(callback: types.CallbackQuery, state: FSMContext, set
await callback.message.edit_text(_("admin_ads_create_source_prompt")) await callback.message.edit_text(_("admin_ads_create_source_prompt"))
try: try:
await callback.answer() await callback.answer()
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/admin/ads.py: %s", exc)
@router.message( @router.message(
+2 -2
View File
@@ -171,8 +171,8 @@ async def change_broadcast_target_handler(
current_lang, i18n, target=new_target current_lang, i18n, target=new_target
), ),
) )
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/admin/broadcast.py: %s", exc)
await callback.answer() await callback.answer()
-3
View File
@@ -141,9 +141,6 @@ async def admin_panel_actions_callback_handler(
from . import payments as admin_payments_handlers from . import payments as admin_payments_handlers
await admin_payments_handlers.view_payments_handler( await admin_payments_handlers.view_payments_handler(
callback, i18n_data, settings, session) callback, i18n_data, settings, session)
elif action == "user_ratings":
await admin_stats_handlers.show_user_ratings_handler(
callback, i18n_data, settings, session)
elif action == "ads": elif action == "ads":
from . import ads as admin_ads_handlers from . import ads as admin_ads_handlers
await admin_ads_handlers.show_ads_menu(callback, settings, i18n_data, session) await admin_ads_handlers.show_ads_menu(callback, settings, i18n_data, session)
+39 -23
View File
@@ -23,7 +23,6 @@ from bot.middlewares.i18n import JsonI18n
router = Router(name="admin_logs_router") router = Router(name="admin_logs_router")
USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$") USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
EMAIL_REGEX = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
async def display_logs_menu(callback: types.CallbackQuery, i18n_data: dict, async def display_logs_menu(callback: types.CallbackQuery, i18n_data: dict,
@@ -172,9 +171,17 @@ async def view_all_logs_handler(callback: types.CallbackQuery,
await callback.answer("Error processing request.", show_alert=True) await callback.answer("Error processing request.", show_alert=True)
return return
hide_admin_events = bool(settings.LOG_ADMIN_HIDE)
logs_models = await message_log_dal.get_all_message_logs( logs_models = await message_log_dal.get_all_message_logs(
session, settings.LOGS_PAGE_SIZE, page_idx * settings.LOGS_PAGE_SIZE) session,
total_logs_count = await message_log_dal.count_all_message_logs(session) settings.LOGS_PAGE_SIZE,
page_idx * settings.LOGS_PAGE_SIZE,
hide_admin_events=hide_admin_events,
)
total_logs_count = await message_log_dal.count_all_message_logs(
session,
hide_admin_events=hide_admin_events,
)
await _display_formatted_logs( await _display_formatted_logs(
target_message=callback.message, target_message=callback.message,
@@ -226,14 +233,12 @@ async def process_user_id_for_logs_handler(message: types.Message,
input_text = message.text.strip() if message.text else "" input_text = message.text.strip() if message.text else ""
user_model_for_logs: Optional[User] = None user_model_for_logs: Optional[User] = None
if input_text.isdigit() or (input_text.startswith("-") and input_text[1:].isdigit()): if input_text.isdigit():
try: try:
user_model_for_logs = await user_dal.get_user_by_id( user_model_for_logs = await user_dal.get_user_by_id(
session, int(input_text)) session, int(input_text))
except ValueError: except ValueError:
pass pass
elif EMAIL_REGEX.match(input_text):
user_model_for_logs = await user_dal.get_user_by_email(session, input_text)
elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]): elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
user_model_for_logs = await user_dal.get_user_by_username( user_model_for_logs = await user_dal.get_user_by_username(
session, input_text[1:]) session, input_text[1:])
@@ -248,7 +253,7 @@ async def process_user_id_for_logs_handler(message: types.Message,
target_user_id = user_model_for_logs.user_id target_user_id = user_model_for_logs.user_id
user_display_name = user_model_for_logs.first_name or ( user_display_name = user_model_for_logs.first_name or (
f"@{user_model_for_logs.username}" f"@{user_model_for_logs.username}"
if user_model_for_logs.username else (user_model_for_logs.email or f"ID {target_user_id}")) if user_model_for_logs.username else f"ID {target_user_id}")
logs_models = await message_log_dal.get_user_message_logs( logs_models = await message_log_dal.get_user_message_logs(
session, target_user_id, settings.LOGS_PAGE_SIZE, 0) session, target_user_id, settings.LOGS_PAGE_SIZE, 0)
@@ -295,7 +300,7 @@ async def view_user_logs_paginated_handler(callback: types.CallbackQuery,
user_display_name = user_model_for_logs.first_name or ( user_display_name = user_model_for_logs.first_name or (
f"@{user_model_for_logs.username}" f"@{user_model_for_logs.username}"
if user_model_for_logs.username else (user_model_for_logs.email or f"ID {target_user_id}")) if user_model_for_logs.username else f"ID {target_user_id}")
logs_models = await message_log_dal.get_user_message_logs( logs_models = await message_log_dal.get_user_message_logs(
session, target_user_id, settings.LOGS_PAGE_SIZE, session, target_user_id, settings.LOGS_PAGE_SIZE,
@@ -347,8 +352,12 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
try: try:
# Get all logs (limit to 10000 for performance) # Get all logs (limit to 10000 for performance)
logs_models = await message_log_dal.get_all_message_logs( logs_models = await message_log_dal.get_all_message_logs(
session, limit=10000, offset=0) session,
limit=10000,
offset=0,
hide_admin_events=bool(settings.LOG_ADMIN_HIDE),
)
if not logs_models: if not logs_models:
await callback.message.answer(_( await callback.message.answer(_(
"admin_logs_csv_no_data" "admin_logs_csv_no_data"
@@ -358,7 +367,7 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
# Create CSV content # Create CSV content
csv_buffer = io.StringIO() csv_buffer = io.StringIO()
csv_writer = csv.writer(csv_buffer, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL) csv_writer = csv.writer(csv_buffer, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL)
# Write header # Write header
headers = [ headers = [
_("admin_csv_header_log_id"), _("admin_csv_header_log_id"),
@@ -367,22 +376,26 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
_("admin_csv_header_telegram_username"), _("admin_csv_header_telegram_username"),
_("admin_csv_header_telegram_first_name"), _("admin_csv_header_telegram_first_name"),
_("admin_csv_header_event_type"), _("admin_csv_header_event_type"),
_("admin_csv_header_content"),
_("admin_csv_header_is_admin_event"), _("admin_csv_header_is_admin_event"),
_("admin_csv_header_target_user_id"), _("admin_csv_header_target_user_id"),
_("admin_csv_header_raw_update_preview")
] ]
include_sensitive_fields = bool(settings.LOG_EXPORT_INCLUDE_SENSITIVE)
if include_sensitive_fields:
headers.extend([
_("admin_csv_header_content"),
_("admin_csv_header_raw_update_preview"),
])
csv_writer.writerow(headers) csv_writer.writerow(headers)
# Write data rows # Write data rows
for log in logs_models: for log in logs_models:
# Format timestamp # Format timestamp
timestamp_str = log.timestamp.strftime('%Y-%m-%d %H:%M:%S UTC') if log.timestamp else '' timestamp_str = log.timestamp.strftime('%Y-%m-%d %H:%M:%S UTC') if log.timestamp else ''
# Clean content and raw_update_preview (remove newlines and quotes for CSV) # Clean content and raw_update_preview (remove newlines and quotes for CSV)
content_clean = (log.content or '').replace('\n', ' ').replace('\r', ' ').strip() content_clean = (log.content or '').replace('\n', ' ').replace('\r', ' ').strip()
raw_update_clean = (log.raw_update_preview or '').replace('\n', ' ').replace('\r', ' ').strip() raw_update_clean = (log.raw_update_preview or '').replace('\n', ' ').replace('\r', ' ').strip()
row = [ row = [
log.log_id or '', log.log_id or '',
timestamp_str, timestamp_str,
@@ -390,27 +403,30 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
log.telegram_username or '', log.telegram_username or '',
log.telegram_first_name or '', log.telegram_first_name or '',
log.event_type or '', log.event_type or '',
content_clean,
'Yes' if log.is_admin_event else 'No', 'Yes' if log.is_admin_event else 'No',
log.target_user_id or '', log.target_user_id or '',
raw_update_clean
] ]
if include_sensitive_fields:
row.extend([
content_clean,
raw_update_clean,
])
csv_writer.writerow(row) csv_writer.writerow(row)
# Create file # Create file
csv_content = csv_buffer.getvalue() csv_content = csv_buffer.getvalue()
csv_buffer.close() csv_buffer.close()
# Generate filename with current timestamp # Generate filename with current timestamp
now = datetime.now() now = datetime.now()
filename = f"message_logs_{now.strftime('%Y%m%d_%H%M%S')}.csv" filename = f"message_logs_{now.strftime('%Y%m%d_%H%M%S')}.csv"
# Send as document # Send as document
csv_file = types.BufferedInputFile( csv_file = types.BufferedInputFile(
csv_content.encode('utf-8-sig'), # BOM for Excel compatibility csv_content.encode('utf-8-sig'), # BOM for Excel compatibility
filename=filename filename=filename
) )
await callback.message.answer_document( await callback.message.answer_document(
csv_file, csv_file,
caption=_( caption=_(
@@ -419,7 +435,7 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
date=now.strftime('%Y-%m-%d %H:%M:%S') date=now.strftime('%Y-%m-%d %H:%M:%S')
) )
) )
except Exception as e: except Exception as e:
logging.error(f"Error exporting logs to CSV: {e}", exc_info=True) logging.error(f"Error exporting logs to CSV: {e}", exc_info=True)
await callback.message.answer(_( await callback.message.answer(_(
+2 -2
View File
@@ -1,5 +1,5 @@
import logging import logging
import random import secrets
import string import string
import csv import csv
import io import io
@@ -56,7 +56,7 @@ async def create_bulk_promo_prompt_handler(callback: types.CallbackQuery,
def generate_unique_promo_code(length: int = 8) -> str: def generate_unique_promo_code(length: int = 8) -> str:
"""Generate a unique random promo code""" """Generate a unique random promo code"""
characters = string.ascii_uppercase + string.digits characters = string.ascii_uppercase + string.digits
return ''.join(random.choice(characters) for _ in range(length)) return ''.join(secrets.choice(characters) for _ in range(length))
# Step 1: Process quantity # Step 1: Process quantity
+196 -45
View File
@@ -28,25 +28,82 @@ async def create_promo_prompt_handler(callback: types.CallbackQuery,
return return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = 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 = _( prompt_text = _(
"admin_promo_step1_code" "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: try:
await callback.message.edit_text( await callback.message.edit_text(
prompt_text, prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), reply_markup=builder.as_markup(),
parse_mode="HTML") parse_mode="HTML")
except Exception as e: except Exception as e:
logging.warning( 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( await callback.message.answer(
prompt_text, prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), reply_markup=builder.as_markup(),
parse_mode="HTML") parse_mode="HTML")
await callback.answer() 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 # Step 1: Process promo code
@@ -80,19 +137,31 @@ async def process_promo_code_handler(message: types.Message,
return return
await state.update_data(promo_code=code_str) await state.update_data(promo_code=code_str)
# Step 2: Ask for bonus days # Get promo type from state
prompt_text = _( data = await state.get_data()
"admin_promo_step2_bonus_days", promo_type = data.get("promo_type", "bonus_days")
code=code_str
) # 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( await message.answer(
prompt_text, prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML" parse_mode="HTML"
) )
await state.set_state(AdminStates.waiting_for_promo_bonus_days) await state.set_state(next_state)
except Exception as e: except Exception as e:
logging.error(f"Error processing promo code: {e}") logging.error(f"Error processing promo code: {e}")
@@ -119,9 +188,9 @@ async def process_promo_bonus_days_handler(message: types.Message,
"admin_promo_invalid_bonus_days" "admin_promo_invalid_bonus_days"
)) ))
return return
await state.update_data(bonus_days=bonus_days) await state.update_data(bonus_days=bonus_days)
# Step 3: Ask for max activations # Step 3: Ask for max activations
data = await state.get_data() data = await state.get_data()
prompt_text = _( prompt_text = _(
@@ -129,14 +198,14 @@ async def process_promo_bonus_days_handler(message: types.Message,
code=data.get("promo_code"), code=data.get("promo_code"),
bonus_days=bonus_days bonus_days=bonus_days
) )
await message.answer( await message.answer(
prompt_text, prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML" parse_mode="HTML"
) )
await state.set_state(AdminStates.waiting_for_promo_max_activations) await state.set_state(AdminStates.waiting_for_promo_max_activations)
except ValueError: except ValueError:
await message.answer(_( await message.answer(_(
"admin_promo_invalid_number" "admin_promo_invalid_number"
@@ -146,6 +215,53 @@ async def process_promo_bonus_days_handler(message: types.Message,
await message.answer(_("error_occurred_try_again")) 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 # Step 3: Process max activations
@router.message(AdminStates.waiting_for_promo_max_activations, F.text) @router.message(AdminStates.waiting_for_promo_max_activations, F.text)
async def process_promo_max_activations_handler(message: types.Message, async def process_promo_max_activations_handler(message: types.Message,
@@ -168,15 +284,25 @@ async def process_promo_max_activations_handler(message: types.Message,
return return
await state.update_data(max_activations=max_activations) await state.update_data(max_activations=max_activations)
# Step 4: Ask for validity # Step 4: Ask for validity
data = await state.get_data() data = await state.get_data()
prompt_text = _( promo_type = data.get("promo_type", "bonus_days")
"admin_promo_step4_validity",
code=data.get("promo_code"), if promo_type == "discount":
bonus_days=data.get("bonus_days"), prompt_text = _(
max_activations=max_activations "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 # Create keyboard for validity options
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
@@ -240,12 +366,15 @@ async def process_promo_set_validity(callback: types.CallbackQuery,
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
data = await state.get_data() data = await state.get_data()
prompt_text = _( promo_type = data.get("promo_type", "bonus_days")
"admin_promo_enter_validity_days",
code=data.get("promo_code"), # Display the correct text based on promo type
bonus_days=data.get("bonus_days"), if promo_type == "discount":
max_activations=data.get("max_activations") 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: try:
await callback.message.edit_text( await callback.message.edit_text(
@@ -310,40 +439,60 @@ async def create_promo_code_final(callback_or_message,
try: try:
data = await state.get_data() data = await state.get_data()
promo_type = data.get("promo_type", "bonus_days")
# Prepare promo code data # Prepare promo code data
promo_data = { promo_data = {
"code": data["promo_code"], "code": data["promo_code"],
"bonus_days": data["bonus_days"], "promo_type": promo_type,
"max_activations": data["max_activations"], "max_activations": data["max_activations"],
"current_activations": 0, "current_activations": 0,
"is_active": True, "is_active": True,
"created_by_admin_id": callback_or_message.from_user.id, "created_by_admin_id": callback_or_message.from_user.id,
"created_at": datetime.now(timezone.utc) "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 # Set validity
if data.get("validity_days"): if data.get("validity_days"):
promo_data["valid_until"] = datetime.now(timezone.utc) + timedelta(days=data["validity_days"]) promo_data["valid_until"] = datetime.now(timezone.utc) + timedelta(days=data["validity_days"])
else: else:
promo_data["valid_until"] = None promo_data["valid_until"] = None
# Create promo code # Create promo code
created_promo = await promo_code_dal.create_promo_code(session, promo_data) created_promo = await promo_code_dal.create_promo_code(session, promo_data)
await session.commit() await session.commit()
# Log successful creation # 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 # Success message
valid_until_str = _("admin_promo_unlimited") if not data.get("validity_days") else f"{data['validity_days']} дней" valid_until_str = _("admin_promo_unlimited") if not data.get("validity_days") else f"{data['validity_days']} дней"
success_text = _(
"admin_promo_created_success", # Format success message based on type
code=data["promo_code"], if promo_type == "discount":
bonus_days=data["bonus_days"], success_text = _(
max_activations=data["max_activations"], "admin_promo_created_success_discount",
valid_until_str=valid_until_str 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 if hasattr(callback_or_message, 'message'): # CallbackQuery
try: try:
@@ -385,8 +534,10 @@ async def create_promo_code_final(callback_or_message,
@router.callback_query( @router.callback_query(
F.data == "admin_action:main", F.data == "admin_action:main",
StateFilter( StateFilter(
AdminStates.waiting_for_promo_type_selection,
AdminStates.waiting_for_promo_code, AdminStates.waiting_for_promo_code,
AdminStates.waiting_for_promo_bonus_days, AdminStates.waiting_for_promo_bonus_days,
AdminStates.waiting_for_promo_discount_percentage,
AdminStates.waiting_for_promo_max_activations, AdminStates.waiting_for_promo_max_activations,
AdminStates.waiting_for_promo_validity_days, AdminStates.waiting_for_promo_validity_days,
), ),
+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" 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([ text = "\n".join([
_("admin_promo_card_title", code=promo.code), _("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_activations", current=promo.current_activations, max=promo.max_activations),
_("admin_promo_card_validity", validity=validity), _("admin_promo_card_validity", validity=validity),
_("admin_promo_card_status", status=status), _("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) _ = 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) 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( if not promo_models:
[_("admin_active_promos_list_header"), ""] + [ text = f"{_('admin_active_promos_list_header')}\n\n{_('admin_no_active_promos')}"
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')}" else:
for p in promo_models 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.message.edit_text(text, reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), parse_mode="HTML")
await callback.answer() await callback.answer()
@@ -300,7 +320,9 @@ async def promo_export_all_handler(callback: types.CallbackQuery, i18n_data: dic
# CSV headers (forced to English) # CSV headers (forced to English)
writer.writerow([ writer.writerow([
i18n.gettext(export_lang, "admin_promo_csv_code"), i18n.gettext(export_lang, "admin_promo_csv_code"),
"Type",
i18n.gettext(export_lang, "admin_promo_csv_bonus_days"), 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_max_activations"),
i18n.gettext(export_lang, "admin_promo_csv_current_activations"), i18n.gettext(export_lang, "admin_promo_csv_current_activations"),
i18n.gettext(export_lang, "admin_promo_csv_status"), 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_at"),
i18n.gettext(export_lang, "admin_promo_csv_created_by_admin_id"), i18n.gettext(export_lang, "admin_promo_csv_created_by_admin_id"),
]) ])
for promo in all_promos: for promo in all_promos:
# Определяем статус # Определяем статус
status_emoji, status_text = get_promo_status_emoji_and_text(promo, i18n, export_lang) 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 # Формируем данные для CSV
row = [ row = [
promo.code, promo.code,
promo.bonus_days, promo_type,
bonus_days_val,
discount_val,
promo.max_activations, promo.max_activations,
promo.current_activations, promo.current_activations,
status_text, status_text,
@@ -374,13 +403,26 @@ async def promo_edit_select_handler(callback: types.CallbackQuery, i18n_data: di
return return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
promo_id = int(callback.data.split(":")[1]) 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 = 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_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_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}")) 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.message.edit_text(_("admin_promo_edit_select_field"), reply_markup=builder.as_markup())
await callback.answer() 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(":") action, field, promo_id_str = callback.data.split(":")
await state.update_data(promo_id=int(promo_id_str), field_to_edit=field) await state.update_data(promo_id=int(promo_id_str), field_to_edit=field)
prompts = { prompts = {
"bonus_days": "admin_promo_prompt_bonus_days", "bonus_days": "admin_promo_prompt_bonus_days",
"discount_percentage": "admin_promo_prompt_discount_percentage",
"max_activations": "admin_promo_prompt_max_activations", "max_activations": "admin_promo_prompt_max_activations",
"valid_until": "admin_promo_prompt_validity_days" "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 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() await callback.answer()
@router.message(StateFilter(AdminStates.waiting_for_promo_edit_details)) @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: try:
value = message.text value = message.text
update_data = {} update_data = {}
if field == "bonus_days": if field == "bonus_days":
update_data["bonus_days"] = int(value) 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": elif field == "max_activations":
update_data["max_activations"] = int(value) update_data["max_activations"] = int(value)
elif field == "valid_until": 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): if await promo_code_dal.update_promo_code(session, promo_id, update_data):
await session.commit() await session.commit()
await message.answer(_("admin_promo_edit_success")) await message.answer(_("admin_promo_edit_success"))
# Reset state and show updated details # Reset state and show updated details
await state.clear() await state.clear()
text, keyboard = await get_promo_detail_text_and_keyboard(promo_id, session, i18n, current_lang) text, keyboard = await get_promo_detail_text_and_keyboard(promo_id, session, i18n, current_lang)
+1 -135
View File
@@ -2,7 +2,6 @@ import logging
from aiogram import Router, F, types from aiogram import Router, F, types
from typing import Optional, Dict, List from typing import Optional, Dict, List
from datetime import datetime from datetime import datetime
import html
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings from config.settings import Settings
@@ -11,44 +10,12 @@ from db.dal import user_dal, payment_dal, panel_sync_dal
from db.models import Payment, PanelSyncStatus from db.models import Payment, PanelSyncStatus
from bot.services.panel_api_service import PanelApiService from bot.services.panel_api_service import PanelApiService
from bot.keyboards.inline.admin_keyboards import ( from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard
get_back_to_admin_panel_keyboard,
get_back_to_user_management_keyboard,
)
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
router = Router(name="admin_statistics_router") router = Router(name="admin_statistics_router")
def _format_rating_user_label(user_row: Dict[str, object],
bot_username: Optional[str] = None) -> str:
user_id = int(user_row.get("user_id", 0) or 0)
username = user_row.get("username")
first_name = user_row.get("first_name")
user_id_text = str(user_id)
user_id_html = html.escape(user_id_text)
if bot_username:
safe_bot_username = html.escape(bot_username)
user_id_html = (
f'<a href="https://t.me/{safe_bot_username}?start=admin_user_{user_id_text}">'
f"{user_id_html}</a>"
)
parts: List[str] = []
if username:
parts.append(f"@{html.escape(str(username))}")
elif first_name:
parts.append(html.escape(str(first_name)))
if not parts:
parts.append(f"ID {user_id_html}")
else:
parts.append(f"(ID {user_id_html})")
return " ".join(parts)
async def show_statistics_handler(callback: types.CallbackQuery, async def show_statistics_handler(callback: types.CallbackQuery,
i18n_data: dict, settings: Settings, i18n_data: dict, settings: Settings,
session: AsyncSession): session: AsyncSession):
@@ -288,104 +255,3 @@ async def show_statistics_handler(callback: types.CallbackQuery,
reply_markup=get_back_to_admin_panel_keyboard( reply_markup=get_back_to_admin_panel_keyboard(
current_lang, i18n)) current_lang, i18n))
break break
async def show_user_ratings_handler(
callback: 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")
if not i18n or not callback.message:
await callback.answer("Error displaying ratings.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
await callback.answer()
top_limit = 10
bot_username: Optional[str] = None
try:
me = await callback.bot.get_me()
bot_username = me.username
except Exception as e_get_me:
logging.warning("Failed to resolve bot username for ratings links: %s",
e_get_me)
traffic_top = await user_dal.get_top_users_by_traffic_used(session, limit=top_limit)
lifetime_traffic_top = await user_dal.get_top_users_by_lifetime_traffic_used(
session, limit=top_limit
)
invited_top = await user_dal.get_top_users_by_referrals_count(session, limit=top_limit)
revenue_top = await user_dal.get_top_users_by_referral_revenue(session, limit=top_limit)
text_parts: List[str] = [
_("admin_user_ratings_header", top_limit=top_limit),
"",
f"<b>{_('admin_user_ratings_traffic_month_title')}</b>",
]
if traffic_top:
for idx, row in enumerate(traffic_top, start=1):
traffic_gb = float(row.get("traffic_used_bytes") or 0) / (1024**3)
text_parts.append(
_(
"admin_user_ratings_traffic_item",
rank=idx,
user=_format_rating_user_label(row, bot_username),
traffic_gb=f"{traffic_gb:.2f}",
)
)
else:
text_parts.append(_("admin_user_ratings_empty"))
text_parts.extend(["", f"<b>{_('admin_user_ratings_traffic_lifetime_title')}</b>"])
if lifetime_traffic_top:
for idx, row in enumerate(lifetime_traffic_top, start=1):
traffic_gb = float(row.get("lifetime_used_traffic_bytes") or 0) / (1024**3)
text_parts.append(
_(
"admin_user_ratings_traffic_item",
rank=idx,
user=_format_rating_user_label(row, bot_username),
traffic_gb=f"{traffic_gb:.2f}",
)
)
else:
text_parts.append(_("admin_user_ratings_empty"))
text_parts.extend(["", f"<b>{_('admin_user_ratings_invited_title')}</b>"])
if invited_top:
for idx, row in enumerate(invited_top, start=1):
text_parts.append(
_(
"admin_user_ratings_invited_item",
rank=idx,
user=_format_rating_user_label(row, bot_username),
invited_count=int(row.get("invited_count") or 0),
)
)
else:
text_parts.append(_("admin_user_ratings_empty"))
text_parts.extend(["", f"<b>{_('admin_user_ratings_revenue_title')}</b>"])
if revenue_top:
for idx, row in enumerate(revenue_top, start=1):
text_parts.append(
_(
"admin_user_ratings_revenue_item",
rank=idx,
user=_format_rating_user_label(row, bot_username),
revenue=f"{float(row.get('referral_revenue') or 0):.2f}",
)
)
else:
text_parts.append(_("admin_user_ratings_empty"))
await callback.message.edit_text(
"\n".join(text_parts),
reply_markup=get_back_to_user_management_keyboard(current_lang, i18n),
parse_mode="HTML",
)
+5 -178
View File
@@ -18,108 +18,6 @@ from bot.middlewares.i18n import JsonI18n
router = Router(name="admin_sync_router") router = Router(name="admin_sync_router")
def _normalize_panel_email(value: Optional[str]) -> Optional[str]:
email = (value or "").strip().lower()
return email or None
def _extract_lifetime_used_traffic_bytes(panel_user_data: dict) -> Optional[int]:
user_traffic = panel_user_data.get("userTraffic") or {}
raw_value = (
user_traffic.get("lifetimeUsedTrafficBytes")
if isinstance(user_traffic, dict)
else None
)
if raw_value is None:
raw_value = panel_user_data.get("lifetimeUsedTrafficBytes")
try:
if raw_value is None:
return None
return int(raw_value)
except (TypeError, ValueError):
return None
async def _bind_panel_email_to_user(
session: AsyncSession,
*,
existing_user,
email_from_panel: Optional[str],
panel_uuid: str,
) -> tuple[object, bool]:
"""Bind panel email to a local user without violating the unique email index.
Panel email is treated as verified because it comes from the operator-managed
panel. If the same email already belongs to an email-only local account for
this panel user, merge that account into the Telegram/local user.
"""
if not email_from_panel:
return existing_user, False
if existing_user.email == email_from_panel:
if not existing_user.email_verified_at:
existing_user.email_verified_at = datetime.now(timezone.utc)
return existing_user, True
return existing_user, False
user_with_email = await user_dal.get_user_by_email(session, email_from_panel)
if user_with_email and user_with_email.user_id != existing_user.user_id:
can_merge_email_identity = (
not user_with_email.telegram_id
and user_with_email.panel_user_uuid in (None, panel_uuid)
and (not existing_user.email or existing_user.email == email_from_panel)
)
if can_merge_email_identity:
try:
merged_user = await user_dal.merge_users(
session,
source_user_id=user_with_email.user_id,
target_user_id=existing_user.user_id,
)
if not merged_user.email:
merged_user.email = email_from_panel
if not merged_user.email_verified_at:
merged_user.email_verified_at = datetime.now(timezone.utc)
logging.info(
"Merged email-only user %s into user %s while binding panel email %s for panel UUID %s.",
user_with_email.user_id,
merged_user.user_id,
email_from_panel,
panel_uuid,
)
return merged_user, True
except Exception as merge_error:
logging.warning(
"Could not merge email-only user %s into user %s for panel email %s: %s",
user_with_email.user_id,
existing_user.user_id,
email_from_panel,
merge_error,
)
return existing_user, False
logging.warning(
"Panel email %s for panel UUID %s is already linked to local user %s; "
"skipping email binding for user %s.",
email_from_panel,
panel_uuid,
user_with_email.user_id,
existing_user.user_id,
)
return existing_user, False
existing_user.email = email_from_panel
existing_user.email_verified_at = datetime.now(timezone.utc)
logging.info(
"Bound panel email %s to local user %s for panel UUID %s.",
email_from_panel,
existing_user.user_id,
panel_uuid,
)
return existing_user, True
async def perform_sync( async def perform_sync(
panel_service: PanelApiService, panel_service: PanelApiService,
session: AsyncSession, session: AsyncSession,
@@ -178,7 +76,6 @@ async def perform_sync(
"shortUuid" "shortUuid"
) )
telegram_id_from_panel = panel_user_dict.get("telegramId") telegram_id_from_panel = panel_user_dict.get("telegramId")
email_from_panel = _normalize_panel_email(panel_user_dict.get("email"))
if not panel_uuid: if not panel_uuid:
sync_errors.append(f"Panel user missing UUID: {panel_user_dict}") sync_errors.append(f"Panel user missing UUID: {panel_user_dict}")
@@ -196,20 +93,15 @@ async def perform_sync(
# First, try to find by telegram ID if available # First, try to find by telegram ID if available
if telegram_id_from_panel: if telegram_id_from_panel:
existing_user = await user_dal.get_user_by_telegram_id( existing_user = await user_dal.get_user_by_id(
session, telegram_id_from_panel session, telegram_id_from_panel
) )
if not existing_user:
existing_user = await user_dal.get_user_by_id(
session, telegram_id_from_panel
)
if existing_user: if existing_user:
logging.debug( logging.debug(
f"Found user by telegramId {telegram_id_from_panel}" f"Found user by telegramId {telegram_id_from_panel}"
) )
# If not found by telegram ID, try to find by panel UUID. # If not found by telegram ID, try to find by panel UUID
# The panel UUID is the strongest local link for subscription sync.
if not existing_user: if not existing_user:
existing_user = await user_dal.get_user_by_panel_uuid( existing_user = await user_dal.get_user_by_panel_uuid(
session, panel_uuid session, panel_uuid
@@ -227,15 +119,6 @@ async def perform_sync(
f"TelegramId mismatch: panel={telegram_id_from_panel}, local={existing_user.user_id}" f"TelegramId mismatch: panel={telegram_id_from_panel}, local={existing_user.user_id}"
) )
# Finally, fall back to email. This mainly catches panel users that
# were first imported as email-only identities.
if not existing_user and email_from_panel:
existing_user = await user_dal.get_user_by_email(
session, email_from_panel
)
if existing_user:
logging.debug(f"Found user by email {email_from_panel}")
if not existing_user: if not existing_user:
users_not_found_in_db += 1 users_not_found_in_db += 1
if telegram_id_from_panel: if telegram_id_from_panel:
@@ -243,13 +126,6 @@ async def perform_sync(
try: try:
user_data = { user_data = {
"user_id": telegram_id_from_panel, "user_id": telegram_id_from_panel,
"telegram_id": telegram_id_from_panel,
"email": email_from_panel,
"email_verified_at": (
datetime.now(timezone.utc)
if email_from_panel
else None
),
"username": None, # Username will be updated when user interacts with bot "username": None, # Username will be updated when user interacts with bot
"first_name": None, # Panel doesn't provide this info "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
@@ -278,28 +154,6 @@ async def perform_sync(
f"Error creating user {telegram_id_from_panel}: {e_create}" f"Error creating user {telegram_id_from_panel}: {e_create}"
) )
continue continue
elif email_from_panel:
try:
new_user, was_created = await user_dal.create_email_user(
session,
email=email_from_panel,
language_code="ru",
)
new_user.panel_user_uuid = panel_uuid
if was_created:
users_created += 1
logging.info(
f"Created new email user {new_user.user_id} from panel sync with UUID {panel_uuid}"
)
existing_user = new_user
except Exception as e_create_email:
sync_errors.append(
f"Error creating email user {email_from_panel}: {str(e_create_email)}"
)
logging.error(
f"Error creating email user {email_from_panel}: {e_create_email}"
)
continue
else: else:
logging.debug( logging.debug(
f"Panel user with UUID {panel_uuid} (no telegramId) not found in local DB - skipping" f"Panel user with UUID {panel_uuid} (no telegramId) not found in local DB - skipping"
@@ -321,39 +175,16 @@ async def perform_sync(
logging.info( logging.info(
f"Updated panel UUID for user {actual_user_id}: {panel_uuid}" f"Updated panel UUID for user {actual_user_id}: {panel_uuid}"
) )
existing_user, email_was_bound = await _bind_panel_email_to_user(
session,
existing_user=existing_user,
email_from_panel=email_from_panel,
panel_uuid=panel_uuid,
)
if email_was_bound:
user_was_updated = True
if (
telegram_id_from_panel
and existing_user.telegram_id != telegram_id_from_panel
):
existing_user.telegram_id = telegram_id_from_panel
user_was_updated = True
lifetime_used = _extract_lifetime_used_traffic_bytes(panel_user_dict)
if (
lifetime_used is not None
and existing_user.lifetime_used_traffic_bytes != lifetime_used
):
existing_user.lifetime_used_traffic_bytes = lifetime_used
user_was_updated = True
# Ensure panel description contains Telegram fields # Ensure panel description contains Telegram fields
try: try:
if panel_uuid and existing_user: if panel_uuid and existing_user:
description_text = "\n".join( description_text = "\n".join(
line for line in [ [
existing_user.email or "",
existing_user.username or "", existing_user.username or "",
existing_user.first_name or "", existing_user.first_name or "",
existing_user.last_name or "", existing_user.last_name or "",
] if line ]
) )
# Update description only when it differs from the current one on panel # Update description only when it differs from the current one on panel
current_panel_description = ( current_panel_description = (
@@ -365,11 +196,7 @@ async def perform_sync(
and desired_description != current_panel_description and desired_description != current_panel_description
): ):
await panel_service.update_user_details_on_panel( await panel_service.update_user_details_on_panel(
panel_uuid, { panel_uuid, {"description": description_text}
"description": description_text,
**({"email": existing_user.email} if existing_user.email else {}),
**({"telegramId": existing_user.telegram_id} if existing_user.telegram_id else {}),
}
) )
except Exception as e_desc: except Exception as e_desc:
logging.warning( logging.warning(
+40 -62
View File
@@ -31,45 +31,6 @@ from bot.utils.telegram_markup import (
router = Router(name="admin_user_management_router") router = Router(name="admin_user_management_router")
USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$") USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
EMAIL_REGEX = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def _format_traffic_period(strategy: Optional[str], get_text: Callable[..., str]) -> Optional[str]:
if not strategy:
return None
strategy_upper = str(strategy).upper()
key_map = {
"MONTH": "traffic_period_month",
"WEEK": "traffic_period_week",
"DAY": "traffic_period_day",
"NO_RESET": "traffic_period_no_reset",
}
label_key = key_map.get(strategy_upper)
return get_text(label_key) if label_key else strategy_upper
def _format_used_with_period(get_text: Callable[..., str], used_display: str, period_label: Optional[str]) -> str:
if not period_label:
return used_display
return get_text("traffic_used_with_period", traffic_used=used_display, traffic_period=period_label)
async def _find_user_by_admin_input(
session: AsyncSession,
input_text: str,
) -> Optional[User]:
if input_text.isdigit() or (input_text.startswith("-") and input_text[1:].isdigit()):
try:
return await user_dal.get_user_by_id(session, int(input_text))
except ValueError:
return None
if EMAIL_REGEX.match(input_text):
return await user_dal.get_user_by_email(session, input_text)
if input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
return await user_dal.get_user_by_username(session, input_text[1:])
if USERNAME_REGEX.match(input_text):
return await user_dal.get_user_by_username(session, input_text)
return None
async def users_list_handler(callback: types.CallbackQuery, async def users_list_handler(callback: types.CallbackQuery,
@@ -269,10 +230,6 @@ async def format_user_card(user: User, session: AsyncSession,
card_parts.append(f"{_('admin_user_id_label')} {hcode(str(user.user_id))}") 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_name_label')} {hcode(user_name)}")
card_parts.append(f"{_('admin_user_username_label')} {hcode(username_display)}") card_parts.append(f"{_('admin_user_username_label')} {hcode(username_display)}")
if user.email:
card_parts.append(f"{_('admin_user_email_label')} {hcode(user.email)}")
if user.telegram_id and int(user.telegram_id) != int(user.user_id):
card_parts.append(f"{_('admin_user_telegram_id_label')} {hcode(str(user.telegram_id))}")
card_parts.append(f"{_('admin_user_language_label')} {hcode(user.language_code or na_value)}") 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)}") card_parts.append(f"{_('admin_user_registration_label')} {hcode(registration_date)}")
@@ -306,22 +263,10 @@ async def format_user_card(user: User, session: AsyncSession,
traffic_limit = subscription_details.get('traffic_limit_bytes') traffic_limit = subscription_details.get('traffic_limit_bytes')
traffic_used = subscription_details.get('traffic_used_bytes') traffic_used = subscription_details.get('traffic_used_bytes')
traffic_strategy = subscription_details.get('traffic_limit_strategy') if traffic_limit and traffic_used is not None:
period_label = _format_traffic_period(traffic_strategy, _) traffic_limit_gb = traffic_limit / (1024**3)
if traffic_used is not None or traffic_limit is not None: traffic_used_gb = traffic_used / (1024**3)
used_display = _("traffic_na") card_parts.append(f"{_('admin_user_traffic_label')} {hcode(f'{traffic_used_gb:.2f}GB / {traffic_limit_gb:.2f}GB')}")
if traffic_used is not None:
traffic_used_gb = traffic_used / (1024**3)
used_display = f"{traffic_used_gb:.2f}GB"
used_display = _format_used_with_period(_, used_display, period_label)
if traffic_limit:
traffic_limit_gb = traffic_limit / (1024**3)
limit_display = f"{traffic_limit_gb:.2f}GB"
else:
limit_display = _("traffic_unlimited")
card_parts.append(f"{_('admin_user_traffic_label')} {hcode(f'{used_display} / {limit_display}')}")
else: else:
card_parts.append(f"{_('admin_user_subscription_label')} {hcode(_('admin_user_subscription_none'))}") card_parts.append(f"{_('admin_user_subscription_label')} {hcode(_('admin_user_subscription_none'))}")
except Exception as e: except Exception as e:
@@ -384,7 +329,18 @@ async def process_user_search_handler(message: types.Message, state: FSMContext,
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
input_text = message.text.strip() if message.text else "" input_text = message.text.strip() if message.text else ""
user_model = await _find_user_by_admin_input(session, input_text) user_model: Optional[User] = None
# Try to find user by ID or username
if input_text.isdigit():
try:
user_model = await user_dal.get_user_by_id(session, int(input_text))
except ValueError:
pass
elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
user_model = await user_dal.get_user_by_username(session, input_text[1:])
elif USERNAME_REGEX.match(input_text):
user_model = await user_dal.get_user_by_username(session, input_text)
if not user_model: if not user_model:
await message.answer(_( await message.answer(_(
@@ -1189,7 +1145,18 @@ async def process_ban_user_handler(message: types.Message, state: FSMContext,
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
input_text = message.text.strip() if message.text else "" input_text = message.text.strip() if message.text else ""
user_model = await _find_user_by_admin_input(session, input_text) user_model: Optional[User] = None
# Try to find user by ID or username
if input_text.isdigit():
try:
user_model = await user_dal.get_user_by_id(session, int(input_text))
except ValueError:
pass
elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
user_model = await user_dal.get_user_by_username(session, input_text[1:])
elif USERNAME_REGEX.match(input_text):
user_model = await user_dal.get_user_by_username(session, input_text)
if not user_model: if not user_model:
await message.answer(_( await message.answer(_(
@@ -1245,7 +1212,18 @@ async def process_unban_user_handler(message: types.Message, state: FSMContext,
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
input_text = message.text.strip() if message.text else "" input_text = message.text.strip() if message.text else ""
user_model = await _find_user_by_admin_input(session, input_text) user_model: Optional[User] = None
# Try to find user by ID or username
if input_text.isdigit():
try:
user_model = await user_dal.get_user_by_id(session, int(input_text))
except ValueError:
pass
elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
user_model = await user_dal.get_user_by_username(session, input_text[1:])
elif USERNAME_REGEX.match(input_text):
user_model = await user_dal.get_user_by_username(session, input_text)
if not user_model: if not user_model:
await message.answer(_( await message.answer(_(
+427 -200
View File
@@ -1,6 +1,5 @@
import logging import logging
import json import json
import asyncio
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
@@ -25,22 +24,10 @@ from bot.services.notification_service import NotificationService
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard 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.text_sanitizer import sanitize_display_name, username_for_display
from bot.utils.config_link import prepare_config_links from bot.utils.config_link import prepare_config_links
from bot.utils.request_security import ip_in_allowlist, request_client_ip
payment_processing_lock = asyncio.Lock()
YOOKASSA_EVENT_PAYMENT_SUCCEEDED = 'payment.succeeded' YOOKASSA_EVENT_PAYMENT_SUCCEEDED = 'payment.succeeded'
YOOKASSA_EVENT_PAYMENT_CANCELED = 'payment.canceled' YOOKASSA_EVENT_PAYMENT_CANCELED = 'payment.canceled'
YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE = 'payment.waiting_for_capture' YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE = 'payment.waiting_for_capture'
YOOKASSA_WEBHOOK_ALLOWED_IPS = [
"185.71.76.0/27",
"185.71.77.0/27",
"77.75.153.0/25",
"77.75.156.11",
"77.75.156.35",
"77.75.154.128/25",
"2a02:5180::/32",
]
async def process_successful_payment(session: AsyncSession, bot: Bot, async def process_successful_payment(session: AsyncSession, bot: Bot,
@@ -49,7 +36,8 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
panel_service: PanelApiService, panel_service: PanelApiService,
subscription_service: SubscriptionService, subscription_service: SubscriptionService,
referral_service: ReferralService, referral_service: ReferralService,
lknpd_service: Optional[LknpdService] = None): yookassa_service: Optional[YooKassaService] = None,
lknpd_service: Optional[LknpdService] = None) -> bool:
metadata = payment_info_from_webhook.get("metadata", {}) metadata = payment_info_from_webhook.get("metadata", {})
user_id_str = metadata.get("user_id") user_id_str = metadata.get("user_id")
subscription_months_str = metadata.get("subscription_months") subscription_months_str = metadata.get("subscription_months")
@@ -60,80 +48,135 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
auto_renew_subscription_id_str = metadata.get( auto_renew_subscription_id_str = metadata.get(
"auto_renew_for_subscription_id") "auto_renew_for_subscription_id")
# 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 ( if (
not user_id_str not user_id_str
or (not subscription_months_str and not traffic_gb_str) or (not subscription_months_str and not traffic_gb_str)
or (not payment_db_id_str and not auto_renew_subscription_id_str) or not payment_db_id_str
): ):
logging.error( logging.error(
f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}" f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}"
) )
return return False
db_user = None db_user = None
payment_before_update = None
try: try:
user_id = int(user_id_str) user_id = int(user_id_str)
subscription_months = float(subscription_months_str or 0) subscription_months = float(subscription_months_str or 0)
traffic_amount_gb = float(traffic_gb_str) if traffic_gb_str else subscription_months traffic_amount_gb = float(traffic_gb_str) if traffic_gb_str else subscription_months
payment_db_id = int( if not payment_db_id_str.isdigit():
payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None logging.error(
is_auto_renew = bool(auto_renew_subscription_id_str and not payment_db_id and sale_mode != "traffic") "Invalid payment_db_id metadata for payment %s: %s",
payment_info_from_webhook.get("id"),
payment_db_id_str,
)
return False
payment_db_id = int(payment_db_id_str)
is_auto_renew = bool(auto_renew_subscription_id_str and sale_mode != "traffic")
promo_code_id = int( promo_code_id = int(
promo_code_id_str promo_code_id_str
) if promo_code_id_str and promo_code_id_str.isdigit() else None ) if promo_code_id_str and promo_code_id_str.isdigit() else None
amount_data = payment_info_from_webhook.get("amount", {}) 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)) payment_value = float(amount_data.get("value", 0.0))
yk_payment_id_from_hook = payment_info_from_webhook.get("id") yk_payment_id_from_hook = payment_info_from_webhook.get("id")
payment_record = None payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists if not payment_record:
if payment_db_id is None and auto_renew_subscription_id_str: logging.error(
f"Payment record {payment_db_id} not found for YK ID {yk_payment_id_from_hook}."
)
return False
if payment_record.user_id != user_id:
logging.error(
"Payment ownership mismatch for payment %s: metadata user_id=%s, db user_id=%s",
payment_db_id,
user_id,
payment_record.user_id,
)
return False
# Provider-backed verification (defense-in-depth): verify actual YooKassa payment state
if yk_payment_id_from_hook and yookassa_service and yookassa_service.configured:
provider_payment_info = await yookassa_service.get_payment_info(yk_payment_id_from_hook)
if not provider_payment_info:
logging.error(
"YooKassa webhook verification failed: payment %s not found via provider API",
yk_payment_id_from_hook,
)
return False
provider_status = str(provider_payment_info.get("status") or "")
provider_paid = bool(provider_payment_info.get("paid"))
if provider_status != "succeeded" or not provider_paid:
logging.error(
"YooKassa webhook verification failed: payment %s status/paid mismatch (status=%s, paid=%s)",
yk_payment_id_from_hook,
provider_status,
provider_paid,
)
return False
provider_metadata_raw = provider_payment_info.get("metadata") or {}
provider_metadata = provider_metadata_raw if isinstance(provider_metadata_raw, dict) else {}
if str(provider_metadata.get("user_id") or "") != str(user_id):
logging.error(
"YooKassa webhook verification failed: user_id mismatch for payment %s (provider=%s, expected=%s)",
yk_payment_id_from_hook,
provider_metadata.get("user_id"),
user_id,
)
return False
provider_payment_db_id = str(provider_metadata.get("payment_db_id") or "").strip()
if provider_payment_db_id != str(payment_db_id):
logging.error(
"YooKassa webhook verification failed: payment_db_id mismatch for payment %s (provider=%s, expected=%s)",
yk_payment_id_from_hook,
provider_metadata.get("payment_db_id"),
payment_db_id,
)
return False
try: try:
if not yk_payment_id_from_hook: provider_amount = float(provider_payment_info.get("amount_value") or 0.0)
if round(provider_amount, 2) != round(payment_value, 2):
logging.error( logging.error(
"Auto-renew webhook missing YooKassa payment id; cannot ensure payment record." "YooKassa webhook verification failed: amount mismatch for payment %s (payload %.2f vs provider %.2f)",
yk_payment_id_from_hook,
payment_value,
provider_amount,
) )
return return False
from db.dal import payment_dal as _payment_dal if payment_record and round(float(payment_record.amount), 2) != round(provider_amount, 2):
payment_record = await _payment_dal.get_payment_by_provider_payment_id( logging.error(
session, yk_payment_id_from_hook "YooKassa webhook verification failed: DB amount mismatch for payment %s (db %.2f vs provider %.2f)",
) payment_record.payment_id,
if not payment_record: float(payment_record.amount),
payment_record = await _payment_dal.ensure_payment_with_provider_id( provider_amount,
session,
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 return False
except Exception as e_ensure: provider_currency = str(provider_payment_info.get("amount_currency") or "").upper()
if payment_record and provider_currency and str(payment_record.currency or "").upper() != provider_currency:
logging.error(
"YooKassa webhook verification failed: currency mismatch for payment %s (db=%s, provider=%s)",
payment_record.payment_id,
payment_record.currency,
provider_currency,
)
return False
except Exception as e_amount_verify:
logging.error( logging.error(
f"Failed to ensure payment record for auto-renew webhook (YK {payment_info_from_webhook.get('id')}): {e_ensure}", "YooKassa webhook verification failed for payment %s: cannot validate amount (%s)",
exc_info=True, yk_payment_id_from_hook,
e_amount_verify,
) )
return return False
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": if payment_record and payment_record.status == "succeeded":
logging.info( logging.info(
f"Skipping duplicate YooKassa webhook for payment {payment_db_id} (YK: {yk_payment_id_from_hook})." f"Skipping duplicate YooKassa webhook for payment {payment_db_id} (YK: {yk_payment_id_from_hook})."
) )
return return True
db_user = await user_dal.get_user_by_id(session, user_id) db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user: if not db_user:
@@ -145,7 +188,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
session, payment_db_id, "failed_user_not_found", session, payment_db_id, "failed_user_not_found",
payment_info_from_webhook.get("id")) payment_info_from_webhook.get("id"))
return return False
except (TypeError, ValueError) as e: except (TypeError, ValueError) as e:
logging.error( logging.error(
@@ -161,16 +204,62 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
logging.error( logging.error(
f"Failed to update payment status after metadata error: {e_upd}" f"Failed to update payment status after metadata error: {e_upd}"
) )
return return False
try: try:
yk_payment_id_from_hook = payment_info_from_webhook.get("id") yk_payment_id_from_hook = payment_info_from_webhook.get("id")
payment_before_update = None provider_payment_id = str(yk_payment_id_from_hook or "").strip()
if not provider_payment_id:
raise ValueError(
f"Missing provider payment id in successful YooKassa webhook for payment {payment_db_id}"
)
if payment_db_id is not None: if payment_db_id is not None:
payment_before_update = await payment_dal.get_payment_by_db_id( payment_before_update = await payment_dal.get_payment_by_db_id(
session, session,
payment_db_id, payment_db_id,
) )
if payment_before_update and payment_before_update.status == "succeeded":
logging.info(
"YooKassa webhook ignored: payment %s already succeeded (db_id=%s)",
yk_payment_id_from_hook,
payment_db_id,
)
return True
claimed_for_processing = await payment_dal.mark_provider_payment_processing_once(
session,
payment_db_id,
provider_payment_id,
expected_status_prefix="pending",
)
if not claimed_for_processing:
payment_after_claim = await payment_dal.get_payment_by_db_id(
session,
payment_db_id,
)
if payment_after_claim and payment_after_claim.status == "succeeded":
logging.info(
"YooKassa webhook ignored: payment %s already succeeded after claim attempt",
payment_db_id,
)
return True
# Another transaction is processing this payment now.
if payment_after_claim and payment_after_claim.status == "processing":
logging.info(
"YooKassa webhook: payment %s is already being processed by another worker",
payment_db_id,
)
return False
logging.warning(
"YooKassa webhook: payment %s cannot be claimed for processing (status=%s)",
payment_db_id,
payment_after_claim.status if payment_after_claim else None,
)
return False
should_send_lknpd_receipt = bool( should_send_lknpd_receipt = bool(
lknpd_service lknpd_service
and lknpd_service.configured and lknpd_service.configured
@@ -202,7 +291,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
else: else:
display_last4 = None display_last4 = None
else: else:
# Wallets, SBP, etc. use provided title/type; no last4 # Wallets, SBP, etc. - use provided title/type; no last4
display_network = title or (pm_type.upper() if pm_type else "Payment method") display_network = title or (pm_type.upper() if pm_type else "Payment method")
display_last4 = None display_last4 = None
@@ -227,37 +316,58 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
logging.exception("Failed to persist multi-card YooKassa method from webhook") logging.exception("Failed to persist multi-card YooKassa method from webhook")
except Exception: except Exception:
logging.exception("Failed to persist YooKassa payment method from webhook") logging.exception("Failed to persist YooKassa payment method from webhook")
months_for_activation = int(subscription_months) if sale_mode != "traffic" else 0 months_for_activation = int(subscription_months) if sale_mode != "traffic" else 0
activation_details = await subscription_service.activate_subscription( try:
session, activation_details = await subscription_service.activate_subscription(
user_id, session,
months_for_activation, user_id,
payment_value, months_for_activation,
payment_db_id, payment_value,
promo_code_id_from_payment=promo_code_id, payment_db_id,
provider="yookassa", promo_code_id_from_payment=promo_code_id,
sale_mode=sale_mode, provider="yookassa",
traffic_gb=traffic_amount_gb if sale_mode == "traffic" else None, sale_mode=sale_mode,
) traffic_gb=traffic_amount_gb if sale_mode == "traffic" else None,
)
except Exception:
previous_status = payment_before_update.status if payment_before_update else "pending_yookassa"
await payment_dal.rollback_provider_payment_processing(
session,
payment_db_id,
rollback_status=previous_status,
provider_payment_id=provider_payment_id,
)
logging.exception(
"Failed to activate subscription for payment %s; rolled back payment status for retry",
payment_db_id,
)
return False
if not activation_details or not activation_details.get('end_date'): if not activation_details or not activation_details.get('end_date'):
logging.error( logging.error(
f"Failed to activate subscription for user {user_id} after payment {yk_payment_id_from_hook}" f"Failed to activate subscription for user {user_id} after payment {yk_payment_id_from_hook}"
) )
raise Exception( previous_status = payment_before_update.status if payment_before_update else "pending_yookassa"
f"Subscription Error: Failed to activate for user {user_id}") await payment_dal.rollback_provider_payment_processing(
session,
updated_payment_record = await payment_dal.update_payment_status_by_db_id( payment_db_id,
session, rollback_status=previous_status,
payment_db_id=payment_db_id, provider_payment_id=provider_payment_id,
new_status=payment_info_from_webhook.get("status", "succeeded"),
yk_payment_id=yk_payment_id_from_hook)
if not updated_payment_record:
logging.error(
f"Failed to update payment record {payment_db_id} for yk_id {yk_payment_id_from_hook}"
) )
raise Exception( return False
f"DB Error: Could not update payment record {payment_db_id}")
marked = await payment_dal.mark_provider_payment_succeeded_once(
session,
payment_db_id,
provider_payment_id,
)
if not marked:
logging.warning(
"YooKassa webhook: payment %s could not be atomically marked succeeded after activation",
payment_db_id,
)
return False
base_subscription_end_date = activation_details['end_date'] base_subscription_end_date = activation_details['end_date']
final_end_date_for_user = base_subscription_end_date final_end_date_for_user = base_subscription_end_date
@@ -323,7 +433,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
details_message = _( details_message = _(
"payment_successful_traffic_full", "payment_successful_traffic_full",
traffic_gb=traffic_label, traffic_gb=traffic_label,
end_date=final_end_date_for_user.strftime('%Y-%m-%d') if final_end_date_for_user else "", end_date=final_end_date_for_user.strftime('%Y-%m-%d') if final_end_date_for_user else "-",
config_link=config_link_text, config_link=config_link_text,
) )
details_markup = get_connect_and_main_keyboard( details_markup = get_connect_and_main_keyboard(
@@ -405,7 +515,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
await notification_service.notify_payment_received( await notification_service.notify_payment_received(
user_id=user_id, user_id=user_id,
amount=payment_value, amount=payment_value,
currency=settings.DEFAULT_CURRENCY_SYMBOL, currency="RUB",
months=int(subscription_months) if sale_mode != "traffic" else 0, months=int(subscription_months) if sale_mode != "traffic" else 0,
payment_provider="yookassa", # This is specifically for YooKassa webhook payment_provider="yookassa", # This is specifically for YooKassa webhook
username=user.username if user else None, username=user.username if user else None,
@@ -414,6 +524,8 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
except Exception as e: except Exception as e:
logging.error(f"Failed to send payment notification: {e}") logging.error(f"Failed to send payment notification: {e}")
return True
except Exception as e_process: except Exception as e_process:
logging.error( logging.error(
f"Error during process_successful_payment main try block for user {user_id}: {e_process}", f"Error during process_successful_payment main try block for user {user_id}: {e_process}",
@@ -480,24 +592,21 @@ async def yookassa_webhook_route(request: web.Request):
i18n_instance: JsonI18n = request.app['i18n'] i18n_instance: JsonI18n = request.app['i18n']
settings: Settings = request.app['settings'] settings: Settings = request.app['settings']
panel_service: PanelApiService = request.app['panel_service'] panel_service: PanelApiService = request.app['panel_service']
yookassa_service: Optional[YooKassaService] = request.app.get('yookassa_service')
subscription_service: SubscriptionService = request.app[ subscription_service: SubscriptionService = request.app[
'subscription_service'] 'subscription_service']
referral_service: ReferralService = request.app['referral_service'] referral_service: ReferralService = request.app['referral_service']
lknpd_service: Optional[LknpdService] = request.app.get('lknpd_service') lknpd_service: Optional[LknpdService] = request.app.get('lknpd_service')
async_session_factory: sessionmaker = request.app[ async_session_factory: sessionmaker = request.app[
'async_session_factory'] 'async_session_factory']
except KeyError: except KeyError as e_app_ctx:
logging.exception( logging.error(
"KeyError accessing app context in yookassa_webhook_route.") f"KeyError accessing app context in yookassa_webhook_route: {e_app_ctx}.",
exc_info=True)
return web.Response( return web.Response(
status=500, status=500,
text="Internal Server Error: Missing app context component") text="Internal Server Error: Missing app context component")
client_ip = request_client_ip(request, trusted_proxies=settings.trusted_proxies)
if not ip_in_allowlist(client_ip, YOOKASSA_WEBHOOK_ALLOWED_IPS):
logging.warning("YooKassa webhook denied from unauthorized IP source.")
return web.Response(status=403)
try: try:
event_json = await request.json() event_json = await request.json()
@@ -515,7 +624,7 @@ async def yookassa_webhook_route(request: web.Request):
logging.error( logging.error(
f"YooKassa webhook payment {payment_data_from_notification.id} lacks metadata. Cannot process." f"YooKassa webhook payment {payment_data_from_notification.id} lacks metadata. Cannot process."
) )
return web.Response(status=200, text="ok_error_no_metadata") return web.Response(status=200, text="yookassa_missing_metadata")
# Safely extract payment_method details (SDK objects may not have to_dict) # Safely extract payment_method details (SDK objects may not have to_dict)
pm_obj = getattr(payment_data_from_notification, 'payment_method', None) pm_obj = getattr(payment_data_from_notification, 'payment_method', None)
@@ -570,122 +679,240 @@ async def yookassa_webhook_route(request: web.Request):
"payment_method": pm_dict, "payment_method": pm_dict,
} }
async with payment_processing_lock: async with async_session_factory() as session:
async with async_session_factory() as session: try:
try: # Defense-in-depth: for state mutations beyond succeeded, verify against provider API.
if notification_object.event == YOOKASSA_EVENT_PAYMENT_SUCCEEDED: if notification_object.event in {
if payment_dict_for_processing.get( YOOKASSA_EVENT_PAYMENT_CANCELED,
"paid") and payment_dict_for_processing.get( YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE,
"status") == "succeeded": }:
await process_successful_payment( if not yookassa_service or not yookassa_service.configured:
session, bot, payment_dict_for_processing, logging.critical(
i18n_instance, settings, panel_service, "YooKassa webhook rejected: verification service is not configured for event %s (payment_id=%s)",
subscription_service, referral_service, notification_object.event,
lknpd_service) payment_dict_for_processing.get("id"),
await session.commit() )
else: return web.Response(status=503, text="yookassa_verification_required")
logging.warning(
f"Payment Succeeded event for {payment_dict_for_processing.get('id')} " provider_payment_info = await yookassa_service.get_payment_info(
f"but data not as expected: status='{payment_dict_for_processing.get('status')}', " payment_dict_for_processing.get("id")
f"paid='{payment_dict_for_processing.get('paid')}'" )
) if not provider_payment_info:
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_CANCELED: logging.error(
await process_cancelled_payment( "YooKassa webhook verification failed: payment %s not found via provider API",
payment_dict_for_processing.get("id"),
)
return web.Response(status=503, text="yookassa_verification_failed")
provider_status = str(provider_payment_info.get("status") or "")
provider_paid = bool(provider_payment_info.get("paid"))
provider_metadata_raw = provider_payment_info.get("metadata") or {}
provider_metadata = provider_metadata_raw if isinstance(provider_metadata_raw, dict) else {}
payment_dict_for_processing["status"] = provider_status or payment_dict_for_processing.get("status")
payment_dict_for_processing["paid"] = provider_paid
payment_dict_for_processing["metadata"] = dict(provider_metadata)
provider_amount_value = provider_payment_info.get("amount_value")
provider_amount_currency = provider_payment_info.get("amount_currency")
if provider_amount_value is not None and provider_amount_currency:
payment_dict_for_processing["amount"] = {
"value": str(provider_amount_value),
"currency": str(provider_amount_currency),
}
provider_pm = provider_payment_info.get("payment_method")
if isinstance(provider_pm, dict) and provider_pm.get("id"):
# Use provider payment_method as authoritative.
payment_dict_for_processing["payment_method"] = provider_pm
if notification_object.event == YOOKASSA_EVENT_PAYMENT_SUCCEEDED:
if not yookassa_service or not yookassa_service.configured:
logging.critical(
"YooKassa webhook rejected: verification service is not configured for succeeded event (payment_id=%s)",
payment_dict_for_processing.get("id"),
)
return web.Response(status=503, text="yookassa_verification_required")
if payment_dict_for_processing.get(
"paid") and payment_dict_for_processing.get(
"status") == "succeeded":
processed = await process_successful_payment(
session, bot, payment_dict_for_processing, session, bot, payment_dict_for_processing,
i18n_instance, settings) i18n_instance, settings, panel_service,
subscription_service, referral_service,
yookassa_service,
lknpd_service)
if not processed:
metadata_for_result = payment_dict_for_processing.get("metadata") or {}
payment_db_id_raw = metadata_for_result.get("payment_db_id")
payment_db_id_for_check = None
if isinstance(payment_db_id_raw, int):
payment_db_id_for_check = payment_db_id_raw
elif isinstance(payment_db_id_raw, str) and payment_db_id_raw.isdigit():
payment_db_id_for_check = int(payment_db_id_raw)
terminal_failure_recorded = False
if payment_db_id_for_check is not None:
payment_after_processing = await payment_dal.get_payment_by_db_id(
session,
payment_db_id_for_check,
)
terminal_failure_recorded = bool(
payment_after_processing
and isinstance(payment_after_processing.status, str)
and payment_after_processing.status.startswith("failed")
)
if terminal_failure_recorded:
try:
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"Failed to commit failure status for YooKassa payment %s",
payment_dict_for_processing.get("id"),
)
return web.Response(status=503, text="yookassa_processing_failed_retry")
return web.Response(status=200, text="ok")
await session.rollback()
logging.warning(
"YooKassa payment %s processing returned non-terminal failure; responding 503 for retry",
payment_dict_for_processing.get("id"),
)
return web.Response(status=503, text="yookassa_processing_failed_retry")
await session.commit() await session.commit()
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE: else:
# Bind-only flow: save method and cancel auth if metadata has bind_only logging.warning(
metadata = payment_dict_for_processing.get("metadata", {}) or {} f"Payment Succeeded event for {payment_dict_for_processing.get('id')} "
if settings.yookassa_autopayments_active and metadata.get("bind_only") == "1": f"but data not as expected: status='{payment_dict_for_processing.get('status')}', "
try: f"paid='{payment_dict_for_processing.get('paid')}'"
user_id_str = metadata.get("user_id") )
if user_id_str and user_id_str.isdigit(): await session.rollback()
user_id = int(user_id_str) return web.Response(status=503, text="yookassa_invalid_succeeded_payload")
payment_method = payment_dict_for_processing.get("payment_method") elif notification_object.event == YOOKASSA_EVENT_PAYMENT_CANCELED:
if isinstance(payment_method, dict) and payment_method.get("id"): if payment_dict_for_processing.get("status") not in {"canceled", "cancelled"}:
pm_type = payment_method.get("type") logging.error(
title = payment_method.get("title") "YooKassa webhook rejected: canceled event status mismatch for payment %s (status=%s)",
card = payment_method.get("card") or {} payment_dict_for_processing.get("id"),
account_number = payment_method.get("account_number") or payment_method.get("account") payment_dict_for_processing.get("status"),
display_network = None )
display_last4 = None return web.Response(status=503, text="yookassa_invalid_canceled_payload")
if (pm_type or "").lower() in {"bank_card", "bank-card", "card"}: await process_cancelled_payment(
display_network = card.get("card_type") or title or "Card" session, bot, payment_dict_for_processing,
display_last4 = card.get("last4") i18n_instance, settings)
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}: await session.commit()
# Normalize wallet display name to avoid leaking full account from title elif notification_object.event == YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE:
display_network = "YooMoney" # Bind-only flow: save method and cancel auth if metadata has bind_only
if isinstance(account_number, str) and len(account_number) >= 4: metadata = payment_dict_for_processing.get("metadata", {}) or {}
display_last4 = account_number[-4:] if settings.yookassa_autopayments_active and metadata.get("bind_only") == "1":
else: if payment_dict_for_processing.get("status") != "waiting_for_capture":
display_last4 = None logging.error(
else: "YooKassa webhook rejected: waiting_for_capture event status mismatch for payment %s (status=%s)",
display_network = title or (pm_type.upper() if pm_type else "Payment method") payment_dict_for_processing.get("id"),
display_last4 = None payment_dict_for_processing.get("status"),
await user_billing_dal.upsert_yk_payment_method( )
return web.Response(status=503, text="yookassa_invalid_waiting_payload")
try:
user_id_str = metadata.get("user_id")
if user_id_str and user_id_str.isdigit():
user_id = int(user_id_str)
payment_method = payment_dict_for_processing.get("payment_method")
if isinstance(payment_method, dict) and payment_method.get("id"):
pm_type = payment_method.get("type")
title = payment_method.get("title")
# Support both webhook shape (nested card) and provider shape (card_last4)
last4_val = None
card = payment_method.get("card") or {}
if isinstance(card, dict) and card.get("last4"):
last4_val = card.get("last4")
if not last4_val:
last4_val = payment_method.get("card_last4")
display_network = None
display_last4 = None
if (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
display_network = title or "Card"
display_last4 = last4_val
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
display_network = "YooMoney"
display_last4 = last4_val
else:
display_network = title or (pm_type.upper() if pm_type else "Payment method")
display_last4 = last4_val
await user_billing_dal.upsert_yk_payment_method(
session,
user_id=user_id,
payment_method_id=payment_method.get("id"),
card_last4=display_last4,
card_network=display_network,
)
await session.commit()
# Save multi-card entry and mark default if first
try:
from db.dal import user_billing_dal as ub
await ub.upsert_user_payment_method(
session, session,
user_id=user_id, user_id=user_id,
payment_method_id=payment_method.get("id"), provider_payment_method_id=payment_method.get("id"),
provider="yookassa",
card_last4=display_last4, card_last4=display_last4,
card_network=display_network, card_network=display_network,
set_default=True,
) )
await session.commit() await session.commit()
# Save multi-card entry and mark default if first except Exception:
try: await session.rollback()
from db.dal import user_billing_dal as ub # Notify user about successful binding with Back button
await ub.upsert_user_payment_method( try:
session, # Use user's DB language for bind success notification
user_id=user_id, i18n_lang = settings.DEFAULT_LANGUAGE
provider_payment_method_id=payment_method.get("id"), from db.dal import user_dal
provider="yookassa", db_user = await user_dal.get_user_by_id(session, user_id)
card_last4=display_last4, if db_user and db_user.language_code:
card_network=display_network, i18n_lang = db_user.language_code
set_default=True, _ = lambda key, **kwargs: i18n_instance.gettext(i18n_lang, key, **kwargs)
) from bot.keyboards.inline.user_keyboards import get_back_to_payment_methods_keyboard
await session.commit() await bot.send_message(
except Exception: chat_id=user_id,
await session.rollback() text=_("payment_method_bound_success"),
# Notify user about successful binding with Back button reply_markup=get_back_to_payment_methods_keyboard(i18n_lang, i18n_instance)
try: )
# Use user's DB language for bind success notification except Exception as exc:
i18n_lang = settings.DEFAULT_LANGUAGE logging.debug(
from db.dal import user_dal "Failed to notify user %s about payment method binding: %s",
db_user = await user_dal.get_user_by_id(session, user_id) user_id,
if db_user and db_user.language_code: exc,
i18n_lang = db_user.language_code )
_ = lambda key, **kwargs: i18n_instance.gettext(i18n_lang, key, **kwargs) # Attempt to cancel the authorization to avoid charge hold
from bot.keyboards.inline.user_keyboards import get_back_to_payment_methods_keyboard try:
await bot.send_message( yk: YooKassaService = request.app.get('yookassa_service')
chat_id=user_id, if yk:
text=_("payment_method_bound_success"), await yk.cancel_payment(payment_dict_for_processing.get("id"))
reply_markup=get_back_to_payment_methods_keyboard(i18n_lang, i18n_instance) except Exception:
) logging.exception("Failed to cancel bind-only payment auth")
except Exception: except Exception:
pass logging.exception("Failed to handle bind-only waiting_for_capture webhook")
# Attempt to cancel the authorization to avoid charge hold except Exception as e_webhook_db_processing:
try: await session.rollback()
yk: YooKassaService = request.app.get('yookassa_service') logging.error(
if yk: f"Error processing YooKassa webhook event '{notification_object.event}' "
await yk.cancel_payment(payment_dict_for_processing.get("id")) f"for YK Payment ID {payment_dict_for_processing.get('id')} in DB transaction: {e_webhook_db_processing}",
except Exception: exc_info=True)
logging.exception("Failed to cancel bind-only payment auth") return web.Response(
except Exception: status=503, text="yookassa_processing_error_retry")
logging.exception("Failed to handle bind-only waiting_for_capture webhook")
except Exception:
await session.rollback()
logging.exception(
"Error processing YooKassa webhook event '%s' for YK Payment ID %s in DB transaction.",
notification_object.event,
payment_dict_for_processing.get('id'))
return web.Response(
status=500, text="internal_processing_error")
return web.Response(status=200, text="ok") return web.Response(status=200, text="ok")
except json.JSONDecodeError: except json.JSONDecodeError:
logging.error("YooKassa Webhook: Invalid JSON received.") logging.error("YooKassa Webhook: Invalid JSON received.")
return web.Response(status=400, text="bad_request_invalid_json") return web.Response(status=400, text="bad_request_invalid_json")
except Exception: except Exception as e_general_webhook:
logging.exception("YooKassa Webhook general processing error.") logging.error(
return web.Response(status=500, text="internal_error") f"YooKassa Webhook general processing error: {e_general_webhook}",
exc_info=True)
return web.Response(status=503,
text="yookassa_general_error_retry")
+94 -48
View File
@@ -4,6 +4,7 @@ from aiogram import Router, F, types, Bot
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from typing import Optional from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from aiogram.utils.markdown import hcode
from config.settings import Settings from config.settings import Settings
from bot.states.user_states import UserPromoStates from bot.states.user_states import UserPromoStates
@@ -15,7 +16,6 @@ from bot.keyboards.inline.user_keyboards import (
) )
from datetime import datetime from datetime import datetime
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from bot.utils.callback_answer import safe_answer_callback
from .start import send_main_menu from .start import send_main_menu
@@ -35,18 +35,15 @@ async def prompt_promo_code_input(callback: types.CallbackQuery,
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n: if not i18n:
await safe_answer_callback(callback, "Language service error.", show_alert=True) await callback.answer("Language service error.", show_alert=True)
return return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
if not callback.message: if not callback.message:
logging.error( logging.error(
"CallbackQuery has no message in prompt_promo_code_input") "CallbackQuery has no message in prompt_promo_code_input")
await safe_answer_callback( await callback.answer(_("error_occurred_processing_request"),
callback, show_alert=True)
_("error_occurred_processing_request"),
show_alert=True,
)
return return
try: try:
@@ -61,7 +58,7 @@ async def prompt_promo_code_input(callback: types.CallbackQuery,
text=_(key="promo_code_prompt"), text=_(key="promo_code_prompt"),
reply_markup=get_back_to_main_menu_markup(current_lang, i18n)) reply_markup=get_back_to_main_menu_markup(current_lang, i18n))
await safe_answer_callback(callback) await callback.answer()
await state.set_state(UserPromoStates.waiting_for_promo_code) await state.set_state(UserPromoStates.waiting_for_promo_code)
logging.info( logging.info(
f"User {callback.from_user.id} entered state UserPromoStates.waiting_for_promo_code. " f"User {callback.from_user.id} entered state UserPromoStates.waiting_for_promo_code. "
@@ -122,41 +119,93 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
except Exception as e: except Exception as e:
logging.error(f"Failed to send suspicious promo notification: {e}") logging.error(f"Failed to send suspicious promo notification: {e}")
success, result = await promo_code_service.apply_promo_code( response_to_user_text = _("promo_code_not_found",
session, user.id, code_input, current_lang) code=hcode(code_input.upper()))
if success: reply_markup = get_back_to_main_menu_markup(current_lang, i18n)
await session.commit()
logging.info(
f"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_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_text,
)
reply_markup = get_connect_and_main_keyboard(
current_lang,
i18n,
settings,
config_link_display,
connect_button_url=connect_button_url,
)
else: else:
await session.commit() # Try as BONUS code first (existing behavior)
logging.info( success, result = await promo_code_service.apply_promo_code(
f"Promo code '{code_input}' application failed for user {user.id}. Reason: {result}" session, user.id, code_input, current_lang)
)
response_to_user_text = result if success:
reply_markup = get_back_to_main_menu_markup( # Bonus code success
current_lang, i18n await session.commit()
) logging.info(
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_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_text,
)
reply_markup = get_connect_and_main_keyboard(
current_lang,
i18n,
settings,
config_link_display,
connect_button_url=connect_button_url,
)
else:
# 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}. "
f"Bonus reason: {result}. Discount reason: {result_discount}"
)
bonus_not_found_text = _(
"promo_code_not_found", code=code_input.upper()
)
discount_not_found_text = _(
"promo_code_not_found_or_not_discount", code=code_input.upper()
)
if result != bonus_not_found_text and result_discount == discount_not_found_text:
response_to_user_text = result
else:
response_to_user_text = result_discount # Prefer the discount attempt error
reply_markup = get_back_to_main_menu_markup(
current_lang, i18n
)
await message.answer( await message.answer(
response_to_user_text, response_to_user_text,
@@ -179,7 +228,7 @@ async def cancel_promo_input_via_button(
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n: if not i18n:
logging.error("i18n missing in cancel_promo_input_via_button") logging.error("i18n missing in cancel_promo_input_via_button")
await safe_answer_callback(callback, "Language error", show_alert=True) await callback.answer("Language error", show_alert=True)
return return
logging.info( logging.info(
@@ -198,8 +247,5 @@ async def cancel_promo_input_via_button(
else: else:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
await safe_answer_callback( await callback.answer(_("promo_input_cancelled_short"),
callback, show_alert=False)
_("promo_input_cancelled_short"),
show_alert=False,
)
+12 -29
View File
@@ -2,11 +2,9 @@ import logging
from aiogram import Router, F, types, Bot from aiogram import Router, F, types, Bot
from aiogram.filters import Command from aiogram.filters import Command
from typing import Optional, Union from typing import Optional, Union
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings from config.settings import Settings
from db.dal import user_dal
from bot.services.referral_service import ReferralService from bot.services.referral_service import ReferralService
from bot.keyboards.inline.user_keyboards import get_back_to_main_menu_markup from bot.keyboards.inline.user_keyboards import get_back_to_main_menu_markup
@@ -45,6 +43,15 @@ async def referral_command_handler(event: Union[types.Message,
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
if not settings.REFERRAL_ENABLED:
await target_message_obj.answer(
_("referral_no_bonuses_configured"),
reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
)
if isinstance(event, types.CallbackQuery):
await event.answer()
return
try: try:
bot_info = await bot.get_me() bot_info = await bot.get_me()
bot_username = bot_info.username bot_username = bot_info.username
@@ -105,16 +112,6 @@ async def referral_command_handler(event: Union[types.Message,
bonus_details=bonus_details_str, bonus_details=bonus_details_str,
invited_count=referral_stats["invited_count"], invited_count=referral_stats["invited_count"],
purchased_count=referral_stats["purchased_count"]) purchased_count=referral_stats["purchased_count"])
if settings.SUBSCRIPTION_MINI_APP_URL:
db_user = await user_dal.get_user_by_id(session, inviter_user_id)
referral_code = await user_dal.ensure_referral_code(session, db_user) if db_user else None
webapp_referral_link = _build_webapp_referral_link(
settings.SUBSCRIPTION_MINI_APP_URL,
referral_code,
)
if webapp_referral_link:
webapp_label = "Web App ссылка" if current_lang == "ru" else "Web App link"
text += f"\n\n🔗 {webapp_label}:\n<code>{webapp_referral_link}</code>"
from bot.keyboards.inline.user_keyboards import get_referral_link_keyboard from bot.keyboards.inline.user_keyboards import get_referral_link_keyboard
reply_markup_val = get_referral_link_keyboard(current_lang, i18n) reply_markup_val = get_referral_link_keyboard(current_lang, i18n)
@@ -148,6 +145,9 @@ async def referral_action_handler(callback: types.CallbackQuery, settings: Setti
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
if action == "share_message": if action == "share_message":
if not settings.REFERRAL_ENABLED:
await callback.answer(_("referral_no_bonuses_configured"), show_alert=True)
return
try: try:
bot_info = await bot.get_me() bot_info = await bot.get_me()
bot_username = bot_info.username bot_username = bot_info.username
@@ -179,20 +179,3 @@ async def referral_action_handler(callback: types.CallbackQuery, settings: Setti
await callback.answer("Произошла ошибка", show_alert=True) await callback.answer("Произошла ошибка", show_alert=True)
await callback.answer() await callback.answer()
def _build_webapp_referral_link(base_url: Optional[str], referral_code: Optional[str]) -> Optional[str]:
if not base_url or not referral_code:
return None
parts = urlsplit(base_url)
query = dict(parse_qsl(parts.query, keep_blank_values=True))
query["ref"] = f"u{referral_code}"
return urlunsplit(
(
parts.scheme,
parts.netloc,
parts.path or "/",
urlencode(query),
parts.fragment,
)
)
+144 -352
View File
@@ -14,10 +14,8 @@ from db.models import User
from bot.keyboards.inline.user_keyboards import ( from bot.keyboards.inline.user_keyboards import (
get_main_menu_inline_keyboard, get_main_menu_inline_keyboard,
get_bot_interface_inline_keyboard,
get_language_selection_keyboard, get_language_selection_keyboard,
get_channel_subscription_keyboard, get_channel_subscription_keyboard,
get_information_links_keyboard,
) )
from bot.services.subscription_service import SubscriptionService from bot.services.subscription_service import SubscriptionService
from bot.services.panel_api_service import PanelApiService from bot.services.panel_api_service import PanelApiService
@@ -26,29 +24,10 @@ from bot.services.promo_code_service import PromoCodeService
from config.settings import Settings from config.settings import Settings
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from bot.utils.text_sanitizer import sanitize_username, sanitize_display_name from bot.utils.text_sanitizer import sanitize_username, sanitize_display_name
from bot.utils.callback_answer import safe_answer_callback
router = Router(name="user_start_router") router = Router(name="user_start_router")
async def should_show_trial_button(
settings: Settings,
subscription_service: SubscriptionService,
session: AsyncSession,
user_id: int) -> bool:
if not settings.TRIAL_ENABLED:
return False
if hasattr(subscription_service, 'has_had_any_subscription') and callable(
getattr(subscription_service, 'has_had_any_subscription')):
return not await subscription_service.has_had_any_subscription(
session, user_id)
logging.error(
"Method has_had_any_subscription is missing in SubscriptionService!"
)
return False
async def send_main_menu(target_event: Union[types.Message, async def send_main_menu(target_event: Union[types.Message,
types.CallbackQuery], types.CallbackQuery],
settings: Settings, settings: Settings,
@@ -69,20 +48,30 @@ async def send_main_menu(target_event: Union[types.Message,
if isinstance(target_event, types.CallbackQuery): if isinstance(target_event, types.CallbackQuery):
try: try:
await target_event.answer(err_msg_fallback, show_alert=True) await target_event.answer(err_msg_fallback, show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
elif isinstance(target_event, types.Message): elif isinstance(target_event, types.Message):
try: try:
await target_event.answer(err_msg_fallback) await target_event.answer(err_msg_fallback)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
return return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
show_trial_button_in_menu = await should_show_trial_button( show_trial_button_in_menu = False
settings, subscription_service, session, user_id) if settings.TRIAL_ENABLED:
if hasattr(
subscription_service, 'has_had_any_subscription') and callable(
getattr(subscription_service, 'has_had_any_subscription')):
if not await subscription_service.has_had_any_subscription(
session, user_id):
show_trial_button_in_menu = True
else:
logging.error(
"Method has_had_any_subscription is missing in SubscriptionService for send_main_menu!"
)
text = _(key="main_menu_greeting", user_name=user_full_name) text = _(key="main_menu_greeting", user_name=user_full_name)
reply_markup = get_main_menu_inline_keyboard(current_lang, i18n, settings, reply_markup = get_main_menu_inline_keyboard(current_lang, i18n, settings,
@@ -100,11 +89,8 @@ async def send_main_menu(target_event: Union[types.Message,
f"send_main_menu: target_message_obj is None for event from user {user_id}." f"send_main_menu: target_message_obj is None for event from user {user_id}."
) )
if isinstance(target_event, types.CallbackQuery): if isinstance(target_event, types.CallbackQuery):
await safe_answer_callback( await target_event.answer(_("error_displaying_menu"),
target_event, show_alert=True)
_("error_displaying_menu"),
show_alert=True,
)
return return
try: try:
@@ -114,7 +100,10 @@ async def send_main_menu(target_event: Union[types.Message,
await target_message_obj.answer(text, reply_markup=reply_markup) await target_message_obj.answer(text, reply_markup=reply_markup)
if isinstance(target_event, types.CallbackQuery): if isinstance(target_event, types.CallbackQuery):
await safe_answer_callback(target_event) try:
await target_event.answer()
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
except Exception as e_send_edit: except Exception as e_send_edit:
logging.warning( logging.warning(
f"Failed to send/edit main menu (user: {user_id}, is_edit: {is_edit}): {type(e_send_edit).__name__} - {e_send_edit}." f"Failed to send/edit main menu (user: {user_id}, is_edit: {is_edit}): {type(e_send_edit).__name__} - {e_send_edit}."
@@ -127,72 +116,11 @@ async def send_main_menu(target_event: Union[types.Message,
f"Also failed to send new main menu message for user {user_id}: {e_send_new}" f"Also failed to send new main menu message for user {user_id}: {e_send_new}"
) )
if isinstance(target_event, types.CallbackQuery): if isinstance(target_event, types.CallbackQuery):
await safe_answer_callback(
target_event,
_("error_occurred_try_again") if is_edit else None,
)
async def send_bot_interface_menu(
target_event: Union[types.Message, types.CallbackQuery],
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession,
is_edit: bool = False):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
logging.error("i18n_instance missing in send_bot_interface_menu.")
return
user_id = target_event.from_user.id
show_trial_button_in_menu = await should_show_trial_button(
settings, subscription_service, session, user_id)
text = i18n.gettext(current_lang, "bot_interface_menu_title")
reply_markup = get_bot_interface_inline_keyboard(
current_lang, i18n, settings, show_trial_button_in_menu)
target_message_obj: Optional[types.Message] = None
if isinstance(target_event, types.Message):
target_message_obj = target_event
elif isinstance(target_event, types.CallbackQuery) and target_event.message:
target_message_obj = target_event.message
if not target_message_obj:
logging.error(
"send_bot_interface_menu: target_message_obj is None for user %s.",
user_id,
)
return
try:
if is_edit:
await target_message_obj.edit_text(text, reply_markup=reply_markup)
else:
await target_message_obj.answer(text, reply_markup=reply_markup)
if isinstance(target_event, types.CallbackQuery):
await safe_answer_callback(target_event)
except Exception as e_send_edit:
logging.warning(
"Failed to send/edit bot interface menu (user: %s, is_edit: %s): %s - %s.",
user_id,
is_edit,
type(e_send_edit).__name__,
e_send_edit,
)
if is_edit:
try: try:
await target_message_obj.answer(text, reply_markup=reply_markup) await target_event.answer(
except Exception as e_send_new: _("error_occurred_try_again") if is_edit else None)
logging.error( except Exception as exc:
"Also failed to send new bot interface menu for user %s: %s", logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
user_id,
e_send_new,
)
async def ensure_required_channel_subscription( async def ensure_required_channel_subscription(
@@ -206,8 +134,15 @@ async def ensure_required_channel_subscription(
Verify that the user is a member of the required channel (if configured). Verify that the user is a member of the required channel (if configured).
Returns True when access can proceed, False when user must subscribe first. Returns True when access can proceed, False when user must subscribe first.
""" """
if not settings.REQUIRED_CHANNEL_SUBSCRIBE_TO_USE:
return True
required_channel_id = settings.REQUIRED_CHANNEL_ID required_channel_id = settings.REQUIRED_CHANNEL_ID
if not required_channel_id: if not required_channel_id:
logging.warning(
"REQUIRED_CHANNEL_SUBSCRIBE_TO_USE is enabled but REQUIRED_CHANNEL_ID is not set. "
"Channel gate is skipped."
)
return True return True
if isinstance(event, types.CallbackQuery): if isinstance(event, types.CallbackQuery):
@@ -285,13 +220,13 @@ async def ensure_required_channel_subscription(
if isinstance(event, types.CallbackQuery): if isinstance(event, types.CallbackQuery):
try: try:
await event.answer(error_text, show_alert=True) await event.answer(error_text, show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
if message_obj: if message_obj:
try: try:
await message_obj.answer(error_text) await message_obj.answer(error_text)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
else: else:
await event.answer(error_text) await event.answer(error_text)
return False return False
@@ -306,13 +241,13 @@ async def ensure_required_channel_subscription(
if isinstance(event, types.CallbackQuery): if isinstance(event, types.CallbackQuery):
try: try:
await event.answer(error_text, show_alert=True) await event.answer(error_text, show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
if message_obj: if message_obj:
try: try:
await message_obj.answer(error_text) await message_obj.answer(error_text)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
else: else:
await event.answer(error_text) await event.answer(error_text)
return False return False
@@ -361,12 +296,12 @@ async def ensure_required_channel_subscription(
if keyboard is None and message_obj: if keyboard is None and message_obj:
try: try:
await message_obj.answer(prompt_text) await message_obj.answer(prompt_text)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
try: try:
await event.answer(prompt_text, show_alert=True) await event.answer(prompt_text, show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
else: else:
await event.answer(prompt_text, reply_markup=keyboard) await event.answer(prompt_text, reply_markup=keyboard)
@@ -376,21 +311,16 @@ async def ensure_required_channel_subscription(
@router.message(CommandStart()) @router.message(CommandStart())
@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"^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"^promo_(\w+)$").as_("promo_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match"))) @router.message(CommandStart(magic=F.args.regexp(r"^(?!ref_|promo_)([A-Za-z0-9_\-]{2,64})$").as_("ad_param_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^page_ref$").as_("page_ref_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^(?!ref_|promo_|admin_user_|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$").as_("ad_param_match")))
async def start_command_handler(message: types.Message, async def start_command_handler(message: types.Message,
state: FSMContext, state: FSMContext,
settings: Settings, settings: Settings,
i18n_data: dict, i18n_data: dict,
subscription_service: SubscriptionService, subscription_service: SubscriptionService,
referral_service: ReferralService,
session: AsyncSession, session: AsyncSession,
ref_match: Optional[re.Match] = None, ref_match: Optional[re.Match] = None,
promo_match: Optional[re.Match] = None, promo_match: Optional[re.Match] = None,
page_ref_match: Optional[re.Match] = None, ad_param_match: Optional[re.Match] = None):
ad_param_match: Optional[re.Match] = None,
admin_user_match: Optional[re.Match] = None):
await state.clear() await state.clear()
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -400,64 +330,11 @@ async def start_command_handler(message: types.Message,
user = message.from_user user = message.from_user
user_id = user.id user_id = user.id
if admin_user_match and user_id in settings.ADMIN_IDS:
target_user_id = int(admin_user_match.group(1))
target_user = await user_dal.get_user_by_id(session, target_user_id)
if not target_user:
await message.answer(
_("admin_user_not_found", input=hd.quote(str(target_user_id)))
)
return
try:
from bot.handlers.admin.user_management import (
format_user_card,
get_user_card_keyboard,
_send_with_profile_link_fallback,
)
referral_service = ReferralService(
settings, subscription_service, message.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,
target_user.referred_by_id,
)
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",
)
return
except Exception as e_admin_card:
logging.error(
"Failed to open admin user card via deep-link for %s: %s",
target_user_id,
e_admin_card,
exc_info=True,
)
await message.answer(_("admin_user_card_error"))
return
referred_by_user_id: Optional[int] = None referred_by_user_id: Optional[int] = None
promo_code_to_apply: Optional[str] = None promo_code_to_apply: Optional[str] = None
should_open_referral_from_start = False
ad_start_param: Optional[str] = None ad_start_param: Optional[str] = None
if ref_match: if ref_match and settings.REFERRAL_ENABLED:
raw_ref_value = ref_match.group(1) raw_ref_value = ref_match.group(1)
if raw_ref_value.isdigit(): if raw_ref_value.isdigit():
if settings.LEGACY_REFS: if settings.LEGACY_REFS:
@@ -475,12 +352,14 @@ async def start_command_handler(message: types.Message,
session, normalized_code) session, normalized_code)
if ref_user and ref_user.user_id != user_id: if ref_user and ref_user.user_id != user_id:
referred_by_user_id = ref_user.user_id referred_by_user_id = ref_user.user_id
elif ref_match and not settings.REFERRAL_ENABLED:
logging.info(
"User %s started with referral parameter while referral system is disabled.",
user_id,
)
elif promo_match: elif promo_match:
promo_code_to_apply = promo_match.group(1) promo_code_to_apply = promo_match.group(1)
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}") logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
elif page_ref_match:
should_open_referral_from_start = True
logging.info(f"User {user_id} started with page_ref deep-link.")
elif ad_param_match: elif ad_param_match:
ad_start_param = ad_param_match.group(1) ad_start_param = ad_param_match.group(1)
logging.info(f"User {user_id} started with ad start param: {ad_start_param}") logging.info(f"User {user_id} started with ad start param: {ad_start_param}")
@@ -490,7 +369,6 @@ async def start_command_handler(message: types.Message,
sanitized_last_name = sanitize_display_name(user.last_name) sanitized_last_name = sanitize_display_name(user.last_name)
db_user = await user_dal.get_user_by_id(session, user_id) db_user = await user_dal.get_user_by_id(session, user_id)
is_existing_user = db_user is not None
if not db_user: if not db_user:
user_data_to_create = { user_data_to_create = {
"user_id": user_id, "user_id": user_id,
@@ -520,50 +398,6 @@ async def start_command_handler(message: types.Message,
f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}." f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}."
) )
# Auto-grant referral welcome bonus to newly registered referred users.
referral_welcome_days = max(
0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0)
)
if referred_by_user_id and referral_welcome_days > 0:
try:
referral_bonus_end_date = await subscription_service.extend_active_subscription_days(
session,
user_id,
referral_welcome_days,
reason="referral_welcome_bonus",
)
if referral_bonus_end_date:
await session.commit()
logging.info(
"Referral welcome bonus applied: user %s got %s days, new end date %s.",
user_id,
referral_welcome_days,
referral_bonus_end_date.isoformat(),
)
await message.answer(
_(
"referral_welcome_bonus_applied",
days=referral_welcome_days,
end_date=referral_bonus_end_date.strftime("%d.%m.%Y %H:%M:%S"),
),
parse_mode="HTML",
)
else:
await session.rollback()
logging.warning(
"Referral welcome bonus was not applied for user %s (referred by %s).",
user_id,
referred_by_user_id,
)
except Exception as referral_bonus_error:
await session.rollback()
logging.error(
"Failed to apply referral welcome bonus for user %s: %s",
user_id,
referral_bonus_error,
exc_info=True,
)
# Send notification about new user registration # Send notification about new user registration
try: try:
from bot.services.notification_service import NotificationService from bot.services.notification_service import NotificationService
@@ -628,33 +462,28 @@ async def start_command_handler(message: types.Message,
logging.error(f"Failed to attribute user {user_id} to ad '{ad_start_param}': {e_attr}") logging.error(f"Failed to attribute user {user_id} to ad '{ad_start_param}': {e_attr}")
try: try:
await session.rollback() await session.rollback()
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
if not await ensure_required_channel_subscription(message, settings, i18n, if not await ensure_required_channel_subscription(message, settings, i18n,
current_lang, session, current_lang, session,
db_user): db_user):
return return
open_referral_page_for_existing_user = (
should_open_referral_from_start and is_existing_user
)
# Send welcome message if not disabled # Send welcome message if not disabled
if not settings.DISABLE_WELCOME_MESSAGE and not open_referral_page_for_existing_user: if not settings.DISABLE_WELCOME_MESSAGE:
await message.answer(_(key="welcome", user_name=hd.quote(user.full_name))) await message.answer(_(key="welcome", user_name=hd.quote(user.full_name)))
# Auto-apply promo code if provided via start parameter # Auto-apply promo code if provided via start parameter
if promo_code_to_apply: if promo_code_to_apply:
try: try:
from bot.services.promo_code_service import PromoCodeService
promo_code_service = PromoCodeService(settings, subscription_service, message.bot, i18n) promo_code_service = PromoCodeService(settings, subscription_service, message.bot, i18n)
success, result = await promo_code_service.apply_promo_code( success_bonus, bonus_result = await promo_code_service.apply_promo_code(
session, user_id, promo_code_to_apply, current_lang session, user_id, promo_code_to_apply, current_lang
) )
if success: if success_bonus:
await session.commit() await session.commit()
logging.info(f"Auto-applied promo code '{promo_code_to_apply}' for user {user_id}") logging.info(f"Auto-applied promo code '{promo_code_to_apply}' for user {user_id}")
@@ -664,7 +493,7 @@ async def start_command_handler(message: types.Message,
connect_button_url = active.get("connect_button_url") 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") config_link_text = config_link_display or _("config_link_not_available")
new_end_date = result if isinstance(result, datetime) else None new_end_date = bonus_result if isinstance(bonus_result, datetime) else None
promo_success_text = _( promo_success_text = _(
"promo_code_applied_success_full", "promo_code_applied_success_full",
@@ -687,23 +516,54 @@ async def start_command_handler(message: types.Message,
# Don't show main menu if promo was successfully applied # Don't show main menu if promo was successfully applied
return return
else:
success_discount, discount_result = await promo_code_service.apply_discount_promo_code(
session, user_id, promo_code_to_apply, current_lang
)
if success_discount:
await session.commit() await session.commit()
logging.warning(f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}: {result}") discount_pct = discount_result if isinstance(discount_result, int) else 0
await message.answer(str(result), parse_mode="HTML") logging.info(
# Continue to show main menu if promo failed f"Auto-applied discount promo code '{promo_code_to_apply}' for user {user_id}: {discount_pct}%"
)
if settings.LOG_PROMO_ACTIVATIONS:
try:
from bot.services.notification_service import NotificationService
notification_service = NotificationService(message.bot, settings, i18n)
await notification_service.notify_discount_promo_activation(
user_id=user_id,
promo_code=promo_code_to_apply.upper(),
discount_percentage=discount_pct,
username=user.username,
)
except Exception as notify_error:
logging.error(f"Failed to send discount promo activation notification: {notify_error}")
from bot.keyboards.inline.user_keyboards import get_back_to_main_menu_markup
await message.answer(
_(
"discount_promo_code_applied_success",
code=hd.quote(promo_code_to_apply.upper()),
discount=discount_pct,
),
reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
parse_mode="HTML",
)
return
await session.rollback()
logging.warning(
f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}. "
f"Bonus reason: {bonus_result}. Discount reason: {discount_result}"
)
# Continue to show main menu if promo failed
except Exception as e: except Exception as e:
logging.error(f"Error auto-applying promo code '{promo_code_to_apply}' for user {user_id}: {e}") logging.error(f"Error auto-applying promo code '{promo_code_to_apply}' for user {user_id}: {e}")
await session.rollback() await session.rollback()
if open_referral_page_for_existing_user:
from . import referral as user_referral_handlers
await user_referral_handlers.referral_command_handler(
message, settings, i18n_data, referral_service, message.bot, session
)
return
await send_main_menu(message, await send_main_menu(message,
settings, settings,
i18n_data, i18n_data,
@@ -712,31 +572,6 @@ async def start_command_handler(message: types.Message,
is_edit=False) is_edit=False)
@router.message(Command("tg"))
async def tg_interface_command_handler(message: types.Message,
state: FSMContext,
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession):
await state.clear()
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
db_user = await user_dal.get_user_by_id(session, message.from_user.id)
if not await ensure_required_channel_subscription(message, settings, i18n,
current_lang, session,
db_user):
return
await send_bot_interface_menu(message,
settings,
i18n_data,
subscription_service,
session,
is_edit=False)
@router.callback_query(F.data == "channel_subscription:verify") @router.callback_query(F.data == "channel_subscription:verify")
async def verify_channel_subscription_callback( async def verify_channel_subscription_callback(
callback: types.CallbackQuery, callback: types.CallbackQuery,
@@ -767,7 +602,15 @@ async def verify_channel_subscription_callback(
welcome_text = _(key="welcome", welcome_text = _(key="welcome",
user_name=hd.quote(callback.from_user.full_name)) user_name=hd.quote(callback.from_user.full_name))
if callback.message: if callback.message:
await callback.message.answer(welcome_text) try:
await callback.message.edit_text(welcome_text)
except Exception as welcome_edit_error:
logging.debug(
"Failed to edit subscription prompt to welcome for user %s: %s",
callback.from_user.id,
welcome_edit_error,
)
await callback.message.answer(welcome_text)
else: else:
fallback_bot: Optional[Bot] = getattr(callback, "bot", None) fallback_bot: Optional[Bot] = getattr(callback, "bot", None)
if fallback_bot: if fallback_bot:
@@ -775,20 +618,24 @@ async def verify_channel_subscription_callback(
welcome_text) welcome_text)
try: try:
await safe_answer_callback( await callback.answer(_(key="channel_subscription_verified_success"),
callback, show_alert=True)
_(key="channel_subscription_verified_success"), except Exception as exc:
show_alert=True, logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
)
except Exception:
pass
await send_main_menu(callback, menu_target_event: Union[types.Message, types.CallbackQuery] = callback
should_edit_menu_message = bool(callback.message)
if not settings.DISABLE_WELCOME_MESSAGE and callback.message:
menu_target_event = callback.message
should_edit_menu_message = False
await send_main_menu(menu_target_event,
settings, settings,
i18n_data, i18n_data,
subscription_service, subscription_service,
session, session,
is_edit=bool(callback.message)) is_edit=should_edit_menu_message)
@router.message(Command("language")) @router.message(Command("language"))
@@ -810,11 +657,7 @@ async def language_command_handler(
event, types.CallbackQuery) else event event, types.CallbackQuery) else event
if not target_message_obj: if not target_message_obj:
if isinstance(event, types.CallbackQuery): if isinstance(event, types.CallbackQuery):
await safe_answer_callback( await event.answer(_("error_occurred_try_again"), show_alert=True)
event,
_("error_occurred_try_again"),
show_alert=True,
)
return return
if isinstance(event, types.CallbackQuery): if isinstance(event, types.CallbackQuery):
@@ -825,7 +668,7 @@ async def language_command_handler(
except Exception: except Exception:
await target_message_obj.answer(text_to_send, await target_message_obj.answer(text_to_send,
reply_markup=reply_markup) reply_markup=reply_markup)
await safe_answer_callback(event) await event.answer()
else: else:
await target_message_obj.answer(text_to_send, await target_message_obj.answer(text_to_send,
reply_markup=reply_markup) reply_markup=reply_markup)
@@ -837,21 +680,15 @@ async def select_language_callback_handler(
subscription_service: SubscriptionService, session: AsyncSession): subscription_service: SubscriptionService, session: AsyncSession):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message: if not i18n or not callback.message:
await safe_answer_callback( await callback.answer("Service error or message context lost.",
callback, show_alert=True)
"Service error or message context lost.",
show_alert=True,
)
return return
try: try:
lang_code = callback.data.split("_")[2] lang_code = callback.data.split("_")[2]
except IndexError: except IndexError:
await safe_answer_callback( await callback.answer("Error processing language selection.",
callback, show_alert=True)
"Error processing language selection.",
show_alert=True,
)
return return
user_id = callback.from_user.id user_id = callback.from_user.id
@@ -862,22 +699,18 @@ async def select_language_callback_handler(
i18n_data["current_language"] = lang_code i18n_data["current_language"] = lang_code
_ = lambda key, **kwargs: i18n.gettext(lang_code, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(lang_code, key, **kwargs)
await safe_answer_callback(callback, _(key="language_set_alert")) await callback.answer(_(key="language_set_alert"))
logging.info( logging.info(
f"User {user_id} language updated to {lang_code} in session.") f"User {user_id} language updated to {lang_code} in session.")
else: else:
await safe_answer_callback( await callback.answer("Could not set language.", show_alert=True)
callback,
"Could not set language.",
show_alert=True,
)
return return
except Exception as e_lang_update: except Exception as e_lang_update:
logging.error( logging.error(
f"Error updating lang for user {user_id}: {e_lang_update}", f"Error updating lang for user {user_id}: {e_lang_update}",
exc_info=True) exc_info=True)
await safe_answer_callback(callback, "Error setting language.", show_alert=True) await callback.answer("Error setting language.", show_alert=True)
return return
await send_main_menu(callback, await send_main_menu(callback,
settings, settings,
@@ -895,6 +728,10 @@ async def main_action_callback_handler(
promo_code_service: PromoCodeService, session: AsyncSession): promo_code_service: PromoCodeService, session: AsyncSession):
action = callback.data.split(":")[1] action = callback.data.split(":")[1]
user_id = callback.from_user.id user_id = callback.from_user.id
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
) if i18n else key
from . import subscription as user_subscription_handlers from . import subscription as user_subscription_handlers
from . import referral as user_referral_handlers from . import referral as user_referral_handlers
@@ -902,16 +739,12 @@ async def main_action_callback_handler(
from . import trial_handler as user_trial_handlers from . import trial_handler as user_trial_handlers
if not callback.message: if not callback.message:
await safe_answer_callback( await callback.answer("Error: message context lost.", show_alert=True)
callback,
"Error: message context lost.",
show_alert=True,
)
return return
if action == "subscribe": if action == "subscribe":
await user_subscription_handlers.display_subscription_options( await user_subscription_handlers.display_subscription_options(
callback, i18n_data, settings, session) callback, i18n_data, settings, session, promo_code_service=promo_code_service)
elif action == "my_subscription": elif action == "my_subscription":
await user_subscription_handlers.my_subscription_command_handler( await user_subscription_handlers.my_subscription_command_handler(
callback, i18n_data, settings, panel_service, subscription_service, callback, i18n_data, settings, panel_service, subscription_service,
@@ -921,6 +754,10 @@ async def main_action_callback_handler(
callback, i18n_data, settings, panel_service, subscription_service, callback, i18n_data, settings, panel_service, subscription_service,
session, bot) session, bot)
elif action == "referral": elif action == "referral":
if not settings.REFERRAL_ENABLED:
await callback.answer(_("referral_no_bonuses_configured"),
show_alert=True)
return
await user_referral_handlers.referral_command_handler( await user_referral_handlers.referral_command_handler(
callback, settings, i18n_data, referral_service, bot, session) callback, settings, i18n_data, referral_service, bot, session)
elif action == "apply_promo": elif action == "apply_promo":
@@ -932,44 +769,6 @@ async def main_action_callback_handler(
elif action == "language": elif action == "language":
await language_command_handler(callback, i18n_data, settings) await language_command_handler(callback, i18n_data, settings)
elif action == "info":
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language",
settings.DEFAULT_LANGUAGE)
if not i18n:
await safe_answer_callback(
callback,
"Language service error.",
show_alert=True,
)
return
_ = lambda key, **kwargs: i18n.gettext(
current_lang, key, **kwargs) if i18n else key
privacy_url = settings.PRIVACY_POLICY_URL
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
if not privacy_url and not user_agreement_url:
await safe_answer_callback(
callback,
_("error_occurred_try_again"),
show_alert=True,
)
return
reply_markup = get_information_links_keyboard(
current_lang,
i18n,
privacy_url,
user_agreement_url,
)
try:
await callback.message.edit_text(_(key="info_links_message"),
reply_markup=reply_markup)
except Exception:
await callback.message.answer(_(key="info_links_message"),
reply_markup=reply_markup)
await safe_answer_callback(callback)
elif action == "back_to_main": elif action == "back_to_main":
await send_main_menu(callback, await send_main_menu(callback,
settings, settings,
@@ -985,11 +784,4 @@ async def main_action_callback_handler(
session, session,
is_edit=False) is_edit=False)
else: else:
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") await callback.answer(_("main_menu_unknown_action"), show_alert=True)
_ = lambda key, **kwargs: i18n.gettext(
i18n_data.get("current_language"), key, **kwargs) if i18n else key
await safe_answer_callback(
callback,
_("main_menu_unknown_action"),
show_alert=True,
)
+111 -95
View File
@@ -1,5 +1,6 @@
import hashlib import hashlib
import logging import logging
import math
from aiogram import Router, F, types, Bot from aiogram import Router, F, types, Bot
from aiogram.filters import Command from aiogram.filters import Command
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
@@ -39,7 +40,13 @@ def _hwid_callback_token(hwid: Optional[str]) -> str:
return hashlib.sha256(hwid_str.encode()).hexdigest()[:32] 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): async def display_subscription_options(
event: Union[types.Message, types.CallbackQuery],
i18n_data: dict,
settings: Settings,
session: AsyncSession,
promo_code_service=None,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -50,32 +57,57 @@ async def display_subscription_options(event: Union[types.Message, types.Callbac
if isinstance(event, types.CallbackQuery): if isinstance(event, types.CallbackQuery):
try: try:
await event.answer(err_msg, show_alert=True) await event.answer(err_msg, show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
elif isinstance(event, types.Message): elif isinstance(event, types.Message):
await event.answer(err_msg) await event.answer(err_msg)
return return
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL currency_symbol_val = "RUB"
traffic_packages = getattr(settings, "traffic_packages", {}) or {} traffic_packages = getattr(settings, "traffic_packages", {}) or {}
stars_traffic_packages = getattr(settings, "stars_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) traffic_mode = bool(getattr(settings, "traffic_sale_mode", False) or stars_traffic_packages)
options_are_stars = False
if traffic_mode: if traffic_mode:
if traffic_packages: if traffic_packages:
options = traffic_packages options = traffic_packages
elif stars_traffic_packages: elif stars_traffic_packages:
options = stars_traffic_packages options = stars_traffic_packages
currency_symbol_val = "" currency_symbol_val = ""
options_are_stars = True
else: else:
options = {} options = {}
else: else:
options = settings.subscription_options options = settings.subscription_options
if options: display_options = options
if options and promo_code_service:
try:
active_discount_info = await promo_code_service.get_user_active_discount(
session, event.from_user.id
)
except Exception:
active_discount_info = None
if active_discount_info:
discount_pct, _promo_code = active_discount_info
discounted_options = {}
for period, price in options.items():
if price is None:
discounted_options[period] = price
else:
discounted_price, _ = promo_code_service.calculate_discounted_price(
price, discount_pct
)
if options_are_stars:
discounted_price = math.ceil(discounted_price)
discounted_options[period] = discounted_price
display_options = discounted_options
if display_options:
text_content = get_text("select_traffic_package") if traffic_mode else get_text("select_subscription_period") text_content = get_text("select_traffic_package") if traffic_mode else get_text("select_subscription_period")
reply_markup = get_subscription_options_keyboard( reply_markup = get_subscription_options_keyboard(
options, currency_symbol_val, current_lang, i18n, traffic_mode=traffic_mode display_options, currency_symbol_val, current_lang, i18n, traffic_mode=traffic_mode
) )
else: else:
text_content = get_text("no_subscription_options_available") text_content = get_text("no_subscription_options_available")
@@ -86,8 +118,8 @@ async def display_subscription_options(event: Union[types.Message, types.Callbac
if isinstance(event, types.CallbackQuery): if isinstance(event, types.CallbackQuery):
try: try:
await event.answer(get_text("error_occurred_try_again"), show_alert=True) await event.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
return return
if isinstance(event, types.CallbackQuery): if isinstance(event, types.CallbackQuery):
@@ -97,15 +129,23 @@ async def display_subscription_options(event: Union[types.Message, types.Callbac
await target_message_obj.answer(text_content, reply_markup=reply_markup) await target_message_obj.answer(text_content, reply_markup=reply_markup)
try: try:
await event.answer() await event.answer()
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
else: else:
await target_message_obj.answer(text_content, reply_markup=reply_markup) await target_message_obj.answer(text_content, reply_markup=reply_markup)
@router.callback_query(F.data == "main_action:subscribe") @router.callback_query(F.data == "main_action:subscribe")
async def reshow_subscription_options_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession): async def reshow_subscription_options_callback(
await display_subscription_options(callback, i18n_data, settings, session) callback: types.CallbackQuery,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
promo_code_service=None,
):
await display_subscription_options(
callback, i18n_data, settings, session, promo_code_service=promo_code_service
)
async def my_subscription_command_handler( async def my_subscription_command_handler(
@@ -146,8 +186,8 @@ async def my_subscription_command_handler(
if isinstance(event, types.CallbackQuery): if isinstance(event, types.CallbackQuery):
try: try:
await event.answer() await event.answer()
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
try: try:
await event.message.edit_text(text, reply_markup=kb) await event.message.edit_text(text, reply_markup=kb)
except Exception: except Exception:
@@ -169,41 +209,21 @@ async def my_subscription_command_handler(
if isinstance(val, (int, float)): if isinstance(val, (int, float)):
val_gb = float(val) / (2**30) val_gb = float(val) / (2**30)
return f"{val_gb:.2f} GB" return f"{val_gb:.2f} GB"
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
return str(val) return str(val)
def _format_traffic_period(strategy: Optional[str]) -> Optional[str]:
if not strategy:
return None
strategy_upper = str(strategy).upper()
key_map = {
"MONTH": "traffic_period_month",
"WEEK": "traffic_period_week",
"DAY": "traffic_period_day",
"NO_RESET": "traffic_period_no_reset",
}
label_key = key_map.get(strategy_upper)
return get_text(label_key) if label_key else strategy_upper
def _format_used_with_period(used_display: str, period_label: Optional[str]) -> str:
if not period_label:
return used_display
return get_text("traffic_used_with_period", traffic_used=used_display, traffic_period=period_label)
period_label = _format_traffic_period(active.get("traffic_limit_strategy"))
period_label = period_label or get_text("traffic_period_unknown")
if traffic_mode: if traffic_mode:
limit_display = _fmt_gb(active.get("traffic_limit_bytes")) limit_display = _fmt_gb(active.get("traffic_limit_bytes"))
used_display = _format_used_with_period(_fmt_gb(active.get("traffic_used_bytes")), period_label) used_display = _fmt_gb(active.get("traffic_used_bytes"))
remaining_display = get_text("traffic_na") remaining_display = get_text("traffic_na")
try: try:
limit_val = active.get("traffic_limit_bytes") or 0 limit_val = active.get("traffic_limit_bytes") or 0
used_val = active.get("traffic_used_bytes") or 0 used_val = active.get("traffic_used_bytes") or 0
remaining_val = max(0, float(limit_val) - float(used_val)) remaining_val = max(0, float(limit_val) - float(used_val))
remaining_display = _fmt_gb(remaining_val) remaining_display = _fmt_gb(remaining_val)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
text = get_text( text = get_text(
"my_traffic_details", "my_traffic_details",
status=active.get("status_from_panel", get_text("status_active")).capitalize(), status=active.get("status_from_panel", get_text("status_active")).capitalize(),
@@ -211,7 +231,6 @@ async def my_subscription_command_handler(
traffic_limit=limit_display, traffic_limit=limit_display,
traffic_used=used_display, traffic_used=used_display,
traffic_left=remaining_display, traffic_left=remaining_display,
traffic_period=period_label,
config_link=config_link_value, config_link=config_link_value,
) )
else: else:
@@ -223,12 +242,8 @@ async def my_subscription_command_handler(
config_link=config_link_value, 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_limit=(f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" if active.get("traffic_limit_bytes") else get_text("traffic_unlimited")),
traffic_used=( traffic_used=(
_format_used_with_period( f"{active['traffic_used_bytes'] / 2**30:.2f} GB" if active.get("traffic_used_bytes") is not None else get_text("traffic_na")
f"{active['traffic_used_bytes'] / 2**30:.2f} GB" if active.get("traffic_used_bytes") is not None else get_text("traffic_na"),
period_label,
)
), ),
traffic_period=period_label,
) )
base_markup = get_back_to_main_menu_markup(current_lang, i18n) base_markup = get_back_to_main_menu_markup(current_lang, i18n)
@@ -238,22 +253,23 @@ async def my_subscription_command_handler(
# Build rows to prepend above the base "back" markup # Build rows to prepend above the base "back" markup
prepend_rows = [] prepend_rows = []
# 1) Connect button: prefer the actual subscription URL; fall back to mini-app # 1) Mini-app connect button on top if enabled, otherwise fall back to config link URL
cfg_link_val = connect_button_url or config_link_display if settings.SUBSCRIPTION_MINI_APP_URL:
if cfg_link_val:
prepend_rows.append([
InlineKeyboardButton(
text=get_text("connect_button"),
url=cfg_link_val,
)
])
elif settings.SUBSCRIPTION_MINI_APP_URL:
prepend_rows.append([ prepend_rows.append([
InlineKeyboardButton( InlineKeyboardButton(
text=get_text("connect_button"), text=get_text("connect_button"),
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL), web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
) )
]) ])
else:
cfg_link_val = connect_button_url or config_link_display
if cfg_link_val:
prepend_rows.append([
InlineKeyboardButton(
text=get_text("connect_button"),
url=cfg_link_val,
)
])
if settings.MY_DEVICES_SECTION_ENABLED: if settings.MY_DEVICES_SECTION_ENABLED:
max_devices_value = active.get("max_devices") max_devices_value = active.get("max_devices")
@@ -326,15 +342,15 @@ async def my_subscription_command_handler(
if prepend_rows: if prepend_rows:
kb = prepend_rows + kb kb = prepend_rows + kb
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
markup = InlineKeyboardMarkup(inline_keyboard=kb) markup = InlineKeyboardMarkup(inline_keyboard=kb)
if isinstance(event, types.CallbackQuery): if isinstance(event, types.CallbackQuery):
try: try:
await event.answer() await event.answer()
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
try: try:
await event.message.edit_text(text, 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: except Exception:
@@ -373,8 +389,8 @@ async def my_devices_command_handler(
if isinstance(event, types.CallbackQuery): if isinstance(event, types.CallbackQuery):
try: try:
await event.answer(get_text("my_devices_feature_disabled"), show_alert=True) await event.answer(get_text("my_devices_feature_disabled"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
else: else:
await target.answer(get_text("my_devices_feature_disabled")) await target.answer(get_text("my_devices_feature_disabled"))
return return
@@ -386,8 +402,8 @@ async def my_devices_command_handler(
if isinstance(event, types.CallbackQuery): if isinstance(event, types.CallbackQuery):
try: try:
await event.answer(message, show_alert=True) await event.answer(message, show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
else: else:
await target.answer(message) await target.answer(message)
return return
@@ -397,8 +413,8 @@ async def my_devices_command_handler(
if isinstance(event, types.CallbackQuery): if isinstance(event, types.CallbackQuery):
try: try:
await event.answer(get_text("no_devices_found"), show_alert=True) await event.answer(get_text("no_devices_found"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
else: else:
await target.answer(get_text("no_devices_found")) await target.answer(get_text("no_devices_found"))
return return
@@ -459,8 +475,8 @@ async def my_devices_command_handler(
if isinstance(event, types.CallbackQuery): if isinstance(event, types.CallbackQuery):
try: try:
await event.answer() await event.answer()
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
try: try:
await event.message.edit_text(text, reply_markup=markup) await event.message.edit_text(text, reply_markup=markup)
except Exception: except Exception:
@@ -486,8 +502,8 @@ async def disconnect_device_handler(
if not settings.MY_DEVICES_SECTION_ENABLED: if not settings.MY_DEVICES_SECTION_ENABLED:
try: try:
await callback.answer(get_text("my_devices_feature_disabled"), show_alert=True) await callback.answer(get_text("my_devices_feature_disabled"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
return return
try: try:
@@ -495,8 +511,8 @@ async def disconnect_device_handler(
except Exception: except Exception:
try: try:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
return return
active = await subscription_service.get_active_subscription_details(session, callback.from_user.id) active = await subscription_service.get_active_subscription_details(session, callback.from_user.id)
@@ -533,8 +549,8 @@ async def disconnect_device_handler(
await session.commit() await session.commit()
try: try:
await callback.answer(get_text("device_disconnected")) await callback.answer(get_text("device_disconnected"))
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
await my_devices_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot) await my_devices_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
@@ -560,8 +576,8 @@ async def toggle_autorenew_handler(
except Exception: except Exception:
try: try:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
return return
sub = await session.get(Subscription, sub_id) sub = await session.get(Subscription, sub_id)
@@ -576,8 +592,8 @@ async def toggle_autorenew_handler(
if not has_saved_card: if not has_saved_card:
try: try:
await callback.answer(get_text("autorenew_enable_requires_card"), show_alert=True) await callback.answer(get_text("autorenew_enable_requires_card"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
return return
# Show confirmation popup and inline buttons # Show confirmation popup and inline buttons
@@ -588,12 +604,12 @@ async def toggle_autorenew_handler(
except Exception: except Exception:
try: try:
await callback.message.answer(confirm_text, reply_markup=kb) await callback.message.answer(confirm_text, reply_markup=kb)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
try: try:
await callback.answer() await callback.answer()
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
return return
@@ -618,8 +634,8 @@ async def confirm_autorenew_handler(
except Exception: except Exception:
try: try:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
return return
sub = await session.get(Subscription, sub_id) sub = await session.get(Subscription, sub_id)
@@ -634,20 +650,20 @@ async def confirm_autorenew_handler(
if not has_saved_card: if not has_saved_card:
try: try:
await callback.answer(get_text("autorenew_enable_requires_card"), show_alert=True) await callback.answer(get_text("autorenew_enable_requires_card"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
try: try:
await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot) await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
return return
await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": enable}) await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": enable})
await session.commit() await session.commit()
try: try:
await callback.answer(get_text("subscription_autorenew_updated")) await callback.answer(get_text("subscription_autorenew_updated"))
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot) await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
@@ -671,21 +687,21 @@ async def autorenew_cancel_from_webhook_button(
if not sub: if not sub:
try: try:
await callback.answer(get_text("subscription_not_active"), show_alert=True) await callback.answer(get_text("subscription_not_active"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
return return
if sub.provider != "yookassa": if sub.provider != "yookassa":
try: try:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
return return
await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": False}) await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": False})
await session.commit() await session.commit()
try: try:
await callback.answer(get_text("subscription_autorenew_updated")) await callback.answer(get_text("subscription_autorenew_updated"))
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot) await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
@@ -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
@@ -1,3 +1,4 @@
import logging
from aiogram import Router, F, types from aiogram import Router, F, types
from typing import Optional, List from typing import Optional, List
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -26,8 +27,8 @@ async def payment_methods_manage(callback: types.CallbackQuery, settings: Settin
try: try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True) await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
return return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
@@ -67,8 +68,8 @@ async def payment_methods_manage(callback: types.CallbackQuery, settings: Settin
await callback.message.edit_text(text, reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n)) await callback.message.edit_text(text, reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n))
try: try:
await callback.answer() await callback.answer()
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
@router.callback_query(F.data == "pm:bind") @router.callback_query(F.data == "pm:bind")
@@ -79,8 +80,8 @@ async def payment_method_bind(callback: types.CallbackQuery, settings: Settings,
try: try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True) await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
return return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
@@ -101,8 +102,8 @@ async def payment_method_bind(callback: types.CallbackQuery, settings: Settings,
await callback.message.edit_text(_("payment_methods_title"), reply_markup=get_bind_url_keyboard(resp["confirmation_url"], current_lang, i18n)) await callback.message.edit_text(_("payment_methods_title"), reply_markup=get_bind_url_keyboard(resp["confirmation_url"], current_lang, i18n))
try: try:
await callback.answer() await callback.answer()
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
@router.callback_query(F.data.startswith("pm:delete_confirm")) @router.callback_query(F.data.startswith("pm:delete_confirm"))
@@ -113,8 +114,8 @@ async def payment_method_delete_confirm(callback: types.CallbackQuery, settings:
try: try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True) await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
return return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
parts = callback.data.split(":", 2) parts = callback.data.split(":", 2)
@@ -122,8 +123,8 @@ async def payment_method_delete_confirm(callback: types.CallbackQuery, settings:
await callback.message.edit_text(_("payment_method_delete_confirm"), reply_markup=get_payment_method_delete_confirm_keyboard(pm_id, current_lang, i18n)) await callback.message.edit_text(_("payment_method_delete_confirm"), reply_markup=get_payment_method_delete_confirm_keyboard(pm_id, current_lang, i18n))
try: try:
await callback.answer() await callback.answer()
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
@router.callback_query(F.data.startswith("pm:delete")) @router.callback_query(F.data.startswith("pm:delete"))
@@ -134,8 +135,8 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting
try: try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True) await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
return return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
parts = callback.data.split(":", 2) parts = callback.data.split(":", 2)
@@ -156,8 +157,8 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting
try: try:
legacy_deleted = await user_billing_dal.delete_yk_payment_method(session, callback.from_user.id) legacy_deleted = await user_billing_dal.delete_yk_payment_method(session, callback.from_user.id)
deleted = deleted or legacy_deleted deleted = deleted or legacy_deleted
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
await session.commit() await session.commit()
methods = await list_user_payment_methods(session, callback.from_user.id) methods = await list_user_payment_methods(session, callback.from_user.id)
@@ -189,15 +190,15 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting
await callback.message.edit_text(f"{msg}\n\n{text}", reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n)) await callback.message.edit_text(f"{msg}\n\n{text}", reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n))
try: try:
await callback.answer() await callback.answer()
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
return return
except Exception: except Exception:
await session.rollback() await session.rollback()
try: try:
await callback.answer(_("error_try_again"), show_alert=True) await callback.answer(_("error_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
@router.callback_query(F.data.startswith("pm:view")) @router.callback_query(F.data.startswith("pm:view"))
@@ -208,8 +209,8 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
try: try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True) await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
return return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
@@ -262,14 +263,14 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
lp = result.scalar_one_or_none() lp = result.scalar_one_or_none()
if lp and lp.created_at: if lp and lp.created_at:
last_tx = lp.created_at.strftime('%Y-%m-%d') last_tx = lp.created_at.strftime('%Y-%m-%d')
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}" details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}"
await callback.message.edit_text(details, reply_markup=get_payment_method_details_keyboard(str(sel.method_id), current_lang, i18n)) await callback.message.edit_text(details, reply_markup=get_payment_method_details_keyboard(str(sel.method_id), current_lang, i18n))
try: try:
await callback.answer() await callback.answer()
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
return return
added_at = billing.created_at.strftime('%Y-%m-%d') if getattr(billing, 'created_at', None) else "" added_at = billing.created_at.strftime('%Y-%m-%d') if getattr(billing, 'created_at', None) else ""
@@ -289,8 +290,8 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
last_payment = result.scalar_one_or_none() last_payment = result.scalar_one_or_none()
if last_payment and last_payment.created_at: if last_payment and last_payment.created_at:
last_tx = last_payment.created_at.strftime('%Y-%m-%d') last_tx = last_payment.created_at.strftime('%Y-%m-%d')
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
def _is_yoomoney_network(network: Optional[str]) -> bool: def _is_yoomoney_network(network: Optional[str]) -> bool:
s = (network or "").lower() s = (network or "").lower()
@@ -317,8 +318,8 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
await callback.message.edit_text(details, reply_markup=get_payment_method_details_keyboard(billing.yookassa_payment_method_id, current_lang, i18n)) await callback.message.edit_text(details, reply_markup=get_payment_method_details_keyboard(billing.yookassa_payment_method_id, current_lang, i18n))
try: try:
await callback.answer() await callback.answer()
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
@router.callback_query(F.data.startswith("pm:history")) @router.callback_query(F.data.startswith("pm:history"))
@@ -329,8 +330,8 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin
try: try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True) await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
return return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
@@ -371,8 +372,8 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin
if pm.get("id") == selected_pm_provider_id: if pm.get("id") == selected_pm_provider_id:
filtered.append(p) filtered.append(p)
continue continue
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
user_payments = filtered user_payments = filtered
if not user_payments: if not user_payments:
@@ -459,6 +460,6 @@ async def payment_methods_list(callback: types.CallbackQuery, settings: Settings
await callback.message.edit_text(text, reply_markup=get_payment_methods_list_keyboard(cards, page, current_lang, i18n)) await callback.message.edit_text(text, reply_markup=get_payment_methods_list_keyboard(cards, page, current_lang, i18n))
try: try:
await callback.answer() await callback.answer()
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
@@ -1,3 +1,4 @@
import logging
from typing import Optional from typing import Optional
from aiogram import F, Router, types from aiogram import F, Router, types
@@ -11,6 +12,8 @@ from config.settings import Settings
router = Router(name="user_subscription_payments_crypto_router") router = Router(name="user_subscription_payments_crypto_router")
from bot.handlers.user.subscription.payments_subscription import resolve_fiat_offer_price_for_user
@router.callback_query(F.data.startswith("pay_crypto:")) @router.callback_query(F.data.startswith("pay_crypto:"))
async def pay_crypto_callback_handler( async def pay_crypto_callback_handler(
callback: types.CallbackQuery, callback: types.CallbackQuery,
@@ -18,6 +21,7 @@ async def pay_crypto_callback_handler(
i18n_data: dict, i18n_data: dict,
session: AsyncSession, session: AsyncSession,
cryptopay_service: CryptoPayService, cryptopay_service: CryptoPayService,
promo_code_service=None,
): ):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -26,31 +30,68 @@ async def pay_crypto_callback_handler(
if not i18n or not callback.message: if not i18n or not callback.message:
try: try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True) await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_crypto.py: %s", exc)
return return
if not cryptopay_service or not getattr(cryptopay_service, "configured", False): if not cryptopay_service or not getattr(cryptopay_service, "configured", False):
try: try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_crypto.py: %s", exc)
return return
try: try:
_, data_payload = callback.data.split(":", 1) _, data_payload = callback.data.split(":", 1)
parts = data_payload.split(":") parts = data_payload.split(":")
months = float(parts[0]) months = float(parts[0])
price_amount = float(parts[1]) callback_price_amount = float(parts[1])
sale_mode = parts[2] if len(parts) > 2 else "subscription" sale_mode = parts[2] if len(parts) > 2 else "subscription"
except (ValueError, IndexError): except (ValueError, IndexError):
try: try:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_crypto.py: %s", exc)
return return
user_id = callback.from_user.id user_id = callback.from_user.id
resolved_price_amount = await resolve_fiat_offer_price_for_user(
session=session,
settings=settings,
user_id=user_id,
months=months,
sale_mode=sale_mode,
promo_code_service=promo_code_service,
)
if resolved_price_amount is None:
logging.warning(
"CryptoPay: no server-side price for user %s, value=%s, mode=%s",
user_id,
months,
sale_mode,
)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_crypto.py: %s", exc)
return
if abs(resolved_price_amount - callback_price_amount) > 0.01:
logging.warning(
"CryptoPay: callback price mismatch for user %s, value=%s, mode=%s, callback=%.2f, resolved=%.2f",
user_id,
months,
sale_mode,
callback_price_amount,
resolved_price_amount,
)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_crypto.py: %s", exc)
return
price_amount = resolved_price_amount
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}" human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
payment_description = ( payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value) get_text("payment_description_traffic", traffic_gb=human_value)
@@ -65,6 +106,7 @@ async def pay_crypto_callback_handler(
amount=price_amount, amount=price_amount,
description=payment_description, description=payment_description,
sale_mode=sale_mode, sale_mode=sale_mode,
promo_code_service=promo_code_service,
) )
if invoice_url: if invoice_url:
@@ -101,15 +143,15 @@ async def pay_crypto_callback_handler(
), ),
disable_web_page_preview=False, disable_web_page_preview=False,
) )
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_crypto.py: %s", exc)
try: try:
await callback.answer() await callback.answer()
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_crypto.py: %s", exc)
return return
try: try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True) await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_crypto.py: %s", exc)
@@ -14,6 +14,8 @@ from db.dal import payment_dal
router = Router(name="user_subscription_payments_freekassa_router") router = Router(name="user_subscription_payments_freekassa_router")
from bot.handlers.user.subscription.payments_subscription import resolve_fiat_offer_price_for_user
@router.callback_query(F.data.startswith("pay_fk:")) @router.callback_query(F.data.startswith("pay_fk:"))
async def pay_fk_callback_handler( async def pay_fk_callback_handler(
callback: types.CallbackQuery, callback: types.CallbackQuery,
@@ -21,6 +23,7 @@ async def pay_fk_callback_handler(
i18n_data: dict, i18n_data: dict,
freekassa_service: FreeKassaService, freekassa_service: FreeKassaService,
session: AsyncSession, session: AsyncSession,
promo_code_service=None,
): ):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -29,53 +32,93 @@ async def pay_fk_callback_handler(
if not i18n or not callback.message: if not i18n or not callback.message:
try: try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True) await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
return return
if not freekassa_service or not freekassa_service.configured: if not freekassa_service or not freekassa_service.configured:
logging.error("FreeKassa service is not configured or unavailable.") logging.error("FreeKassa service is not configured or unavailable.")
try: try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
try: try:
await callback.message.edit_text(get_text("payment_service_unavailable")) await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
return return
try: try:
_, data_payload = callback.data.split(":", 1) _, data_payload = callback.data.split(":", 1)
parts = data_payload.split(":") parts = data_payload.split(":")
months = float(parts[0]) months = float(parts[0])
price_rub = float(parts[1]) callback_price_rub = float(parts[1])
sale_mode = parts[2] if len(parts) > 2 else "subscription" sale_mode = parts[2] if len(parts) > 2 else "subscription"
except (ValueError, IndexError): except (ValueError, IndexError):
logging.error(f"Invalid pay_fk data in callback: {callback.data}") logging.error(f"Invalid pay_fk data in callback: {callback.data}")
try: try:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
return return
user_id = callback.from_user.id user_id = callback.from_user.id
resolved_price_rub = await resolve_fiat_offer_price_for_user(
session=session,
settings=settings,
user_id=user_id,
months=months,
sale_mode=sale_mode,
promo_code_service=promo_code_service,
)
if resolved_price_rub is None:
logging.warning(
"FreeKassa: no server-side price for user %s, value=%s, mode=%s",
user_id,
months,
sale_mode,
)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
return
if abs(resolved_price_rub - callback_price_rub) > 0.01:
logging.warning(
"FreeKassa: callback price mismatch for user %s, value=%s, mode=%s, callback=%.2f, resolved=%.2f",
user_id,
months,
sale_mode,
callback_price_rub,
resolved_price_rub,
)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
return
price_rub = resolved_price_rub
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}" human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
payment_description = ( payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value) get_text("payment_description_traffic", traffic_gb=human_value)
if sale_mode == "traffic" if sale_mode == "traffic"
else get_text("payment_description_subscription", months=int(months)) else get_text("payment_description_subscription", months=int(months))
) )
currency_code = getattr(freekassa_service, "default_currency", None) or settings.DEFAULT_CURRENCY_SYMBOL or "RUB" currency_code = getattr(freekassa_service, "default_currency", None) or "RUB"
payment_record_payload = { payment_record_payload = {
"user_id": user_id, "user_id": user_id,
"amount": price_rub, "amount": price_rub,
"original_amount": None,
"discount_applied": None,
"currency": currency_code, "currency": currency_code,
"status": "pending_freekassa", "status": "pending_freekassa",
"description": payment_description, "description": payment_description,
"subscription_duration_months": int(months), "subscription_duration_months": int(months),
"provider": "freekassa", "provider": "freekassa",
"promo_code_id": None,
} }
try: try:
@@ -89,12 +132,12 @@ async def pay_fk_callback_handler(
) )
try: try:
await callback.message.edit_text(get_text("error_creating_payment_record")) await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
try: try:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
return return
success, response_data = await freekassa_service.create_order( success, response_data = await freekassa_service.create_order(
@@ -108,6 +151,8 @@ async def pay_fk_callback_handler(
extra_params={ extra_params={
"us_method": freekassa_service.payment_method_id, "us_method": freekassa_service.payment_method_id,
}, },
promo_code_service=promo_code_service,
session=session,
) )
if success: if success:
@@ -173,12 +218,12 @@ async def pay_fk_callback_handler(
), ),
disable_web_page_preview=False, disable_web_page_preview=False,
) )
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
try: try:
await callback.answer() await callback.answer()
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
return return
logging.error( logging.error(
@@ -206,9 +251,9 @@ async def pay_fk_callback_handler(
try: try:
await callback.message.edit_text(get_text("error_payment_gateway")) await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
try: try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True) await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
@@ -14,41 +14,17 @@ from db.dal import payment_dal
router = Router(name="user_subscription_payments_platega_router") router = Router(name="user_subscription_payments_platega_router")
@router.callback_query( from bot.handlers.user.subscription.payments_subscription import resolve_fiat_offer_price_for_user
F.data.startswith("pay_platega_sbp:")
| F.data.startswith("pay_platega_crypto:") @router.callback_query(F.data.startswith("pay_platega:"))
| F.data.startswith("pay_platega:")
)
async def pay_platega_callback_handler( async def pay_platega_callback_handler(
callback: types.CallbackQuery, callback: types.CallbackQuery,
settings: Settings, settings: Settings,
i18n_data: dict, i18n_data: dict,
platega_service: PlategaService, platega_service: PlategaService,
session: AsyncSession, session: AsyncSession,
promo_code_service=None,
): ):
callback_prefix, _, _ = (callback.data or "").partition(":")
if callback_prefix == "pay_platega_crypto":
platega_method_id = settings.PLATEGA_CRYPTO_METHOD
platega_variant = "crypto"
if not settings.PLATEGA_CRYPTO_ENABLED:
try:
await callback.answer()
except Exception:
pass
return
elif callback_prefix == "pay_platega_sbp":
platega_method_id = settings.platega_sbp_method_resolved
platega_variant = "sbp"
if not settings.PLATEGA_SBP_ENABLED:
try:
await callback.answer()
except Exception:
pass
return
else:
# Legacy callback (pre-split): keep working as SBP
platega_method_id = settings.platega_sbp_method_resolved
platega_variant = "sbp"
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
@@ -56,53 +32,95 @@ async def pay_platega_callback_handler(
if not i18n or not callback.message: if not i18n or not callback.message:
try: try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True) await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
return return
if not platega_service or not platega_service.configured: if not platega_service or not platega_service.configured:
logging.error("Platega service is not configured or unavailable.") logging.error("Platega service is not configured or unavailable.")
try: try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
try: try:
await callback.message.edit_text(get_text("payment_service_unavailable")) await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
return return
try: try:
_, data_payload = callback.data.split(":", 1) _, data_payload = callback.data.split(":", 1)
parts = data_payload.split(":") parts = data_payload.split(":")
months = float(parts[0]) months = float(parts[0])
price_rub = float(parts[1]) callback_price_rub = float(parts[1])
sale_mode = parts[2] if len(parts) > 2 else "subscription" sale_mode = parts[2] if len(parts) > 2 else "subscription"
except (ValueError, IndexError): except (ValueError, IndexError):
logging.error(f"Invalid pay_platega data in callback: {callback.data}") logging.error(f"Invalid pay_platega data in callback: {callback.data}")
try: try:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
return return
user_id = callback.from_user.id user_id = callback.from_user.id
resolved_price_rub = await resolve_fiat_offer_price_for_user(
session=session,
settings=settings,
user_id=user_id,
months=months,
sale_mode=sale_mode,
promo_code_service=promo_code_service,
)
if resolved_price_rub is None:
logging.warning(
"Platega: no server-side price for user %s, value=%s, mode=%s",
user_id,
months,
sale_mode,
)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
return
if abs(resolved_price_rub - callback_price_rub) > 0.01:
logging.warning(
"Platega: callback price mismatch for user %s, value=%s, mode=%s, callback=%.2f, resolved=%.2f",
user_id,
months,
sale_mode,
callback_price_rub,
resolved_price_rub,
)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
return
price_rub = resolved_price_rub
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}" human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
payment_description = ( payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value) get_text("payment_description_traffic", traffic_gb=human_value)
if sale_mode == "traffic" if sale_mode == "traffic"
else get_text("payment_description_subscription", months=int(months)) else get_text("payment_description_subscription", months=int(months))
) )
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" currency_code = "RUB"
# Price is already discounted at payments_subscription.py stage
# Service will handle discount metadata if needed
payment_record_payload = { payment_record_payload = {
"user_id": user_id, "user_id": user_id,
"amount": price_rub, "amount": price_rub,
"original_amount": None,
"discount_applied": None,
"currency": currency_code, "currency": currency_code,
"status": "pending_platega", "status": "pending_platega",
"description": payment_description, "description": payment_description,
"subscription_duration_months": int(months), "subscription_duration_months": int(months),
"provider": "platega", "provider": "platega",
"promo_code_id": None,
} }
try: try:
@@ -116,12 +134,12 @@ async def pay_platega_callback_handler(
) )
try: try:
await callback.message.edit_text(get_text("error_creating_payment_record")) await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
try: try:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
return return
payload_meta = json.dumps( payload_meta = json.dumps(
@@ -130,7 +148,6 @@ async def pay_platega_callback_handler(
"user_id": user_id, "user_id": user_id,
"months": months, "months": months,
"sale_mode": sale_mode, "sale_mode": sale_mode,
"platega_variant": platega_variant,
} }
) )
@@ -142,7 +159,8 @@ async def pay_platega_callback_handler(
currency=currency_code, currency=currency_code,
description=payment_description, description=payment_description,
payload=payload_meta, payload=payload_meta,
payment_method=platega_method_id, promo_code_service=promo_code_service,
session=session,
) )
if success: if success:
@@ -204,12 +222,12 @@ async def pay_platega_callback_handler(
), ),
disable_web_page_preview=False, disable_web_page_preview=False,
) )
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
try: try:
await callback.answer() await callback.answer()
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
return return
logging.error( logging.error(
@@ -231,9 +249,9 @@ async def pay_platega_callback_handler(
try: try:
await callback.message.edit_text(get_text("error_payment_gateway")) await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
try: try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True) await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
@@ -13,6 +13,8 @@ from db.dal import payment_dal
router = Router(name="user_subscription_payments_severpay_router") router = Router(name="user_subscription_payments_severpay_router")
from bot.handlers.user.subscription.payments_subscription import resolve_fiat_offer_price_for_user
@router.callback_query(F.data.startswith("pay_severpay:")) @router.callback_query(F.data.startswith("pay_severpay:"))
async def pay_severpay_callback_handler( async def pay_severpay_callback_handler(
callback: types.CallbackQuery, callback: types.CallbackQuery,
@@ -20,6 +22,7 @@ async def pay_severpay_callback_handler(
i18n_data: dict, i18n_data: dict,
severpay_service: SeverPayService, severpay_service: SeverPayService,
session: AsyncSession, session: AsyncSession,
promo_code_service=None,
): ):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -28,53 +31,95 @@ async def pay_severpay_callback_handler(
if not i18n or not callback.message: if not i18n or not callback.message:
try: try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True) await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
return return
if not severpay_service or not severpay_service.configured: if not severpay_service or not severpay_service.configured:
logging.error("SeverPay service is not configured or unavailable.") logging.error("SeverPay service is not configured or unavailable.")
try: try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
try: try:
await callback.message.edit_text(get_text("payment_service_unavailable")) await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
return return
try: try:
_, data_payload = callback.data.split(":", 1) _, data_payload = callback.data.split(":", 1)
parts = data_payload.split(":") parts = data_payload.split(":")
months = float(parts[0]) months = float(parts[0])
price_rub = float(parts[1]) callback_price_rub = float(parts[1])
sale_mode = parts[2] if len(parts) > 2 else "subscription" sale_mode = parts[2] if len(parts) > 2 else "subscription"
except (ValueError, IndexError): except (ValueError, IndexError):
logging.error(f"Invalid pay_severpay data in callback: {callback.data}") logging.error(f"Invalid pay_severpay data in callback: {callback.data}")
try: try:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
return return
user_id = callback.from_user.id user_id = callback.from_user.id
resolved_price_rub = await resolve_fiat_offer_price_for_user(
session=session,
settings=settings,
user_id=user_id,
months=months,
sale_mode=sale_mode,
promo_code_service=promo_code_service,
)
if resolved_price_rub is None:
logging.warning(
"SeverPay: no server-side price for user %s, value=%s, mode=%s",
user_id,
months,
sale_mode,
)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
return
if abs(resolved_price_rub - callback_price_rub) > 0.01:
logging.warning(
"SeverPay: callback price mismatch for user %s, value=%s, mode=%s, callback=%.2f, resolved=%.2f",
user_id,
months,
sale_mode,
callback_price_rub,
resolved_price_rub,
)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
return
price_rub = resolved_price_rub
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}" human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
payment_description = ( payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value) get_text("payment_description_traffic", traffic_gb=human_value)
if sale_mode == "traffic" if sale_mode == "traffic"
else get_text("payment_description_subscription", months=int(months)) else get_text("payment_description_subscription", months=int(months))
) )
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" currency_code = "RUB"
# Price is already discounted at payments_subscription.py stage
# Service will handle discount metadata if needed
payment_record_payload = { payment_record_payload = {
"user_id": user_id, "user_id": user_id,
"amount": price_rub, "amount": price_rub,
"original_amount": None,
"discount_applied": None,
"currency": currency_code, "currency": currency_code,
"status": "pending_severpay", "status": "pending_severpay",
"description": payment_description, "description": payment_description,
"subscription_duration_months": int(months), "subscription_duration_months": int(months),
"provider": "severpay", "provider": "severpay",
"promo_code_id": None,
} }
try: try:
@@ -88,12 +133,12 @@ async def pay_severpay_callback_handler(
) )
try: try:
await callback.message.edit_text(get_text("error_creating_payment_record")) await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
try: try:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
return return
success, response_data = await severpay_service.create_payment( success, response_data = await severpay_service.create_payment(
@@ -103,6 +148,8 @@ async def pay_severpay_callback_handler(
amount=price_rub, amount=price_rub,
currency=currency_code, currency=currency_code,
description=payment_description, description=payment_description,
promo_code_service=promo_code_service,
session=session,
) )
if success: if success:
@@ -164,12 +211,12 @@ async def pay_severpay_callback_handler(
), ),
disable_web_page_preview=False, disable_web_page_preview=False,
) )
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
try: try:
await callback.answer() await callback.answer()
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
return return
logging.error( logging.error(
@@ -191,9 +238,9 @@ async def pay_severpay_callback_handler(
try: try:
await callback.message.edit_text(get_text("error_payment_gateway")) await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
try: try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True) await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
@@ -19,6 +19,7 @@ async def pay_stars_callback_handler(
i18n_data: dict, i18n_data: dict,
session: AsyncSession, session: AsyncSession,
stars_service: StarsService, stars_service: StarsService,
promo_code_service=None,
): ):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -27,15 +28,15 @@ async def pay_stars_callback_handler(
if not i18n or not callback.message: if not i18n or not callback.message:
try: try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True) await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_stars.py: %s", exc)
return return
if not settings.STARS_ENABLED: if not settings.STARS_ENABLED:
try: try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_stars.py: %s", exc)
return return
try: try:
@@ -47,8 +48,8 @@ async def pay_stars_callback_handler(
except (ValueError, IndexError): except (ValueError, IndexError):
try: try:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_stars.py: %s", exc)
return return
user_id = callback.from_user.id user_id = callback.from_user.id
@@ -66,6 +67,7 @@ async def pay_stars_callback_handler(
stars_price=stars_price, stars_price=stars_price,
description=payment_description, description=payment_description,
sale_mode=sale_mode, sale_mode=sale_mode,
promo_code_service=promo_code_service,
) )
if payment_db_id: if payment_db_id:
@@ -76,34 +78,44 @@ async def pay_stars_callback_handler(
months=int(months), months=int(months),
traffic_gb=human_value, traffic_gb=human_value,
), ),
reply_markup=InlineKeyboardMarkup(inline_keyboard=[ reply_markup=InlineKeyboardMarkup(
[InlineKeyboardButton( inline_keyboard=[
text=get_text("back_to_payment_methods_button"), [
callback_data=f"subscribe_period:{human_value}", InlineKeyboardButton(
)] text=get_text("back_to_payment_methods_button"),
]), callback_data=f"subscribe_period:{human_value}",
)
],
[
InlineKeyboardButton(
text=get_text("cancel_button"),
callback_data="main_action:subscribe",
)
],
]
),
) )
except Exception as e_edit: except Exception as e_edit:
logging.warning(f"Stars payment: failed to show invoice info message ({e_edit})") logging.warning(f"Stars payment: failed to show invoice info message ({e_edit})")
try: try:
await callback.answer() await callback.answer()
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_stars.py: %s", exc)
return return
try: try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True) await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_stars.py: %s", exc)
@router.pre_checkout_query() @router.pre_checkout_query()
async def handle_pre_checkout_query(query: types.PreCheckoutQuery): async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
try: try:
await query.answer(ok=True) await query.answer(ok=True)
except Exception: except Exception as exc:
# Nothing else to do here; Telegram will show an error if not answered # Nothing else to do here; Telegram will show an error if not answered
pass logging.debug("Failed to answer pre_checkout_query in payments_stars: %s", exc)
@router.message(F.successful_payment) @router.message(F.successful_payment)
@@ -1,4 +1,5 @@
import logging import logging
import math
from typing import Optional from typing import Optional
from aiogram import F, Router, types from aiogram import F, Router, types
@@ -11,12 +12,43 @@ from config.settings import Settings
router = Router(name="user_subscription_payments_selection_router") router = Router(name="user_subscription_payments_selection_router")
async def resolve_fiat_offer_price_for_user(
session: AsyncSession,
settings: Settings,
user_id: int,
months: float,
sale_mode: str,
promo_code_service=None,
) -> Optional[float]:
"""Resolve offer price server-side to prevent callback payload tampering."""
price_source = (
getattr(settings, "traffic_packages", {}) or {}
if sale_mode == "traffic"
else (settings.subscription_options or {})
)
base_price = price_source.get(months)
if base_price is None:
return None
resolved_price = float(base_price)
if promo_code_service:
active_discount_info = await promo_code_service.get_user_active_discount(session, user_id)
if active_discount_info:
discount_pct, _ = active_discount_info
resolved_price, _ = promo_code_service.calculate_discounted_price(
resolved_price,
discount_pct,
)
return resolved_price
@router.callback_query(F.data.startswith("subscribe_period:")) @router.callback_query(F.data.startswith("subscribe_period:"))
async def select_subscription_period_callback_handler( async def select_subscription_period_callback_handler(
callback: types.CallbackQuery, callback: types.CallbackQuery,
settings: Settings, settings: Settings,
i18n_data: dict, i18n_data: dict,
session: AsyncSession, session: AsyncSession,
promo_code_service=None, # Injected from dispatcher
): ):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -25,8 +57,8 @@ async def select_subscription_period_callback_handler(
if not i18n or not callback.message: if not i18n or not callback.message:
try: try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True) await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_subscription.py: %s", exc)
return return
traffic_packages = getattr(settings, "traffic_packages", {}) or {} traffic_packages = getattr(settings, "traffic_packages", {}) or {}
@@ -38,8 +70,8 @@ async def select_subscription_period_callback_handler(
logging.error(f"Invalid subscription period in callback_data: {callback.data}") logging.error(f"Invalid subscription period in callback_data: {callback.data}")
try: try:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_subscription.py: %s", exc)
return return
price_source = traffic_packages if traffic_mode else settings.subscription_options price_source = traffic_packages if traffic_mode else settings.subscription_options
@@ -47,7 +79,49 @@ async def select_subscription_period_callback_handler(
price_rub = price_source.get(months) price_rub = price_source.get(months)
stars_price = stars_price_source.get(months) stars_price = stars_price_source.get(months)
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL currency_symbol_val = "RUB"
# Check for active discount and apply if exists
discount_text = ""
if promo_code_service and (price_rub is not None or stars_price is not None):
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
if price_rub is not None:
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,
currency_symbol=currency_symbol_val,
)
if stars_price is not None:
original_stars_price = stars_price
discounted_stars_price, _ = promo_code_service.calculate_discounted_price(
float(stars_price), discount_pct
)
discounted_stars_price = math.ceil(discounted_stars_price)
stars_price = discounted_stars_price
if not discount_text:
discount_amt = original_stars_price - discounted_stars_price
discount_text = get_text(
"active_discount_notice",
code=promo_code,
discount_pct=discount_pct,
original_price=original_stars_price,
discounted_price=discounted_stars_price,
discount_amount=discount_amt,
currency_symbol="",
)
if price_rub is None: if price_rub is None:
if traffic_mode and not price_source and stars_price is not None: if traffic_mode and not price_source and stars_price is not None:
@@ -67,8 +141,8 @@ async def select_subscription_period_callback_handler(
) )
try: try:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_subscription.py: %s", exc)
return return
price_rub = 0.0 price_rub = 0.0
currency_symbol_val = "" currency_symbol_val = ""
@@ -78,11 +152,14 @@ async def select_subscription_period_callback_handler(
) )
try: try:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_subscription.py: %s", exc)
return return
text_content = get_text("choose_payment_method_traffic") if traffic_mode else get_text("choose_payment_method") 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( reply_markup = get_payment_method_keyboard(
months, months,
price_rub, price_rub,
@@ -103,5 +180,5 @@ async def select_subscription_period_callback_handler(
await callback.message.answer(text_content, reply_markup=reply_markup) await callback.message.answer(text_content, reply_markup=reply_markup)
try: try:
await callback.answer() await callback.answer()
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_subscription.py: %s", exc)
@@ -13,11 +13,13 @@ from bot.keyboards.inline.user_keyboards import (
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from bot.services.yookassa_service import YooKassaService from bot.services.yookassa_service import YooKassaService
from config.settings import Settings from config.settings import Settings
from db.dal import payment_dal, user_billing_dal from db.dal import payment_dal, user_billing_dal, active_discount_dal
router = Router(name="user_subscription_payments_yookassa_router") router = Router(name="user_subscription_payments_yookassa_router")
from bot.handlers.user.subscription.payments_subscription import resolve_fiat_offer_price_for_user
def _format_value(val: float) -> str: def _format_value(val: float) -> str:
return str(int(val)) if float(val).is_integer() else f"{val:g}" return str(int(val)) if float(val).is_integer() else f"{val:g}"
@@ -60,6 +62,7 @@ async def _initiate_yk_payment(
settings: Settings, settings: Settings,
session: AsyncSession, session: AsyncSession,
yookassa_service: YooKassaService, yookassa_service: YooKassaService,
promo_code_service, # NEW: Added promo_code_service
i18n: Optional[JsonI18n], i18n: Optional[JsonI18n],
current_lang: str, current_lang: str,
get_text, get_text,
@@ -77,6 +80,46 @@ async def _initiate_yk_payment(
if not callback.message: if not callback.message:
return False 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
active_promo_code_id = active_discount.promo_code_id
denominator = 1 - discount_pct / 100
if denominator <= 0:
price_source = (
getattr(settings, "traffic_packages", {}) or {}
if sale_mode == "traffic"
else (settings.subscription_options or {})
)
fallback_original = price_source.get(months)
if fallback_original is not None:
original_price = fallback_original
discount_amount = original_price - price_rub
logging.info(
f"Recording {discount_pct}% discount for YooKassa payment: "
f"original {original_price:.2f} -> final {price_rub}"
)
else:
logging.warning(
"YooKassa discount %s%% has invalid denominator and no fallback price for months=%s.",
discount_pct,
months,
)
else:
original_price = price_rub / denominator
discount_amount = original_price - price_rub
logging.info(
f"Recording {discount_pct}% discount for YooKassa payment: "
f"original {original_price:.2f} -> final {price_rub}"
)
payment_description = ( payment_description = (
get_text("payment_description_traffic", traffic_gb=_format_value(months)) get_text("payment_description_traffic", traffic_gb=_format_value(months))
if sale_mode == "traffic" if sale_mode == "traffic"
@@ -84,11 +127,14 @@ async def _initiate_yk_payment(
) )
payment_record_data = { payment_record_data = {
"user_id": user_id, "user_id": user_id,
"amount": price_rub, "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, "currency": currency_code_for_yk,
"status": "pending_yookassa", "status": "pending_yookassa",
"description": payment_description, "description": payment_description,
"subscription_duration_months": int(months), "subscription_duration_months": int(months),
"promo_code_id": active_promo_code_id, # NEW: Link to promo code
} }
db_payment_record = None db_payment_record = None
@@ -106,15 +152,15 @@ async def _initiate_yk_payment(
) )
try: try:
await callback.message.edit_text(get_text("error_creating_payment_record")) await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return False return False
if not db_payment_record: if not db_payment_record:
try: try:
await callback.message.edit_text(get_text("error_creating_payment_record")) await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return False return False
yookassa_metadata = { yookassa_metadata = {
@@ -178,8 +224,8 @@ async def _initiate_yk_payment(
card_network=display_network, card_network=display_network,
set_default=save_payment_method, set_default=save_payment_method,
) )
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
await session.commit() await session.commit()
except Exception: except Exception:
await session.rollback() await session.rollback()
@@ -207,8 +253,8 @@ async def _initiate_yk_payment(
) )
try: try:
await callback.message.edit_text(get_text("error_payment_gateway_link_failed")) await callback.message.edit_text(get_text("error_payment_gateway_link_failed"))
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return False return False
try: try:
@@ -247,8 +293,8 @@ async def _initiate_yk_payment(
), ),
disable_web_page_preview=False, disable_web_page_preview=False,
) )
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return True return True
if payment_response_yk and payment_method_id: if payment_response_yk and payment_method_id:
@@ -276,8 +322,8 @@ async def _initiate_yk_payment(
) )
try: try:
await callback.message.edit_text(get_text("error_payment_gateway")) await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return False return False
message_text = get_text("yookassa_autopay_charge_initiated") message_text = get_text("yookassa_autopay_charge_initiated")
@@ -293,8 +339,8 @@ async def _initiate_yk_payment(
message_text, message_text,
reply_markup=get_back_to_main_menu_markup(current_lang, i18n), reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
) )
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return True return True
try: try:
@@ -313,13 +359,13 @@ async def _initiate_yk_payment(
) )
try: try:
await callback.message.edit_text(get_text("error_payment_gateway")) await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return False return False
@router.callback_query(F.data.startswith("pay_yk:")) @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): 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) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
@@ -327,8 +373,8 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
if not i18n or not callback.message: if not i18n or not callback.message:
try: try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True) await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return return
if not yookassa_service or not yookassa_service.configured: if not yookassa_service or not yookassa_service.configured:
@@ -337,8 +383,8 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
await target_msg_edit.edit_text(get_text("payment_service_unavailable")) await target_msg_edit.edit_text(get_text("payment_service_unavailable"))
try: try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return return
try: try:
@@ -347,8 +393,8 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
logging.error(f"Invalid pay_yk data in callback: {callback.data}") logging.error(f"Invalid pay_yk data in callback: {callback.data}")
try: try:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return return
parsed = _parse_offer_payload(data_payload) parsed = _parse_offer_payload(data_payload)
@@ -356,12 +402,50 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
logging.error(f"Invalid pay_yk payload structure: {callback.data}") logging.error(f"Invalid pay_yk payload structure: {callback.data}")
try: try:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return return
months, price_rub, sale_mode = parsed months, callback_price_rub, sale_mode = parsed
user_id = callback.from_user.id user_id = callback.from_user.id
resolved_price_rub = await resolve_fiat_offer_price_for_user(
session=session,
settings=settings,
user_id=user_id,
months=months,
sale_mode=sale_mode,
promo_code_service=promo_code_service,
)
if resolved_price_rub is None:
logging.warning(
"YooKassa: no server-side price for user %s, value=%s, mode=%s",
user_id,
months,
sale_mode,
)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return
if abs(resolved_price_rub - callback_price_rub) > 0.01:
logging.warning(
"YooKassa: callback price mismatch for user %s, value=%s, mode=%s, callback=%.2f, resolved=%.2f",
user_id,
months,
sale_mode,
callback_price_rub,
resolved_price_rub,
)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return
price_rub = resolved_price_rub
currency_code_for_yk = "RUB" currency_code_for_yk = "RUB"
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode) autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
autopay_require_binding = bool( autopay_require_binding = bool(
@@ -404,12 +488,12 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
sale_mode=sale_mode, sale_mode=sale_mode,
), ),
) )
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
try: try:
await callback.answer() await callback.answer()
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return return
await _initiate_yk_payment( await _initiate_yk_payment(
@@ -417,6 +501,7 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
settings=settings, settings=settings,
session=session, session=session,
yookassa_service=yookassa_service, yookassa_service=yookassa_service,
promo_code_service=promo_code_service,
i18n=i18n, i18n=i18n,
current_lang=current_lang, current_lang=current_lang,
get_text=get_text, get_text=get_text,
@@ -430,12 +515,12 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
) )
try: try:
await callback.answer() await callback.answer()
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
@router.callback_query(F.data.startswith("pay_yk_new:")) @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): 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) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
@@ -443,20 +528,20 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti
if not i18n or not callback.message: if not i18n or not callback.message:
try: try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True) await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return return
if not yookassa_service or not yookassa_service.configured: if not yookassa_service or not yookassa_service.configured:
logging.error("YooKassa service unavailable for pay_yk_new.") logging.error("YooKassa service unavailable for pay_yk_new.")
try: try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
try: try:
await callback.message.edit_text(get_text("payment_service_unavailable")) await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return return
try: try:
@@ -465,8 +550,8 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti
logging.error(f"Invalid pay_yk_new data in callback: {callback.data}") logging.error(f"Invalid pay_yk_new data in callback: {callback.data}")
try: try:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return return
parsed = _parse_offer_payload(data_payload) parsed = _parse_offer_payload(data_payload)
@@ -474,12 +559,49 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti
logging.error(f"Invalid pay_yk_new payload structure: {callback.data}") logging.error(f"Invalid pay_yk_new payload structure: {callback.data}")
try: try:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return return
months, price_rub, sale_mode = parsed months, callback_price_rub, sale_mode = parsed
user_id = callback.from_user.id user_id = callback.from_user.id
resolved_price_rub = await resolve_fiat_offer_price_for_user(
session=session,
settings=settings,
user_id=user_id,
months=months,
sale_mode=sale_mode,
promo_code_service=promo_code_service,
)
if resolved_price_rub is None:
logging.warning(
"YooKassa: no server-side price for new-card flow, user=%s, value=%s, mode=%s",
user_id,
months,
sale_mode,
)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return
if abs(resolved_price_rub - callback_price_rub) > 0.01:
logging.warning(
"YooKassa: callback price mismatch in new-card flow, user=%s, value=%s, mode=%s, callback=%.2f, resolved=%.2f",
user_id,
months,
sale_mode,
callback_price_rub,
resolved_price_rub,
)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return
price_rub = resolved_price_rub
currency_code_for_yk = "RUB" currency_code_for_yk = "RUB"
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode) autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
autopay_require_binding = bool( autopay_require_binding = bool(
@@ -491,6 +613,7 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti
settings=settings, settings=settings,
session=session, session=session,
yookassa_service=yookassa_service, yookassa_service=yookassa_service,
promo_code_service=promo_code_service,
i18n=i18n, i18n=i18n,
current_lang=current_lang, current_lang=current_lang,
get_text=get_text, get_text=get_text,
@@ -504,12 +627,12 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti
) )
try: try:
await callback.answer() await callback.answer()
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
@router.callback_query(F.data.startswith("pay_yk_saved_list:")) @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): async def pay_yk_saved_list_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) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
@@ -517,8 +640,8 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set
if not i18n or not callback.message: if not i18n or not callback.message:
try: try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True) await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return return
try: try:
@@ -527,8 +650,8 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set
logging.error(f"Invalid pay_yk_saved_list data: {callback.data}") logging.error(f"Invalid pay_yk_saved_list data: {callback.data}")
try: try:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return return
parts = data_payload.split(":") parts = data_payload.split(":")
@@ -536,32 +659,69 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set
logging.error(f"pay_yk_saved_list payload missing components: {callback.data}") logging.error(f"pay_yk_saved_list payload missing components: {callback.data}")
try: try:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return return
try: try:
months = float(parts[0]) months = float(parts[0])
price_rub = float(parts[1]) callback_price_rub = float(parts[1])
page = int(parts[2]) if len(parts) > 2 else 0 page = int(parts[2]) if len(parts) > 2 else 0
sale_mode = parts[3] if len(parts) > 3 else "subscription" sale_mode = parts[3] if len(parts) > 3 else "subscription"
except (ValueError, IndexError): except (ValueError, IndexError):
logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}") logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}")
try: try:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return return
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode) autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
if not autopay_enabled: if not autopay_enabled:
try: try:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return return
user_id = callback.from_user.id user_id = callback.from_user.id
resolved_price_rub = await resolve_fiat_offer_price_for_user(
session=session,
settings=settings,
user_id=user_id,
months=months,
sale_mode=sale_mode,
promo_code_service=promo_code_service,
)
if resolved_price_rub is None:
logging.warning(
"YooKassa: no server-side price for saved-list flow, user=%s, value=%s, mode=%s",
user_id,
months,
sale_mode,
)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return
if abs(resolved_price_rub - callback_price_rub) > 0.01:
logging.warning(
"YooKassa: callback price mismatch in saved-list flow, user=%s, value=%s, mode=%s, callback=%.2f, resolved=%.2f",
user_id,
months,
sale_mode,
callback_price_rub,
resolved_price_rub,
)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return
price_rub = resolved_price_rub
try: try:
saved_methods = await user_billing_dal.list_user_payment_methods( saved_methods = await user_billing_dal.list_user_payment_methods(
session, user_id, provider="yookassa" session, user_id, provider="yookassa"
@@ -597,12 +757,12 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set
sale_mode=sale_mode, sale_mode=sale_mode,
), ),
) )
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
try: try:
await callback.answer() await callback.answer()
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return return
cards: List[Tuple[str, str]] = [] cards: List[Tuple[str, str]] = []
@@ -644,16 +804,16 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set
sale_mode=sale_mode, sale_mode=sale_mode,
), ),
) )
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
try: try:
await callback.answer() await callback.answer()
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
@router.callback_query(F.data.startswith("pay_yk_use_saved:")) @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): 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) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
@@ -661,20 +821,20 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett
if not i18n or not callback.message: if not i18n or not callback.message:
try: try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True) await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return return
if not yookassa_service or not yookassa_service.configured: if not yookassa_service or not yookassa_service.configured:
logging.error("YooKassa service unavailable for pay_yk_use_saved.") logging.error("YooKassa service unavailable for pay_yk_use_saved.")
try: try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
try: try:
await callback.message.edit_text(get_text("payment_service_unavailable")) await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return return
try: try:
@@ -683,8 +843,8 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett
logging.error(f"Invalid pay_yk_use_saved data: {callback.data}") logging.error(f"Invalid pay_yk_use_saved data: {callback.data}")
try: try:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return return
parts = data_payload.split(":") parts = data_payload.split(":")
@@ -692,32 +852,67 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett
logging.error(f"pay_yk_use_saved payload missing components: {callback.data}") logging.error(f"pay_yk_use_saved payload missing components: {callback.data}")
try: try:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return return
try: try:
months = float(parts[0]) months = float(parts[0])
price_rub = float(parts[1]) callback_price_rub = float(parts[1])
sale_mode = parts[3] if len(parts) > 3 else "subscription" sale_mode = parts[3] if len(parts) > 3 else "subscription"
except (ValueError, IndexError): except (ValueError, IndexError):
logging.error(f"pay_yk_use_saved months/price parsing error: {callback.data}") logging.error(f"pay_yk_use_saved months/price parsing error: {callback.data}")
try: try:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return return
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode) autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
if not autopay_enabled: if not autopay_enabled:
try: try:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return return
method_identifier = parts[2] method_identifier = parts[2]
user_id = callback.from_user.id user_id = callback.from_user.id
resolved_price_rub = await resolve_fiat_offer_price_for_user(
session=session,
settings=settings,
user_id=user_id,
months=months,
sale_mode=sale_mode,
promo_code_service=promo_code_service,
)
if resolved_price_rub is None:
logging.warning(
"YooKassa: no server-side price for use-saved flow, user=%s, value=%s, mode=%s",
user_id,
months,
sale_mode,
)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return
if abs(resolved_price_rub - callback_price_rub) > 0.01:
logging.warning(
"YooKassa: callback price mismatch in use-saved flow, user=%s, value=%s, mode=%s, callback=%.2f, resolved=%.2f",
user_id,
months,
sale_mode,
callback_price_rub,
resolved_price_rub,
)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception as exc:
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return
try: try:
saved_methods = await user_billing_dal.list_user_payment_methods( saved_methods = await user_billing_dal.list_user_payment_methods(
@@ -741,10 +936,11 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett
logging.warning(f"Selected payment method not found for user {user_id}: {method_identifier}") logging.warning(f"Selected payment method not found for user {user_id}: {method_identifier}")
try: try:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
return return
price_rub = resolved_price_rub
currency_code_for_yk = "RUB" currency_code_for_yk = "RUB"
await _initiate_yk_payment( await _initiate_yk_payment(
@@ -752,6 +948,7 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett
settings=settings, settings=settings,
session=session, session=session,
yookassa_service=yookassa_service, yookassa_service=yookassa_service,
promo_code_service=promo_code_service,
i18n=i18n, i18n=i18n,
current_lang=current_lang, current_lang=current_lang,
get_text=get_text, get_text=get_text,
@@ -767,5 +964,5 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett
) )
try: try:
await callback.answer() await callback.answer()
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
+20 -20
View File
@@ -35,8 +35,8 @@ async def request_trial_confirmation_handler(
if not i18n or not callback.message: if not i18n or not callback.message:
try: try:
await callback.answer(_("error_occurred_try_again"), show_alert=True) await callback.answer(_("error_occurred_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
return return
show_trial_btn_in_menu_if_fail = False show_trial_btn_in_menu_if_fail = False
@@ -53,8 +53,8 @@ async def request_trial_confirmation_handler(
) )
try: try:
await callback.answer() await callback.answer()
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
return return
if await subscription_service.has_had_any_subscription(session, user_id): if await subscription_service.has_had_any_subscription(session, user_id):
@@ -66,8 +66,8 @@ async def request_trial_confirmation_handler(
) )
try: try:
await callback.answer() await callback.answer()
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
return return
# Directly activate trial without confirmation # Directly activate trial without confirmation
@@ -84,8 +84,8 @@ async def request_trial_confirmation_handler(
if activation_result and activation_result.get("activated"): if activation_result and activation_result.get("activated"):
try: try:
await callback.answer(_("trial_activated_alert"), show_alert=True) await callback.answer(_("trial_activated_alert"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
end_date_obj = activation_result.get("end_date") end_date_obj = activation_result.get("end_date")
config_link_display_for_trial, connect_button_url_for_trial = await prepare_config_links( config_link_display_for_trial, connect_button_url_for_trial = await prepare_config_links(
@@ -134,8 +134,8 @@ async def request_trial_confirmation_handler(
final_message_text_in_chat = _(message_key_from_service) final_message_text_in_chat = _(message_key_from_service)
try: try:
await callback.answer(final_message_text_in_chat, show_alert=True) await callback.answer(final_message_text_in_chat, show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
if ( if (
settings.TRIAL_ENABLED settings.TRIAL_ENABLED
and not await subscription_service.has_had_any_subscription( and not await subscription_service.has_had_any_subscription(
@@ -197,15 +197,15 @@ async def confirm_activate_trial_handler(
if not i18n or not callback.message: if not i18n or not callback.message:
try: try:
await callback.answer(_("error_occurred_try_again"), show_alert=True) await callback.answer(_("error_occurred_try_again"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
return return
if not settings.TRIAL_ENABLED: if not settings.TRIAL_ENABLED:
try: try:
await callback.answer(_("trial_feature_disabled"), show_alert=True) await callback.answer(_("trial_feature_disabled"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
await send_main_menu( await send_main_menu(
callback, settings, i18n_data, subscription_service, session, is_edit=True callback, settings, i18n_data, subscription_service, session, is_edit=True
@@ -216,8 +216,8 @@ async def confirm_activate_trial_handler(
await callback.answer( await callback.answer(
_("trial_already_had_subscription_or_trial"), show_alert=True _("trial_already_had_subscription_or_trial"), show_alert=True
) )
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
await send_main_menu( await send_main_menu(
callback, settings, i18n_data, subscription_service, session, is_edit=True callback, settings, i18n_data, subscription_service, session, is_edit=True
) )
@@ -236,8 +236,8 @@ async def confirm_activate_trial_handler(
if activation_result and activation_result.get("activated"): if activation_result and activation_result.get("activated"):
try: try:
await callback.answer(_("trial_activated_alert"), show_alert=True) await callback.answer(_("trial_activated_alert"), show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
end_date_obj = activation_result.get("end_date") end_date_obj = activation_result.get("end_date")
config_link_display_for_trial, connect_button_url_for_trial = await prepare_config_links( config_link_display_for_trial, connect_button_url_for_trial = await prepare_config_links(
@@ -274,8 +274,8 @@ async def confirm_activate_trial_handler(
final_message_text_in_chat = _(message_key_from_service) final_message_text_in_chat = _(message_key_from_service)
try: try:
await callback.answer(final_message_text_in_chat, show_alert=True) await callback.answer(final_message_text_in_chat, show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
if ( if (
settings.TRIAL_ENABLED settings.TRIAL_ENABLED
and not await subscription_service.has_had_any_subscription( and not await subscription_service.has_had_any_subscription(
+1 -25
View File
@@ -64,12 +64,10 @@ def get_user_management_keyboard(i18n_instance, lang: str) -> InlineKeyboardMark
callback_data="admin_action:users_search_prompt") callback_data="admin_action:users_search_prompt")
builder.button(text=_(key="admin_ban_management_section"), builder.button(text=_(key="admin_ban_management_section"),
callback_data="admin_section:ban_management") callback_data="admin_section:ban_management")
builder.button(text=_(key="admin_user_ratings_button"),
callback_data="admin_action:user_ratings")
builder.button(text=_(key="back_to_admin_panel_button"), builder.button(text=_(key="back_to_admin_panel_button"),
callback_data="admin_action:main") callback_data="admin_action:main")
builder.adjust(2, 2, 1) builder.adjust(2, 1, 1)
return builder.as_markup() return builder.as_markup()
@@ -268,8 +266,6 @@ def get_banned_users_keyboard(banned_users: List[User], current_page: int,
user_display_parts.append(user_row.first_name) user_display_parts.append(user_row.first_name)
if user_row.username: if user_row.username:
user_display_parts.append(f"(@{user_row.username})") user_display_parts.append(f"(@{user_row.username})")
elif user_row.email:
user_display_parts.append(f"({user_row.email})")
if not user_display_parts: if not user_display_parts:
user_display_parts.append(f"ID: {user_row.user_id}") user_display_parts.append(f"ID: {user_row.user_id}")
@@ -323,8 +319,6 @@ def get_users_list_keyboard(users: List[User], current_page: int,
user_display_parts = [] user_display_parts = []
if user.username: if user.username:
user_display_parts.append(f"@{user.username}") user_display_parts.append(f"@{user.username}")
elif user.email:
user_display_parts.append(user.email)
user_display_parts.append(f"ID: {user.user_id}") user_display_parts.append(f"ID: {user.user_id}")
if user.first_name: if user.first_name:
user_display_parts.append(f"- {user.first_name}") user_display_parts.append(f"- {user.first_name}")
@@ -466,21 +460,3 @@ def get_back_to_admin_panel_keyboard(lang: str,
builder.button(text=_(key="back_to_admin_panel_button"), builder.button(text=_(key="back_to_admin_panel_button"),
callback_data="admin_action:main") callback_data="admin_action:main")
return builder.as_markup() return builder.as_markup()
def get_back_to_stats_monitoring_keyboard(lang: str,
i18n_instance) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.button(text=_(key="back_to_stats_monitoring_button"),
callback_data="admin_section:stats_monitoring")
return builder.as_markup()
def get_back_to_user_management_keyboard(lang: str,
i18n_instance) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.button(text=_(key="back_to_user_management_button"),
callback_data="admin_section:user_management")
return builder.as_markup()
+18 -90
View File
@@ -13,50 +13,11 @@ def get_main_menu_inline_keyboard(
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
if settings.SUBSCRIPTION_MINI_APP_URL:
builder.row(
InlineKeyboardButton(
text=_(key="menu_personal_account_button"),
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
)
)
else:
builder.row(
InlineKeyboardButton(
text=_(key="menu_personal_account_button"),
callback_data="main_action:my_subscription",
)
)
if settings.SUPPORT_LINK:
builder.row(
InlineKeyboardButton(text=_(key="menu_support_button"),
url=settings.SUPPORT_LINK))
return builder.as_markup()
def get_bot_interface_inline_keyboard(
lang: str,
i18n_instance,
settings: Settings,
show_trial_button: bool = False) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
if show_trial_button and settings.TRIAL_ENABLED: if show_trial_button and settings.TRIAL_ENABLED:
builder.row( builder.row(
InlineKeyboardButton(text=_(key="menu_activate_trial_button"), InlineKeyboardButton(text=_(key="menu_activate_trial_button"),
callback_data="main_action:request_trial")) callback_data="main_action:request_trial"))
if settings.SUBSCRIPTION_MINI_APP_URL:
builder.row(
InlineKeyboardButton(
text=_(key="menu_personal_account_button"),
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
)
)
builder.row( builder.row(
InlineKeyboardButton(text=_(key="menu_subscribe_inline"), InlineKeyboardButton(text=_(key="menu_subscribe_inline"),
callback_data="main_action:subscribe")) callback_data="main_action:subscribe"))
@@ -67,14 +28,16 @@ def get_bot_interface_inline_keyboard(
) )
) )
referral_button = InlineKeyboardButton(
text=_(key="menu_referral_inline"),
callback_data="main_action:referral")
promo_button = InlineKeyboardButton( promo_button = InlineKeyboardButton(
text=_(key="menu_apply_promo_button"), text=_(key="menu_apply_promo_button"),
callback_data="main_action:apply_promo") callback_data="main_action:apply_promo")
builder.row(referral_button) if settings.REFERRAL_ENABLED:
builder.row(promo_button) referral_button = InlineKeyboardButton(
text=_(key="menu_referral_inline"),
callback_data="main_action:referral")
builder.row(referral_button, promo_button)
else:
builder.row(promo_button)
language_button = InlineKeyboardButton( language_button = InlineKeyboardButton(
text=_(key="menu_language_settings_inline"), text=_(key="menu_language_settings_inline"),
@@ -95,36 +58,14 @@ def get_bot_interface_inline_keyboard(
InlineKeyboardButton(text=_(key="menu_support_button"), InlineKeyboardButton(text=_(key="menu_support_button"),
url=settings.SUPPORT_LINK)) url=settings.SUPPORT_LINK))
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL if settings.TERMS_OF_SERVICE_URL:
if settings.PRIVACY_POLICY_URL or user_agreement_url:
builder.row( builder.row(
InlineKeyboardButton(text=_(key="menu_info_button"), InlineKeyboardButton(text=_(key="menu_terms_button"),
callback_data="main_action:info")) url=settings.TERMS_OF_SERVICE_URL))
return builder.as_markup() return builder.as_markup()
def get_information_links_keyboard(
lang: str,
i18n_instance,
privacy_policy_url: Optional[str],
user_agreement_url: Optional[str]) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
if privacy_policy_url:
builder.row(
InlineKeyboardButton(text=_(key="privacy_policy_button"),
url=privacy_policy_url))
if user_agreement_url:
builder.row(
InlineKeyboardButton(text=_(key="user_agreement_button"),
url=user_agreement_url))
builder.row(
InlineKeyboardButton(text=_(key="back_to_main_menu_button"),
callback_data="main_action:back_to_main"))
return builder.as_markup()
def get_language_selection_keyboard(i18n_instance, def get_language_selection_keyboard(i18n_instance,
current_lang: str) -> InlineKeyboardMarkup: current_lang: str) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(current_lang, key, **kwargs _ = lambda key, **kwargs: i18n_instance.gettext(current_lang, key, **kwargs
@@ -195,14 +136,6 @@ def get_payment_method_keyboard(months: int, price: float,
return str(int(val)) if float(val).is_integer() else f"{val:g}" return str(int(val)) if float(val).is_integer() else f"{val:g}"
value_str = _format_value(months) value_str = _format_value(months)
mode_suffix = f":{sale_mode}" mode_suffix = f":{sale_mode}"
import logging as _kbd_logging
_kbd_logging.info(
"payment_method_keyboard build: order=%s | platega_enabled=%s sbp=%s crypto=%s",
settings.payment_methods_order,
settings.PLATEGA_ENABLED,
settings.PLATEGA_SBP_ENABLED,
settings.PLATEGA_CRYPTO_ENABLED,
)
for method in settings.payment_methods_order: for method in settings.payment_methods_order:
if method == "severpay" and getattr(settings, "SEVERPAY_ENABLED", False): if method == "severpay" and getattr(settings, "SEVERPAY_ENABLED", False):
builder.button( builder.button(
@@ -214,15 +147,10 @@ def get_payment_method_keyboard(months: int, price: float,
text=_("pay_with_sbp_button"), text=_("pay_with_sbp_button"),
callback_data=f"pay_fk:{value_str}:{price}{mode_suffix}", callback_data=f"pay_fk:{value_str}:{price}{mode_suffix}",
) )
elif method == "platega_sbp" and settings.PLATEGA_ENABLED and settings.PLATEGA_SBP_ENABLED: elif method == "platega" and settings.PLATEGA_ENABLED:
builder.button( builder.button(
text=_("pay_with_platega_sbp_button"), text=_("pay_with_platega_button"),
callback_data=f"pay_platega_sbp:{value_str}:{price}{mode_suffix}", callback_data=f"pay_platega:{value_str}:{price}{mode_suffix}",
)
elif method == "platega_crypto" and settings.PLATEGA_ENABLED and settings.PLATEGA_CRYPTO_ENABLED:
builder.button(
text=_("pay_with_platega_crypto_button"),
callback_data=f"pay_platega_crypto:{value_str}:{price}{mode_suffix}",
) )
elif method == "yookassa" and settings.YOOKASSA_ENABLED: elif method == "yookassa" and settings.YOOKASSA_ENABLED:
builder.button( builder.button(
@@ -457,17 +385,17 @@ def get_connect_and_main_keyboard(
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
button_target = connect_button_url or config_link button_target = connect_button_url or config_link
if button_target: if settings.SUBSCRIPTION_MINI_APP_URL:
builder.row(
InlineKeyboardButton(text=_("connect_button"), url=button_target)
)
elif settings.SUBSCRIPTION_MINI_APP_URL:
builder.row( builder.row(
InlineKeyboardButton( InlineKeyboardButton(
text=_("connect_button"), text=_("connect_button"),
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL), web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
) )
) )
elif button_target:
builder.row(
InlineKeyboardButton(text=_("connect_button"), url=button_target)
)
else: else:
builder.row( builder.row(
InlineKeyboardButton( InlineKeyboardButton(
+53 -57
View File
@@ -40,12 +40,6 @@ from bot.handlers.admin.sync_admin import perform_sync
from bot.utils.message_queue import init_queue_manager from bot.utils.message_queue import init_queue_manager
def redact_token(value: str, token: Optional[str]) -> str:
if not value or not token:
return value
return value.replace(token, "***")
async def register_all_routers(dp: Dispatcher, settings: Settings): async def register_all_routers(dp: Dispatcher, settings: Settings):
dp.include_router(build_root_router(settings)) dp.include_router(build_root_router(settings))
logging.info("All application routers registered.") logging.info("All application routers registered.")
@@ -64,49 +58,49 @@ async def on_startup_configured(dispatcher: Dispatcher):
telegram_webhook_url_to_set = settings.WEBHOOK_BASE_URL telegram_webhook_url_to_set = settings.WEBHOOK_BASE_URL
if telegram_webhook_url_to_set: if telegram_webhook_url_to_set:
full_telegram_webhook_url = ( full_telegram_webhook_url = settings.telegram_full_webhook_url
f"{str(telegram_webhook_url_to_set).rstrip('/')}{settings.telegram_webhook_path}" if not full_telegram_webhook_url:
) logging.error(
"STARTUP: Telegram webhook URL could not be built (WEBHOOK_BASE_URL missing)."
)
raise SystemExit("WEBHOOK_BASE_URL is required. Polling mode is disabled.")
logging.info( logging.info(
"STARTUP: Attempting to set Telegram webhook to: %s", "STARTUP: Attempting to set Telegram webhook (path=%s)",
redact_token(full_telegram_webhook_url, settings.BOT_TOKEN), settings.telegram_webhook_path,
) )
try: try:
current_webhook_info = await bot.get_webhook_info() current_webhook_info = await bot.get_webhook_info()
logging.info( if current_webhook_info.url:
f"STARTUP: Current Telegram webhook info BEFORE setting: {current_webhook_info.model_dump_json(exclude_none=True, indent=2)}" logging.info("STARTUP: Telegram webhook already set (non-empty URL).")
) else:
logging.info("STARTUP: Telegram webhook currently empty (will set).")
telegram_webhook_secret = (settings.TELEGRAM_WEBHOOK_SECRET or "").strip() or None
set_success = await bot.set_webhook( set_success = await bot.set_webhook(
url=full_telegram_webhook_url, url=full_telegram_webhook_url,
secret_token=settings.WEBHOOK_SECRET_TOKEN,
drop_pending_updates=True, drop_pending_updates=True,
allowed_updates=dispatcher.resolve_used_update_types(), allowed_updates=dispatcher.resolve_used_update_types(),
secret_token=telegram_webhook_secret,
) )
if set_success: if set_success:
logging.info( logging.info("STARTUP: bot.set_webhook returned SUCCESS (True).")
"STARTUP: bot.set_webhook to %s returned SUCCESS (True).",
redact_token(full_telegram_webhook_url, settings.BOT_TOKEN),
)
else: else:
logging.error( logging.error("STARTUP: bot.set_webhook returned FAILURE (False).")
"STARTUP: bot.set_webhook to %s returned FAILURE (False).",
redact_token(full_telegram_webhook_url, settings.BOT_TOKEN),
)
new_webhook_info = await bot.get_webhook_info() new_webhook_info = await bot.get_webhook_info()
logging.info(
f"STARTUP: Telegram Webhook info AFTER setting: {new_webhook_info.model_dump_json(exclude_none=True, indent=2)}"
)
if not new_webhook_info.url: if not new_webhook_info.url:
logging.error( logging.error(
"STARTUP: CRITICAL - Telegram Webhook URL is EMPTY after set attempt. Check bot token and URL validity." "STARTUP: CRITICAL - Telegram Webhook URL is EMPTY after set attempt. Check bot token and URL validity."
) )
except Exception: except Exception as e_setwebhook:
logging.exception("STARTUP: EXCEPTION during set/get Telegram webhook.") logging.error(
"STARTUP: EXCEPTION during set/get Telegram webhook: %s",
e_setwebhook,
exc_info=True,
)
else: else:
logging.error( logging.error(
"STARTUP: WEBHOOK_BASE_URL not set in environment. Webhook mode is required. Exiting." "STARTUP: WEBHOOK_BASE_URL not set in environment. Webhook mode is required. Exiting."
@@ -117,7 +111,7 @@ async def on_startup_configured(dispatcher: Dispatcher):
try: try:
menu_text = i18n_instance.gettext( menu_text = i18n_instance.gettext(
settings.DEFAULT_LANGUAGE, settings.DEFAULT_LANGUAGE,
"menu_personal_account_button", "menu_my_subscription_inline",
) )
await bot.set_chat_menu_button( await bot.set_chat_menu_button(
menu_button=MenuButtonWebApp( menu_button=MenuButtonWebApp(
@@ -129,31 +123,39 @@ async def on_startup_configured(dispatcher: Dispatcher):
logging.info( logging.info(
"STARTUP: Mini app domain registered and default menu button restored." "STARTUP: Mini app domain registered and default menu button restored."
) )
except Exception: except Exception as e:
logging.exception("STARTUP: Failed to register mini app domain.") logging.error(
f"STARTUP: Failed to register mini app domain: {e}", exc_info=True
try:
bot_commands = [
BotCommand(command="tg", description="Интерфейс в боте"),
]
if settings.START_COMMAND_DESCRIPTION:
bot_commands.insert(
0,
BotCommand(command="start",
description=settings.START_COMMAND_DESCRIPTION),
) )
await bot.set_my_commands(bot_commands)
logging.info("STARTUP: bot command descriptions set.") if settings.START_COMMAND_DESCRIPTION:
except Exception: try:
logging.exception("STARTUP: Failed to set bot commands.") await bot.set_my_commands([
BotCommand(command="start", description=settings.START_COMMAND_DESCRIPTION)
])
logging.info("STARTUP: /start command description set.")
except Exception as e:
logging.error(f"STARTUP: Failed to set bot commands: {e}", exc_info=True)
# Initialize message queue manager # Initialize message queue manager
try: try:
queue_manager = init_queue_manager(bot) queue_manager = init_queue_manager(bot)
dispatcher["queue_manager"] = queue_manager dispatcher["queue_manager"] = queue_manager
logging.info("STARTUP: Message queue manager initialized") logging.info("STARTUP: Message queue manager initialized")
except Exception: except Exception as e:
logging.exception("STARTUP: Failed to initialize message queue manager.") logging.error(f"STARTUP: Failed to initialize message queue manager: {e}", exc_info=True)
# Initialize promo discount expiration worker
try:
promo_code_service: Optional[PromoCodeService] = dispatcher.get("promo_code_service")
if promo_code_service:
await promo_code_service.setup_discount_expiration_worker(async_session_factory)
logging.info("STARTUP: Promo discount expiration worker initialized")
except Exception as e:
logging.error(
f"STARTUP: Failed to initialize promo discount expiration worker: {e}",
exc_info=True,
)
# Automatic sync on startup # Automatic sync on startup
try: try:
@@ -172,8 +174,8 @@ async def on_startup_configured(dispatcher: Dispatcher):
else: else:
logging.warning(f"STARTUP: Automatic sync completed with issues. Status: {sync_result.get('status', 'unknown')}") logging.warning(f"STARTUP: Automatic sync completed with issues. Status: {sync_result.get('status', 'unknown')}")
except Exception: except Exception as e:
logging.exception("STARTUP: Failed to run automatic sync.") logging.error(f"STARTUP: Failed to run automatic sync: {e}", exc_info=True)
logging.info("STARTUP: Bot on_startup_configured completed.") logging.info("STARTUP: Bot on_startup_configured completed.")
@@ -249,14 +251,8 @@ async def run_bot(settings_param: Settings):
actual_bot_username = "your_bot_username" actual_bot_username = "your_bot_username"
try: try:
bot_info = await bot.get_me() bot_info = await bot.get_me()
if bot_info.username: actual_bot_username = bot_info.username
actual_bot_username = bot_info.username logging.info(f"Bot username resolved: @{actual_bot_username}")
dp["bot_username"] = actual_bot_username
logging.info(f"Bot username resolved: @{actual_bot_username}")
else:
logging.warning(
"Bot username is empty; Telegram Login Widget will be unavailable."
)
except Exception as e: except Exception as e:
logging.error( logging.error(
f"Failed to get bot info (e.g., for YooKassa default URL): {e}. Using fallback: {actual_bot_username}" f"Failed to get bot info (e.g., for YooKassa default URL): {e}. Using fallback: {actual_bot_username}"
+9 -8
View File
@@ -40,13 +40,14 @@ class ActionLoggerMiddleware(BaseMiddleware):
is_admin_event_flag = True is_admin_event_flag = True
raw_update_snippet = None raw_update_snippet = None
try: if self.settings.LOG_STORE_RAW_UPDATES:
raw_update_snippet = event.model_dump_json(exclude_none=True, try:
indent=None)[:1000] raw_update_snippet = event.model_dump_json(exclude_none=True,
except AttributeError: indent=None)[:1000]
raw_update_snippet = str(event)[:1000] except AttributeError:
except Exception: raw_update_snippet = str(event)[:1000]
raw_update_snippet = str(event)[:1000] except Exception:
raw_update_snippet = str(event)[:1000]
current_event_type = event.event_type current_event_type = event.event_type
@@ -83,7 +84,7 @@ class ActionLoggerMiddleware(BaseMiddleware):
"telegram_username": telegram_username, "telegram_username": telegram_username,
"telegram_first_name": telegram_first_name, "telegram_first_name": telegram_first_name,
"event_type": current_event_type, "event_type": current_event_type,
"content": content[:1000] if content else "N/A", "content": (content[:1000] if content else "N/A") if self.settings.LOG_STORE_MESSAGE_CONTENT else None,
"raw_update_preview": raw_update_snippet, "raw_update_preview": raw_update_snippet,
"is_admin_event": is_admin_event_flag, "is_admin_event": is_admin_event_flag,
"target_user_id": target_user_id_for_log, "target_user_id": target_user_id_for_log,
+5 -2
View File
@@ -32,6 +32,9 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
event: Update, event: Update,
data: Dict[str, Any], data: Dict[str, Any],
) -> Any: ) -> Any:
if not self.settings.REQUIRED_CHANNEL_SUBSCRIBE_TO_USE:
return await handler(event, data)
required_channel_id = self.settings.REQUIRED_CHANNEL_ID required_channel_id = self.settings.REQUIRED_CHANNEL_ID
if not required_channel_id: if not required_channel_id:
return await handler(event, data) return await handler(event, data)
@@ -124,8 +127,8 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
) -> None: ) -> None:
try: try:
await callback.answer(prompt_text, show_alert=True) await callback.answer(prompt_text, show_alert=True)
except Exception: except Exception as exc:
pass logging.debug("Failed to answer callback for channel gate prompt: %s", exc)
if callback.message: if callback.message:
try: try:
+3 -14
View File
@@ -22,17 +22,13 @@ class ProfileSyncMiddleware(BaseMiddleware):
if session and tg_user: if session and tg_user:
try: try:
db_user = await user_dal.get_user_by_telegram_id(session, tg_user.id) db_user = await user_dal.get_user_by_id(session, tg_user.id)
if not db_user:
db_user = await user_dal.get_user_by_id(session, tg_user.id)
if db_user: if db_user:
update_payload: Dict[str, Any] = {} update_payload: Dict[str, Any] = {}
sanitized_username = sanitize_username(tg_user.username) sanitized_username = sanitize_username(tg_user.username)
sanitized_first_name = sanitize_display_name(tg_user.first_name) sanitized_first_name = sanitize_display_name(tg_user.first_name)
sanitized_last_name = sanitize_display_name(tg_user.last_name) sanitized_last_name = sanitize_display_name(tg_user.last_name)
if db_user.telegram_id != tg_user.id:
update_payload["telegram_id"] = tg_user.id
if db_user.username != sanitized_username: if db_user.username != sanitized_username:
update_payload["username"] = sanitized_username update_payload["username"] = sanitized_username
if db_user.first_name != sanitized_first_name: if db_user.first_name != sanitized_first_name:
@@ -41,7 +37,7 @@ class ProfileSyncMiddleware(BaseMiddleware):
update_payload["last_name"] = sanitized_last_name update_payload["last_name"] = sanitized_last_name
if update_payload: if update_payload:
await user_dal.update_user(session, db_user.user_id, update_payload) await user_dal.update_user(session, tg_user.id, update_payload)
logging.info( logging.info(
f"ProfileSyncMiddleware: Updated user {tg_user.id} profile fields: {list(update_payload.keys())}" f"ProfileSyncMiddleware: Updated user {tg_user.id} profile fields: {list(update_payload.keys())}"
) )
@@ -51,20 +47,13 @@ class ProfileSyncMiddleware(BaseMiddleware):
panel_service = data.get("panel_service") panel_service = data.get("panel_service")
if panel_service and db_user.panel_user_uuid: if panel_service and db_user.panel_user_uuid:
description_text = "\n".join([ description_text = "\n".join([
db_user.email or "",
username_for_display(tg_user.username, with_at=False) if sanitized_username is not None else "", username_for_display(tg_user.username, with_at=False) if sanitized_username is not None else "",
sanitized_first_name or "", sanitized_first_name or "",
sanitized_last_name or "", sanitized_last_name or "",
]).strip() ]).strip()
panel_payload = {
"description": description_text,
"telegramId": tg_user.id,
}
if db_user.email:
panel_payload["email"] = db_user.email
await panel_service.update_user_details_on_panel( await panel_service.update_user_details_on_panel(
db_user.panel_user_uuid, db_user.panel_user_uuid,
panel_payload, {"description": description_text},
) )
except Exception as e_upd_desc: except Exception as e_upd_desc:
logging.warning( logging.warning(
+133 -48
View File
@@ -1,7 +1,5 @@
import hashlib
import logging import logging
import json import json
import hmac
from typing import Optional from typing import Optional
from aiogram import Bot from aiogram import Bot
@@ -21,8 +19,6 @@ from db.dal import payment_dal, user_dal
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from bot.utils.config_link import prepare_config_links from bot.utils.config_link import prepare_config_links
logger = logging.getLogger(__name__)
class CryptoPayService: class CryptoPayService:
def __init__( def __init__(
@@ -42,7 +38,6 @@ class CryptoPayService:
self.async_session_factory = async_session_factory self.async_session_factory = async_session_factory
self.subscription_service = subscription_service self.subscription_service = subscription_service
self.referral_service = referral_service self.referral_service = referral_service
self.token = token
if token: if token:
net = Networks.TEST_NET if str(network).lower() == "testnet" else Networks.MAIN_NET net = Networks.TEST_NET if str(network).lower() == "testnet" else Networks.MAIN_NET
self.client = AioCryptoPay(token=token, network=net) self.client = AioCryptoPay(token=token, network=net)
@@ -70,24 +65,68 @@ class CryptoPayService:
amount: float, amount: float,
description: str, description: str,
sale_mode: str = "subscription", sale_mode: str = "subscription",
url_kind: str = "bot", promo_code_service=None,
) -> Optional[str]: ) -> Optional[str]:
if not self.configured or not self.client: if not self.configured or not self.client:
logging.error("CryptoPayService not configured") logging.error("CryptoPayService not configured")
return None 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
promo_code_id = active_discount.promo_code_id
denominator = 1 - discount_pct / 100
if denominator <= 0:
price_source = (
getattr(self.settings, "traffic_packages", {}) or {}
if sale_mode == "traffic"
else (self.settings.subscription_options or {})
)
fallback_original = price_source.get(months)
if fallback_original is not None:
original_amount = fallback_original
discount_amount = original_amount - amount
logging.info(
f"Recording {discount_pct}% discount for CryptoPay payment: "
f"original {original_amount:.2f} -> final {amount}"
)
else:
logging.warning(
"CryptoPay discount %s%% has invalid denominator and no fallback price for months=%s.",
discount_pct,
months,
)
else:
original_amount = amount / denominator
discount_amount = original_amount - amount
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 # Create pending payment in DB and commit to persist
try: try:
payment_record = await payment_dal.create_payment_record( payment_record = await payment_dal.create_payment_record(
session, session,
{ {
"user_id": user_id, "user_id": user_id,
"amount": float(amount), "amount": amount,
"original_amount": original_amount,
"discount_applied": discount_amount,
"currency": self.settings.CRYPTOPAY_ASSET, "currency": self.settings.CRYPTOPAY_ASSET,
"status": "pending_cryptopay", "status": "pending_cryptopay",
"description": description, "description": description,
"subscription_duration_months": int(months), "subscription_duration_months": int(months),
"provider": "cryptopay", "provider": "cryptopay",
"promo_code_id": promo_code_id,
}, },
) )
await session.commit() await session.commit()
@@ -122,22 +161,16 @@ class CryptoPayService:
str(invoice.status), str(invoice.status),
) )
await session.commit() await session.commit()
except Exception: except Exception as e_db_update:
await session.rollback() await session.rollback()
logging.exception( logging.error(
"Failed to update cryptopay payment record %s.", f"Failed to update cryptopay payment record {payment_record.payment_id}: {e_db_update}",
payment_record.payment_id, exc_info=True,
) )
return None return None
if url_kind == "web":
return (
getattr(invoice, "web_app_invoice_url", None)
or getattr(invoice, "mini_app_invoice_url", None)
or invoice.bot_invoice_url
)
return invoice.bot_invoice_url return invoice.bot_invoice_url
except Exception: except Exception as e:
logging.exception("CryptoPay invoice creation failed.") logging.error(f"CryptoPay invoice creation failed: {e}", exc_info=True)
return None return None
async def _invoice_paid_handler(self, update: Update, app: web.Application): async def _invoice_paid_handler(self, update: Update, app: web.Application):
@@ -152,8 +185,8 @@ class CryptoPayService:
payment_db_id = int(meta["payment_db_id"]) payment_db_id = int(meta["payment_db_id"])
sale_mode = meta.get("sale_mode") or ("traffic" if self.settings.traffic_sale_mode else "subscription") 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 traffic_gb = float(meta.get("traffic_gb")) if meta.get("traffic_gb") else months
except Exception: except Exception as e:
logging.exception("Failed to parse CryptoPay payload.") logging.error(f"Failed to parse CryptoPay payload: {e}")
return return
async_session_factory: sessionmaker = app["async_session_factory"] async_session_factory: sessionmaker = app["async_session_factory"]
@@ -165,22 +198,92 @@ class CryptoPayService:
async with async_session_factory() as session: async with async_session_factory() as session:
try: try:
await payment_dal.update_provider_payment_and_status( # 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
if payment_record.user_id != user_id:
logging.error(
"CryptoPay webhook: user mismatch for payment %s (db=%s, payload=%s)",
payment_db_id,
payment_record.user_id,
user_id,
)
return
provider_currency = None
for candidate in (
getattr(invoice, "fiat", None),
getattr(invoice, "asset", None),
getattr(invoice, "paid_asset", None),
settings.CRYPTOPAY_ASSET,
):
if candidate:
provider_currency = str(candidate).upper()
break
expected_currency = str(payment_record.currency or "").upper()
if expected_currency and provider_currency and expected_currency != provider_currency:
logging.error(
"CryptoPay webhook: currency mismatch for payment %s (expected %s, got %s)",
payment_db_id,
expected_currency,
provider_currency,
)
return
if payment_record.status == "succeeded":
logging.info("CryptoPay webhook: payment %s already succeeded", payment_db_id)
return
try:
expected_amount = float(payment_record.amount)
incoming_amount = float(invoice.amount)
if round(incoming_amount, 2) != round(expected_amount, 2):
logging.error(
"CryptoPay webhook: amount mismatch for payment %s (expected %.2f, got %.2f)",
payment_db_id,
expected_amount,
incoming_amount,
)
return
except Exception as amount_exc:
logging.error(
"CryptoPay webhook: failed to compare amount for payment %s: %s",
payment_db_id,
amount_exc,
)
return
marked = await payment_dal.mark_provider_payment_succeeded_once(
session, session,
payment_db_id, payment_db_id,
str(invoice.invoice_id), str(invoice.invoice_id),
"succeeded",
) )
if not marked:
logging.info(
"CryptoPay webhook: payment %s already processed atomically",
payment_db_id,
)
return
activation = await subscription_service.activate_subscription( activation = await subscription_service.activate_subscription(
session, session,
user_id, user_id,
int(months) if sale_mode != "traffic" else 0, int(months) if sale_mode != "traffic" else 0,
float(invoice.amount), float(invoice.amount),
payment_db_id, payment_db_id,
promo_code_id_from_payment=payment_record.promo_code_id,
provider="cryptopay", provider="cryptopay",
sale_mode=sale_mode, sale_mode=sale_mode,
traffic_gb=traffic_gb if sale_mode == "traffic" else None, traffic_gb=traffic_gb if sale_mode == "traffic" else None,
) )
if not activation or not activation.get("end_date"):
raise RuntimeError(
f"CryptoPay webhook: activation failed for payment {payment_db_id}"
)
referral_bonus = None referral_bonus = None
if sale_mode != "traffic": if sale_mode != "traffic":
referral_bonus = await referral_service.apply_referral_bonuses_for_payment( referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
@@ -191,9 +294,9 @@ class CryptoPayService:
skip_if_active_before_payment=False, skip_if_active_before_payment=False,
) )
await session.commit() await session.commit()
except Exception: except Exception as e:
await session.rollback() await session.rollback()
logging.exception("Failed to process CryptoPay invoice.") logging.error(f"Failed to process CryptoPay invoice: {e}", exc_info=True)
return return
db_user = await user_dal.get_user_by_id(session, user_id) db_user = await user_dal.get_user_by_id(session, user_id)
@@ -254,8 +357,8 @@ class CryptoPayService:
parse_mode="HTML", parse_mode="HTML",
disable_web_page_preview=True, disable_web_page_preview=True,
) )
except Exception: except Exception as e:
logging.exception("Failed to send CryptoPay success message.") logging.error(f"Failed to send CryptoPay success message: {e}")
# Send notification about payment # Send notification about payment
try: try:
@@ -264,36 +367,18 @@ class CryptoPayService:
await notification_service.notify_payment_received( await notification_service.notify_payment_received(
user_id=user_id, user_id=user_id,
amount=float(invoice.amount), amount=float(invoice.amount),
currency=invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL, currency=invoice.asset or "RUB",
months=int(months) if sale_mode != "traffic" else 0, months=int(months) if sale_mode != "traffic" else 0,
traffic_gb=traffic_gb if sale_mode == "traffic" else None, traffic_gb=traffic_gb if sale_mode == "traffic" else None,
payment_provider="crypto_pay", payment_provider="crypto_pay",
username=user.username if user else None username=user.username if user else None
) )
except Exception: except Exception as e:
logging.exception("Failed to send crypto_pay payment notification.") logging.error(f"Failed to send crypto_pay payment notification: {e}")
def _validate_webhook_signature(self, raw_body: bytes, signature: str) -> bool:
if not self.token:
return False
expected_signature = hmac.new(
hashlib.sha256(self.token.encode("utf-8")).digest(),
raw_body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected_signature, signature or ""):
logger.error("CryptoPay signature mismatch")
return False
return True
async def webhook_route(self, request: web.Request) -> web.Response: async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured or not self.client: if not self.configured or not self.client:
return web.Response(status=503, text="cryptopay_disabled") return web.Response(status=503, text="cryptopay_disabled")
raw_body = await request.read()
signature = request.headers.get("crypto-pay-api-signature", "")
if not self._validate_webhook_signature(raw_body, signature):
return web.Response(status=401)
return await self.client.get_updates(request) return await self.client.get_updates(request)
-605
View File
@@ -1,605 +0,0 @@
import asyncio
import hashlib
import hmac
import logging
import re
import secrets
import smtplib
import ssl
from dataclasses import dataclass
from datetime import datetime, timezone, timedelta
from email.message import EmailMessage
from email.utils import formataddr
from typing import Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from db.dal import security_dal
from db.models import EmailVerificationCode
from bot.services.email_templates import EmailContent, render_login_code
logger = logging.getLogger(__name__)
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
@dataclass(frozen=True)
class SmtpAttempt:
port: int
use_ssl: bool
starttls: bool
@dataclass(frozen=True)
class EmailCodeRequestResult:
ok: bool
error: Optional[str] = None
retry_after: Optional[int] = None
@dataclass(frozen=True)
class EmailCodeVerifyResult:
ok: bool
error: Optional[str] = None
retry_after: Optional[int] = None
@dataclass(frozen=True)
class EmailMagicVerifyResult:
ok: bool
error: Optional[str] = None
email: Optional[str] = None
purpose: Optional[str] = None
target_user_id: Optional[int] = None
def normalize_email(value: str) -> str:
return (value or "").strip().lower()
def is_valid_email(value: str) -> bool:
email = normalize_email(value)
return bool(email and len(email) <= 254 and EMAIL_RE.match(email))
def _email_throttle_identifier(email: str, purpose: str, target_user_id: Optional[int]) -> str:
target_part = "none" if target_user_id is None else str(target_user_id)
return f"{purpose}:{target_part}:{email}"
class EmailAuthService:
def __init__(self, settings: Settings):
self.settings = settings
def _smtp_attempts(self) -> list[SmtpAttempt]:
attempts: list[SmtpAttempt] = []
primary_port = int(self.settings.SMTP_PORT)
for port in self.settings.smtp_ports_to_try:
if port == primary_port:
use_ssl = bool(self.settings.SMTP_USE_SSL or port == 465)
starttls = bool(self.settings.SMTP_STARTTLS and not use_ssl)
else:
use_ssl = port == 465
starttls = bool(self.settings.SMTP_STARTTLS and not use_ssl)
attempts.append(SmtpAttempt(port=port, use_ssl=use_ssl, starttls=starttls))
return attempts or [
SmtpAttempt(
port=primary_port,
use_ssl=bool(self.settings.SMTP_USE_SSL or primary_port == 465),
starttls=bool(
self.settings.SMTP_STARTTLS
and not self.settings.SMTP_USE_SSL
and primary_port != 465
),
)
]
def _hash_code(self, email: str, purpose: str, code: str) -> str:
secret = hmac.new(
self.settings.BOT_TOKEN.encode("utf-8"),
b"remnawave-tg-shop-email-code",
hashlib.sha256,
).digest()
payload = f"{purpose}:{email}:{code}".encode("utf-8")
return hmac.new(secret, payload, hashlib.sha256).hexdigest()
def _hash_magic_token(self, token: str) -> str:
secret = hmac.new(
self.settings.BOT_TOKEN.encode("utf-8"),
b"remnawave-tg-shop-email-magic",
hashlib.sha256,
).digest()
return hmac.new(secret, token.encode("utf-8"), hashlib.sha256).hexdigest()
def _build_magic_link(self, *, token: str, purpose: str) -> Optional[str]:
base_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip()
if not base_url:
return None
from urllib.parse import urlencode, urlsplit, urlunsplit
parsed = urlsplit(base_url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
return None
params = {"login_token": token}
if purpose and purpose != "login":
params["login_purpose"] = purpose
existing_query = parsed.query
new_query = urlencode(params)
merged_query = f"{existing_query}&{new_query}" if existing_query else new_query
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, merged_query, parsed.fragment))
async def request_code(
self,
session: AsyncSession,
*,
email: str,
purpose: str,
language_code: str,
target_user_id: Optional[int] = None,
) -> EmailCodeRequestResult:
normalized_email = normalize_email(email)
if not self.settings.email_auth_configured:
return EmailCodeRequestResult(ok=False, error="email_auth_not_configured")
if not is_valid_email(normalized_email):
return EmailCodeRequestResult(ok=False, error="invalid_email")
now = datetime.now(timezone.utc)
throttle = await security_dal.check_throttle(
session,
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
identifier=_email_throttle_identifier(normalized_email, purpose, target_user_id),
now=now,
)
if throttle.locked:
return EmailCodeRequestResult(
ok=False,
error="rate_limited",
retry_after=throttle.retry_after,
)
latest_code = await self._get_latest_code(
session,
email=normalized_email,
purpose=purpose,
target_user_id=target_user_id,
)
if latest_code and latest_code.created_at:
created_at = latest_code.created_at
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
resend_after = max(1, int(self.settings.EMAIL_CODE_RESEND_SECONDS))
elapsed = int((now - created_at).total_seconds())
if elapsed < resend_after and latest_code.consumed_at is None:
return EmailCodeRequestResult(
ok=False,
error="rate_limited",
retry_after=resend_after - elapsed,
)
await session.execute(
update(EmailVerificationCode)
.where(
EmailVerificationCode.email == normalized_email,
EmailVerificationCode.purpose == purpose,
EmailVerificationCode.target_user_id == target_user_id,
EmailVerificationCode.status == "active",
EmailVerificationCode.consumed_at.is_(None),
)
.values(status="superseded")
)
code = f"{secrets.randbelow(1_000_000):06d}"
magic_token = secrets.token_urlsafe(32)
magic_link = self._build_magic_link(token=magic_token, purpose=purpose)
code_model = EmailVerificationCode(
email=normalized_email,
code_hash=self._hash_code(normalized_email, purpose, code),
magic_token_hash=self._hash_magic_token(magic_token) if magic_link else None,
purpose=purpose,
target_user_id=target_user_id,
expires_at=now + timedelta(seconds=max(60, int(self.settings.EMAIL_CODE_TTL_SECONDS))),
status="active",
)
session.add(code_model)
await session.flush()
await self._send_code_email(
email=normalized_email,
code=code,
language_code=language_code,
magic_link=magic_link,
)
return EmailCodeRequestResult(ok=True)
async def verify_code(
self,
session: AsyncSession,
*,
email: str,
purpose: str,
code: str,
target_user_id: Optional[int] = None,
) -> EmailCodeVerifyResult:
normalized_email = normalize_email(email)
normalized_code = re.sub(r"\D", "", code or "")
if not is_valid_email(normalized_email):
return EmailCodeVerifyResult(ok=False, error="invalid_code")
now = datetime.now(timezone.utc)
throttle_identifier = _email_throttle_identifier(
normalized_email,
purpose,
target_user_id,
)
throttle = await security_dal.check_throttle(
session,
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
identifier=throttle_identifier,
now=now,
)
if throttle.locked:
return EmailCodeVerifyResult(
ok=False,
error="rate_limited",
retry_after=throttle.retry_after,
)
latest_code = await self._get_latest_code(
session,
email=normalized_email,
purpose=purpose,
target_user_id=target_user_id,
)
if not latest_code or latest_code.consumed_at is not None:
return EmailCodeVerifyResult(ok=False, error="invalid_code")
expires_at = latest_code.expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if expires_at < now:
return EmailCodeVerifyResult(ok=False, error="expired_code")
max_attempts = max(1, int(self.settings.EMAIL_CODE_MAX_ATTEMPTS))
if int(latest_code.attempts or 0) >= max_attempts:
return EmailCodeVerifyResult(ok=False, error="too_many_attempts")
if len(normalized_code) != 6:
latest_code.attempts = int(latest_code.attempts or 0) + 1
throttle_result = await security_dal.record_throttle_failure(
session,
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
identifier=throttle_identifier,
max_failures=self.settings.BRUTE_FORCE_MAX_FAILURES,
window_seconds=self.settings.BRUTE_FORCE_WINDOW_SECONDS,
lock_seconds=self.settings.BRUTE_FORCE_LOCK_SECONDS,
now=now,
)
await session.flush()
if throttle_result.locked:
return EmailCodeVerifyResult(
ok=False,
error="rate_limited",
retry_after=throttle_result.retry_after,
)
if int(latest_code.attempts or 0) >= max_attempts:
return EmailCodeVerifyResult(ok=False, error="too_many_attempts")
return EmailCodeVerifyResult(ok=False, error="invalid_code")
expected_hash = self._hash_code(normalized_email, purpose, normalized_code)
if not hmac.compare_digest(expected_hash, latest_code.code_hash):
latest_code.attempts = int(latest_code.attempts or 0) + 1
throttle_result = await security_dal.record_throttle_failure(
session,
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
identifier=throttle_identifier,
max_failures=self.settings.BRUTE_FORCE_MAX_FAILURES,
window_seconds=self.settings.BRUTE_FORCE_WINDOW_SECONDS,
lock_seconds=self.settings.BRUTE_FORCE_LOCK_SECONDS,
now=now,
)
await session.flush()
if throttle_result.locked:
return EmailCodeVerifyResult(
ok=False,
error="rate_limited",
retry_after=throttle_result.retry_after,
)
return EmailCodeVerifyResult(ok=False, error="invalid_code")
latest_code.consumed_at = now
await security_dal.clear_throttle_state(
session,
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
identifier=throttle_identifier,
)
await session.flush()
return EmailCodeVerifyResult(ok=True)
async def _get_latest_code(
self,
session: AsyncSession,
*,
email: str,
purpose: str,
target_user_id: Optional[int],
) -> Optional[EmailVerificationCode]:
stmt = (
select(EmailVerificationCode)
.where(
EmailVerificationCode.email == email,
EmailVerificationCode.purpose == purpose,
EmailVerificationCode.target_user_id == target_user_id,
EmailVerificationCode.status == "active",
EmailVerificationCode.consumed_at.is_(None),
)
.order_by(EmailVerificationCode.created_at.desc())
.limit(1)
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def verify_magic_token(
self,
session: AsyncSession,
*,
token: str,
purpose: str,
target_user_id: Optional[int] = None,
) -> EmailMagicVerifyResult:
if not token:
return EmailMagicVerifyResult(ok=False, error="invalid_token")
token_hash = self._hash_magic_token(token)
now = datetime.now(timezone.utc)
stmt = (
select(EmailVerificationCode)
.where(
EmailVerificationCode.magic_token_hash == token_hash,
EmailVerificationCode.purpose == purpose,
EmailVerificationCode.target_user_id == target_user_id,
EmailVerificationCode.status == "active",
EmailVerificationCode.consumed_at.is_(None),
)
.limit(1)
)
result = await session.execute(stmt)
record = result.scalar_one_or_none()
if not record:
return EmailMagicVerifyResult(ok=False, error="invalid_token")
expires_at = record.expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if expires_at < now:
return EmailMagicVerifyResult(ok=False, error="expired_token")
record.consumed_at = now
throttle_identifier = _email_throttle_identifier(
record.email,
purpose,
target_user_id,
)
await security_dal.clear_throttle_state(
session,
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
identifier=throttle_identifier,
)
await session.flush()
return EmailMagicVerifyResult(
ok=True,
email=record.email,
purpose=record.purpose,
target_user_id=record.target_user_id,
)
async def _send_code_email(
self,
*,
email: str,
code: str,
language_code: str,
magic_link: Optional[str] = None,
) -> None:
await asyncio.to_thread(
self._send_code_email_sync,
email=email,
code=code,
language_code=language_code,
magic_link=magic_link,
)
async def send_custom_email(
self,
*,
email: str,
subject: str,
body: str,
html_body: Optional[str] = None,
) -> None:
await asyncio.to_thread(
self._send_custom_email_sync,
email=email,
subject=subject,
body=body,
html_body=html_body,
)
async def send_rendered_email(
self,
*,
email: str,
content: EmailContent,
) -> None:
await self.send_custom_email(
email=email,
subject=content.subject,
body=content.text,
html_body=content.html,
)
def _send_code_email_sync(
self,
*,
email: str,
code: str,
language_code: str,
magic_link: Optional[str] = None,
) -> None:
content = render_login_code(
self.settings,
code=code,
language_code=language_code,
magic_link=magic_link,
)
message = EmailMessage()
message["Subject"] = content.subject
message["From"] = formataddr(
(
self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE,
self.settings.SMTP_FROM_EMAIL or "",
)
)
message["To"] = email
message.set_content(content.text)
message.add_alternative(content.html, subtype="html")
context = ssl.create_default_context()
smtp_host = self.settings.SMTP_HOST
timeout = max(5, int(self.settings.SMTP_TIMEOUT_SECONDS))
attempts = self._smtp_attempts()
last_error: Optional[BaseException] = None
for attempt_number, attempt in enumerate(attempts, start=1):
try:
self._send_message_via_smtp(
message=message,
smtp_host=smtp_host,
smtp_port=attempt.port,
timeout=timeout,
context=context,
use_ssl=attempt.use_ssl,
starttls=attempt.starttls,
)
logger.info(
"Email verification code sent to %s via %s:%s",
email,
smtp_host,
attempt.port,
)
return
except (OSError, smtplib.SMTPException, TimeoutError) as exc:
last_error = exc
log_level = logging.WARNING if attempt_number < len(attempts) else logging.ERROR
logger.log(
log_level,
"SMTP send attempt %s/%s failed via %s:%s (ssl=%s, starttls=%s): %s",
attempt_number,
len(attempts),
smtp_host,
attempt.port,
attempt.use_ssl,
attempt.starttls,
exc,
)
if last_error:
raise last_error
def _send_custom_email_sync(
self,
*,
email: str,
subject: str,
body: str,
html_body: Optional[str] = None,
) -> None:
message = EmailMessage()
message["Subject"] = subject
message["From"] = formataddr(
(
self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE,
self.settings.SMTP_FROM_EMAIL or "",
)
)
message["To"] = email
message.set_content(body)
if html_body:
message.add_alternative(html_body, subtype="html")
context = ssl.create_default_context()
smtp_host = self.settings.SMTP_HOST
timeout = max(5, int(self.settings.SMTP_TIMEOUT_SECONDS))
attempts = self._smtp_attempts()
last_error: Optional[BaseException] = None
for attempt_number, attempt in enumerate(attempts, start=1):
try:
self._send_message_via_smtp(
message=message,
smtp_host=smtp_host,
smtp_port=attempt.port,
timeout=timeout,
context=context,
use_ssl=attempt.use_ssl,
starttls=attempt.starttls,
)
logger.info(
"Custom email sent to %s via %s:%s",
email,
smtp_host,
attempt.port,
)
return
except (OSError, smtplib.SMTPException, TimeoutError) as exc:
last_error = exc
log_level = logging.WARNING if attempt_number < len(attempts) else logging.ERROR
logger.log(
log_level,
"SMTP send attempt %s/%s failed for custom email via %s:%s (ssl=%s, starttls=%s): %s",
attempt_number,
len(attempts),
smtp_host,
attempt.port,
attempt.use_ssl,
attempt.starttls,
exc,
)
if last_error:
raise last_error
def _send_message_via_smtp(
self,
*,
message: EmailMessage,
smtp_host: str,
smtp_port: int,
timeout: int,
context: ssl.SSLContext,
use_ssl: bool,
starttls: bool,
) -> None:
if use_ssl:
with smtplib.SMTP_SSL(
smtp_host,
smtp_port,
context=context,
timeout=timeout,
) as smtp:
smtp.ehlo()
smtp.login(self.settings.SMTP_USERNAME, self.settings.SMTP_PASSWORD)
smtp.send_message(message)
return
with smtplib.SMTP(smtp_host, smtp_port, timeout=timeout) as smtp:
smtp.ehlo()
if starttls:
smtp.starttls(context=context)
smtp.ehlo()
smtp.login(self.settings.SMTP_USERNAME, self.settings.SMTP_PASSWORD)
smtp.send_message(message)
-484
View File
@@ -1,484 +0,0 @@
"""Branded HTML email templates that mirror the subscription Mini App look.
The web app uses a dark theme with a configurable accent colour
(`WEBAPP_PRIMARY_COLOR`) and an optional logo (`WEBAPP_LOGO_URL`). The same
accent + logo are reused here so emails feel like part of the product. All
copy goes through the shared `JsonI18n` instance so translations live in
``locales/<lang>.json`` next to the rest of the bot strings.
"""
from __future__ import annotations
import html
import re
from dataclasses import dataclass
from typing import Optional, Sequence, Tuple
from urllib.parse import urlsplit
from bot.middlewares.i18n import JsonI18n, get_i18n_instance
from config.settings import Settings
_BG = "#05070a"
_CARD_BG = "#0e1116"
_BORDER = "#1a1f27"
_TEXT = "#e6e9ef"
_TEXT_MUTED = "#9aa3b2"
_TEXT_DIM = "#5d6573"
_DEFAULT_ACCENT = "#00fe7a"
_HEX_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
@dataclass(frozen=True)
class EmailContent:
subject: str
text: str
html: str
def _safe_color(value: Optional[str]) -> str:
if not value:
return _DEFAULT_ACCENT
candidate = value.strip()
if _HEX_RE.match(candidate):
return candidate
return _DEFAULT_ACCENT
def _public_logo_url(settings: Settings) -> Optional[str]:
"""Email recipients can't reach the in-app /webapp-logo proxy, so the
raw https URL from the env is used directly. Anything else is dropped."""
raw = (settings.WEBAPP_LOGO_URL or "").strip()
if not raw:
return None
parsed = urlsplit(raw)
if parsed.scheme != "https" or not parsed.hostname:
return None
return raw
def _brand_title(settings: Settings) -> str:
title = (settings.WEBAPP_TITLE or "").strip()
return title or "Subscription"
def _normalize_lang(language_code: Optional[str], settings: Settings) -> str:
return (language_code or settings.DEFAULT_LANGUAGE or "ru").split("-")[0]
def _resolve_i18n(i18n: Optional[JsonI18n]) -> JsonI18n:
return i18n or get_i18n_instance()
def _t_html(i18n: JsonI18n, lang: str, key: str, **kwargs) -> str:
"""Translate for HTML context: format args are HTML-escaped, the
translated template itself is treated as already-safe HTML (locale files
are author-controlled and may include simple inline tags like <strong>)."""
safe_kwargs = {k: html.escape(str(v)) for k, v in kwargs.items()}
return i18n.gettext(lang, key, **safe_kwargs)
def _t_text(i18n: JsonI18n, lang: str, key: str, **kwargs) -> str:
return i18n.gettext(lang, key, **kwargs)
def _layout(
*,
settings: Settings,
preheader: str,
heading: str,
intro_html: str,
body_html: str,
footer_html: str,
) -> str:
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
brand_title = html.escape(_brand_title(settings))
logo_url = _public_logo_url(settings)
logo_block = ""
if logo_url:
logo_block = (
f'<img src="{html.escape(logo_url, quote=True)}" width="64" height="64" '
f'alt="" style="display:block;border:0;outline:none;text-decoration:none;'
f'border-radius:16px;background:{_CARD_BG};">'
)
return f"""<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="supported-color-schemes" content="dark">
<title>{html.escape(heading)}</title>
</head>
<body style="margin:0;padding:0;background:{_BG};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:{_TEXT};">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;mso-hide:all;">{html.escape(preheader)}</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:{_BG};">
<tr>
<td align="center" style="padding:32px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:480px;">
<tr>
<td align="center" style="padding-bottom:24px;">
{logo_block}
<div style="margin-top:14px;font-family:'JetBrains Mono','SFMono-Regular',Menlo,Consolas,monospace;font-weight:800;font-size:22px;line-height:1.05;color:{accent};letter-spacing:0;">{brand_title}</div>
</td>
</tr>
<tr>
<td style="background:{_CARD_BG};border:1px solid {_BORDER};border-radius:18px;padding:28px;">
<h1 style="margin:0 0 10px 0;font-size:20px;line-height:1.25;font-weight:700;color:#ffffff;">{html.escape(heading)}</h1>
<div style="margin:0 0 20px 0;font-size:14px;line-height:1.55;color:{_TEXT_MUTED};">{intro_html}</div>
{body_html}
</td>
</tr>
<tr>
<td align="center" style="padding-top:20px;">
<div style="font-size:11px;line-height:1.55;color:{_TEXT_DIM};">{footer_html}</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
"""
def _info_rows_html(rows: Sequence[Tuple[str, str]]) -> str:
if not rows:
return ""
last = len(rows) - 1
cells = []
for index, (label, value) in enumerate(rows):
border = "" if index == last else f"border-bottom:1px solid {_BORDER};"
cells.append(
f'<tr>'
f'<td style="padding:11px 0;{border}font-size:12px;color:{_TEXT_DIM};text-transform:uppercase;letter-spacing:0.04em;">{html.escape(label)}</td>'
f'<td align="right" style="padding:11px 0;{border}font-family:\'JetBrains Mono\',\'SFMono-Regular\',Menlo,Consolas,monospace;font-size:14px;font-weight:600;color:{_TEXT};">{html.escape(value)}</td>'
f'</tr>'
)
return (
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
f'style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};border-radius:14px;padding:6px 16px;">'
+ "".join(cells)
+ "</table>"
)
def _cta_button_html(*, label: str, url: str, accent: str) -> str:
safe_label = html.escape(label)
safe_url = html.escape(url, quote=True)
# Accent green is light, so contrast text is dark; works for the default and similar light accents.
return (
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
f'style="width:100%;margin:22px 0 18px 0;">'
f'<tr><td align="center" bgcolor="{accent}" style="background:{accent};border-radius:12px;">'
f'<a href="{safe_url}" target="_blank" rel="noopener" '
f'style="display:block;width:100%;box-sizing:border-box;padding:15px 22px;'
f'font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Helvetica,Arial,sans-serif;'
f'font-size:15px;font-weight:700;color:#05070a;text-decoration:none;letter-spacing:0.02em;text-align:center;">{safe_label}</a>'
f'</td></tr></table>'
)
def _format_amount(amount: float, currency: str) -> str:
rounded = round(float(amount), 2)
if rounded.is_integer():
body = f"{int(rounded)}"
else:
body = f"{rounded:.2f}"
suffix = (currency or "").strip()
return f"{body} {suffix}".strip()
def _format_traffic(traffic_gb: Optional[float]) -> str:
if traffic_gb is None:
return ""
value = float(traffic_gb)
return str(int(value)) if value.is_integer() else f"{value:g}"
def _format_minutes(seconds: int) -> int:
return max(1, int(seconds) // 60)
def render_login_code(
settings: Settings,
*,
code: str,
language_code: Optional[str],
magic_link: Optional[str] = None,
i18n: Optional[JsonI18n] = None,
) -> EmailContent:
i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language_code, settings)
minutes = _format_minutes(settings.EMAIL_CODE_TTL_SECONDS)
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
brand = _brand_title(settings)
safe_magic_link = (magic_link or "").strip()
subject = _t_text(i18n, lang, "email_login_code_subject", code=code)
preheader = _t_text(i18n, lang, "email_login_code_preheader", minutes=minutes)
heading = _t_text(i18n, lang, "email_login_code_heading")
intro = _t_text(i18n, lang, "email_login_code_intro")
expiry_html = _t_html(i18n, lang, "email_login_code_expiry_html", minutes=minutes)
security = _t_text(i18n, lang, "email_login_code_security")
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
text_lines = [_t_text(i18n, lang, "email_login_code_text", code=code, minutes=minutes)]
if safe_magic_link:
text_lines.append(
_t_text(i18n, lang, "email_login_code_text_magic", url=safe_magic_link)
)
code_block = (
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 18px 0;">'
f'<tr><td align="center" style="background:{_BG};border:1px solid {_BORDER};border-radius:14px;padding:22px 16px;">'
f'<div style="font-family:\'JetBrains Mono\',\'SFMono-Regular\',Menlo,Consolas,monospace;font-size:36px;line-height:1;font-weight:700;letter-spacing:10px;color:{accent};">'
f'{html.escape(code)}'
f'</div></td></tr></table>'
)
magic_block = ""
if safe_magic_link:
cta_label = _t_text(i18n, lang, "email_login_code_magic_cta")
divider_label = _t_text(i18n, lang, "email_login_code_magic_or")
magic_intro = _t_text(i18n, lang, "email_login_code_magic_intro")
magic_hint = _t_text(i18n, lang, "email_login_code_magic_hint")
divider_html = (
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:4px 0 14px 0;">'
f'<tr>'
f'<td width="40%" style="border-bottom:1px solid {_BORDER};font-size:0;line-height:0;">&nbsp;</td>'
f'<td align="center" style="padding:0 10px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:{_TEXT_DIM};white-space:nowrap;">{html.escape(divider_label)}</td>'
f'<td width="40%" style="border-bottom:1px solid {_BORDER};font-size:0;line-height:0;">&nbsp;</td>'
f'</tr></table>'
)
magic_block = (
divider_html
+ f'<p style="margin:0 0 4px 0;font-size:13px;line-height:1.55;color:{_TEXT_MUTED};text-align:center;">{html.escape(magic_intro)}</p>'
+ _cta_button_html(label=cta_label, url=safe_magic_link, accent=accent)
+ f'<p style="margin:0 0 6px 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};text-align:center;">{html.escape(magic_hint)}</p>'
)
body_html = (
code_block
+ f'<p style="margin:0 0 8px 0;font-size:13px;line-height:1.55;color:{_TEXT_MUTED};">{expiry_html}</p>'
+ f'<p style="margin:0 0 4px 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(security)}</p>'
+ magic_block
)
rendered = _layout(
settings=settings,
preheader=preheader,
heading=heading,
intro_html=html.escape(intro),
body_html=body_html,
footer_html=footer,
)
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
def render_account_merged(
settings: Settings,
*,
language_code: Optional[str],
primary_user_id: Optional[int],
removed_user_id: Optional[int],
final_end_date_text: str,
i18n: Optional[JsonI18n] = None,
) -> EmailContent:
i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language_code, settings)
brand = _brand_title(settings)
primary = "" if primary_user_id is None else f"#{primary_user_id}"
removed = "" if removed_user_id is None else f"#{removed_user_id}"
end_date = final_end_date_text or ""
subject = _t_text(i18n, lang, "email_account_merged_subject")
preheader = _t_text(i18n, lang, "email_account_merged_preheader")
heading = _t_text(i18n, lang, "email_account_merged_heading")
intro = _t_text(i18n, lang, "email_account_merged_intro")
note = _t_text(i18n, lang, "email_account_merged_note")
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
text = _t_text(
i18n,
lang,
"email_account_merged_text",
primary=primary,
removed=removed,
end_date=end_date,
)
rows = [
(_t_text(i18n, lang, "email_account_merged_row_kept"), primary),
(_t_text(i18n, lang, "email_account_merged_row_removed"), removed),
(_t_text(i18n, lang, "email_account_merged_row_end_date"), end_date),
]
body_html = (
_info_rows_html(rows)
+ f'<p style="margin:0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(note)}</p>'
)
rendered = _layout(
settings=settings,
preheader=preheader,
heading=heading,
intro_html=html.escape(intro),
body_html=body_html,
footer_html=footer,
)
return EmailContent(subject=subject, text=text, html=rendered)
def render_payment_success(
settings: Settings,
*,
language_code: Optional[str],
sale_mode: str,
months: int,
traffic_gb: Optional[float],
amount: float,
currency: str,
end_date_text: str,
dashboard_url: Optional[str],
provider_label: Optional[str] = None,
i18n: Optional[JsonI18n] = None,
) -> EmailContent:
i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language_code, settings)
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
brand = _brand_title(settings)
is_traffic = sale_mode == "traffic"
amount_text = _format_amount(amount, currency)
safe_dashboard_url = (dashboard_url or "").strip()
end_date = end_date_text or ""
traffic_label = _format_traffic(traffic_gb)
subject = _t_text(i18n, lang, "email_payment_success_subject")
preheader = _t_text(i18n, lang, "email_payment_success_preheader")
heading = _t_text(i18n, lang, "email_payment_success_heading")
footer_note = _t_text(i18n, lang, "email_payment_success_footer_note")
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
cta_label = _t_text(i18n, lang, "email_payment_success_cta")
if is_traffic:
intro = _t_text(i18n, lang, "email_payment_success_intro_traffic", traffic_gb=traffic_label)
period_label = _t_text(i18n, lang, "email_payment_success_row_traffic")
period_value = _t_text(i18n, lang, "email_payment_success_traffic_value", traffic_gb=traffic_label)
text = _t_text(
i18n,
lang,
"email_payment_success_text_traffic",
amount=amount_text,
traffic_gb=traffic_label,
end_date=end_date,
)
else:
months_int = int(months or 0)
intro = _t_text(i18n, lang, "email_payment_success_intro_subscription", months=months_int)
period_label = _t_text(i18n, lang, "email_payment_success_row_period")
period_value = _t_text(
i18n,
lang,
"email_payment_success_period_value",
months=months_int,
)
text = _t_text(
i18n,
lang,
"email_payment_success_text_subscription",
amount=amount_text,
months=months_int,
end_date=end_date,
)
rows: list[Tuple[str, str]] = [
(period_label, period_value),
(_t_text(i18n, lang, "email_payment_success_row_amount"), amount_text),
(_t_text(i18n, lang, "email_payment_success_row_end_date"), end_date),
]
if provider_label:
rows.append((_t_text(i18n, lang, "email_payment_success_row_method"), provider_label))
text_lines = [text]
if safe_dashboard_url:
text_lines.append(_t_text(i18n, lang, "email_payment_success_text_dashboard", url=safe_dashboard_url))
body_parts = [_info_rows_html(rows)]
if safe_dashboard_url:
body_parts.append(_cta_button_html(label=cta_label, url=safe_dashboard_url, accent=accent))
body_parts.append(
f'<p style="margin:6px 0 0 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(footer_note)}</p>'
)
rendered = _layout(
settings=settings,
preheader=preheader,
heading=heading,
intro_html=html.escape(intro),
body_html="".join(body_parts),
footer_html=footer,
)
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
def render_subscription_expiring(
settings: Settings,
*,
language_code: Optional[str],
days_left: int,
end_date_text: str,
dashboard_url: Optional[str],
i18n: Optional[JsonI18n] = None,
) -> EmailContent:
i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language_code, settings)
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
brand = _brand_title(settings)
safe_dashboard_url = (dashboard_url or "").strip()
days = max(0, int(days_left))
end_date = end_date_text or ""
if days == 0:
suffix = "today"
elif days == 1:
suffix = "tomorrow"
else:
suffix = "days"
subject = _t_text(i18n, lang, f"email_subscription_expiring_subject_{suffix}", days=days)
heading = _t_text(i18n, lang, f"email_subscription_expiring_heading_{suffix}", days=days)
preheader = _t_text(i18n, lang, f"email_subscription_expiring_preheader_{suffix}", days=days)
intro = _t_text(i18n, lang, f"email_subscription_expiring_intro_{suffix}", days=days)
note = _t_text(i18n, lang, "email_subscription_expiring_note")
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
cta_label = _t_text(i18n, lang, "email_subscription_expiring_cta")
rows = [
(_t_text(i18n, lang, "email_subscription_expiring_row_days_left"), str(days)),
(_t_text(i18n, lang, "email_subscription_expiring_row_end_date"), end_date),
]
text_lines = [
_t_text(i18n, lang, "email_subscription_expiring_text", heading=heading, end_date=end_date),
]
if safe_dashboard_url:
text_lines.append(
_t_text(i18n, lang, "email_subscription_expiring_text_renew", url=safe_dashboard_url)
)
body_parts = [_info_rows_html(rows)]
if safe_dashboard_url:
body_parts.append(_cta_button_html(label=cta_label, url=safe_dashboard_url, accent=accent))
body_parts.append(
f'<p style="margin:6px 0 0 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(note)}</p>'
)
rendered = _layout(
settings=settings,
preheader=preheader,
heading=heading,
intro_html=html.escape(intro),
body_html="".join(body_parts),
footer_html=footer,
)
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
+139 -44
View File
@@ -7,7 +7,6 @@ import logging
import time import time
from decimal import Decimal, ROUND_HALF_UP from decimal import Decimal, ROUND_HALF_UP
from typing import Optional, Dict, Any, Tuple from typing import Optional, Dict, Any, Tuple
from urllib.parse import parse_qsl
from aiohttp import ClientSession, ClientTimeout, web from aiohttp import ClientSession, ClientTimeout, web
from aiogram import Bot from aiogram import Bot
@@ -22,7 +21,6 @@ from bot.services.notification_service import NotificationService
from db.dal import payment_dal, user_dal from db.dal import payment_dal, user_dal
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from bot.utils.config_link import prepare_config_links from bot.utils.config_link import prepare_config_links
from bot.utils.request_security import ip_in_allowlist, request_client_ip
class FreeKassaService: class FreeKassaService:
@@ -46,7 +44,7 @@ class FreeKassaService:
self.shop_id: Optional[str] = settings.FREEKASSA_MERCHANT_ID self.shop_id: Optional[str] = settings.FREEKASSA_MERCHANT_ID
self.api_key: Optional[str] = settings.FREEKASSA_API_KEY self.api_key: Optional[str] = settings.FREEKASSA_API_KEY
self.second_secret: Optional[str] = settings.FREEKASSA_SECOND_SECRET self.second_secret: Optional[str] = settings.FREEKASSA_SECOND_SECRET
self.default_currency: str = (settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper() self.default_currency: str = "RUB"
self.server_ip: Optional[str] = settings.FREEKASSA_PAYMENT_IP self.server_ip: Optional[str] = settings.FREEKASSA_PAYMENT_IP
self.payment_method_id: Optional[int] = settings.FREEKASSA_PAYMENT_METHOD_ID self.payment_method_id: Optional[int] = settings.FREEKASSA_PAYMENT_METHOD_ID
@@ -80,11 +78,70 @@ class FreeKassaService:
ip_address: Optional[str] = None, ip_address: Optional[str] = None,
payment_method_id: Optional[int] = None, payment_method_id: Optional[int] = None,
extra_params: Optional[Dict[str, Any]] = None, extra_params: Optional[Dict[str, Any]] = None,
promo_code_service=None,
session=None,
) -> Tuple[bool, Dict[str, Any]]: ) -> Tuple[bool, Dict[str, Any]]:
if not self.configured: if not self.configured:
logging.error("FreeKassaService is not configured. Cannot create order.") logging.error("FreeKassaService is not configured. Cannot create order.")
return False, {"message": "service_not_configured"} 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
promo_code_id = active_discount.promo_code_id
denominator = 1 - discount_pct / 100
if denominator <= 0:
traffic_mode = bool(getattr(self.settings, "traffic_sale_mode", False))
price_source = (
getattr(self.settings, "traffic_packages", {}) or {}
if traffic_mode
else (self.settings.subscription_options or {})
)
fallback_original = price_source.get(months)
if fallback_original is not None:
original_amount = fallback_original
discount_amount = original_amount - amount
logging.info(
f"Recording {discount_pct}% discount for FreeKassa payment: "
f"original {original_amount:.2f} -> final {amount}"
)
else:
logging.warning(
"FreeKassa discount %s%% has invalid denominator and no fallback price for months=%s.",
discount_pct,
months,
)
else:
original_amount = amount / denominator
discount_amount = original_amount - amount
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 ip_address = ip_address or self.server_ip
if not ip_address: if not ip_address:
logging.error("FreeKassaService: payment IP is required but not configured.") logging.error("FreeKassaService: payment IP is required but not configured.")
@@ -137,7 +194,7 @@ class FreeKassaService:
return True, response_data return True, response_data
except Exception as exc: except Exception as exc:
logging.exception("FreeKassa create_order: request failed.") logging.error("FreeKassa create_order: request failed: %s", exc, exc_info=True)
return False, {"message": str(exc)} return False, {"message": str(exc)}
async def _get_session(self) -> ClientSession: async def _get_session(self) -> ClientSession:
@@ -171,59 +228,72 @@ class FreeKassaService:
def _validate_signature( def _validate_signature(
self, self,
raw_body: bytes, merchant_order_id: str,
amount: str,
provided_signature: str, provided_signature: str,
payload: Optional[Dict[str, Any]] = None,
) -> bool: ) -> bool:
if not provided_signature: if not provided_signature:
return False return False
if not self.second_secret:
return False
expected_signature = hmac.new( if self.shop_id and self.second_secret:
self.second_secret.encode("utf-8"), signature_source = f"{self.shop_id}:{amount}:{self.second_secret}:{merchant_order_id}"
raw_body, expected_signature = hashlib.md5(
hashlib.sha256, signature_source.encode("utf-8"),
).hexdigest() usedforsecurity=False,
return hmac.compare_digest(expected_signature, provided_signature) ).hexdigest()
if expected_signature.lower() == provided_signature.lower():
return True
if self.api_key and payload:
items = [
(key, value)
for key, value in payload.items()
if key not in {"signature", "SIGN"} and value is not None
]
items.sort(key=lambda pair: pair[0])
message = "|".join(str(value) for _, value in items)
alt_signature = hmac.new(self.api_key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
if alt_signature.lower() == provided_signature.lower():
return True
return False
async def webhook_route(self, request: web.Request) -> web.Response: async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured: if not self.configured:
return web.Response(status=503, text="freekassa_disabled") return web.Response(status=503, text="freekassa_disabled")
try: try:
client_ip = request_client_ip(request, trusted_proxies=self.settings.trusted_proxies) data = await request.post()
if not ip_in_allowlist(client_ip, self.settings.freekassa_trusted_ips): except Exception as e:
return web.Response(status=403) logging.error(f"FreeKassa webhook: failed to read POST data: {e}")
raw_body = await request.read()
except Exception:
logging.exception("FreeKassa webhook: failed to read request body.")
return web.Response(status=400, text="bad_request") return web.Response(status=400, text="bad_request")
payload_dict: Dict[str, Any] = {} payload_dict: Dict[str, Any]
if raw_body: if data:
payload_dict = {str(k): v for k, v in data.items()}
else:
try: try:
if request.content_type.startswith("application/json"): json_payload = await request.json()
decoded_json = json.loads(raw_body.decode("utf-8")) payload_dict = {str(k): v for k, v in json_payload.items()} if isinstance(json_payload, dict) else {}
if isinstance(decoded_json, dict): data = json_payload
payload_dict = {str(k): v for k, v in decoded_json.items()}
else:
payload_dict = {
str(key): value
for key, value in parse_qsl(raw_body.decode("utf-8"), keep_blank_values=True)
}
except Exception: except Exception:
payload_dict = {} payload_dict = {}
data = {}
def _get(key: str, default: Optional[str] = None) -> Optional[str]: def _get(key: str, default: Optional[str] = None) -> Optional[str]:
if isinstance(data, dict):
return data.get(key) or data.get(key.lower()) or default
return payload_dict.get(key) or payload_dict.get(key.lower()) or default return payload_dict.get(key) or payload_dict.get(key.lower()) or default
merchant_id = _get("MERCHANT_ID") merchant_id = _get("MERCHANT_ID")
if merchant_id != self.shop_id: if merchant_id != self.shop_id:
return web.Response(status=403) logging.error(f"FreeKassa webhook: merchant mismatch (got {merchant_id})")
return web.Response(status=403, text="merchant_mismatch")
signature = _get("SIGN") or _get("signature") signature = _get("SIGN") or _get("signature")
if not signature: if not signature:
logging.error("FreeKassa webhook: missing signature")
return web.Response(status=400, text="missing_signature") return web.Response(status=400, text="missing_signature")
order_id_str = _get("MERCHANT_ORDER_ID") or _get("ORDER_ID") or _get("o") order_id_str = _get("MERCHANT_ORDER_ID") or _get("ORDER_ID") or _get("o")
@@ -231,9 +301,11 @@ class FreeKassaService:
provider_payment_id = _get("intid") or _get("payment_id") or _get("transaction_id") provider_payment_id = _get("intid") or _get("payment_id") or _get("transaction_id")
if not order_id_str or not amount_str: if not order_id_str or not amount_str:
logging.error("FreeKassa webhook: missing order_id or amount")
return web.Response(status=400, text="missing_data") return web.Response(status=400, text="missing_data")
if not self._validate_signature(raw_body, signature): if not self._validate_signature(order_id_str, amount_str, signature, payload_dict):
logging.error("FreeKassa webhook: invalid signature")
return web.Response(status=403, text="invalid_signature") return web.Response(status=403, text="invalid_signature")
try: try:
@@ -248,6 +320,16 @@ class FreeKassaService:
logging.error(f"FreeKassa webhook: payment {payment_db_id} not found") logging.error(f"FreeKassa webhook: payment {payment_db_id} not found")
return web.Response(status=404, text="payment_not_found") return web.Response(status=404, text="payment_not_found")
if payment.currency and str(payment.currency).upper() != str(self.default_currency or payment.currency).upper():
# FreeKassa sends amount without currency; ensure DB currency matches configured service currency
logging.error(
"FreeKassa webhook: currency mismatch for payment %s (db=%s, expected=%s)",
payment_db_id,
payment.currency,
self.default_currency,
)
return web.Response(status=400, text="currency_mismatch")
if payment.status == "succeeded": if payment.status == "succeeded":
logging.info(f"FreeKassa webhook: payment {payment_db_id} already succeeded") logging.info(f"FreeKassa webhook: payment {payment_db_id} already succeeded")
return web.Response(text="YES") return web.Response(text="YES")
@@ -257,22 +339,30 @@ class FreeKassaService:
amount_decimal = Decimal(amount_str) amount_decimal = Decimal(amount_str)
expected_amount = Decimal(str(payment.amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) expected_amount = Decimal(str(payment.amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
if amount_decimal.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) != expected_amount: if amount_decimal.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) != expected_amount:
logging.warning( logging.error(
f"FreeKassa webhook: amount mismatch for payment {payment_db_id} " f"FreeKassa webhook: amount mismatch for payment {payment_db_id} "
f"(expected {expected_amount}, got {amount_decimal})" f"(expected {expected_amount}, got {amount_decimal})"
) )
return web.Response(status=400, text="amount_mismatch")
except Exception as e: except Exception as e:
logging.warning(f"FreeKassa webhook: failed to compare amount for payment {payment_db_id}: {e}") logging.error(f"FreeKassa webhook: failed to compare amount for payment {payment_db_id}: {e}")
return web.Response(status=400, text="amount_validation_error")
activation = None activation = None
referral_bonus = None referral_bonus = None
try: try:
await payment_dal.update_provider_payment_and_status( provider_id = str(provider_payment_id or f"freekassa:{order_id_str}")
marked = await payment_dal.mark_provider_payment_succeeded_once(
session=session, session=session,
payment_db_id=payment.payment_id, payment_db_id=payment.payment_id,
provider_payment_id=str(provider_payment_id or f"freekassa:{order_id_str}"), provider_payment_id=provider_id,
new_status="succeeded",
) )
if not marked:
logging.info(
"FreeKassa webhook: payment %s already processed atomically",
payment.payment_id,
)
return web.Response(text="YES")
months = payment.subscription_duration_months or 1 months = payment.subscription_duration_months or 1
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription" sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
@@ -283,10 +373,15 @@ class FreeKassaService:
int(months) if sale_mode != "traffic" else 0, int(months) if sale_mode != "traffic" else 0,
float(payment.amount), float(payment.amount),
payment.payment_id, payment.payment_id,
promo_code_id_from_payment=payment.promo_code_id,
provider="freekassa", provider="freekassa",
sale_mode=sale_mode, sale_mode=sale_mode,
traffic_gb=months if sale_mode == "traffic" else None, traffic_gb=months if sale_mode == "traffic" else None,
) )
if not activation or not activation.get("end_date"):
raise RuntimeError(
f"FreeKassa webhook: activation failed for payment {payment.payment_id}"
)
referral_bonus = None referral_bonus = None
if sale_mode != "traffic": if sale_mode != "traffic":
@@ -299,9 +394,9 @@ class FreeKassaService:
) )
await session.commit() await session.commit()
except Exception: except Exception as e:
await session.rollback() await session.rollback()
logging.exception("FreeKassa webhook: failed to process payment %s.", payment_db_id) logging.error(f"FreeKassa webhook: failed to process payment {payment_db_id}: {e}", exc_info=True)
return web.Response(status=500, text="processing_error") return web.Response(status=500, text="processing_error")
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id) db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
@@ -385,8 +480,8 @@ class FreeKassaService:
parse_mode="HTML", parse_mode="HTML",
disable_web_page_preview=True, disable_web_page_preview=True,
) )
except Exception: except Exception as e:
logging.exception("FreeKassa notification: failed to send message to user %s.", payment.user_id) logging.error(f"FreeKassa notification: failed to send message to user {payment.user_id}: {e}")
try: try:
notification_service = NotificationService(self.bot, self.settings, self.i18n) notification_service = NotificationService(self.bot, self.settings, self.i18n)
@@ -399,8 +494,8 @@ class FreeKassaService:
payment_provider="freekassa", payment_provider="freekassa",
username=db_user.username if db_user else None, username=db_user.username if db_user else None,
) )
except Exception: except Exception as e:
logging.exception("FreeKassa notification: failed to notify admins.") logging.error(f"FreeKassa notification: failed to notify admins: {e}")
return web.Response(text="YES") return web.Response(text="YES")
+39 -124
View File
@@ -120,8 +120,8 @@ class NotificationService:
logging.error( logging.error(
f"Failed to send notification to log channel {self.settings.LOG_CHAT_ID}: {exc}" f"Failed to send notification to log channel {self.settings.LOG_CHAT_ID}: {exc}"
) )
except Exception: except Exception as e:
logging.exception("Failed to send notification to log channel %s.", self.settings.LOG_CHAT_ID) logging.error(f"Failed to send notification to log channel {self.settings.LOG_CHAT_ID}: {e}")
return return
try: try:
@@ -143,8 +143,8 @@ class NotificationService:
# Queue message for sending (groups are rate limited to 15/minute) # Queue message for sending (groups are rate limited to 15/minute)
await queue_manager.send_message(self.settings.LOG_CHAT_ID, **kwargs) await queue_manager.send_message(self.settings.LOG_CHAT_ID, **kwargs)
except Exception: except Exception as e:
logging.exception("Failed to queue notification to log channel %s.", self.settings.LOG_CHAT_ID) logging.error(f"Failed to queue notification to log channel {self.settings.LOG_CHAT_ID}: {e}")
async def _send_to_admins(self, message: str): async def _send_to_admins(self, message: str):
"""Send message to all admin users using message queue""" """Send message to all admin users using message queue"""
@@ -162,8 +162,8 @@ class NotificationService:
parse_mode="HTML", parse_mode="HTML",
disable_web_page_preview=True disable_web_page_preview=True
) )
except Exception: except Exception as e:
logging.exception("Failed to send notification to admin %s.", admin_id) logging.error(f"Failed to send notification to admin {admin_id}: {e}")
return return
for admin_id in self.settings.ADMIN_IDS: for admin_id in self.settings.ADMIN_IDS:
@@ -174,8 +174,8 @@ class NotificationService:
parse_mode="HTML", parse_mode="HTML",
disable_web_page_preview=True disable_web_page_preview=True
) )
except Exception: except Exception as e:
logging.exception("Failed to queue notification to admin %s.", admin_id) logging.error(f"Failed to queue notification to admin {admin_id}: {e}")
async def notify_new_user_registration(self, user_id: int, username: Optional[str] = None, async def notify_new_user_registration(self, user_id: int, username: Optional[str] = None,
first_name: Optional[str] = None, first_name: Optional[str] = None,
@@ -212,118 +212,7 @@ class NotificationService:
# Send to log channel # Send to log channel
profile_keyboard = self._build_profile_keyboard(_, user_id, referred_by_id) profile_keyboard = self._build_profile_keyboard(_, user_id, referred_by_id)
await self._send_to_log_channel(message, reply_markup=profile_keyboard) await self._send_to_log_channel(message, reply_markup=profile_keyboard)
async def notify_new_email_user_registration(
self,
user_id: int,
email: str,
referred_by_id: Optional[int] = None,
):
"""Send notification about new user registration via email (Web App)."""
if not self.settings.LOG_NEW_USERS:
return
admin_lang = self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
referral_text = ""
if 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_email_user_registration",
user_id=user_id,
email=hd.quote(email),
referral_text=referral_text,
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
)
# Email users have a synthetic (negative) user_id with no Telegram profile,
# so we only attach the referrer button when a real referrer is present.
reply_markup: Optional[InlineKeyboardMarkup] = None
if referred_by_id and referred_by_id > 0:
reply_markup = InlineKeyboardMarkup(inline_keyboard=[[
InlineKeyboardButton(
text=_("log_open_referrer_profile_button"),
url=f"tg://user?id={referred_by_id}",
)
]])
await self._send_to_log_channel(message, reply_markup=reply_markup)
async def notify_account_email_linked(
self,
user_id: int,
email: str,
telegram_id: Optional[int] = None,
username: Optional[str] = None,
first_name: Optional[str] = None,
):
"""Send notification when an email is linked to a Telegram-created account."""
if not self.settings.LOG_NEW_USERS:
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=telegram_id or user_id,
username=username,
first_name=first_name,
)
message = _(
"log_account_email_linked",
user_id=user_id,
telegram_id=telegram_id or user_id,
user_display=user_display,
email=hd.quote(email),
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
)
reply_markup: Optional[InlineKeyboardMarkup] = None
if telegram_id and telegram_id > 0:
reply_markup = self._build_profile_keyboard(_, telegram_id)
await self._send_to_log_channel(message, reply_markup=reply_markup)
async def notify_account_telegram_linked(
self,
user_id: int,
email: Optional[str],
telegram_id: int,
username: Optional[str] = None,
first_name: Optional[str] = None,
):
"""Send notification when Telegram is linked to an email-created account."""
if not self.settings.LOG_NEW_USERS:
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=telegram_id,
username=username,
first_name=first_name,
)
message = _(
"log_account_telegram_linked",
user_id=user_id,
telegram_id=telegram_id,
user_display=user_display,
email=hd.quote(email or ""),
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
)
profile_keyboard = self._build_profile_keyboard(_, telegram_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, async def notify_payment_received(self, user_id: int, amount: float, currency: str,
months: int, payment_provider: str, months: int, payment_provider: str,
username: Optional[str] = None, username: Optional[str] = None,
@@ -382,15 +271,15 @@ class NotificationService:
"""Send notification about promo code activation""" """Send notification about promo code activation"""
if not self.settings.LOG_PROMO_ACTIVATIONS: if not self.settings.LOG_PROMO_ACTIVATIONS:
return return
admin_lang = self.settings.DEFAULT_LANGUAGE admin_lang = self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k _ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
user_display = self._format_user_display( user_display = self._format_user_display(
user_id=user_id, user_id=user_id,
username=username, username=username,
) )
message = _( message = _(
"log_promo_activation", "log_promo_activation",
user_display=user_display, user_display=user_display,
@@ -398,7 +287,33 @@ class NotificationService:
bonus_days=bonus_days, bonus_days=bonus_days,
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S") 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_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 # Send to log channel
profile_keyboard = self._build_profile_keyboard(_, user_id) profile_keyboard = self._build_profile_keyboard(_, user_id)
await self._send_to_log_channel(message, reply_markup=profile_keyboard) await self._send_to_log_channel(message, reply_markup=profile_keyboard)
+39 -8
View File
@@ -59,6 +59,29 @@ class PanelApiService:
headers["Authorization"] = f"Bearer {self.api_key}" headers["Authorization"] = f"Bearer {self.api_key}"
return headers return headers
@staticmethod
def _sanitize_payload_for_log(payload: Any) -> Any:
if isinstance(payload, dict):
redacted: Dict[str, Any] = {}
for key, value in payload.items():
lowered = str(key).lower()
if any(mask_key in lowered for mask_key in (
"token",
"secret",
"password",
"authorization",
"api_key",
"apikey",
"key",
)):
redacted[key] = "***"
else:
redacted[key] = PanelApiService._sanitize_payload_for_log(value)
return redacted
if isinstance(payload, list):
return [PanelApiService._sanitize_payload_for_log(item) for item in payload]
return payload
async def _request(self, async def _request(self,
method: str, method: str,
endpoint: str, endpoint: str,
@@ -83,8 +106,8 @@ class PanelApiService:
if current_params: if current_params:
try: try:
url_with_params_for_log += "?" + urlencode(current_params) url_with_params_for_log += "?" + urlencode(current_params)
except Exception: except Exception as exc:
pass logging.debug("Failed to encode params for panel API log URL: %s", exc)
json_payload_for_log = kwargs.get('json') if method.upper() in [ json_payload_for_log = kwargs.get('json') if method.upper() in [
"POST", "PATCH", "PUT" "POST", "PATCH", "PUT"
@@ -92,10 +115,11 @@ class PanelApiService:
log_prefix = f"Panel API Req: {method.upper()} {url_with_params_for_log}" log_prefix = f"Panel API Req: {method.upper()} {url_with_params_for_log}"
if json_payload_for_log: if json_payload_for_log:
try: try:
payload_str = json.dumps(json_payload_for_log) sanitized_payload = self._sanitize_payload_for_log(json_payload_for_log)
payload_str = json.dumps(sanitized_payload)
log_prefix += f" | Payload: {payload_str[:300]}{'...' if len(payload_str) > 300 else ''}" log_prefix += f" | Payload: {payload_str[:300]}{'...' if len(payload_str) > 300 else ''}"
except Exception: except Exception:
log_prefix += f" | Payload: {str(json_payload_for_log)[:300]}..." log_prefix += " | Payload: <unavailable>"
try: try:
async with aiohttp_session.request(method.upper(), async with aiohttp_session.request(method.upper(),
url_for_request, url_for_request,
@@ -106,7 +130,8 @@ class PanelApiService:
log_suffix = f"| Status: {response_status}" log_suffix = f"| Status: {response_status}"
if log_full_response or not (200 <= response_status < 300): should_log_full_body = bool(log_full_response and self.settings.LOG_LEVEL == "DEBUG")
if should_log_full_body or not (200 <= response_status < 300):
try: try:
parsed_json_for_log = json.loads(response_text) parsed_json_for_log = json.loads(response_text)
pretty_response_text = json.dumps(parsed_json_for_log, pretty_response_text = json.dumps(parsed_json_for_log,
@@ -174,7 +199,7 @@ class PanelApiService:
"message": f"Connection error: {str(e)}" "message": f"Connection error: {str(e)}"
} }
except aiohttp.ClientError as e: except aiohttp.ClientError as e:
logging.exception("Panel API ClientError to %s.", url_for_request) logging.error(f"Panel API ClientError to {url_for_request}: {e}")
return { return {
"error": True, "error": True,
"status_code": -2, "status_code": -2,
@@ -404,7 +429,10 @@ class PanelApiService:
return response return response
logging.error( logging.error(
f"Failed to create panel user '{username_on_panel}'. Payload: {payload}, Response: {response if not log_response else '(full response logged above)'}" "Failed to create panel user '%s'. Payload: %s, Response: %s",
username_on_panel,
self._sanitize_payload_for_log(payload),
response if not log_response else "(full response logged above)",
) )
return response return response
@@ -426,7 +454,10 @@ class PanelApiService:
return full_response.get("response") return full_response.get("response")
logging.error( logging.error(
f"Failed to update user {user_uuid} details on panel. Payload: {update_payload}, Response: {full_response if not log_response else '(logged above)'}" "Failed to update user %s details on panel. Payload: %s, Response: %s",
user_uuid,
self._sanitize_payload_for_log(update_payload),
full_response if not log_response else "(logged above)",
) )
return None return None
+9 -50
View File
@@ -9,8 +9,6 @@ from sqlalchemy.orm import sessionmaker
from typing import Optional from typing import Optional
from config.settings import Settings from config.settings import Settings
from .panel_api_service import PanelApiService from .panel_api_service import PanelApiService
from .email_auth_service import EmailAuthService
from .email_templates import render_subscription_expiring
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup, get_autorenew_cancel_keyboard from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup, get_autorenew_cancel_keyboard
from db.dal import user_dal from db.dal import user_dal
@@ -28,10 +26,6 @@ class PanelWebhookService:
self.i18n = i18n self.i18n = i18n
self.async_session_factory = async_session_factory self.async_session_factory = async_session_factory
self.panel_service = panel_service self.panel_service = panel_service
if not self.settings.PANEL_WEBHOOK_SECRET:
logging.error(
"PANEL_WEBHOOK_SECRET is not configured. Panel webhooks will be rejected."
)
async def _send_message( async def _send_message(
self, self,
@@ -46,8 +40,8 @@ class PanelWebhookService:
await self.bot.send_message( await self.bot.send_message(
user_id, _(message_key, **kwargs), reply_markup=reply_markup user_id, _(message_key, **kwargs), reply_markup=reply_markup
) )
except Exception: except Exception as e:
logging.exception("Failed to send notification to %s", user_id) logging.error(f"Failed to send notification to {user_id}: {e}")
async def handle_event(self, event_name: str, user_payload: dict): async def handle_event(self, event_name: str, user_payload: dict):
telegram_id = user_payload.get("telegramId") telegram_id = user_payload.get("telegramId")
@@ -60,13 +54,9 @@ class PanelWebhookService:
return return
async with self.async_session_factory() as session: async with self.async_session_factory() as session:
db_user = await user_dal.get_user_by_telegram_id(session, user_id) db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user:
db_user = await user_dal.get_user_by_id(session, user_id)
internal_user_id = db_user.user_id if db_user else user_id
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE 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}" first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}"
user_email = (db_user.email or "").strip() if db_user else ""
markup = get_subscribe_only_markup(lang, self.i18n) markup = get_subscribe_only_markup(lang, self.i18n)
@@ -79,7 +69,7 @@ class PanelWebhookService:
if subscription_service: if subscription_service:
async with self.async_session_factory() as session: async with self.async_session_factory() as session:
from db.dal import subscription_dal from db.dal import subscription_dal
sub = await subscription_dal.get_active_subscription_by_user_id(session, internal_user_id) sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
if sub and sub.auto_renew_enabled and sub.provider == 'yookassa': if sub and sub.auto_renew_enabled and sub.provider == 'yookassa':
try: try:
ok = await subscription_service.charge_subscription_renewal(session, sub) ok = await subscription_service.charge_subscription_renewal(session, sub)
@@ -99,7 +89,7 @@ class PanelWebhookService:
if days_left == 2: if days_left == 2:
async with self.async_session_factory() as session: async with self.async_session_factory() as session:
from db.dal import subscription_dal from db.dal import subscription_dal
sub = await subscription_dal.get_active_subscription_by_user_id(session, internal_user_id) sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
logging.info( logging.info(
"48h webhook check: user_id=%s sub_found=%s auto_renew=%s provider=%s", "48h webhook check: user_id=%s sub_found=%s auto_renew=%s provider=%s",
user_id, user_id,
@@ -125,13 +115,6 @@ class PanelWebhookService:
user_name=first_name, user_name=first_name,
end_date=user_payload.get("expireAt", "")[:10], end_date=user_payload.get("expireAt", "")[:10],
) )
if days_left == 3 and user_email:
await self._send_subscription_expiring_email(
recipient=user_email,
lang=lang,
days_left=days_left,
end_date_text=user_payload.get("expireAt", "")[:10],
)
elif event_name == "user.expired": elif event_name == "user.expired":
if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE: if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
await self._send_message( await self._send_message(
@@ -152,44 +135,20 @@ class PanelWebhookService:
end_date=user_payload.get("expireAt", "")[:10], end_date=user_payload.get("expireAt", "")[:10],
) )
async def _send_subscription_expiring_email(
self,
*,
recipient: str,
lang: str,
days_left: int,
end_date_text: str,
) -> None:
"""Best-effort branded reminder; silently no-ops without SMTP config."""
if not self.settings.email_auth_configured:
return
try:
content = render_subscription_expiring(
self.settings,
language_code=lang,
days_left=days_left,
end_date_text=end_date_text,
dashboard_url=(self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None,
)
email_service = EmailAuthService(self.settings)
await email_service.send_rendered_email(email=recipient, content=content)
except Exception:
logging.exception("Failed to send subscription-expiring email to %s", recipient)
async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response: async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response:
if not self.settings.PANEL_WEBHOOK_SECRET: if not self.settings.PANEL_WEBHOOK_SECRET:
return web.Response(status=401, text="unauthorized") logging.critical("Panel webhook rejected: PANEL_WEBHOOK_SECRET is not configured")
return web.Response(status=503, text="panel_webhook_secret_required")
if not signature_header: if not signature_header:
return web.Response(status=401, text="unauthorized") return web.Response(status=403, text="no_signature")
expected_sig = hmac.new( expected_sig = hmac.new(
self.settings.PANEL_WEBHOOK_SECRET.encode(), self.settings.PANEL_WEBHOOK_SECRET.encode(),
raw_body, raw_body,
hashlib.sha256, hashlib.sha256,
).hexdigest() ).hexdigest()
if not hmac.compare_digest(expected_sig, signature_header): if not hmac.compare_digest(expected_sig, signature_header):
return web.Response(status=401, text="unauthorized") return web.Response(status=403, text="invalid_signature")
try: try:
payload = json.loads(raw_body.decode()) payload = json.loads(raw_body.decode())
+107 -46
View File
@@ -1,4 +1,3 @@
import hmac
import json import json
import logging import logging
from decimal import Decimal, ROUND_HALF_UP from decimal import Decimal, ROUND_HALF_UP
@@ -42,8 +41,6 @@ class PlategaService:
self.merchant_id = settings.PLATEGA_MERCHANT_ID self.merchant_id = settings.PLATEGA_MERCHANT_ID
self.secret = settings.PLATEGA_SECRET self.secret = settings.PLATEGA_SECRET
self.payment_method = settings.PLATEGA_PAYMENT_METHOD self.payment_method = settings.PLATEGA_PAYMENT_METHOD
self.sbp_method = settings.platega_sbp_method_resolved
self.crypto_method = settings.PLATEGA_CRYPTO_METHOD
self.return_url = settings.PLATEGA_RETURN_URL or f"https://t.me/{default_return_url}" 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.failed_url = settings.PLATEGA_FAILED_URL or self.return_url
@@ -59,14 +56,6 @@ class PlategaService:
) )
if not self.configured: if not self.configured:
logging.warning("PlategaService initialized but not fully configured. Payments disabled.") logging.warning("PlategaService initialized but not fully configured. Payments disabled.")
else:
logging.info(
"PlategaService configured. SBP button: %s (method=%s), Crypto button: %s (method=%s)",
"ON" if settings.PLATEGA_SBP_ENABLED else "OFF",
self.sbp_method,
"ON" if settings.PLATEGA_CRYPTO_ENABLED else "OFF",
self.crypto_method,
)
async def _get_session(self) -> ClientSession: async def _get_session(self) -> ClientSession:
if self._session is None or self._session.closed: if self._session is None or self._session.closed:
@@ -87,19 +76,76 @@ class PlategaService:
currency: Optional[str], currency: Optional[str],
description: str, description: str,
payload: Optional[str] = None, payload: Optional[str] = None,
payment_method: Optional[int] = None, promo_code_service=None,
session=None,
) -> Tuple[bool, Dict[str, Any]]: ) -> Tuple[bool, Dict[str, Any]]:
if not self.configured: if not self.configured:
logging.error("PlategaService is not configured. Cannot create transaction.") logging.error("PlategaService is not configured. Cannot create transaction.")
return False, {"message": "service_not_configured"} return False, {"message": "service_not_configured"}
session = await self._get_session() # 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
promo_code_id = active_discount.promo_code_id
denominator = 1 - discount_pct / 100
if denominator <= 0:
traffic_mode = bool(getattr(self.settings, "traffic_sale_mode", False))
price_source = (
getattr(self.settings, "traffic_packages", {}) or {}
if traffic_mode
else (self.settings.subscription_options or {})
)
fallback_original = price_source.get(months)
if fallback_original is not None:
original_amount = fallback_original
discount_amount = original_amount - amount
logging.info(
f"Recording {discount_pct}% discount for Platega payment: "
f"original {original_amount:.2f} -> final {amount}"
)
else:
logging.warning(
"Platega discount %s%% has invalid denominator and no fallback price for months=%s.",
discount_pct,
months,
)
else:
original_amount = amount / denominator
discount_amount = original_amount - amount
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" url = f"{self.base_url}/transaction/process"
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper() currency_code = (currency or "RUB").upper()
method_id = int(payment_method if payment_method is not None else self.payment_method)
body: Dict[str, Any] = { body: Dict[str, Any] = {
"paymentMethod": method_id, "paymentMethod": int(self.payment_method),
"paymentDetails": {"amount": float(amount), "currency": currency_code}, "paymentDetails": {"amount": float(amount), "currency": currency_code},
"description": description, "description": description,
"return": self.return_url, "return": self.return_url,
@@ -109,15 +155,9 @@ class PlategaService:
# Remove optional keys with falsy values to avoid validation errors # 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, "")} clean_body = {k: v for k, v in body.items() if v not in (None, "")}
safe_headers = {
"X-MerchantId": self._auth_headers.get("X-MerchantId"),
"X-Secret": "***" if self._auth_headers.get("X-Secret") else "",
"Content-Type": self._auth_headers.get("Content-Type"),
}
logging.info("Platega create_transaction request: url=%s headers=%s body=%s", url, safe_headers, clean_body)
try: try:
async with session.post(url, json=clean_body, headers=self._auth_headers) as response: async with http_session.post(url, json=clean_body, headers=self._auth_headers) as response:
response_text = await response.text() response_text = await response.text()
try: try:
response_data = json.loads(response_text) if response_text else {} response_data = json.loads(response_text) if response_text else {}
@@ -139,7 +179,7 @@ class PlategaService:
return True, response_data return True, response_data
except Exception as exc: except Exception as exc:
logging.exception("Platega create_transaction: request failed.") logging.error("Platega create_transaction: request failed: %s", exc, exc_info=True)
return False, {"message": str(exc)} return False, {"message": str(exc)}
async def webhook_route(self, request: web.Request) -> web.Response: async def webhook_route(self, request: web.Request) -> web.Response:
@@ -148,23 +188,20 @@ class PlategaService:
try: try:
data = await request.json() data = await request.json()
except Exception: except Exception as exc:
logging.exception("Platega webhook: failed to parse JSON.") logging.error("Platega webhook: failed to parse JSON: %s", exc)
return web.Response(status=400, text="bad_request") return web.Response(status=400, text="bad_request")
header_merchant = request.headers.get("X-MerchantId") header_merchant = request.headers.get("X-MerchantId")
header_secret = request.headers.get("X-Secret") header_secret = request.headers.get("X-Secret")
if not ( if header_merchant != self.merchant_id or header_secret != self.secret:
hmac.compare_digest(str(header_merchant or ""), str(self.merchant_id or ""))
and hmac.compare_digest(str(header_secret or ""), str(self.secret or ""))
):
logging.error("Platega webhook: invalid auth headers") logging.error("Platega webhook: invalid auth headers")
return web.Response(status=403, text="forbidden") return web.Response(status=403, text="forbidden")
transaction_id = str(data.get("id") or data.get("transactionId") or "").strip() transaction_id = str(data.get("id") or data.get("transactionId") or "").strip()
status = str(data.get("status") or "").upper() status = str(data.get("status") or "").upper()
amount_raw = data.get("amount") amount_raw = data.get("amount")
currency = data.get("currency") or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB" currency = data.get("currency") or "RUB"
if not transaction_id or not status: if not transaction_id or not status:
logging.error("Platega webhook: missing transaction id or status in payload: %s", data) logging.error("Platega webhook: missing transaction id or status in payload: %s", data)
@@ -183,27 +220,46 @@ class PlategaService:
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription" sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
if status == "CONFIRMED": if status == "CONFIRMED":
if currency:
provider_currency = str(currency).upper()
expected_currency = str(payment.currency or "").upper()
if expected_currency and expected_currency != provider_currency:
logging.error(
"Platega webhook: currency mismatch for payment %s (expected %s, got %s)",
payment.payment_id,
expected_currency,
provider_currency,
)
return web.Response(status=400, text="currency_mismatch")
if amount_raw is not None: if amount_raw is not None:
try: try:
incoming_amount = Decimal(str(amount_raw)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) 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) expected_amount = Decimal(str(payment.amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
if incoming_amount != expected_amount: if incoming_amount != expected_amount:
logging.warning( logging.error(
"Platega webhook: amount mismatch for payment %s (expected %s, got %s)", "Platega webhook: amount mismatch for payment %s (expected %s, got %s)",
payment.payment_id, payment.payment_id,
expected_amount, expected_amount,
incoming_amount, incoming_amount,
) )
return web.Response(status=400, text="amount_mismatch")
except Exception as exc: except Exception as exc:
logging.warning("Platega webhook: failed to compare amounts for %s: %s", payment.payment_id, exc) logging.error("Platega webhook: failed to compare amounts for %s: %s", payment.payment_id, exc)
return web.Response(status=400, text="amount_validation_error")
try: try:
await payment_dal.update_provider_payment_and_status( marked = await payment_dal.mark_provider_payment_succeeded_once(
session, session,
payment.payment_id, payment.payment_id,
transaction_id, transaction_id,
"succeeded",
) )
if not marked:
logging.info(
"Platega webhook: payment %s already processed atomically",
payment.payment_id,
)
return web.Response(text="ok")
activation = await self.subscription_service.activate_subscription( activation = await self.subscription_service.activate_subscription(
session, session,
@@ -211,10 +267,15 @@ class PlategaService:
int(payment_months) if sale_mode != "traffic" else 0, int(payment_months) if sale_mode != "traffic" else 0,
float(payment.amount), float(payment.amount),
payment.payment_id, payment.payment_id,
promo_code_id_from_payment=payment.promo_code_id,
provider="platega", provider="platega",
sale_mode=sale_mode, sale_mode=sale_mode,
traffic_gb=payment_months if sale_mode == "traffic" else None, traffic_gb=payment_months if sale_mode == "traffic" else None,
) )
if not activation or not activation.get("end_date"):
raise RuntimeError(
f"Platega webhook: activation failed for payment {payment.payment_id}"
)
referral_bonus = None referral_bonus = None
if sale_mode != "traffic": if sale_mode != "traffic":
@@ -227,9 +288,9 @@ class PlategaService:
) )
await session.commit() await session.commit()
except Exception: except Exception as exc:
await session.rollback() await session.rollback()
logging.exception("Platega webhook: failed to process payment %s.", transaction_id) logging.error("Platega webhook: failed to process payment %s: %s", transaction_id, exc, exc_info=True)
return web.Response(status=500, text="processing_error") return web.Response(status=500, text="processing_error")
db_user = await user_dal.get_user_by_id(session, payment.user_id) db_user = await user_dal.get_user_by_id(session, payment.user_id)
@@ -308,8 +369,8 @@ class PlategaService:
parse_mode="HTML", parse_mode="HTML",
disable_web_page_preview=True, disable_web_page_preview=True,
) )
except Exception: except Exception as exc:
logging.exception("Platega webhook: failed to notify user %s.", payment.user_id) logging.error("Platega webhook: failed to notify user %s: %s", payment.user_id, exc)
try: try:
notification_service = NotificationService(self.bot, self.settings, self.i18n) notification_service = NotificationService(self.bot, self.settings, self.i18n)
@@ -322,12 +383,12 @@ class PlategaService:
payment_provider="platega", payment_provider="platega",
username=db_user.username if db_user else None, username=db_user.username if db_user else None,
) )
except Exception: except Exception as exc:
logging.exception("Platega webhook: failed to notify admins.") logging.error("Platega webhook: failed to notify admins: %s", exc)
return web.Response(text="ok") return web.Response(text="ok")
if status in {"CANCELED", "CANCELLED", "CHARGEBACKED"}: if status in {"CANCELED", "CANCELLED", "CHARGEBACK", "CHARGEBACKED"}:
try: try:
await payment_dal.update_provider_payment_and_status( await payment_dal.update_provider_payment_and_status(
session, session,
@@ -336,9 +397,9 @@ class PlategaService:
"canceled", "canceled",
) )
await session.commit() await session.commit()
except Exception: except Exception as exc:
await session.rollback() await session.rollback()
logging.exception("Platega webhook: failed to cancel payment %s.", transaction_id) logging.error("Platega webhook: failed to cancel payment %s: %s", transaction_id, exc)
return web.Response(status=500, text="processing_error") return web.Response(status=500, text="processing_error")
db_user = await user_dal.get_user_by_id(session, payment.user_id) db_user = await user_dal.get_user_by_id(session, payment.user_id)
@@ -346,8 +407,8 @@ class PlategaService:
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k _ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
try: try:
await self.bot.send_message(payment.user_id, _("payment_failed")) await self.bot.send_message(payment.user_id, _("payment_failed"))
except Exception: except Exception as exc:
pass logging.debug("Platega webhook: failed to send cancellation message to user %s: %s", payment.user_id, exc)
return web.Response(text="ok_canceled") return web.Response(text="ok_canceled")
logging.warning("Platega webhook: unhandled status '%s' for transaction %s", status, transaction_id) logging.warning("Platega webhook: unhandled status '%s' for transaction %s", status, transaction_id)
+406 -43
View File
@@ -1,15 +1,14 @@
import logging import logging
from html import escape as html_escape import asyncio
from datetime import datetime from datetime import datetime, timezone, timedelta
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional, Tuple, Dict from typing import Optional, Tuple
from aiogram import Bot from aiogram import Bot
from sqlalchemy.orm import sessionmaker
from config.settings import Settings from config.settings import Settings
from db.dal import security_dal
from db.dal import promo_code_dal, user_dal from db.dal import promo_code_dal, user_dal, active_discount_dal, payment_dal
from db.models import PromoCode, User
from .subscription_service import SubscriptionService from .subscription_service import SubscriptionService
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
@@ -25,9 +24,122 @@ class PromoCodeService:
self.subscription_service = subscription_service self.subscription_service = subscription_service
self.bot = bot self.bot = bot
self.i18n = i18n self.i18n = i18n
self.discount_payment_timeout_minutes = max(
1,
int(getattr(settings, "DISCOUNT_PROMO_PAYMENT_TIMEOUT_MINUTES", 10) or 10),
)
self._discount_expiration_task: Optional[asyncio.Task] = None
self._async_session_factory: Optional[sessionmaker] = None
def _throttle_identifier(self, user_id: int) -> str: async def setup_discount_expiration_worker(
return f"user:{int(user_id)}" self,
async_session_factory: sessionmaker,
) -> None:
"""Attach DB session factory and start background cleanup loop."""
self._async_session_factory = async_session_factory
if self._discount_expiration_task and not self._discount_expiration_task.done():
return
self._discount_expiration_task = asyncio.create_task(
self._discount_expiration_loop(),
name="PromoDiscountExpirationLoop",
)
logging.info("PromoCodeService: started discount expiration background worker.")
async def close(self) -> None:
"""Gracefully stop background workers."""
if not self._discount_expiration_task:
return
self._discount_expiration_task.cancel()
try:
await self._discount_expiration_task
except asyncio.CancelledError:
pass
except Exception:
logging.exception("PromoCodeService: failed while stopping expiration worker")
finally:
self._discount_expiration_task = None
async def _discount_expiration_loop(self) -> None:
"""Periodically clears expired discount reservations and notifies users."""
while True:
try:
if not self._async_session_factory:
await asyncio.sleep(30)
continue
await self._process_expired_discounts_once()
except asyncio.CancelledError:
logging.info("PromoCodeService: discount expiration loop cancelled.")
raise
except Exception:
logging.exception("PromoCodeService: unhandled error in discount expiration loop")
await asyncio.sleep(30)
async def _process_expired_discounts_once(self) -> None:
if not self._async_session_factory:
return
now_utc = datetime.now(timezone.utc)
notifications_to_send: list[tuple[int, str]] = []
async with self._async_session_factory() as session:
expired_discounts = await active_discount_dal.get_expired_active_discounts(
session,
now=now_utc,
limit=100,
)
if not expired_discounts:
return
for expired in expired_discounts:
cleared = await active_discount_dal.clear_active_discount_if_matches(
session,
user_id=expired.user_id,
promo_code_id=expired.promo_code_id,
expires_at_lte=now_utc,
)
if not cleared:
continue
await promo_code_dal.decrement_promo_code_usage(session, expired.promo_code_id)
db_user = await user_dal.get_user_by_id(session, expired.user_id)
user_lang = (
db_user.language_code
if db_user and db_user.language_code
else self.settings.DEFAULT_LANGUAGE
)
promo = await promo_code_dal.get_promo_code_by_id(session, expired.promo_code_id)
promo_code = promo.code if promo else ""
message_text = self.i18n.gettext(
user_lang,
"discount_promo_expired_need_reactivate",
code_part=(f" (<code>{promo_code}</code>)" if promo_code else ""),
)
notifications_to_send.append((expired.user_id, message_text))
logging.info(
"Expired discount reservation removed: user=%s, promo=%s",
expired.user_id,
expired.promo_code_id,
)
await session.commit()
for user_id, message_text in notifications_to_send:
try:
await self.bot.send_message(
chat_id=user_id,
text=message_text,
parse_mode="HTML",
)
except Exception:
logging.exception(
"Failed to send discount expiration message to user %s",
user_id,
)
async def apply_promo_code( async def apply_promo_code(
self, self,
@@ -37,45 +149,19 @@ class PromoCodeService:
user_lang: str, user_lang: str,
) -> Tuple[bool, datetime | str]: ) -> Tuple[bool, datetime | str]:
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw) _ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
code_input_upper = (code_input or "").strip().upper()[:100] code_input_upper = code_input.strip().upper()
code_display = html_escape(code_input_upper[:100], quote=False)
throttle_identifier = self._throttle_identifier(user_id)
throttle = await security_dal.check_throttle( promo_data = await promo_code_dal.get_active_bonus_promo_code_by_code_str(
session,
scope=security_dal.PROMO_CODE_APPLY_SCOPE,
identifier=throttle_identifier,
)
if throttle.locked:
return False, _(
"promo_code_too_many_attempts",
seconds=throttle.retry_after or max(1, int(self.settings.BRUTE_FORCE_LOCK_SECONDS)),
)
promo_data = await promo_code_dal.get_active_promo_code_by_code_str(
session, code_input_upper) session, code_input_upper)
if not promo_data: if not promo_data:
throttle_result = await security_dal.record_throttle_failure( return False, _("promo_code_not_found", code=code_input_upper)
session,
scope=security_dal.PROMO_CODE_APPLY_SCOPE,
identifier=throttle_identifier,
max_failures=self.settings.BRUTE_FORCE_MAX_FAILURES,
window_seconds=self.settings.BRUTE_FORCE_WINDOW_SECONDS,
lock_seconds=self.settings.BRUTE_FORCE_LOCK_SECONDS,
)
if throttle_result.locked:
return False, _(
"promo_code_too_many_attempts",
seconds=throttle_result.retry_after or max(1, int(self.settings.BRUTE_FORCE_LOCK_SECONDS)),
)
return False, _("promo_code_not_found", code=code_display)
existing_activation = await promo_code_dal.get_user_activation_for_promo( existing_activation = await promo_code_dal.get_user_activation_for_promo(
session, promo_data.promo_code_id, user_id) session, promo_data.promo_code_id, user_id)
if existing_activation: if existing_activation:
return False, _("promo_code_already_used_by_user", return False, _("promo_code_already_used_by_user",
code=code_display) code=code_input_upper)
bonus_days = promo_data.bonus_days bonus_days = promo_data.bonus_days
@@ -92,11 +178,6 @@ class PromoCodeService:
session, promo_data.promo_code_id) session, promo_data.promo_code_id)
if activation_recorded and promo_incremented: if activation_recorded and promo_incremented:
await security_dal.clear_throttle_state(
session,
scope=security_dal.PROMO_CODE_APPLY_SCOPE,
identifier=throttle_identifier,
)
# Send notification about promo activation # Send notification about promo activation
try: try:
notification_service = NotificationService(self.bot, self.settings, self.i18n) notification_service = NotificationService(self.bot, self.settings, self.i18n)
@@ -119,3 +200,285 @@ class PromoCodeService:
return False, _("error_applying_promo_bonus") return False, _("error_applying_promo_bonus")
else: else:
return False, _("error_applying_promo_bonus") 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,
include_expired=True,
)
if existing_discount:
now_utc = datetime.now(timezone.utc)
if existing_discount.expires_at <= now_utc:
cleared = await active_discount_dal.clear_active_discount_if_expired(
session,
user_id,
now=now_utc,
)
if cleared:
await promo_code_dal.decrement_promo_code_usage(
session,
existing_discount.promo_code_id,
)
existing_discount = None
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)
# Reserve discount for limited time and count activation immediately
expires_at = datetime.now(timezone.utc) + timedelta(
minutes=self.discount_payment_timeout_minutes,
)
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,
expires_at=expires_at,
)
if not active_discount:
# This shouldn't happen since we checked above, but just in case
return False, _("error_applying_promo_discount")
promo_incremented = await promo_code_dal.increment_promo_code_usage(
session,
promo_data.promo_code_id,
)
if not promo_incremented:
await active_discount_dal.clear_active_discount_if_matches(
session,
user_id=user_id,
promo_code_id=promo_data.promo_code_id,
)
return False, _("promo_code_not_found_or_not_discount", code=code_input_upper)
logging.info(
f"Discount promo code {code_input_upper} activated for user {user_id}: "
f"{promo_data.discount_percentage}% off until {expires_at.isoformat()}"
)
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,
include_expired=True,
)
if not active_discount:
return None
now_utc = datetime.now(timezone.utc)
if active_discount.expires_at <= now_utc:
cleared = await active_discount_dal.clear_active_discount_if_expired(
session,
user_id,
now=now_utc,
)
if cleared:
await promo_code_dal.decrement_promo_code_usage(
session,
active_discount.promo_code_id,
)
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}"
)
cleared = await active_discount_dal.clear_active_discount(session, user_id)
if cleared:
await promo_code_dal.decrement_promo_code_usage(session, promo.promo_code_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 discount after successful payment.
The payment record is the source of truth. Even if the active reservation was
concurrently expired/cleared, we still record promo activation and reconcile
current_activations so successful discounted payments are always accounted for.
"""
payment_record = await payment_dal.get_payment_by_db_id(session, payment_id)
if not payment_record:
logging.warning(
"Payment %s not found for discount consumption (user %s).",
payment_id,
user_id,
)
return False
if not payment_record.discount_applied:
return False
promo_code_id = payment_record.promo_code_id
if not promo_code_id:
logging.warning(
"Payment %s for user %s has discount_applied but no promo_code_id.",
payment_id,
user_id,
)
return False
existing_activation = await promo_code_dal.get_user_activation_for_promo(
session, promo_code_id, user_id
)
activation_created = False
if existing_activation:
if existing_activation.payment_id is None:
updated_payment = await promo_code_dal.set_activation_payment_id(
session, promo_code_id, user_id, payment_id
)
if updated_payment:
logging.info(
"Linked discount promo %s activation to payment %s for user %s.",
promo_code_id,
payment_id,
user_id,
)
else:
activation_recorded = await promo_code_dal.record_promo_activation(
session,
promo_code_id,
user_id,
payment_id=payment_id,
)
if not activation_recorded:
logging.error(
"Failed to record discount activation for user %s, promo %s.",
user_id,
promo_code_id,
)
return False
activation_created = True
active_discount = await active_discount_dal.get_active_discount(
session,
user_id,
include_expired=True,
)
# Reservation is best-effort cleanup at this point; payment success already happened.
if active_discount and active_discount.promo_code_id == promo_code_id:
await active_discount_dal.clear_active_discount_if_matches(
session,
user_id=user_id,
promo_code_id=promo_code_id,
)
elif active_discount and active_discount.promo_code_id != promo_code_id:
logging.info(
"Active discount promo %s differs from payment promo %s during consumption.",
active_discount.promo_code_id,
promo_code_id,
)
else:
logging.info(
"Discount reservation already absent at consumption time (user=%s, promo=%s, payment=%s)",
user_id,
promo_code_id,
payment_id,
)
# If reservation was already expired/removed and we had to create activation now,
# restore current_activations to match the successful payment.
if activation_created:
await promo_code_dal.increment_promo_code_usage(
session,
promo_code_id,
allow_overflow=True,
)
await session.flush()
logging.info(
"Discount consumed for user %s, promo %s, payment %s",
user_id,
promo_code_id,
payment_id,
)
return True
+10
View File
@@ -32,6 +32,13 @@ class ReferralService:
current_payment_db_id: Optional[int] = None, current_payment_db_id: Optional[int] = None,
skip_if_active_before_payment: bool = True) -> Dict[str, Any]: skip_if_active_before_payment: bool = True) -> Dict[str, Any]:
if not getattr(self.settings, "REFERRAL_ENABLED", True):
return {
"referee_bonus_applied_days": None,
"referee_new_end_date": None,
"inviter_bonus_applied_flag": False,
}
referee_final_end_date: Optional[datetime] = None referee_final_end_date: Optional[datetime] = None
referee_bonus_applied_days: Optional[int] = None referee_bonus_applied_days: Optional[int] = None
inviter_bonus_successfully_applied = False inviter_bonus_successfully_applied = False
@@ -260,6 +267,9 @@ class ReferralService:
async def generate_referral_link(self, session: AsyncSession, async def generate_referral_link(self, session: AsyncSession,
bot_username: str, bot_username: str,
inviter_user_id: int) -> Optional[str]: inviter_user_id: int) -> Optional[str]:
if not getattr(self.settings, "REFERRAL_ENABLED", True):
return None
try: try:
user = await user_dal.get_user_by_id(session, inviter_user_id) user = await user_dal.get_user_by_id(session, inviter_user_id)
if not user: if not user:
+125 -21
View File
@@ -99,14 +99,73 @@ class SeverPayService:
amount: float, amount: float,
currency: Optional[str], currency: Optional[str],
description: str, description: str,
promo_code_service=None,
session=None,
) -> Tuple[bool, Dict[str, Any]]: ) -> Tuple[bool, Dict[str, Any]]:
if not self.configured: if not self.configured:
logging.error("SeverPayService is not configured. Cannot create payment.") logging.error("SeverPayService is not configured. Cannot create payment.")
return False, {"message": "service_not_configured"} return False, {"message": "service_not_configured"}
session = await self._get_session() # 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
promo_code_id = active_discount.promo_code_id
denominator = 1 - discount_pct / 100
if denominator <= 0:
traffic_mode = bool(getattr(self.settings, "traffic_sale_mode", False))
price_source = (
getattr(self.settings, "traffic_packages", {}) or {}
if traffic_mode
else (self.settings.subscription_options or {})
)
fallback_original = price_source.get(months)
if fallback_original is not None:
original_amount = fallback_original
discount_amount = original_amount - amount
logging.info(
f"Recording {discount_pct}% discount for SeverPay payment: "
f"original {original_amount:.2f} -> final {amount}"
)
else:
logging.warning(
"SeverPay discount %s%% has invalid denominator and no fallback price for months=%s.",
discount_pct,
months,
)
else:
original_amount = amount / denominator
discount_amount = original_amount - amount
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" url = f"{self.base_url}/payin/create"
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper() currency_code = (currency or "RUB").upper()
amount_str = self._format_amount(amount) amount_str = self._format_amount(amount)
body = { body = {
@@ -124,7 +183,7 @@ class SeverPayService:
signed_body = self._build_signed_body(body) signed_body = self._build_signed_body(body)
try: try:
async with session.post(url, json=signed_body) as response: async with http_session.post(url, json=signed_body) as response:
response_text = await response.text() response_text = await response.text()
try: try:
response_data = json.loads(response_text) if response_text else {} response_data = json.loads(response_text) if response_text else {}
@@ -142,7 +201,7 @@ class SeverPayService:
return True, response_data.get("data") or response_data return True, response_data.get("data") or response_data
except Exception as exc: except Exception as exc:
logging.exception("SeverPay create_payment: request failed.") logging.error("SeverPay create_payment: request failed: %s", exc, exc_info=True)
return False, {"message": str(exc)} return False, {"message": str(exc)}
async def webhook_route(self, request: web.Request) -> web.Response: async def webhook_route(self, request: web.Request) -> web.Response:
@@ -151,8 +210,8 @@ class SeverPayService:
try: try:
payload = await request.json() payload = await request.json()
except Exception: except Exception as exc:
logging.exception("SeverPay webhook: failed to parse JSON.") logging.error("SeverPay webhook: failed to parse JSON: %s", exc)
return web.json_response({"status": False, "msg": "bad_request"}, status=400) return web.json_response({"status": False, "msg": "bad_request"}, status=400)
if not isinstance(payload, dict) or not self._validate_signature(payload): if not isinstance(payload, dict) or not self._validate_signature(payload):
@@ -169,6 +228,8 @@ class SeverPayService:
provider_payment_id = str(data.get("id") or data.get("uid") or "") provider_payment_id = str(data.get("id") or data.get("uid") or "")
order_id_raw = data.get("order_id") order_id_raw = data.get("order_id")
status = str(data.get("status") or "").lower() status = str(data.get("status") or "").lower()
amount_raw = data.get("amount")
currency_raw = data.get("currency")
payment_db_id: Optional[int] = None payment_db_id: Optional[int] = None
try: try:
@@ -190,16 +251,58 @@ class SeverPayService:
logging.error("SeverPay webhook: payment not found (order_id=%s, provider_id=%s)", order_id_raw, provider_payment_id) 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) return web.json_response({"status": False, "msg": "payment_not_found"}, status=404)
if payment.status == "succeeded" and status == "success":
logging.info("SeverPay webhook: payment %s already succeeded", payment.payment_id)
return web.json_response({"status": True})
if status == "success" and 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.error(
"SeverPay webhook: amount mismatch for payment %s (expected %s, got %s)",
payment.payment_id,
expected_amount,
incoming_amount,
)
return web.json_response({"status": False, "msg": "amount_mismatch"}, status=400)
except Exception as exc:
logging.error(
"SeverPay webhook: failed to compare amounts for payment %s: %s",
payment.payment_id,
exc,
)
return web.json_response({"status": False, "msg": "amount_validation_error"}, status=400)
if currency_raw:
provider_currency = str(currency_raw).upper()
expected_currency = str(payment.currency or "").upper()
if expected_currency and provider_currency != expected_currency:
logging.error(
"SeverPay webhook: currency mismatch for payment %s (expected %s, got %s)",
payment.payment_id,
expected_currency,
provider_currency,
)
return web.json_response({"status": False, "msg": "currency_mismatch"}, status=400)
payment_months = payment.subscription_duration_months or 1 payment_months = payment.subscription_duration_months or 1
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription" sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
if status == "success": if status == "success":
try: try:
await payment_dal.update_provider_payment_and_status( provider_id = provider_payment_id or str(payment.payment_id)
marked = await payment_dal.mark_provider_payment_succeeded_once(
session, session,
payment.payment_id, payment.payment_id,
provider_payment_id or str(payment.payment_id), provider_id,
"succeeded",
) )
if not marked:
logging.info(
"SeverPay webhook: payment %s already processed atomically",
payment.payment_id,
)
return web.json_response({"status": True})
activation = await self.subscription_service.activate_subscription( activation = await self.subscription_service.activate_subscription(
session, session,
@@ -207,6 +310,7 @@ class SeverPayService:
int(payment_months) if sale_mode != "traffic" else 0, int(payment_months) if sale_mode != "traffic" else 0,
float(payment.amount), float(payment.amount),
payment.payment_id, payment.payment_id,
promo_code_id_from_payment=payment.promo_code_id,
provider="severpay", provider="severpay",
sale_mode=sale_mode, sale_mode=sale_mode,
traffic_gb=payment_months if sale_mode == "traffic" else None, traffic_gb=payment_months if sale_mode == "traffic" else None,
@@ -223,9 +327,9 @@ class SeverPayService:
) )
await session.commit() await session.commit()
except Exception: except Exception as exc:
await session.rollback() await session.rollback()
logging.exception("SeverPay webhook: failed to process payment %s.", provider_payment_id) 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) 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) db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
@@ -304,8 +408,8 @@ class SeverPayService:
parse_mode="HTML", parse_mode="HTML",
disable_web_page_preview=True, disable_web_page_preview=True,
) )
except Exception: except Exception as exc:
logging.exception("SeverPay webhook: failed to notify user %s.", payment.user_id) logging.error("SeverPay webhook: failed to notify user %s: %s", payment.user_id, exc)
try: try:
notification_service = NotificationService(self.bot, self.settings, self.i18n) notification_service = NotificationService(self.bot, self.settings, self.i18n)
@@ -318,8 +422,8 @@ class SeverPayService:
payment_provider="severpay", payment_provider="severpay",
username=db_user.username if db_user else None, username=db_user.username if db_user else None,
) )
except Exception: except Exception as exc:
logging.exception("SeverPay webhook: failed to notify admins.") logging.error("SeverPay webhook: failed to notify admins: %s", exc)
return web.json_response({"status": True}) return web.json_response({"status": True})
@@ -332,9 +436,9 @@ class SeverPayService:
"failed", "failed",
) )
await session.commit() await session.commit()
except Exception: except Exception as exc:
await session.rollback() await session.rollback()
logging.exception("SeverPay webhook: failed to mark payment %s as failed.", provider_payment_id) 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) 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) db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
@@ -342,8 +446,8 @@ class SeverPayService:
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k _ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
try: try:
await self.bot.send_message(payment.user_id, _("payment_failed")) await self.bot.send_message(payment.user_id, _("payment_failed"))
except Exception: except Exception as exc:
pass logging.debug("SeverPay webhook: failed to send cancellation message to user %s: %s", payment.user_id, exc)
return web.json_response({"status": True}) return web.json_response({"status": True})
if status in {"process", "new"}: if status in {"process", "new"}:
@@ -355,9 +459,9 @@ class SeverPayService:
"pending_severpay", "pending_severpay",
) )
await session.commit() await session.commit()
except Exception: except Exception as exc:
await session.rollback() await session.rollback()
logging.exception("SeverPay webhook: failed to update pending status for %s.", provider_payment_id) logging.error("SeverPay webhook: failed to update pending status for %s: %s", provider_payment_id, exc)
return web.json_response({"status": True}) return web.json_response({"status": True})
logging.warning("SeverPay webhook: unhandled status '%s' for payment %s", status, provider_payment_id) logging.warning("SeverPay webhook: unhandled status '%s' for payment %s", status, provider_payment_id)
+129 -34
View File
@@ -1,4 +1,5 @@
import logging import logging
import math
from typing import Optional from typing import Optional
from aiogram import Bot, types from aiogram import Bot, types
@@ -26,16 +27,94 @@ class StarsService:
self.subscription_service = subscription_service self.subscription_service = subscription_service
self.referral_service = referral_service self.referral_service = referral_service
async def create_invoice(self, session: AsyncSession, user_id: int, months: int, def _resolve_base_stars_price(self, months: float, sale_mode: str) -> Optional[int]:
stars_price: int, description: str, sale_mode: str = "subscription") -> Optional[int]: stars_price_source = (
self.settings.stars_traffic_packages
if sale_mode == "traffic"
else self.settings.stars_subscription_options
)
if sale_mode != "traffic":
months_key = int(months) if float(months).is_integer() else months
base_price = stars_price_source.get(months_key)
if base_price is not None:
return base_price
if float(months).is_integer():
return stars_price_source.get(float(months_key))
return None
base_price = stars_price_source.get(months)
if base_price is not None:
return base_price
for package_size, package_price in stars_price_source.items():
if math.isclose(float(package_size), float(months), rel_tol=0.0, abs_tol=1e-9):
return package_price
return None
async def create_invoice(self, session: AsyncSession, user_id: int, months: float,
stars_price: int, description: str, sale_mode: str = "subscription",
promo_code_service=None) -> Optional[int]:
# Always resolve base price server-side and reject unknown packages.
resolved_base_price = self._resolve_base_stars_price(months, sale_mode)
if resolved_base_price is None:
logging.warning(
"Stars invoice rejected: base price not found for sale_mode=%s months=%s.",
sale_mode,
months,
)
return None
original_stars_price = int(resolved_base_price)
# Detect callback tampering (or stale callback payload) and prefer server-side price.
if int(stars_price) != original_stars_price:
logging.warning(
"Stars callback price mismatch for user %s: callback=%s, resolved=%s, sale_mode=%s, months=%s",
user_id,
stars_price,
original_stars_price,
sale_mode,
months,
)
# Invoice amount starts from the base price and discount is applied once.
stars_price = original_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(original_stars_price), promo_code_service
)
if discount_float:
stars_price = math.ceil(final_price_float)
discount_amount_stars = original_stars_price - stars_price
logging.info(
"Stars discount applied: %s -> %.2f -> %s (ceiling)",
original_stars_price,
final_price_float,
stars_price,
)
payment_record_data = { payment_record_data = {
"user_id": user_id, "user_id": user_id,
"amount": float(stars_price), "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", "currency": "XTR",
"status": "pending_stars", "status": "pending_stars",
"description": description, "description": description,
"subscription_duration_months": int(months), "subscription_duration_months": int(months),
"provider": "telegram_stars", "provider": "telegram_stars",
"promo_code_id": promo_code_id,
} }
try: try:
db_payment_record = await payment_dal.create_payment_record( db_payment_record = await payment_dal.create_payment_record(
@@ -55,7 +134,7 @@ class StarsService:
title=description, title=description,
description=description, description=description,
payload=payload, payload=payload,
provider_token="", # Required to be empty for Telegram Stars (XTR) per Telegram Bot API. provider_token=self.settings.STARS_PROVIDER_TOKEN or "",
currency="XTR", currency="XTR",
prices=prices, prices=prices,
) )
@@ -72,45 +151,61 @@ class StarsService:
stars_amount: int, stars_amount: int,
i18n_data: dict, i18n_data: dict,
sale_mode: str = "subscription") -> None: 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
activation_details = None
referral_bonus = None
try: try:
await payment_dal.update_provider_payment_and_status( provider_payment_id = str(
session, payment_db_id, message.successful_payment.provider_payment_charge_id
message.successful_payment.provider_payment_charge_id, or f"stars:{payment_db_id}"
"succeeded") )
marked = await payment_dal.mark_provider_payment_succeeded_once(
session,
payment_db_id,
provider_payment_id,
)
if not marked:
logging.info(
"Stars payment %s already processed atomically",
payment_db_id,
)
return
activation_details = await self.subscription_service.activate_subscription(
session,
message.from_user.id,
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"):
raise RuntimeError(
f"Failed to activate subscription after stars payment {payment_db_id}"
)
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() await session.commit()
except Exception as e_upd: except Exception as e_upd:
await session.rollback() await session.rollback()
logging.error( logging.error(
f"Failed to update stars payment record {payment_db_id}: {e_upd}", f"Failed to process stars payment record {payment_db_id}: {e_upd}",
exc_info=True) exc_info=True)
return return
activation_details = await self.subscription_service.activate_subscription(
session,
message.from_user.id,
int(months) if sale_mode != "traffic" else 0,
float(stars_amount),
payment_db_id,
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 = 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 applied_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 final_end = referral_bonus.get("referee_new_end_date") if referral_bonus else None
if not final_end: if not final_end:
+105 -201
View File
@@ -5,15 +5,13 @@ from typing import Optional, Dict, Any, List, Tuple
from aiogram import Bot from aiogram import Bot
from bot.middlewares.i18n import JsonI18n 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, user_billing_dal, payment_dal
from bot.utils.date_utils import add_months from bot.utils.date_utils import add_months
from bot.utils.config_link import prepare_config_links from bot.utils.config_link import prepare_config_links
from db.models import User, Subscription from db.models import User, Subscription
from config.settings import Settings from config.settings import Settings
from .panel_api_service import PanelApiService from .panel_api_service import PanelApiService
from .email_auth_service import EmailAuthService
from .email_templates import render_payment_success
class SubscriptionService: class SubscriptionService:
@@ -57,33 +55,6 @@ class SubscriptionService:
except Exception: except Exception:
return False return False
def _extract_panel_traffic_details(
self, panel_user_data: Dict[str, Any]
) -> Tuple[Optional[int], Optional[int], Optional[str]]:
traffic_stats = panel_user_data.get("userTraffic") or {}
used = traffic_stats.get("usedTrafficBytes")
if used is None:
used = panel_user_data.get("usedTrafficBytes")
limit = panel_user_data.get("trafficLimitBytes")
strategy = panel_user_data.get("trafficLimitStrategy")
if strategy is None:
strategy = traffic_stats.get("trafficLimitStrategy")
return used, limit, strategy
def _extract_lifetime_used_traffic(
self, panel_user_data: Dict[str, Any]
) -> Optional[int]:
traffic_stats = panel_user_data.get("userTraffic") or {}
lifetime = traffic_stats.get("lifetimeUsedTrafficBytes")
if lifetime is None:
lifetime = panel_user_data.get("lifetimeUsedTrafficBytes")
try:
if lifetime is None:
return None
return int(lifetime)
except (TypeError, ValueError):
return None
async def _notify_admin_panel_user_creation_failed(self, user_id: int): async def _notify_admin_panel_user_creation_failed(self, user_id: int):
if not self.bot or not self.i18n or not self.settings.ADMIN_IDS: if not self.bot or not self.i18n or not self.settings.ADMIN_IDS:
return return
@@ -98,42 +69,6 @@ class SubscriptionService:
f"Failed to notify admin {admin_id} about panel user creation failure: {e}" f"Failed to notify admin {admin_id} about panel user creation failure: {e}"
) )
def _telegram_id_for_panel(self, db_user: User) -> Optional[int]:
if db_user.telegram_id:
return int(db_user.telegram_id)
if db_user.user_id and int(db_user.user_id) > 0:
return int(db_user.user_id)
return None
async def _panel_username_for_user(
self, session: AsyncSession, db_user: User
) -> str:
telegram_id = self._telegram_id_for_panel(db_user)
if telegram_id and int(db_user.user_id) == telegram_id:
return f"tg_{telegram_id}"
referral_code = await user_dal.ensure_referral_code(session, db_user)
return f"em_{referral_code}"
def _panel_description_for_user(self, db_user: User) -> str:
lines = [
db_user.email or "",
db_user.username or "",
db_user.first_name or "",
db_user.last_name or "",
]
return "\n".join(line for line in lines if line).strip()
def _panel_identity_payload_for_user(self, db_user: User) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"description": self._panel_description_for_user(db_user),
}
telegram_id = self._telegram_id_for_panel(db_user)
if telegram_id:
payload["telegramId"] = telegram_id
if db_user.email:
payload["email"] = db_user.email
return payload
async def _get_or_create_panel_user_link_details( async def _get_or_create_panel_user_link_details(
self, session: AsyncSession, user_id: int, db_user: Optional[User] = None self, session: AsyncSession, user_id: int, db_user: Optional[User] = None
) -> Tuple[Optional[str], Optional[str], Optional[str], bool]: ) -> Tuple[Optional[str], Optional[str], Optional[str], bool]:
@@ -147,45 +82,25 @@ class SubscriptionService:
return None, None, None, False return None, None, None, False
current_local_panel_uuid = db_user.panel_user_uuid current_local_panel_uuid = db_user.panel_user_uuid
panel_username_on_panel_standard = await self._panel_username_for_user( panel_username_on_panel_standard = f"tg_{user_id}"
session, db_user
)
telegram_id_for_panel = self._telegram_id_for_panel(db_user)
panel_user_obj_from_api = None panel_user_obj_from_api = None
panel_user_created_or_linked_now = False panel_user_created_or_linked_now = False
panel_users_by_tg_id_list = None panel_users_by_tg_id_list = await self.panel_service.get_users_by_filter(
if telegram_id_for_panel: telegram_id=user_id
panel_users_by_tg_id_list = await self.panel_service.get_users_by_filter( )
telegram_id=telegram_id_for_panel
)
if panel_users_by_tg_id_list and len(panel_users_by_tg_id_list) == 1: if panel_users_by_tg_id_list and len(panel_users_by_tg_id_list) == 1:
panel_user_obj_from_api = panel_users_by_tg_id_list[0] panel_user_obj_from_api = panel_users_by_tg_id_list[0]
logging.info( logging.info(
f"Found panel user by telegramId {telegram_id_for_panel}: UUID {panel_user_obj_from_api.get('uuid')}, Username: {panel_user_obj_from_api.get('username')}" f"Found panel user by telegramId {user_id}: UUID {panel_user_obj_from_api.get('uuid')}, Username: {panel_user_obj_from_api.get('username')}"
) )
elif panel_users_by_tg_id_list and len(panel_users_by_tg_id_list) > 1: elif panel_users_by_tg_id_list and len(panel_users_by_tg_id_list) > 1:
logging.error( logging.error(
f"CRITICAL: Multiple panel users found for telegramId {telegram_id_for_panel}. Manual intervention needed." f"CRITICAL: Multiple panel users found for telegramId {user_id}. Manual intervention needed."
) )
return None, None, None, False return None, None, None, False
if not panel_user_obj_from_api and db_user.email:
panel_users_by_email_list = await self.panel_service.get_users_by_filter(
email=db_user.email
)
if panel_users_by_email_list and len(panel_users_by_email_list) == 1:
panel_user_obj_from_api = panel_users_by_email_list[0]
logging.info(
f"Found panel user by email {db_user.email}: UUID {panel_user_obj_from_api.get('uuid')}, Username: {panel_user_obj_from_api.get('username')}"
)
elif panel_users_by_email_list and len(panel_users_by_email_list) > 1:
logging.error(
f"CRITICAL: Multiple panel users found for email {db_user.email}. Manual intervention needed."
)
return None, None, None, False
if not panel_user_obj_from_api: if not panel_user_obj_from_api:
if current_local_panel_uuid: if current_local_panel_uuid:
@@ -204,9 +119,12 @@ class SubscriptionService:
) )
creation_response = await self.panel_service.create_panel_user( creation_response = await self.panel_service.create_panel_user(
username_on_panel=panel_username_on_panel_standard, username_on_panel=panel_username_on_panel_standard,
telegram_id=telegram_id_for_panel, telegram_id=user_id,
email=db_user.email, description="\n".join([
description=self._panel_description_for_user(db_user), (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 "",
]),
specific_squad_uuids=self.settings.parsed_user_squad_uuids, specific_squad_uuids=self.settings.parsed_user_squad_uuids,
external_squad_uuid=self.settings.parsed_user_external_squad_uuid, external_squad_uuid=self.settings.parsed_user_external_squad_uuid,
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes, default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
@@ -230,9 +148,12 @@ class SubscriptionService:
) )
creation_response = await self.panel_service.create_panel_user( creation_response = await self.panel_service.create_panel_user(
username_on_panel=panel_username_on_panel_standard, username_on_panel=panel_username_on_panel_standard,
telegram_id=telegram_id_for_panel, telegram_id=user_id,
email=db_user.email, description="\n".join([
description=self._panel_description_for_user(db_user), (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 "",
]),
specific_squad_uuids=self.settings.parsed_user_squad_uuids, specific_squad_uuids=self.settings.parsed_user_squad_uuids,
external_squad_uuid=self.settings.parsed_user_external_squad_uuid, external_squad_uuid=self.settings.parsed_user_external_squad_uuid,
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes, default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
@@ -278,6 +199,7 @@ class SubscriptionService:
) )
actual_panel_uuid_from_api = panel_user_obj_from_api.get("uuid") actual_panel_uuid_from_api = panel_user_obj_from_api.get("uuid")
actual_panel_username_from_api = panel_user_obj_from_api.get("username")
panel_telegram_id_from_api = panel_user_obj_from_api.get("telegramId") panel_telegram_id_from_api = panel_user_obj_from_api.get("telegramId")
if not actual_panel_uuid_from_api: if not actual_panel_uuid_from_api:
@@ -344,15 +266,24 @@ class SubscriptionService:
if ( if (
panel_user_obj_from_api panel_user_obj_from_api
and current_local_panel_uuid and current_local_panel_uuid
and telegram_id_for_panel and panel_telegram_id_int != user_id
and panel_telegram_id_int != telegram_id_for_panel
): ):
logging.info( logging.info(
f"Panel user {current_local_panel_uuid} has telegramId '{panel_telegram_id_from_api}'. Updating on panel to '{telegram_id_for_panel}'." f"Panel user {current_local_panel_uuid} has telegramId '{panel_telegram_id_from_api}'. Updating on panel to '{user_id}'."
) )
# Also set readable description with Telegram fields
await self.panel_service.update_user_details_on_panel( await self.panel_service.update_user_details_on_panel(
current_local_panel_uuid, current_local_panel_uuid,
self._panel_identity_payload_for_user(db_user), {
"telegramId": user_id,
"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 "",
]
),
},
) )
panel_sub_link_id = panel_user_obj_from_api.get( panel_sub_link_id = panel_user_obj_from_api.get(
@@ -427,7 +358,6 @@ class SubscriptionService:
"is_active": True, "is_active": True,
"status_from_panel": "TRIAL", "status_from_panel": "TRIAL",
"traffic_limit_bytes": self.settings.trial_traffic_limit_bytes, "traffic_limit_bytes": self.settings.trial_traffic_limit_bytes,
"traffic_limit_strategy": self.settings.TRIAL_TRAFFIC_STRATEGY,
"auto_renew_enabled": False, "auto_renew_enabled": False,
} }
try: try:
@@ -449,10 +379,16 @@ class SubscriptionService:
expire_at=end_date, expire_at=end_date,
status="ACTIVE", status="ACTIVE",
traffic_limit_bytes=self.settings.trial_traffic_limit_bytes, traffic_limit_bytes=self.settings.trial_traffic_limit_bytes,
traffic_limit_strategy=self.settings.TRIAL_TRAFFIC_STRATEGY,
) )
panel_update_payload.update(self._panel_identity_payload_for_user(db_user)) # Add user description based on Telegram profile
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( updated_panel_user = await self.panel_service.update_user_details_on_panel(
panel_user_uuid, panel_update_payload panel_user_uuid, panel_update_payload
@@ -508,7 +444,9 @@ class SubscriptionService:
return None return None
panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid) or {} panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid) or {}
current_used, current_limit, _ = self._extract_panel_traffic_details(panel_user_data) 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( active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, panel_user_uuid session, user_id, panel_user_uuid
@@ -562,7 +500,13 @@ class SubscriptionService:
traffic_limit_strategy="NO_RESET", traffic_limit_strategy="NO_RESET",
) )
panel_update_payload.update(self._panel_identity_payload_for_user(db_user)) 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( updated_panel_user = await self.panel_service.update_user_details_on_panel(
panel_user_uuid, panel_update_payload panel_user_uuid, panel_update_payload
@@ -578,16 +522,6 @@ class SubscriptionService:
final_subscription_url = updated_panel_user.get("subscriptionUrl") final_subscription_url = updated_panel_user.get("subscriptionUrl")
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid) final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
await self._send_payment_success_email(
db_user=db_user,
sale_mode="traffic",
months=0,
traffic_gb=float(traffic_gb),
payment_amount=payment_amount,
end_date=None,
provider=provider,
)
return { return {
"subscription_id": new_or_updated_sub.subscription_id, "subscription_id": new_or_updated_sub.subscription_id,
"end_date": final_end_date, "end_date": final_end_date,
@@ -670,7 +604,7 @@ class SubscriptionService:
and promo_model.is_active and promo_model.is_active
and promo_model.current_activations < promo_model.max_activations and promo_model.current_activations < promo_model.max_activations
): ):
applied_promo_bonus_days = promo_model.bonus_days applied_promo_bonus_days = promo_model.bonus_days or 0
duration_days_total += applied_promo_bonus_days duration_days_total += applied_promo_bonus_days
activation = await promo_code_dal.record_promo_activation( activation = await promo_code_dal.record_promo_activation(
@@ -681,7 +615,7 @@ class SubscriptionService:
) )
if activation: if activation:
await promo_code_dal.increment_promo_code_usage( await promo_code_dal.increment_promo_code_usage(
session, promo_code_id_from_payment session, promo_code_id_from_payment, allow_overflow=True
) )
else: else:
logging.warning( logging.warning(
@@ -736,7 +670,14 @@ class SubscriptionService:
traffic_limit_bytes=self.settings.user_traffic_limit_bytes, traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
) )
panel_update_payload.update(self._panel_identity_payload_for_user(db_user)) # Add user description based on Telegram profile
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( updated_panel_user = await self.panel_service.update_user_details_on_panel(
panel_user_uuid, panel_update_payload panel_user_uuid, panel_update_payload
@@ -750,15 +691,21 @@ class SubscriptionService:
final_subscription_url = updated_panel_user.get("subscriptionUrl") final_subscription_url = updated_panel_user.get("subscriptionUrl")
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid) final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
await self._send_payment_success_email( # Consume discount promo code if payment had one
db_user=db_user, try:
sale_mode="subscription", promo_code_service = getattr(self, "promo_code_service", None)
months=months_int, if not promo_code_service:
traffic_gb=None, from .promo_code_service import PromoCodeService
payment_amount=payment_amount,
end_date=final_end_date, promo_code_service = PromoCodeService(
provider=provider, self.settings, self, self.bot, self.i18n
) )
await promo_code_service.consume_discount(session, user_id, 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 { return {
"subscription_id": new_or_updated_sub.subscription_id, "subscription_id": new_or_updated_sub.subscription_id,
@@ -911,22 +858,13 @@ class SubscriptionService:
await user_dal.update_user(session, user_id, {"panel_user_uuid": None}) await user_dal.update_user(session, user_id, {"panel_user_uuid": None})
return None return None
panel_lifetime_used = self._extract_lifetime_used_traffic(panel_user_data)
if (
panel_lifetime_used is not None
and db_user.lifetime_used_traffic_bytes != panel_lifetime_used
):
await user_dal.update_user(
session,
user_id,
{"lifetime_used_traffic_bytes": panel_lifetime_used},
)
if local_active_sub: if local_active_sub:
update_payload_local = {} update_payload_local = {}
panel_status = panel_user_data.get("status", "UNKNOWN").upper() panel_status = panel_user_data.get("status", "UNKNOWN").upper()
panel_expire_at_str = panel_user_data.get("expireAt") panel_expire_at_str = panel_user_data.get("expireAt")
panel_traffic_used, panel_traffic_limit, _ = self._extract_panel_traffic_details(panel_user_data) 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( panel_sub_uuid_from_panel = panel_user_data.get(
"subscriptionUuid" "subscriptionUuid"
) or panel_user_data.get("shortUuid") ) or panel_user_data.get("shortUuid")
@@ -979,7 +917,6 @@ class SubscriptionService:
if panel_user_data.get("expireAt") if panel_user_data.get("expireAt")
else None else None
) )
panel_traffic_used, panel_traffic_limit, panel_traffic_strategy = self._extract_panel_traffic_details(panel_user_data)
config_link_raw = panel_user_data.get("subscriptionUrl") config_link_raw = panel_user_data.get("subscriptionUrl")
display_link, connect_button_url = await prepare_config_links(self.settings, config_link_raw) display_link, connect_button_url = await prepare_config_links(self.settings, config_link_raw)
hwid_limit = panel_user_data.get("hwidDeviceLimit") hwid_limit = panel_user_data.get("hwidDeviceLimit")
@@ -992,9 +929,8 @@ class SubscriptionService:
"status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(), "status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(),
"config_link": display_link, "config_link": display_link,
"connect_button_url": connect_button_url, "connect_button_url": connect_button_url,
"traffic_limit_bytes": panel_traffic_limit, "traffic_limit_bytes": panel_user_data.get("trafficLimitBytes"),
"traffic_used_bytes": panel_traffic_used, "traffic_used_bytes": (panel_user_data.get("userTraffic") or {}).get("usedTrafficBytes"),
"traffic_limit_strategy": panel_traffic_strategy,
"user_bot_username": db_user.username, "user_bot_username": db_user.username,
"is_panel_data": True, "is_panel_data": True,
"max_devices": hwid_limit, "max_devices": hwid_limit,
@@ -1071,15 +1007,30 @@ class SubscriptionService:
logging.error(f"Auto-renew price missing for {months} months") logging.error(f"Auto-renew price missing for {months} months")
return False return False
payment_description = f"Auto-renewal for {months} months"
payment_record = await payment_dal.create_payment_record(
session,
{
"user_id": sub.user_id,
"amount": float(amount),
"currency": "RUB",
"status": "pending_yookassa",
"description": payment_description,
"subscription_duration_months": int(months),
"provider": "yookassa",
},
)
metadata = { metadata = {
"user_id": str(sub.user_id), "user_id": str(sub.user_id),
"auto_renew_for_subscription_id": str(sub.subscription_id), "auto_renew_for_subscription_id": str(sub.subscription_id),
"subscription_months": str(months), "subscription_months": str(months),
"payment_db_id": str(payment_record.payment_id),
} }
resp = await yk.create_payment( resp = await yk.create_payment(
amount=float(amount), amount=float(amount),
currency="RUB", currency="RUB",
description=f"Auto-renewal for {months} months", description=payment_description,
metadata=metadata, metadata=metadata,
payment_method_id=default_pm.provider_payment_method_id, payment_method_id=default_pm.provider_payment_method_id,
save_payment_method=False, save_payment_method=False,
@@ -1088,64 +1039,17 @@ class SubscriptionService:
if not resp or resp.get("status") not in {"pending", "waiting_for_capture", "succeeded"}: if not resp or resp.get("status") not in {"pending", "waiting_for_capture", "succeeded"}:
logging.error(f"Auto-renew create_payment failed: {resp}") logging.error(f"Auto-renew create_payment failed: {resp}")
return False return False
provider_payment_id = resp.get("id")
if provider_payment_id:
await payment_dal.update_provider_payment_and_status(
session,
payment_db_id=payment_record.payment_id,
provider_payment_id=provider_payment_id,
new_status="pending_yookassa",
)
logging.info(f"Auto-renew initiated for user {sub.user_id} payment_id={resp.get('id')}") logging.info(f"Auto-renew initiated for user {sub.user_id} payment_id={resp.get('id')}")
return True return True
_PROVIDER_LABELS = {
"yookassa": "YooKassa",
"freekassa": "FreeKassa",
"platega": "Platega",
"severpay": "SeverPay",
"cryptopay": "Crypto Pay",
"crypto_pay": "Crypto Pay",
"stars": "Telegram Stars",
"tribute": "Tribute",
}
async def _send_payment_success_email(
self,
*,
db_user: User,
sale_mode: str,
months: int,
traffic_gb: Optional[float],
payment_amount: float,
end_date: Optional[datetime],
provider: str,
) -> None:
"""Best-effort branded email confirming the payment. No-op if SMTP or
the user's email aren't set. Failures are logged and swallowed so the
payment flow is never blocked by mail delivery."""
if not self.settings.email_auth_configured:
return
recipient = (db_user.email or "").strip() if db_user else ""
if not recipient:
return
end_date_text = end_date.strftime("%Y-%m-%d") if end_date else ""
provider_label = self._PROVIDER_LABELS.get((provider or "").lower())
dashboard_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None
try:
content = render_payment_success(
self.settings,
language_code=db_user.language_code or self.settings.DEFAULT_LANGUAGE,
sale_mode=sale_mode,
months=int(months or 0),
traffic_gb=traffic_gb,
amount=float(payment_amount or 0),
currency=self.settings.DEFAULT_CURRENCY_SYMBOL,
end_date_text=end_date_text,
dashboard_url=dashboard_url,
provider_label=provider_label,
)
email_service = EmailAuthService(self.settings)
await email_service.send_rendered_email(email=recipient, content=content)
except Exception:
logging.exception(
"Failed to send payment success email to user %s", db_user.user_id
)
async def update_last_notification_sent( async def update_last_notification_sent(
self, session: AsyncSession, user_id: int, subscription_end_date: datetime self, session: AsyncSession, user_id: int, subscription_end_date: datetime
): ):
+21 -19
View File
@@ -35,8 +35,9 @@ class YooKassaService:
self.configured = True self.configured = True
logging.info( logging.info(
f"YooKassa SDK configured for shop_id: {shop_id[:5]}...") f"YooKassa SDK configured for shop_id: {shop_id[:5]}...")
except Exception: except Exception as e:
logging.exception("Failed to configure YooKassa SDK.") logging.error(f"Failed to configure YooKassa SDK: {e}",
exc_info=True)
self.configured = False self.configured = False
if configured_return_url: if configured_return_url:
@@ -160,11 +161,10 @@ class YooKassaService:
f"Amount: {amount} {currency}. Metadata: {metadata}. Receipt: {receipt_data_dict}" f"Amount: {amount} {currency}. Metadata: {metadata}. Receipt: {receipt_data_dict}"
) )
response = await asyncio.to_thread( loop = asyncio.get_running_loop()
YooKassaPayment.create, response = await loop.run_in_executor(
payment_request, None, lambda: YooKassaPayment.create(payment_request,
idempotence_key, idempotence_key))
)
logging.info( logging.info(
f"YooKassa Payment.create response: ID={response.id}, Status={response.status}, Paid={response.paid}" f"YooKassa Payment.create response: ID={response.id}, Status={response.status}, Paid={response.paid}"
@@ -200,8 +200,9 @@ class YooKassaService:
response.test if hasattr(response, 'test') else None, response.test if hasattr(response, 'test') else None,
"payment_method": getattr(response, 'payment_method', None), "payment_method": getattr(response, 'payment_method', None),
} }
except Exception: except Exception as e:
logging.exception("YooKassa payment creation failed.") logging.error(f"YooKassa payment creation failed: {e}",
exc_info=True)
return None return None
async def get_payment_info( async def get_payment_info(
@@ -215,10 +216,9 @@ class YooKassaService:
f"Fetching payment info from YooKassa for ID: {payment_id_in_yookassa}" f"Fetching payment info from YooKassa for ID: {payment_id_in_yookassa}"
) )
payment_info_yk = await asyncio.to_thread( loop = asyncio.get_running_loop()
YooKassaPayment.find_one, payment_info_yk = await loop.run_in_executor(
payment_id_in_yookassa, None, lambda: YooKassaPayment.find_one(payment_id_in_yookassa))
)
if payment_info_yk: if payment_info_yk:
logging.info( logging.info(
@@ -264,9 +264,10 @@ class YooKassaService:
f"No payment info found in YooKassa for ID: {payment_id_in_yookassa}" f"No payment info found in YooKassa for ID: {payment_id_in_yookassa}"
) )
return None return None
except Exception: except Exception as e:
logging.exception( logging.error(
"YooKassa get payment info for %s failed.", payment_id_in_yookassa) f"YooKassa get payment info for {payment_id_in_yookassa} failed: {e}",
exc_info=True)
return None return None
async def cancel_payment(self, payment_id_in_yookassa: str) -> bool: async def cancel_payment(self, payment_id_in_yookassa: str) -> bool:
@@ -274,9 +275,10 @@ class YooKassaService:
logging.error("YooKassa is not configured. Cannot cancel payment.") logging.error("YooKassa is not configured. Cannot cancel payment.")
return False return False
try: try:
await asyncio.to_thread(YooKassaPayment.cancel, payment_id_in_yookassa) loop = asyncio.get_running_loop()
await loop.run_in_executor(None, lambda: YooKassaPayment.cancel(payment_id_in_yookassa))
logging.info(f"Cancelled YooKassa payment {payment_id_in_yookassa}") logging.info(f"Cancelled YooKassa payment {payment_id_in_yookassa}")
return True return True
except Exception: except Exception as e:
logging.exception("Failed to cancel YooKassa payment %s.", payment_id_in_yookassa) logging.error(f"Failed to cancel YooKassa payment {payment_id_in_yookassa}: {e}")
return False return False
+2
View File
@@ -6,8 +6,10 @@ class AdminStates(StatesGroup):
waiting_for_broadcast_message = State() waiting_for_broadcast_message = State()
confirming_broadcast = State() confirming_broadcast = State()
waiting_for_promo_details = State() waiting_for_promo_details = State()
waiting_for_promo_type_selection = State()
waiting_for_promo_code = State() waiting_for_promo_code = State()
waiting_for_promo_bonus_days = State() waiting_for_promo_bonus_days = State()
waiting_for_promo_discount_percentage = State()
waiting_for_promo_max_activations = State() waiting_for_promo_max_activations = State()
waiting_for_promo_validity_days = State() waiting_for_promo_validity_days = State()
waiting_for_promo_edit_details = State() waiting_for_promo_edit_details = State()
-52
View File
@@ -1,52 +0,0 @@
import logging
from typing import Any
from aiogram.exceptions import TelegramAPIError, TelegramBadRequest
from aiogram.types import CallbackQuery
_EXPIRED_CALLBACK_MARKERS = (
"query is too old",
"response timeout expired",
"query id is invalid",
)
def is_expired_callback_answer_error(error: BaseException) -> bool:
if not isinstance(error, TelegramBadRequest):
return False
message = str(error).lower()
return any(marker in message for marker in _EXPIRED_CALLBACK_MARKERS)
async def safe_answer_callback(
callback: CallbackQuery,
*args: Any,
**kwargs: Any,
) -> bool:
try:
await callback.answer(*args, **kwargs)
return True
except TelegramBadRequest as error:
user_id = getattr(getattr(callback, "from_user", None), "id", "unknown")
if is_expired_callback_answer_error(error):
logging.info(
"Ignored expired callback answer for user %s: %s",
user_id,
error,
)
return False
logging.warning(
"Failed to answer callback query for user %s: %s",
user_id,
error,
)
return False
except TelegramAPIError as error:
user_id = getattr(getattr(callback, "from_user", None), "id", "unknown")
logging.warning(
"Telegram API error while answering callback query for user %s: %s",
user_id,
error,
)
return False
+2 -2
View File
@@ -82,9 +82,9 @@ class MessageQueue:
self.total_failed += 1 self.total_failed += 1
logging.error(f"Failed to send queued message to {message.chat_id}: {exc}") logging.error(f"Failed to send queued message to {message.chat_id}: {exc}")
except Exception: except Exception as e:
self.total_failed += 1 self.total_failed += 1
logging.exception("Failed to send queued message to %s.", message.chat_id) logging.error(f"Failed to send queued message to {message.chat_id}: {e}")
finally: finally:
self.is_processing = False self.is_processing = False
-73
View File
@@ -1,73 +0,0 @@
from __future__ import annotations
import ipaddress
from typing import Optional, Sequence
from aiohttp import web
def parse_ip_entries(raw_values: Optional[Sequence[str] | str]) -> list[ipaddress._BaseNetwork]:
if raw_values is None:
return []
if isinstance(raw_values, str):
values = [item.strip() for item in raw_values.split(",")]
else:
values = [str(item).strip() for item in raw_values]
parsed: list[ipaddress._BaseNetwork] = []
for value in values:
if not value:
continue
try:
parsed.append(ipaddress.ip_network(value, strict=False))
except ValueError:
continue
return parsed
def _parse_ip(value: Optional[str]) -> Optional[ipaddress._BaseAddress]:
if not value:
return None
try:
return ipaddress.ip_address(value.strip())
except ValueError:
return None
def _last_forwarded_ip(header_value: str) -> Optional[str]:
candidates = [item.strip() for item in header_value.split(",") if item.strip()]
if not candidates:
return None
candidate = candidates[-1]
return candidate if _parse_ip(candidate) is not None else None
def request_client_ip(
request: web.Request,
*,
trusted_proxies: Optional[Sequence[str] | str] = None,
) -> Optional[str]:
remote_ip = _parse_ip(request.remote or "")
forwarded_for = request.headers.get("X-Forwarded-For", "")
if remote_ip and forwarded_for:
trusted_networks = parse_ip_entries(trusted_proxies)
if any(remote_ip in network for network in trusted_networks):
forwarded_ip = _last_forwarded_ip(forwarded_for)
if forwarded_ip:
return forwarded_ip
if remote_ip:
return str(remote_ip)
forwarded_ip = _last_forwarded_ip(forwarded_for)
return forwarded_ip
def ip_in_allowlist(ip_value: Optional[str], allowed_entries: Optional[Sequence[str] | str]) -> bool:
parsed_ip = _parse_ip(ip_value)
if parsed_ip is None:
return False
allowed_networks = parse_ip_entries(allowed_entries)
return any(parsed_ip in network for network in allowed_networks)
+104 -370
View File
@@ -1,101 +1,7 @@
import logging import logging
import os
import secrets
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field, ValidationError, computed_field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field, ValidationError, computed_field, field_validator, model_validator
from typing import Optional, List, Dict, Any
def _split_csv(value: Optional[str]) -> List[str]:
if not value:
return []
return [item.strip() for item in value.split(",") if item.strip()]
class DBSettings(BaseModel):
user: str
password: str
host: str
port: int
database: str
class PaymentSettings(BaseModel):
yookassa_enabled: bool
yookassa_shop_id: Optional[str]
yookassa_secret_key: Optional[str]
yookassa_return_url: Optional[str]
yookassa_default_receipt_email: Optional[str]
yookassa_vat_code: int
yookassa_payment_mode: str
yookassa_payment_subject: str
yookassa_autopayments_enabled: bool
yookassa_autopayments_require_card_binding: bool
freekassa_enabled: bool
freekassa_merchant_id: Optional[str]
freekassa_second_secret: Optional[str]
freekassa_api_key: Optional[str]
freekassa_payment_ip: Optional[str]
freekassa_payment_method_id: Optional[int]
freekassa_trusted_ips: List[str]
platega_enabled: bool
platega_base_url: str
platega_merchant_id: Optional[str]
platega_secret: Optional[str]
platega_payment_method: int
platega_sbp_enabled: bool
platega_crypto_enabled: bool
platega_sbp_method: int
platega_crypto_method: int
platega_return_url: Optional[str]
platega_failed_url: Optional[str]
severpay_enabled: bool
severpay_mid: Optional[int]
severpay_token: Optional[str]
severpay_return_url: Optional[str]
severpay_base_url: str
severpay_lifetime_minutes: Optional[int]
cryptopay_enabled: bool
cryptopay_token: Optional[str]
cryptopay_network: str
cryptopay_currency_type: str
cryptopay_asset: str
class EmailSettings(BaseModel):
smtp_host: str
smtp_port: int
smtp_fallback_ports: Optional[str]
smtp_timeout_seconds: int
smtp_username: Optional[str]
smtp_password: Optional[str]
smtp_from_email: Optional[str]
smtp_from_name: Optional[str]
smtp_starttls: bool
smtp_use_ssl: bool
email_code_ttl_seconds: int
email_code_resend_seconds: int
email_code_max_attempts: int
brute_force_max_failures: int
brute_force_window_seconds: int
brute_force_lock_seconds: int
class WebAppSettings(BaseModel):
title: str
primary_color: str
logo_url: Optional[str]
logo_emoji: str
session_ttl_seconds: int
session_secret: str
webhook_secret_token: str
auth_max_age_seconds: int
login_token_ttl_seconds: int
server_host: str
server_port: int
enabled: bool
trusted_proxies: List[str]
class Settings(BaseSettings): class Settings(BaseSettings):
@@ -105,20 +11,21 @@ class Settings(BaseSettings):
alias="ADMIN_IDS", alias="ADMIN_IDS",
description="Comma-separated list of admin Telegram User IDs") description="Comma-separated list of admin Telegram User IDs")
POSTGRES_USER: str = Field(...) POSTGRES_USER: str = Field(default="user")
POSTGRES_PASSWORD: str = Field(...) POSTGRES_PASSWORD: str = Field(default="password")
POSTGRES_HOST: str = Field(default="localhost") POSTGRES_HOST: str = Field(default="localhost")
POSTGRES_PORT: int = Field(default=5432) POSTGRES_PORT: int = Field(default=5432)
POSTGRES_DB: str = Field(default="vpn_shop_db") POSTGRES_DB: str = Field(default="vpn_shop_db")
DEFAULT_LANGUAGE: str = Field(default="ru") DEFAULT_LANGUAGE: str = Field(default="ru")
DEFAULT_CURRENCY_SYMBOL: str = Field(default="RUB")
SUPPORT_LINK: Optional[str] = Field(default=None) SUPPORT_LINK: Optional[str] = Field(default=None)
SERVER_STATUS_URL: Optional[str] = Field(default=None) SERVER_STATUS_URL: Optional[str] = Field(default=None)
TERMS_OF_SERVICE_URL: Optional[str] = Field(default=None) TERMS_OF_SERVICE_URL: Optional[str] = Field(default=None)
PRIVACY_POLICY_URL: Optional[str] = Field(default=None) REQUIRED_CHANNEL_SUBSCRIBE_TO_USE: bool = Field(
USER_AGREEMENT_URL: Optional[str] = Field(default=None) default=False,
description="Require users to subscribe to REQUIRED_CHANNEL_ID before using the bot",
)
REQUIRED_CHANNEL_ID: Optional[int] = Field( REQUIRED_CHANNEL_ID: Optional[int] = Field(
default=None, default=None,
description="Telegram channel ID the user must join to access the bot") description="Telegram channel ID the user must join to access the bot")
@@ -169,9 +76,13 @@ class Settings(BaseSettings):
) )
WEBHOOK_BASE_URL: Optional[str] = None WEBHOOK_BASE_URL: Optional[str] = None
TRUSTED_PROXIES: Optional[str] = Field( TELEGRAM_WEBHOOK_PATH: str = Field(
default="127.0.0.1,::1", default="/webhook/telegram",
description="Comma-separated list of reverse proxy IPs or CIDRs trusted to forward X-Forwarded-For.", description="Relative path for Telegram webhook endpoint",
)
TELEGRAM_WEBHOOK_SECRET: Optional[str] = Field(
default=None,
description="Secret token for Telegram webhook header validation",
) )
CRYPTOPAY_TOKEN: Optional[str] = None CRYPTOPAY_TOKEN: Optional[str] = None
@@ -184,24 +95,7 @@ class Settings(BaseSettings):
PLATEGA_MERCHANT_ID: Optional[str] = None PLATEGA_MERCHANT_ID: Optional[str] = None
PLATEGA_SECRET: Optional[str] = None PLATEGA_SECRET: Optional[str] = None
PLATEGA_PAYMENT_METHOD: int = Field( PLATEGA_PAYMENT_METHOD: int = Field(
default=2, default=2, description="Platega payment method ID (e.g., 2 for SBP QR)"
description="Legacy Platega payment method ID. Used as fallback for PLATEGA_SBP_METHOD when the new field is unset.",
)
PLATEGA_SBP_ENABLED: bool = Field(
default=False,
description="Show a separate Platega SBP payment button.",
)
PLATEGA_CRYPTO_ENABLED: bool = Field(
default=False,
description="Show a separate Platega crypto payment button.",
)
PLATEGA_SBP_METHOD: int = Field(
default=2,
description="Platega method ID for SBP QR (default 2).",
)
PLATEGA_CRYPTO_METHOD: int = Field(
default=13,
description="Platega method ID for crypto (default 13).",
) )
PLATEGA_RETURN_URL: Optional[str] = Field(default=None) PLATEGA_RETURN_URL: Optional[str] = Field(default=None)
PLATEGA_FAILED_URL: Optional[str] = Field(default=None) PLATEGA_FAILED_URL: Optional[str] = Field(default=None)
@@ -214,10 +108,6 @@ class Settings(BaseSettings):
FREEKASSA_API_KEY: Optional[str] = None FREEKASSA_API_KEY: Optional[str] = None
FREEKASSA_PAYMENT_IP: Optional[str] = None FREEKASSA_PAYMENT_IP: Optional[str] = None
FREEKASSA_PAYMENT_METHOD_ID: Optional[int] = None FREEKASSA_PAYMENT_METHOD_ID: Optional[int] = None
FREEKASSA_TRUSTED_IPS: str = Field(
default="168.119.157.136,168.119.60.227,178.154.197.79,51.250.54.238",
description="Comma-separated FreeKassa webhook IP allowlist.",
)
SEVERPAY_ENABLED: bool = Field(default=False) SEVERPAY_ENABLED: bool = Field(default=False)
SEVERPAY_MID: Optional[int] = None SEVERPAY_MID: Optional[int] = None
@@ -231,6 +121,10 @@ class Settings(BaseSettings):
YOOKASSA_ENABLED: bool = Field(default=True) YOOKASSA_ENABLED: bool = Field(default=True)
STARS_ENABLED: bool = Field(default=True) STARS_ENABLED: bool = Field(default=True)
STARS_PROVIDER_TOKEN: Optional[str] = Field(
default="",
description="Provider token for Telegram invoices. For Stars (XTR) should stay empty.",
)
PAYMENT_METHODS_ORDER: Optional[str] = Field( PAYMENT_METHODS_ORDER: Optional[str] = Field(
default=None, default=None,
description="Comma-separated list of payment methods to show (e.g., severpay,freekassa,yookassa,platega,stars,cryptopay)", description="Comma-separated list of payment methods to show (e.g., severpay,freekassa,yookassa,platega,stars,cryptopay)",
@@ -289,9 +183,9 @@ class Settings(BaseSettings):
default=True, 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."
) )
REFERRAL_WELCOME_BONUS_DAYS: int = Field( REFERRAL_ENABLED: bool = Field(
default=3, default=True,
description="Welcome bonus days granted to a newly registered user who joined via referral link.", description="Enable referral links, referral menu and referral bonuses",
) )
LEGACY_REFS: bool = Field( LEGACY_REFS: bool = Field(
default=True, default=True,
@@ -314,56 +208,12 @@ class Settings(BaseSettings):
TRIAL_ENABLED: bool = Field(default=True) TRIAL_ENABLED: bool = Field(default=True)
TRIAL_DURATION_DAYS: int = Field(default=3) TRIAL_DURATION_DAYS: int = Field(default=3)
TRIAL_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=5.0) TRIAL_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=5.0)
TRIAL_TRAFFIC_STRATEGY: str = Field(default="NO_RESET")
CRYPT4_ENABLED: bool = Field(default=False, description="Enable happ crypt4 encryption for subscription URLs") 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") 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_HOST: str = Field(default="0.0.0.0")
WEB_SERVER_PORT: int = Field(default=8080) WEB_SERVER_PORT: int = Field(default=8080)
WEBAPP_ENABLED: bool = Field(
default=True,
description="Run the subscription Mini App in the same container on a separate port.",
)
WEBAPP_SERVER_HOST: str = Field(default="0.0.0.0")
WEBAPP_SERVER_PORT: int = Field(default=8081)
WEBAPP_TITLE: str = Field(default="Моя подписка")
WEBAPP_PRIMARY_COLOR: str = Field(default="#00fe7a")
WEBAPP_LOGO_URL: Optional[str] = Field(default=None)
WEBAPP_LOGO_EMOJI: str = Field(default="🫥")
WEBAPP_SESSION_SECRET: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
WEBHOOK_SECRET_TOKEN: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
WEBAPP_SESSION_TTL_SECONDS: int = Field(default=24 * 60 * 60)
WEBAPP_AUTH_MAX_AGE_SECONDS: int = Field(default=24 * 60 * 60)
WEBAPP_LOGIN_TOKEN_TTL_SECONDS: int = Field(default=10 * 60)
SMTP_HOST: str = Field(default="smtp-relay.brevo.com")
SMTP_PORT: int = Field(default=587)
SMTP_FALLBACK_PORTS: Optional[str] = Field(default="2525,465")
SMTP_TIMEOUT_SECONDS: int = Field(default=30)
SMTP_USERNAME: Optional[str] = Field(default=None)
SMTP_PASSWORD: Optional[str] = Field(default=None)
SMTP_FROM_EMAIL: Optional[str] = Field(default=None)
SMTP_FROM_NAME: Optional[str] = Field(default=None)
SMTP_STARTTLS: bool = Field(default=True)
SMTP_USE_SSL: bool = Field(default=False)
EMAIL_CODE_TTL_SECONDS: int = Field(default=10 * 60)
EMAIL_CODE_RESEND_SECONDS: int = Field(default=60)
EMAIL_CODE_MAX_ATTEMPTS: int = Field(default=5)
BRUTE_FORCE_MAX_FAILURES: int = Field(
default=5,
description="Maximum failed code attempts allowed within the throttle window before a temporary lockout is applied.",
)
BRUTE_FORCE_WINDOW_SECONDS: int = Field(
default=15 * 60,
description="Rolling window used to count failed email and promo code attempts.",
)
BRUTE_FORCE_LOCK_SECONDS: int = Field(
default=30 * 60,
description="Temporary lockout duration applied after too many failed code attempts.",
)
LOGS_PAGE_SIZE: int = Field(default=10) LOGS_PAGE_SIZE: int = Field(default=10)
SUBSCRIPTION_MINI_APP_URL: Optional[str] = Field(default=None) SUBSCRIPTION_MINI_APP_URL: Optional[str] = Field(default=None)
@@ -391,103 +241,6 @@ class Settings(BaseSettings):
def DATABASE_URL(self) -> str: def DATABASE_URL(self) -> str:
return f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" return f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
@computed_field
@property
def db_settings(self) -> DBSettings:
return DBSettings(
user=self.POSTGRES_USER,
password=self.POSTGRES_PASSWORD,
host=self.POSTGRES_HOST,
port=self.POSTGRES_PORT,
database=self.POSTGRES_DB,
)
@computed_field
@property
def payment_settings(self) -> PaymentSettings:
return PaymentSettings(
yookassa_enabled=self.YOOKASSA_ENABLED,
yookassa_shop_id=self.YOOKASSA_SHOP_ID,
yookassa_secret_key=self.YOOKASSA_SECRET_KEY,
yookassa_return_url=self.YOOKASSA_RETURN_URL,
yookassa_default_receipt_email=self.YOOKASSA_DEFAULT_RECEIPT_EMAIL,
yookassa_vat_code=self.YOOKASSA_VAT_CODE,
yookassa_payment_mode=self.YOOKASSA_PAYMENT_MODE,
yookassa_payment_subject=self.YOOKASSA_PAYMENT_SUBJECT,
yookassa_autopayments_enabled=self.YOOKASSA_AUTOPAYMENTS_ENABLED,
yookassa_autopayments_require_card_binding=self.YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING,
freekassa_enabled=self.FREEKASSA_ENABLED,
freekassa_merchant_id=self.FREEKASSA_MERCHANT_ID,
freekassa_second_secret=self.FREEKASSA_SECOND_SECRET,
freekassa_api_key=self.FREEKASSA_API_KEY,
freekassa_payment_ip=self.FREEKASSA_PAYMENT_IP,
freekassa_payment_method_id=self.FREEKASSA_PAYMENT_METHOD_ID,
freekassa_trusted_ips=self.freekassa_trusted_ips,
platega_enabled=self.PLATEGA_ENABLED,
platega_base_url=self.PLATEGA_BASE_URL,
platega_merchant_id=self.PLATEGA_MERCHANT_ID,
platega_secret=self.PLATEGA_SECRET,
platega_payment_method=self.PLATEGA_PAYMENT_METHOD,
platega_sbp_enabled=self.PLATEGA_SBP_ENABLED,
platega_crypto_enabled=self.PLATEGA_CRYPTO_ENABLED,
platega_sbp_method=self.platega_sbp_method_resolved,
platega_crypto_method=self.PLATEGA_CRYPTO_METHOD,
platega_return_url=self.PLATEGA_RETURN_URL,
platega_failed_url=self.PLATEGA_FAILED_URL,
severpay_enabled=self.SEVERPAY_ENABLED,
severpay_mid=self.SEVERPAY_MID,
severpay_token=self.SEVERPAY_TOKEN,
severpay_return_url=self.SEVERPAY_RETURN_URL,
severpay_base_url=self.SEVERPAY_BASE_URL,
severpay_lifetime_minutes=self.SEVERPAY_LIFETIME_MINUTES,
cryptopay_enabled=self.CRYPTOPAY_ENABLED,
cryptopay_token=self.CRYPTOPAY_TOKEN,
cryptopay_network=self.CRYPTOPAY_NETWORK,
cryptopay_currency_type=self.CRYPTOPAY_CURRENCY_TYPE,
cryptopay_asset=self.CRYPTOPAY_ASSET,
)
@computed_field
@property
def email_settings(self) -> EmailSettings:
return EmailSettings(
smtp_host=self.SMTP_HOST,
smtp_port=self.SMTP_PORT,
smtp_fallback_ports=self.SMTP_FALLBACK_PORTS,
smtp_timeout_seconds=self.SMTP_TIMEOUT_SECONDS,
smtp_username=self.SMTP_USERNAME,
smtp_password=self.SMTP_PASSWORD,
smtp_from_email=self.SMTP_FROM_EMAIL,
smtp_from_name=self.SMTP_FROM_NAME,
smtp_starttls=self.SMTP_STARTTLS,
smtp_use_ssl=self.SMTP_USE_SSL,
email_code_ttl_seconds=self.EMAIL_CODE_TTL_SECONDS,
email_code_resend_seconds=self.EMAIL_CODE_RESEND_SECONDS,
email_code_max_attempts=self.EMAIL_CODE_MAX_ATTEMPTS,
brute_force_max_failures=self.BRUTE_FORCE_MAX_FAILURES,
brute_force_window_seconds=self.BRUTE_FORCE_WINDOW_SECONDS,
brute_force_lock_seconds=self.BRUTE_FORCE_LOCK_SECONDS,
)
@computed_field
@property
def webapp_settings(self) -> WebAppSettings:
return WebAppSettings(
title=self.WEBAPP_TITLE,
primary_color=self.WEBAPP_PRIMARY_COLOR,
logo_url=self.WEBAPP_LOGO_URL,
logo_emoji=self.WEBAPP_LOGO_EMOJI,
session_ttl_seconds=self.WEBAPP_SESSION_TTL_SECONDS,
session_secret=self.WEBAPP_SESSION_SECRET,
webhook_secret_token=self.WEBHOOK_SECRET_TOKEN,
auth_max_age_seconds=self.WEBAPP_AUTH_MAX_AGE_SECONDS,
login_token_ttl_seconds=self.WEBAPP_LOGIN_TOKEN_TTL_SECONDS,
server_host=self.WEBAPP_SERVER_HOST,
server_port=self.WEBAPP_SERVER_PORT,
enabled=self.WEBAPP_ENABLED,
trusted_proxies=self.trusted_proxies,
)
@computed_field @computed_field
@property @property
def ADMIN_IDS(self) -> List[int]: def ADMIN_IDS(self) -> List[int]:
@@ -545,20 +298,21 @@ class Settings(BaseSettings):
return cleaned return cleaned
return None return None
@computed_field
@property
def trusted_proxies(self) -> List[str]:
return _split_csv(self.TRUSTED_PROXIES)
@computed_field
@property
def freekassa_trusted_ips(self) -> List[str]:
return _split_csv(self.FREEKASSA_TRUSTED_IPS)
@computed_field @computed_field
@property @property
def telegram_webhook_path(self) -> str: def telegram_webhook_path(self) -> str:
return "/tg/webhook" path = (self.TELEGRAM_WEBHOOK_PATH or "").strip() or "/webhook/telegram"
if not path.startswith("/"):
path = f"/{path}"
return path
@computed_field
@property
def telegram_full_webhook_url(self) -> Optional[str]:
base = self.WEBHOOK_BASE_URL
if base:
return f"{base.rstrip('/')}{self.telegram_webhook_path}"
return None
@computed_field @computed_field
@property @property
@@ -779,8 +533,7 @@ class Settings(BaseSettings):
""" """
default_order = [ default_order = [
"freekassa", "freekassa",
"platega_sbp", "platega",
"platega_crypto",
"severpay", "severpay",
"yookassa", "yookassa",
"stars", "stars",
@@ -788,57 +541,12 @@ class Settings(BaseSettings):
] ]
if not self.PAYMENT_METHODS_ORDER: if not self.PAYMENT_METHODS_ORDER:
return default_order return default_order
methods: List[str] = [] methods = []
for item in self.PAYMENT_METHODS_ORDER.split(","): for item in self.PAYMENT_METHODS_ORDER.split(","):
slug = item.strip().lower() slug = item.strip().lower()
if not slug: if slug:
continue methods.append(slug)
if slug == "platega":
# Legacy slug — expand to the new sub-methods preserving order
if "platega_sbp" not in methods:
methods.append("platega_sbp")
if "platega_crypto" not in methods:
methods.append("platega_crypto")
continue
methods.append(slug)
return methods or default_order return methods or default_order
@computed_field
@property
def platega_sbp_method_resolved(self) -> int:
"""SBP method ID, falling back to legacy PLATEGA_PAYMENT_METHOD when SBP-specific value is the default."""
if self.PLATEGA_SBP_METHOD != 2:
return self.PLATEGA_SBP_METHOD
return self.PLATEGA_PAYMENT_METHOD or 2
@computed_field
@property
def email_auth_configured(self) -> bool:
return bool(
self.SMTP_HOST
and self.SMTP_PORT
and self.SMTP_USERNAME
and self.SMTP_PASSWORD
and self.SMTP_FROM_EMAIL
)
@computed_field
@property
def smtp_ports_to_try(self) -> List[int]:
ports: List[int] = []
def add_port(value: Any) -> None:
try:
port = int(str(value).strip())
except (TypeError, ValueError):
return
if 0 < port <= 65535 and port not in ports:
ports.append(port)
add_port(self.SMTP_PORT)
for item in (self.SMTP_FALLBACK_PORTS or "").split(","):
add_port(item)
return ports
# Logging Configuration # Logging Configuration
LOG_LEVEL: str = Field( LOG_LEVEL: str = Field(
@@ -847,7 +555,24 @@ class Settings(BaseSettings):
) )
LOG_CHAT_ID: Optional[int] = Field(default=None, description="Telegram chat/group ID for sending notifications") 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)") LOG_THREAD_ID: Optional[int] = Field(default=None, description="Thread ID for supergroup messages (optional)")
LOG_STORE_MESSAGE_CONTENT: bool = Field(
default=False,
description="Store message/callback content in message logs",
)
LOG_STORE_RAW_UPDATES: bool = Field(
default=False,
description="Store raw update previews in message logs",
)
LOG_EXPORT_INCLUDE_SENSITIVE: bool = Field(
default=False,
description="Include content/raw update fields in admin CSV export",
)
LOG_ADMIN_HIDE: bool = Field(
default=False,
description="Hide admin-generated events from admin logs UI and CSV export",
)
@field_validator('LOG_LEVEL', mode='before') @field_validator('LOG_LEVEL', mode='before')
@classmethod @classmethod
def normalize_log_level(cls, v): def normalize_log_level(cls, v):
@@ -857,33 +582,39 @@ class Settings(BaseSettings):
return "INFO" return "INFO"
return v return v
@field_validator('POSTGRES_USER', 'POSTGRES_PASSWORD', mode='before') @model_validator(mode='before')
@classmethod @classmethod
def validate_required_db_credentials(cls, v): def drop_comment_placeholder_values(cls, values: Any):
if isinstance(v, str): """
v = v.strip() dotenv parses lines like `KEY= # comment` as `"# comment"`.
if not v: Treat such values as unset so defaults/optionals work as expected.
raise ValueError("must not be empty") """
return v if not isinstance(values, dict):
return values
@field_validator('WEBAPP_SESSION_SECRET', 'WEBHOOK_SECRET_TOKEN', mode='before') sanitized: Dict[str, Any] = {}
@classmethod for key, value in values.items():
def normalize_webapp_secrets(cls, v): if isinstance(value, str):
if isinstance(v, str): trimmed = value.strip()
v = v.strip() if trimmed == "#" or trimmed.startswith("# "):
if v: continue
return v sanitized[key] = value
if v: return sanitized
return v
return secrets.token_urlsafe(32)
@field_validator('LOG_CHAT_ID', 'LOG_THREAD_ID', mode='before') @field_validator(
'TELEGRAM_WEBHOOK_PATH',
mode='before',
)
@classmethod @classmethod
def validate_optional_int_fields(cls, v): def normalize_webhook_path(cls, v):
"""Convert empty strings to None for optional integer fields""" if not isinstance(v, str):
if isinstance(v, str) and v.strip() == '': return "/webhook/telegram"
return None cleaned = v.strip()
return v if not cleaned:
return "/webhook/telegram"
if not cleaned.startswith("/"):
cleaned = f"/{cleaned}"
return cleaned
@field_validator( @field_validator(
'REQUIRED_CHANNEL_LINK', 'REQUIRED_CHANNEL_LINK',
@@ -891,15 +622,8 @@ class Settings(BaseSettings):
'PLATEGA_FAILED_URL', 'PLATEGA_FAILED_URL',
'SEVERPAY_RETURN_URL', 'SEVERPAY_RETURN_URL',
'CRYPT4_REDIRECT_URL', 'CRYPT4_REDIRECT_URL',
'PRIVACY_POLICY_URL', 'TELEGRAM_WEBHOOK_SECRET',
'USER_AGREEMENT_URL', 'PANEL_WEBHOOK_SECRET',
'SUBSCRIPTION_MINI_APP_URL',
'WEBAPP_LOGO_URL',
'SMTP_USERNAME',
'SMTP_PASSWORD',
'SMTP_FROM_EMAIL',
'SMTP_FROM_NAME',
'SMTP_FALLBACK_PORTS',
mode='before', mode='before',
) )
@classmethod @classmethod
@@ -908,7 +632,16 @@ class Settings(BaseSettings):
return None return None
return v return v
@field_validator('USER_HWID_DEVICE_LIMIT', 'SEVERPAY_MID', 'SEVERPAY_LIFETIME_MINUTES', mode='before') @field_validator(
'REQUIRED_CHANNEL_ID',
'FREEKASSA_PAYMENT_METHOD_ID',
'USER_HWID_DEVICE_LIMIT',
'SEVERPAY_MID',
'SEVERPAY_LIFETIME_MINUTES',
'LOG_CHAT_ID',
'LOG_THREAD_ID',
mode='before'
)
@classmethod @classmethod
def validate_optional_int(cls, v): def validate_optional_int(cls, v):
if isinstance(v, str): if isinstance(v, str):
@@ -923,6 +656,10 @@ class Settings(BaseSettings):
LOG_PROMO_ACTIVATIONS: bool = Field(default=True, description="Send notifications for promo code activations") LOG_PROMO_ACTIVATIONS: bool = Field(default=True, description="Send notifications for promo code activations")
LOG_TRIAL_ACTIVATIONS: bool = Field(default=True, description="Send notifications for trial activations") LOG_TRIAL_ACTIVATIONS: bool = Field(default=True, description="Send notifications for trial activations")
LOG_SUSPICIOUS_ACTIVITY: bool = Field(default=True, description="Send notifications for suspicious promo attempts") LOG_SUSPICIOUS_ACTIVITY: bool = Field(default=True, description="Send notifications for suspicious promo attempts")
DISCOUNT_PROMO_PAYMENT_TIMEOUT_MINUTES: int = Field(
default=10,
description="How long a discount promo reservation is kept before user payment",
)
model_config = SettingsConfigDict(env_file='.env', model_config = SettingsConfigDict(env_file='.env',
env_file_encoding='utf-8', env_file_encoding='utf-8',
@@ -947,13 +684,10 @@ def get_settings() -> Settings:
logging.warning( logging.warning(
"CRITICAL: PANEL_API_URL is not set. Panel integration will not work." "CRITICAL: PANEL_API_URL is not set. Panel integration will not work."
) )
if not os.getenv("WEBAPP_SESSION_SECRET"): if _settings_instance.WEBHOOK_BASE_URL and not _settings_instance.TELEGRAM_WEBHOOK_SECRET:
logging.warning( logging.warning(
"WEBAPP_SESSION_SECRET is not set. A generated secret will be used for this process only." "WARNING: TELEGRAM_WEBHOOK_SECRET is empty while webhook mode is enabled. "
) "Set TELEGRAM_WEBHOOK_SECRET to validate X-Telegram-Bot-Api-Secret-Token header."
if not os.getenv("WEBHOOK_SECRET_TOKEN"):
logging.warning(
"WEBHOOK_SECRET_TOKEN is not set. A generated secret will be used for this process only."
) )
if not _settings_instance.YOOKASSA_SHOP_ID or not _settings_instance.YOOKASSA_SECRET_KEY: if not _settings_instance.YOOKASSA_SHOP_ID or not _settings_instance.YOOKASSA_SECRET_KEY:
logging.warning( logging.warning(
+339
View File
@@ -0,0 +1,339 @@
import logging
from pathlib import Path
from typing import Tuple
from alembic import command
from alembic.config import Config
from sqlalchemy import inspect, text
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import AsyncEngine
from config.settings import Settings
import os
_BASELINE_REVISION = "0001_initial_schema"
def _build_alembic_config(settings: Settings) -> Config:
project_root = Path(__file__).resolve().parents[1]
config = Config(str(project_root / "alembic.ini"))
config.set_main_option("script_location", str(project_root / "alembic"))
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
return config
def _inspect_database_state(connection: Connection) -> Tuple[bool, bool, bool]:
db_inspector = inspect(connection)
has_alembic_version = db_inspector.has_table("alembic_version")
has_users_table = db_inspector.has_table("users")
has_legacy_migrator_table = db_inspector.has_table("schema_migrations")
return has_alembic_version, has_users_table, has_legacy_migrator_table
def _run_legacy_migrator_compatibility(connection: Connection) -> None:
db_inspector = inspect(connection)
if not db_inspector.has_table("users"):
return
users_columns = {
column["name"]
for column in db_inspector.get_columns("users")
}
user_alter_statements = []
if "channel_subscription_verified" not in users_columns:
user_alter_statements.append(
"ALTER TABLE users ADD COLUMN channel_subscription_verified BOOLEAN"
)
if "channel_subscription_checked_at" not in users_columns:
user_alter_statements.append(
"ALTER TABLE users ADD COLUMN channel_subscription_checked_at TIMESTAMPTZ"
)
if "channel_subscription_verified_for" not in users_columns:
user_alter_statements.append(
"ALTER TABLE users ADD COLUMN channel_subscription_verified_for BIGINT"
)
if "referral_code" not in users_columns:
user_alter_statements.append(
"ALTER TABLE users ADD COLUMN referral_code VARCHAR(16)"
)
for statement in user_alter_statements:
connection.execute(text(statement))
users_columns = {
column["name"]
for column in inspect(connection).get_columns("users")
}
if "referral_code" in users_columns:
connection.execute(
text(
"""
UPDATE users
SET referral_code = NULLIF(UPPER(BTRIM(referral_code)), '')
WHERE referral_code IS NOT NULL
"""
)
)
connection.execute(
text(
"""
WITH duplicate_codes AS (
SELECT
user_id,
ROW_NUMBER() OVER (
PARTITION BY referral_code
ORDER BY user_id
) AS rn
FROM users
WHERE referral_code IS NOT NULL
)
UPDATE users AS u
SET referral_code = UPPER(
SUBSTRING(
md5(
u.user_id::text
|| clock_timestamp()::text
|| random()::text
)
FROM 1 FOR 9
)
)
FROM duplicate_codes AS d
WHERE u.user_id = d.user_id
AND d.rn > 1
"""
)
)
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
)
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
"""
)
)
db_inspector = inspect(connection)
if db_inspector.has_table("payments"):
payments_columns = {
column["name"]
for column in db_inspector.get_columns("payments")
}
if "original_amount" not in payments_columns:
connection.execute(text("ALTER TABLE payments ADD COLUMN original_amount FLOAT"))
if "discount_applied" not in payments_columns:
connection.execute(text("ALTER TABLE payments ADD COLUMN discount_applied FLOAT"))
db_inspector = inspect(connection)
has_promo_codes = db_inspector.has_table("promo_codes")
if has_promo_codes:
promo_columns = {
column["name"]
for column in db_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")
)
if "current_activations" not in promo_columns:
connection.execute(
text(
"ALTER TABLE promo_codes ADD COLUMN current_activations INTEGER NOT NULL DEFAULT 0"
)
)
else:
connection.execute(
text(
"UPDATE promo_codes SET current_activations = 0 "
"WHERE current_activations IS NULL"
)
)
connection.execute(
text("ALTER TABLE promo_codes ALTER COLUMN current_activations SET DEFAULT 0")
)
connection.execute(
text("ALTER TABLE promo_codes ALTER COLUMN current_activations SET NOT NULL")
)
if "bonus_days" in promo_columns:
connection.execute(
text("ALTER TABLE promo_codes ALTER COLUMN bonus_days DROP NOT NULL")
)
connection.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_promo_codes_promo_type ON promo_codes (promo_type)"
)
)
db_inspector = inspect(connection)
has_active_discounts = db_inspector.has_table("active_discounts")
if not has_active_discounts and has_promo_codes:
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
)
"""
)
)
has_active_discounts = True
if has_active_discounts and has_promo_codes:
connection.execute(
text(
"DELETE FROM active_discounts ad "
"WHERE NOT EXISTS (SELECT 1 FROM users u WHERE u.user_id = ad.user_id) "
"OR NOT EXISTS (SELECT 1 FROM promo_codes p WHERE p.promo_code_id = ad.promo_code_id)"
)
)
connection.execute(
text(
"ALTER TABLE active_discounts "
"DROP CONSTRAINT IF EXISTS active_discounts_user_id_fkey"
)
)
connection.execute(
text(
"ALTER TABLE active_discounts "
"DROP CONSTRAINT IF EXISTS fk_active_discounts_user"
)
)
connection.execute(
text(
"ALTER TABLE active_discounts "
"DROP CONSTRAINT IF EXISTS active_discounts_promo_code_id_fkey"
)
)
connection.execute(
text(
"ALTER TABLE active_discounts "
"DROP CONSTRAINT IF EXISTS fk_active_discounts_promo_code"
)
)
connection.execute(
text(
"ALTER TABLE active_discounts "
"ADD CONSTRAINT fk_active_discounts_user "
"FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE"
)
)
connection.execute(
text(
"ALTER TABLE active_discounts "
"ADD CONSTRAINT fk_active_discounts_promo_code "
"FOREIGN KEY (promo_code_id) REFERENCES promo_codes (promo_code_id) ON DELETE CASCADE"
)
)
elif has_active_discounts and not has_promo_codes:
logging.warning(
"Alembic legacy compatibility: skipped active_discounts FK repair "
"because promo_codes table is missing."
)
def _run_stamp(connection: Connection, alembic_config: Config, revision: str) -> None:
alembic_config.attributes["connection"] = connection
command.stamp(alembic_config, revision)
def _run_upgrade(connection: Connection, alembic_config: Config) -> None:
alembic_config.attributes["connection"] = connection
command.upgrade(alembic_config, "head")
async def run_alembic_migrations(settings: Settings, async_engine: AsyncEngine) -> None:
"""Apply Alembic migrations with bootstrap for existing installations."""
alembic_config = _build_alembic_config(settings)
allow_bootstrap_without_legacy = os.getenv("ALEMBIC_ALLOW_STAMP_WITHOUT_LEGACY", "").lower() in {
"1",
"true",
"yes",
"on",
}
async with async_engine.begin() as async_connection:
(
has_alembic_version,
has_users_table,
has_legacy_migrator_table,
) = await async_connection.run_sync(
_inspect_database_state
)
if not has_alembic_version and has_users_table:
if not has_legacy_migrator_table and not allow_bootstrap_without_legacy:
raise RuntimeError(
"Alembic bootstrap refused: found existing users table without "
"alembic_version and without legacy schema_migrations marker. "
"Set ALEMBIC_ALLOW_STAMP_WITHOUT_LEGACY=true to explicitly allow "
f"stamping baseline {_BASELINE_REVISION} after manual verification."
)
if has_legacy_migrator_table:
logging.info(
"Alembic: applying legacy migrator compatibility fixes before stamp."
)
await async_connection.run_sync(_run_legacy_migrator_compatibility)
else:
logging.warning(
"Alembic: existing users table without legacy schema_migrations; "
"proceeding with explicit bootstrap override flag."
)
logging.info(
"Alembic: existing schema detected without alembic_version; stamping %s.",
_BASELINE_REVISION,
)
await async_connection.run_sync(
_run_stamp,
alembic_config,
_BASELINE_REVISION,
)
logging.info("Alembic: running upgrade to head...")
await async_connection.run_sync(_run_upgrade, alembic_config)
logging.info("Alembic: migrations applied successfully.")
-2
View File
@@ -6,7 +6,6 @@ from . import panel_sync_dal
from . import message_log_dal from . import message_log_dal
from . import user_billing_dal from . import user_billing_dal
from . import ad_dal from . import ad_dal
from . import security_dal
__all__ = ( __all__ = (
"user_dal", "user_dal",
@@ -17,7 +16,6 @@ __all__ = (
"message_log_dal", "message_log_dal",
"user_billing_dal", "user_billing_dal",
"ad_dal", "ad_dal",
"security_dal",
) )
+163
View File
@@ -0,0 +1,163 @@
import logging
from typing import Optional, List
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
async def set_active_discount(
session: AsyncSession,
user_id: int,
promo_code_id: int,
discount_percentage: int,
expires_at: datetime,
) -> Optional[ActiveDiscount]:
"""
Set active discount for user.
Returns None if user already has an active discount (enforce one-at-a-time rule).
"""
now_utc = datetime.now(timezone.utc)
existing = await get_active_discount(session, user_id, include_expired=True)
if existing and existing.expires_at > now_utc:
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
if existing and existing.expires_at <= now_utc:
await clear_active_discount_if_expired(session, user_id, now=now_utc)
# Create new active discount
new_discount = ActiveDiscount(
user_id=user_id,
promo_code_id=promo_code_id,
discount_percentage=discount_percentage,
activated_at=now_utc,
expires_at=expires_at,
)
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,
include_expired: bool = False,
) -> Optional[ActiveDiscount]:
"""Get active discount for user if exists."""
now_utc = datetime.now(timezone.utc)
stmt = select(ActiveDiscount).where(ActiveDiscount.user_id == user_id)
if not include_expired:
stmt = stmt.where(ActiveDiscount.expires_at > now_utc)
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_discount_if_expired(
session: AsyncSession,
user_id: int,
now: Optional[datetime] = None,
) -> bool:
"""
Clear active discount for user only when it has already expired.
"""
now_utc = now or datetime.now(timezone.utc)
stmt = delete(ActiveDiscount).where(
ActiveDiscount.user_id == user_id,
ActiveDiscount.expires_at <= now_utc,
)
result = await session.execute(stmt)
await session.flush()
cleared = result.rowcount > 0
if cleared:
logging.info("Expired active discount cleared for user %s", user_id)
return cleared
async def clear_active_discount_if_matches(
session: AsyncSession,
user_id: int,
promo_code_id: Optional[int] = None,
expires_at_lte: Optional[datetime] = None,
) -> bool:
"""
Clear active discount for user only when additional constraints match.
"""
conditions = [ActiveDiscount.user_id == user_id]
if promo_code_id is not None:
conditions.append(ActiveDiscount.promo_code_id == promo_code_id)
if expires_at_lte is not None:
conditions.append(ActiveDiscount.expires_at <= expires_at_lte)
stmt = delete(ActiveDiscount).where(*conditions)
result = await session.execute(stmt)
await session.flush()
cleared = result.rowcount > 0
if cleared:
logging.info(
"Active discount cleared for user %s by constrained cleanup.",
user_id,
)
return cleared
async def get_expired_active_discounts(
session: AsyncSession,
now: Optional[datetime] = None,
limit: int = 100,
) -> List[ActiveDiscount]:
"""Get expired active discount reservations for cleanup/notifications."""
now_utc = now or datetime.now(timezone.utc)
stmt = (
select(ActiveDiscount)
.where(ActiveDiscount.expires_at <= now_utc)
.order_by(ActiveDiscount.expires_at.asc())
.limit(limit)
)
result = await session.execute(stmt)
return list(result.scalars().all())
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
+12 -5
View File
@@ -22,16 +22,23 @@ async def create_message_log(session: AsyncSession,
return None return None
async def get_all_message_logs(session: AsyncSession, limit: int, async def get_all_message_logs(session: AsyncSession,
offset: int) -> List[MessageLog]: limit: int,
stmt = select(MessageLog).order_by( offset: int,
MessageLog.timestamp.desc()).limit(limit).offset(offset) hide_admin_events: bool = False) -> List[MessageLog]:
stmt = select(MessageLog)
if hide_admin_events:
stmt = stmt.where(MessageLog.is_admin_event.is_(False))
stmt = stmt.order_by(MessageLog.timestamp.desc()).limit(limit).offset(offset)
result = await session.execute(stmt) result = await session.execute(stmt)
return result.scalars().all() return result.scalars().all()
async def count_all_message_logs(session: AsyncSession) -> int: async def count_all_message_logs(session: AsyncSession,
hide_admin_events: bool = False) -> int:
stmt = select(func.count()).select_from(MessageLog) stmt = select(func.count()).select_from(MessageLog)
if hide_admin_events:
stmt = stmt.where(MessageLog.is_admin_event.is_(False))
result = await session.execute(stmt) result = await session.execute(stmt)
return result.scalar_one() return result.scalar_one()
+141 -2
View File
@@ -5,7 +5,7 @@ from sqlalchemy.future import select
from sqlalchemy import update, func, and_ from sqlalchemy import update, func, and_
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from db.models import Payment, User from db.models import Payment
async def create_payment_record(session: AsyncSession, async def create_payment_record(session: AsyncSession,
@@ -176,10 +176,149 @@ async def update_provider_payment_and_status(
return payment return payment
async def mark_provider_payment_succeeded_once(
session: AsyncSession,
payment_db_id: int,
provider_payment_id: str) -> bool:
"""Atomically mark payment as succeeded only once.
Returns True only for the first successful transition to "succeeded".
Returns False when payment is missing or already succeeded.
"""
stmt = (
update(Payment)
.where(
Payment.payment_id == payment_db_id,
Payment.status != "succeeded",
)
.values(
status="succeeded",
provider_payment_id=provider_payment_id,
updated_at=func.now(),
)
)
result = await session.execute(stmt)
updated = (result.rowcount or 0) > 0
if updated:
logging.info(
"Payment record %s atomically marked as succeeded (provider id %s).",
payment_db_id,
provider_payment_id,
)
return updated
async def mark_provider_payment_processing_once(
session: AsyncSession,
payment_db_id: int,
provider_payment_id: str,
expected_status_prefix: Optional[str] = None) -> bool:
"""Atomically claim payment for processing exactly once.
Returns True only when status is changed from a non-terminal state to
"processing". This prevents duplicate activation when concurrent
webhooks arrive for the same payment.
"""
conditions = [
Payment.payment_id == payment_db_id,
Payment.status != "succeeded",
Payment.status != "processing",
]
if expected_status_prefix:
if expected_status_prefix == "pending":
# YooKassa may keep authorized payments in waiting_for_capture
# before reporting a final successful capture webhook.
conditions.append(
Payment.status.in_(("waiting_for_capture", "pending", "pending_yookassa"))
)
else:
conditions.append(Payment.status.like(f"{expected_status_prefix}%"))
stmt = (
update(Payment)
.where(*conditions)
.values(
status="processing",
provider_payment_id=provider_payment_id,
updated_at=func.now(),
)
)
result = await session.execute(stmt)
updated = (result.rowcount or 0) > 0
if updated:
logging.info(
"Payment record %s atomically marked as processing (provider id %s).",
payment_db_id,
provider_payment_id,
)
return updated
async def rollback_provider_payment_processing(
session: AsyncSession,
payment_db_id: int,
rollback_status: str,
provider_payment_id: Optional[str] = None) -> bool:
"""Atomically rollback temporary processing status.
Returns True only if payment is currently in "processing" state.
"""
values = {
"status": rollback_status,
"updated_at": func.now(),
}
if provider_payment_id:
values["provider_payment_id"] = provider_payment_id
stmt = (
update(Payment)
.where(
Payment.payment_id == payment_db_id,
Payment.status == "processing",
)
.values(**values)
)
result = await session.execute(stmt)
updated = (result.rowcount or 0) > 0
if updated:
logging.info(
"Payment record %s rolled back from processing to %s.",
payment_db_id,
rollback_status,
)
return updated
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]: async def get_financial_statistics(session: AsyncSession) -> Dict[str, Any]:
"""Get comprehensive financial statistics.""" """Get comprehensive financial statistics."""
from datetime import datetime, timedelta from datetime import datetime, timedelta
from sqlalchemy import and_, text from sqlalchemy import and_
now = datetime.utcnow() now = datetime.utcnow()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
+98 -9
View File
@@ -43,6 +43,34 @@ async def get_active_promo_code_by_code_str(
return result.scalar_one_or_none() 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, async def get_all_active_promo_codes(session: AsyncSession,
limit: int = 20, limit: int = 20,
offset: int = 0) -> List[PromoCode]: offset: int = 0) -> List[PromoCode]:
@@ -104,36 +132,78 @@ async def update_promo_code(session: AsyncSession, promo_id: int,
async def delete_promo_code(session: AsyncSession, promo_id: int) -> Optional[PromoCode]: 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) promo = await get_promo_code_by_id(session, promo_id)
if not promo: if not promo:
return None 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) activations = await get_promo_activations_by_code_id(session, promo_id)
for activation in activations: for activation in activations:
await session.delete(activation) await session.delete(activation)
# 4. Delete the promo code itself
await session.delete(promo) await session.delete(promo)
await session.flush() await session.flush()
logging.info(f"Promo code '{promo.code}' (ID: {promo_id}) deleted successfully")
return promo return promo
async def increment_promo_code_usage( async def increment_promo_code_usage(
session: AsyncSession, promo_code_id: int) -> Optional[PromoCode]: session: AsyncSession,
promo_code_id: int,
allow_overflow: bool = False) -> Optional[PromoCode]:
conditions = [PromoCode.promo_code_id == promo_code_id]
if not allow_overflow:
conditions.append(PromoCode.current_activations < PromoCode.max_activations)
stmt = (
update(PromoCode)
.where(*conditions)
.values(current_activations=PromoCode.current_activations + 1)
)
result = await session.execute(stmt)
if result.rowcount and result.rowcount > 0:
await session.flush()
return await get_promo_code_by_id(session, promo_code_id)
promo = await get_promo_code_by_id(session, promo_code_id) promo = await get_promo_code_by_id(session, promo_code_id)
if promo: if promo:
if promo.current_activations < promo.max_activations: if allow_overflow:
promo.current_activations += 1 logging.warning(
await session.flush() f"Failed to increment promo usage for promo {promo.code} (ID: {promo_code_id})."
await session.refresh(promo) )
return promo
else: else:
logging.warning( logging.warning(
f"Promo code {promo.code} (ID: {promo_code_id}) already reached max activations." f"Promo code {promo.code} (ID: {promo_code_id}) already reached max activations."
) )
return None
return None return None
async def decrement_promo_code_usage(
session: AsyncSession, promo_code_id: int) -> bool:
stmt = (
update(PromoCode)
.where(
PromoCode.promo_code_id == promo_code_id,
PromoCode.current_activations > 0,
)
.values(current_activations=PromoCode.current_activations - 1)
)
result = await session.execute(stmt)
await session.flush()
return bool(result.rowcount and result.rowcount > 0)
async def get_user_activation_for_promo( async def get_user_activation_for_promo(
session: AsyncSession, promo_code_id: int, session: AsyncSession, promo_code_id: int,
user_id: int) -> Optional[PromoCodeActivation]: user_id: int) -> Optional[PromoCodeActivation]:
@@ -189,3 +259,22 @@ async def record_promo_activation(
f"Promo code {promo_code_id} activated by user {user_id}. Activation ID: {new_activation.activation_id}" f"Promo code {promo_code_id} activated by user {user_id}. Activation ID: {new_activation.activation_id}"
) )
return new_activation return new_activation
async def set_activation_payment_id(
session: AsyncSession,
promo_code_id: int,
user_id: int,
payment_id: int) -> bool:
stmt = (
update(PromoCodeActivation)
.where(
PromoCodeActivation.promo_code_id == promo_code_id,
PromoCodeActivation.user_id == user_id,
PromoCodeActivation.payment_id == None,
)
.values(payment_id=payment_id)
)
result = await session.execute(stmt)
await session.flush()
return bool(result.rowcount and result.rowcount > 0)
-160
View File
@@ -1,160 +0,0 @@
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Optional
from sqlalchemy import case, delete, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import SecurityThrottle
EMAIL_CODE_VERIFY_SCOPE = "email_code_verify"
PROMO_CODE_APPLY_SCOPE = "promo_code_apply"
@dataclass(frozen=True)
class ThrottleDecision:
locked: bool
retry_after: Optional[int] = None
def _utc_now(value: Optional[datetime] = None) -> datetime:
if value is None:
value = datetime.now(timezone.utc)
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def _retry_after_seconds(locked_until: Optional[datetime], now: datetime) -> Optional[int]:
if not locked_until:
return None
locked_until = _utc_now(locked_until)
remaining = int((locked_until - now).total_seconds())
return max(1, remaining) if remaining > 0 else None
async def get_throttle_state(
session: AsyncSession,
*,
scope: str,
identifier: str,
) -> Optional[SecurityThrottle]:
stmt = (
select(SecurityThrottle)
.where(
SecurityThrottle.scope == scope,
SecurityThrottle.identifier == identifier,
)
.limit(1)
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def check_throttle(
session: AsyncSession,
*,
scope: str,
identifier: str,
now: Optional[datetime] = None,
) -> ThrottleDecision:
now = _utc_now(now)
row = await get_throttle_state(session, scope=scope, identifier=identifier)
if not row or not row.locked_until:
return ThrottleDecision(locked=False)
locked_until = _utc_now(row.locked_until)
if locked_until <= now:
return ThrottleDecision(locked=False)
return ThrottleDecision(
locked=True,
retry_after=_retry_after_seconds(locked_until, now),
)
async def record_throttle_failure(
session: AsyncSession,
*,
scope: str,
identifier: str,
max_failures: int,
window_seconds: int,
lock_seconds: int,
now: Optional[datetime] = None,
) -> ThrottleDecision:
now = _utc_now(now)
max_failures = max(1, int(max_failures))
window_seconds = max(1, int(window_seconds))
lock_seconds = max(1, int(lock_seconds))
window_cutoff = now - timedelta(seconds=window_seconds)
lock_until = now + timedelta(seconds=lock_seconds)
failure_count_expr = case(
(
or_(
SecurityThrottle.window_started_at.is_(None),
SecurityThrottle.window_started_at <= window_cutoff,
),
1,
),
else_=SecurityThrottle.failures + 1,
)
stmt = (
pg_insert(SecurityThrottle)
.values(
scope=scope,
identifier=identifier,
failures=1,
window_started_at=now,
last_attempt_at=now,
locked_until=lock_until if max_failures <= 1 else None,
)
.on_conflict_do_update(
index_elements=[SecurityThrottle.scope, SecurityThrottle.identifier],
set_={
"failures": failure_count_expr,
"window_started_at": case(
(
or_(
SecurityThrottle.window_started_at.is_(None),
SecurityThrottle.window_started_at <= window_cutoff,
),
now,
),
else_=SecurityThrottle.window_started_at,
),
"last_attempt_at": now,
"locked_until": case(
(failure_count_expr >= max_failures, lock_until),
else_=None,
),
},
)
.returning(SecurityThrottle.locked_until)
)
result = await session.execute(stmt)
locked_until = result.scalar_one_or_none()
locked_until = _utc_now(locked_until) if locked_until else None
if locked_until and locked_until > now:
return ThrottleDecision(
locked=True,
retry_after=_retry_after_seconds(locked_until, now),
)
return ThrottleDecision(locked=False)
async def clear_throttle_state(
session: AsyncSession,
*,
scope: str,
identifier: str,
) -> None:
stmt = delete(SecurityThrottle).where(
SecurityThrottle.scope == scope,
SecurityThrottle.identifier == identifier,
)
await session.execute(stmt)
+1 -1
View File
@@ -130,7 +130,7 @@ async def upsert_subscription(session: AsyncSession,
async def deactivate_other_active_subscriptions( async def deactivate_other_active_subscriptions(
session: AsyncSession, panel_user_uuid: str, session: AsyncSession, panel_user_uuid: str,
current_panel_subscription_uuid: Optional[str]) -> None: current_panel_subscription_uuid: Optional[str]):
stmt = (update(Subscription).where( stmt = (update(Subscription).where(
Subscription.panel_user_uuid == panel_user_uuid, Subscription.panel_user_uuid == panel_user_uuid,
Subscription.is_active == True, Subscription.is_active == True,
+27 -462
View File
@@ -5,9 +5,8 @@ from typing import Optional, List, Dict, Any, Tuple
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from sqlalchemy import update, delete, func, and_, or_, desc from sqlalchemy import update, delete, func, and_, or_
from sqlalchemy.orm import aliased from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from ..models import ( from ..models import (
@@ -24,11 +23,6 @@ from ..models import (
REFERRAL_CODE_ALPHABET = string.ascii_uppercase + string.digits REFERRAL_CODE_ALPHABET = string.ascii_uppercase + string.digits
REFERRAL_CODE_LENGTH = 9 REFERRAL_CODE_LENGTH = 9
MAX_REFERRAL_CODE_ATTEMPTS = 25 MAX_REFERRAL_CODE_ATTEMPTS = 25
MAX_EMAIL_USER_ID_ATTEMPTS = 25
class UserMergeConflictError(ValueError):
pass
def _generate_referral_code_candidate() -> str: def _generate_referral_code_candidate() -> str:
@@ -55,14 +49,6 @@ async def generate_unique_referral_code(session: AsyncSession) -> str:
raise RuntimeError("Failed to generate a unique referral code after several attempts.") raise RuntimeError("Failed to generate a unique referral code after several attempts.")
async def generate_unique_email_user_id(session: AsyncSession) -> int:
for _ in range(MAX_EMAIL_USER_ID_ATTEMPTS):
candidate = -(secrets.randbelow(9_000_000_000_000_000) + 1)
if not await get_user_by_id(session, candidate):
return candidate
raise RuntimeError("Failed to generate a unique email user id after several attempts.")
async def ensure_referral_code(session: AsyncSession, user: User) -> str: async def ensure_referral_code(session: AsyncSession, user: User) -> str:
""" """
Ensure the provided user has a referral code, generating and persisting it if missing. Ensure the provided user has a referral code, generating and persisting it if missing.
@@ -95,23 +81,6 @@ async def get_user_by_username(session: AsyncSession, username: str) -> Optional
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def get_user_by_email(session: AsyncSession, email: str) -> Optional[User]:
clean_email = (email or "").strip().lower()
if not clean_email:
return None
stmt = select(User).where(func.lower(User.email) == clean_email)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_user_by_telegram_id(
session: AsyncSession, telegram_id: int
) -> Optional[User]:
stmt = select(User).where(User.telegram_id == telegram_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_user_by_panel_uuid( async def get_user_by_panel_uuid(
session: AsyncSession, panel_uuid: str session: AsyncSession, panel_uuid: str
) -> Optional[User]: ) -> Optional[User]:
@@ -165,304 +134,6 @@ async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> Tuple
return user, created return user, created
async def create_email_user(
session: AsyncSession,
*,
email: str,
language_code: str,
email_verified_at: Optional[datetime] = None,
referred_by_id: Optional[int] = None,
) -> Tuple[User, bool]:
normalized_email = (email or "").strip().lower()
user_id = await generate_unique_email_user_id(session)
return await create_user(
session,
{
"user_id": user_id,
"email": normalized_email,
"email_verified_at": email_verified_at or datetime.now(timezone.utc),
"language_code": language_code,
"referred_by_id": referred_by_id,
"registration_date": datetime.now(timezone.utc),
},
)
async def _has_active_panel_subscription(
session: AsyncSession, user_id: int, panel_user_uuid: str
) -> bool:
stmt = (
select(Subscription.subscription_id)
.where(
Subscription.user_id == user_id,
Subscription.panel_user_uuid == panel_user_uuid,
Subscription.is_active == True,
Subscription.end_date > datetime.now(timezone.utc),
)
.limit(1)
)
result = await session.execute(stmt)
return result.scalar_one_or_none() is not None
async def _get_latest_subscription_for_user(
session: AsyncSession,
user_id: int,
panel_user_uuid: Optional[str] = None,
*,
active_only: bool = False,
) -> Optional[Subscription]:
stmt = select(Subscription).where(Subscription.user_id == user_id)
if panel_user_uuid is not None:
stmt = stmt.where(Subscription.panel_user_uuid == panel_user_uuid)
if active_only:
stmt = stmt.where(
Subscription.is_active == True,
Subscription.end_date > datetime.now(timezone.utc),
)
stmt = stmt.order_by(Subscription.end_date.desc(), Subscription.subscription_id.desc()).limit(1)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def _get_active_subscription_for_user(
session: AsyncSession,
user_id: int,
panel_user_uuid: Optional[str] = None,
) -> Optional[Subscription]:
return await _get_latest_subscription_for_user(
session,
user_id,
panel_user_uuid,
active_only=True,
)
async def merge_users(
session: AsyncSession,
*,
source_user_id: int,
target_user_id: int,
) -> User:
"""Merge source user data into target user and remove the source row."""
if source_user_id == target_user_id:
target = await get_user_by_id(session, target_user_id)
if not target:
raise ValueError("Target user not found.")
return target
source = await get_user_by_id(session, source_user_id)
target = await get_user_by_id(session, target_user_id)
if not source or not target:
raise ValueError("Both source and target users are required for merge.")
if source.email and target.email and source.email != target.email:
raise UserMergeConflictError("Both accounts already have different emails.")
if (
source.telegram_id
and target.telegram_id
and int(source.telegram_id) != int(target.telegram_id)
):
raise UserMergeConflictError("Both accounts already have different Telegram IDs.")
source_panel_uuid = source.panel_user_uuid
target_panel_uuid = target.panel_user_uuid
panel_uuid_to_keep = target_panel_uuid or source_panel_uuid
now = datetime.now(timezone.utc)
source_active_sub = await _get_active_subscription_for_user(
session, source_user_id, source_panel_uuid
)
target_active_sub = await _get_active_subscription_for_user(
session, target_user_id, target_panel_uuid
)
target_anchor_sub = target_active_sub
if not target_anchor_sub and target_panel_uuid:
target_anchor_sub = await _get_latest_subscription_for_user(
session, target_user_id, target_panel_uuid
)
if not target_anchor_sub and not target_panel_uuid:
target_anchor_sub = await _get_latest_subscription_for_user(session, target_user_id)
if (
source_active_sub
and target_anchor_sub
and source_panel_uuid
and target_panel_uuid
and source_panel_uuid != target_panel_uuid
):
source_end = source_active_sub.end_date
if source_end.tzinfo is None:
source_end = source_end.replace(tzinfo=timezone.utc)
target_end = target_anchor_sub.end_date
if target_end.tzinfo is None:
target_end = target_end.replace(tzinfo=timezone.utc)
source_remaining = max(timedelta(0), source_end - now)
if source_remaining > timedelta(0):
base_end = target_end if target_end > now else now
target_anchor_sub.end_date = base_end + source_remaining
target_anchor_sub.last_notification_sent = None
target_anchor_sub.is_active = True
target_anchor_sub.status_from_panel = "ACTIVE_EXTENDED_BY_MERGE"
source_active_sub.is_active = False
source_active_sub.skip_notifications = True
source_active_sub.last_notification_sent = None
source_active_sub.status_from_panel = "MERGED_INTO_ACCOUNT"
elif (
source_active_sub
and target_panel_uuid
and source_panel_uuid
and source_panel_uuid != target_panel_uuid
and not target_anchor_sub
):
source_active_sub.panel_user_uuid = target_panel_uuid
source_active_sub.last_notification_sent = None
source_active_sub.status_from_panel = "ACTIVE_EXTENDED_BY_MERGE"
email_to_move = source.email if source.email and not target.email else None
email_verified_at_to_move = (
source.email_verified_at
if source.email and (not target.email_verified_at or email_to_move)
else None
)
telegram_id_to_move = (
source.telegram_id if source.telegram_id and not target.telegram_id else None
)
referral_code_to_move = (
source.referral_code if source.referral_code and not target.referral_code else None
)
if email_to_move:
source.email = None
if telegram_id_to_move:
source.telegram_id = None
if referral_code_to_move:
source.referral_code = None
if email_to_move or source_panel_uuid or telegram_id_to_move or referral_code_to_move:
await session.flush()
if email_to_move:
target.email = email_to_move
if email_verified_at_to_move and not target.email_verified_at:
target.email_verified_at = email_verified_at_to_move
if telegram_id_to_move:
target.telegram_id = telegram_id_to_move
if panel_uuid_to_keep and not target.panel_user_uuid:
target.panel_user_uuid = panel_uuid_to_keep
if referral_code_to_move:
target.referral_code = referral_code_to_move
for attr in ("username", "first_name", "last_name", "language_code", "telegram_photo_url"):
if not getattr(target, attr) and getattr(source, attr):
setattr(target, attr, getattr(source, attr))
if not target.channel_subscription_verified and source.channel_subscription_verified is not None:
target.channel_subscription_verified = source.channel_subscription_verified
if not target.channel_subscription_checked_at and source.channel_subscription_checked_at:
target.channel_subscription_checked_at = source.channel_subscription_checked_at
if not target.channel_subscription_verified_for and source.channel_subscription_verified_for:
target.channel_subscription_verified_for = source.channel_subscription_verified_for
if source.lifetime_used_traffic_bytes is not None:
target.lifetime_used_traffic_bytes = (
(target.lifetime_used_traffic_bytes or 0) + source.lifetime_used_traffic_bytes
)
if not target.referred_by_id and source.referred_by_id != target_user_id:
target.referred_by_id = source.referred_by_id
if target.referred_by_id == source_user_id:
target.referred_by_id = source.referred_by_id
if target.referred_by_id == target_user_id:
target.referred_by_id = None
target_method_ids = select(UserPaymentMethod.provider_payment_method_id).where(
UserPaymentMethod.user_id == target_user_id
)
await session.execute(
delete(UserPaymentMethod).where(
UserPaymentMethod.user_id == source_user_id,
UserPaymentMethod.provider_payment_method_id.in_(target_method_ids),
)
)
target_promo_ids = select(PromoCodeActivation.promo_code_id).where(
PromoCodeActivation.user_id == target_user_id
)
await session.execute(
delete(PromoCodeActivation).where(
PromoCodeActivation.user_id == source_user_id,
PromoCodeActivation.promo_code_id.in_(target_promo_ids),
)
)
target_has_billing = (
await session.execute(
select(UserBilling.user_id).where(UserBilling.user_id == target_user_id)
)
).scalar_one_or_none()
if target_has_billing:
await session.execute(delete(UserBilling).where(UserBilling.user_id == source_user_id))
else:
await session.execute(
update(UserBilling)
.where(UserBilling.user_id == source_user_id)
.values(user_id=target_user_id)
)
target_has_attribution = (
await session.execute(
select(AdAttribution.user_id).where(AdAttribution.user_id == target_user_id)
)
).scalar_one_or_none()
if target_has_attribution:
await session.execute(
delete(AdAttribution).where(AdAttribution.user_id == source_user_id)
)
else:
await session.execute(
update(AdAttribution)
.where(AdAttribution.user_id == source_user_id)
.values(user_id=target_user_id)
)
subscription_update_values: Dict[str, Any] = {"user_id": target_user_id}
if panel_uuid_to_keep:
subscription_update_values["panel_user_uuid"] = panel_uuid_to_keep
await session.execute(
update(Subscription)
.where(Subscription.user_id == source_user_id)
.values(**subscription_update_values)
)
for model in (Payment, PromoCodeActivation, UserPaymentMethod):
await session.execute(
update(model)
.where(model.user_id == source_user_id)
.values(user_id=target_user_id)
)
await session.execute(
update(MessageLog)
.where(MessageLog.user_id == source_user_id)
.values(user_id=target_user_id)
)
await session.execute(
update(MessageLog)
.where(MessageLog.target_user_id == source_user_id)
.values(target_user_id=target_user_id)
)
await session.execute(
update(User)
.where(User.referred_by_id == source_user_id)
.values(referred_by_id=target_user_id)
)
await session.delete(source)
await session.flush()
await session.refresh(target)
return target
async def get_user_by_referral_code(session: AsyncSession, referral_code: str) -> Optional[User]: async def get_user_by_referral_code(session: AsyncSession, referral_code: str) -> Optional[User]:
normalized = referral_code.strip().upper() normalized = referral_code.strip().upper()
if not normalized: if not normalized:
@@ -629,22 +300,23 @@ async def get_user_ids_without_active_subscription(session: AsyncSession) -> Lis
from datetime import datetime, timezone from datetime import datetime, timezone
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
active_subs = aliased(Subscription) # Subquery for users with active subscription
active_subs_subq = (
select(Subscription.user_id)
.where(
and_(
Subscription.is_active == True,
Subscription.end_date > now,
)
)
).scalar_subquery()
stmt = ( stmt = (
select(User.user_id) select(User.user_id)
.outerjoin(
active_subs,
and_(
active_subs.user_id == User.user_id,
active_subs.is_active == True,
active_subs.end_date > now,
),
)
.where( .where(
and_( and_(
User.is_banned == False, User.is_banned == False,
active_subs.user_id.is_(None), ~User.user_id.in_(active_subs_subq),
) )
) )
) )
@@ -672,13 +344,25 @@ async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool
or_(MessageLog.user_id == user_id, MessageLog.target_user_id == user_id) or_(MessageLog.user_id == user_id, MessageLog.target_user_id == user_id)
) )
) )
await session.execute(delete(Payment).where(Payment.user_id == user_id))
user_payment_ids_subquery = select(Payment.payment_id).where(Payment.user_id == user_id)
# Keep activations of other users intact if they reference this user's payments
await session.execute( await session.execute(
delete(Subscription).where(Subscription.user_id == user_id) update(PromoCodeActivation)
.where(
PromoCodeActivation.payment_id.in_(user_payment_ids_subquery),
PromoCodeActivation.user_id != user_id,
)
.values(payment_id=None)
) )
await session.execute( await session.execute(
delete(PromoCodeActivation).where(PromoCodeActivation.user_id == user_id) delete(PromoCodeActivation).where(PromoCodeActivation.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( await session.execute(
delete(UserPaymentMethod).where(UserPaymentMethod.user_id == user_id) delete(UserPaymentMethod).where(UserPaymentMethod.user_id == user_id)
) )
@@ -688,122 +372,3 @@ async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool
await session.delete(user) await session.delete(user)
await session.flush() await session.flush()
return True return True
async def get_top_users_by_traffic_used(
session: AsyncSession,
*,
limit: int = 10,
) -> List[Dict[str, Any]]:
"""Return top users by total used traffic across all subscriptions."""
safe_limit = max(1, limit)
total_traffic_used = func.coalesce(func.sum(Subscription.traffic_used_bytes), 0)
stmt = (
select(
User.user_id,
User.username,
User.first_name,
total_traffic_used.label("traffic_used_bytes"),
)
.join(Subscription, Subscription.user_id == User.user_id, isouter=True)
.group_by(User.user_id, User.username, User.first_name)
.having(total_traffic_used > 0)
.order_by(desc("traffic_used_bytes"), User.user_id.asc())
.limit(safe_limit)
)
result = await session.execute(stmt)
return [dict(row._mapping) for row in result]
async def get_top_users_by_lifetime_traffic_used(
session: AsyncSession,
*,
limit: int = 10,
) -> List[Dict[str, Any]]:
"""Return top users by lifetime used traffic from panel data."""
safe_limit = max(1, limit)
lifetime_used = func.coalesce(User.lifetime_used_traffic_bytes, 0)
stmt = (
select(
User.user_id,
User.username,
User.first_name,
lifetime_used.label("lifetime_used_traffic_bytes"),
)
.where(lifetime_used > 0)
.order_by(desc("lifetime_used_traffic_bytes"), User.user_id.asc())
.limit(safe_limit)
)
result = await session.execute(stmt)
return [dict(row._mapping) for row in result]
async def get_top_users_by_referrals_count(
session: AsyncSession,
*,
limit: int = 10,
) -> List[Dict[str, Any]]:
"""Return top users by number of invited users."""
safe_limit = max(1, limit)
referred_user = aliased(User)
invited_count = func.count(referred_user.user_id)
stmt = (
select(
User.user_id,
User.username,
User.first_name,
invited_count.label("invited_count"),
)
.join(referred_user, referred_user.referred_by_id == User.user_id, isouter=True)
.group_by(User.user_id, User.username, User.first_name)
.having(invited_count > 0)
.order_by(desc("invited_count"), User.user_id.asc())
.limit(safe_limit)
)
result = await session.execute(stmt)
return [dict(row._mapping) for row in result]
async def get_top_users_by_referral_revenue(
session: AsyncSession,
*,
limit: int = 10,
) -> List[Dict[str, Any]]:
"""Return top users by total revenue brought by all invited users."""
safe_limit = max(1, limit)
referred_user = aliased(User)
referral_revenue = func.coalesce(func.sum(Payment.amount), 0.0)
stmt = (
select(
User.user_id,
User.username,
User.first_name,
referral_revenue.label("referral_revenue"),
)
.join(referred_user, referred_user.referred_by_id == User.user_id, isouter=True)
.join(
Payment,
and_(
Payment.user_id == referred_user.user_id,
Payment.status == "succeeded",
),
isouter=True,
)
.group_by(User.user_id, User.username, User.first_name)
.having(referral_revenue > 0)
.order_by(desc("referral_revenue"), User.user_id.asc())
.limit(safe_limit)
)
result = await session.execute(stmt)
return [dict(row._mapping) for row in result]
+21 -12
View File
@@ -1,27 +1,40 @@
import logging import logging
from urllib.parse import urlsplit, urlunsplit
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from config.settings import Settings from config.settings import Settings
from .models import Base from .alembic_runner import run_alembic_migrations
from .migrator import run_database_migrations
async_engine = None async_engine = None
def _mask_db_url(url: str) -> str:
try:
parsed = urlsplit(url)
if parsed.username is None:
return url
username = parsed.username
host = parsed.hostname or ""
port = f":{parsed.port}" if parsed.port else ""
netloc = f"{username}:***@{host}{port}"
return urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment))
except Exception:
return "<masked>"
def init_db_connection(settings: Settings) -> sessionmaker: def init_db_connection(settings: Settings) -> sessionmaker:
global async_engine global async_engine
if async_engine is None: if async_engine is None:
masked_url = _mask_db_url(settings.DATABASE_URL)
logging.info( logging.info(
f"Attempting to create SQLAlchemy engine with URL: {settings.DATABASE_URL}" f"Attempting to create SQLAlchemy engine with URL: {masked_url}"
) )
async_engine = create_async_engine( async_engine = create_async_engine(
settings.DATABASE_URL, settings.DATABASE_URL,
echo=False, echo=False,
pool_pre_ping=True, pool_pre_ping=True,
pool_size=20,
max_overflow=10,
) )
local_async_session_factory = async_sessionmaker( local_async_session_factory = async_sessionmaker(
@@ -32,7 +45,7 @@ def init_db_connection(settings: Settings) -> sessionmaker:
autoflush=False, autoflush=False,
) )
logging.info( logging.info(
f"SQLAlchemy Async Engine and SessionFactory configured for PostgreSQL." "SQLAlchemy Async Engine and SessionFactory configured for PostgreSQL."
) )
return local_async_session_factory return local_async_session_factory
@@ -63,12 +76,8 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
"async_engine is not initialized. Call init_db_connection and get session_factory first." "async_engine is not initialized. Call init_db_connection and get session_factory first."
) )
async with async_engine.begin() as conn: await run_alembic_migrations(settings, async_engine)
await conn.run_sync(Base.metadata.create_all) logging.info("PostgreSQL database migrations checked/applied via Alembic.")
await conn.run_sync(run_database_migrations)
logging.info(
"PostgreSQL database initialized/checked successfully using SQLAlchemy."
)
async with session_factory() as session: async with session_factory() as session:
from .dal.panel_sync_dal import get_panel_sync_status, update_panel_sync_status from .dal.panel_sync_dal import get_panel_sync_status, update_panel_sync_status
-425
View File
@@ -1,425 +0,0 @@
import logging
from dataclasses import dataclass
from typing import Callable, List, Set
from sqlalchemy import inspect, text
from sqlalchemy.engine import Connection
@dataclass(frozen=True)
class Migration:
id: str
description: str
upgrade: Callable[[Connection], None]
def _ensure_migrations_table(connection: Connection) -> None:
connection.execute(
text(
"""
CREATE TABLE IF NOT EXISTS schema_migrations (
id VARCHAR(255) PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
)
def _migration_0001_add_channel_subscription_fields(connection: Connection) -> None:
inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
statements: List[str] = []
if "channel_subscription_verified" not in columns:
statements.append(
"ALTER TABLE users ADD COLUMN channel_subscription_verified BOOLEAN"
)
if "channel_subscription_checked_at" not in columns:
statements.append(
"ALTER TABLE users ADD COLUMN channel_subscription_checked_at TIMESTAMPTZ"
)
if "channel_subscription_verified_for" not in columns:
statements.append(
"ALTER TABLE users ADD COLUMN channel_subscription_verified_for BIGINT"
)
for stmt in statements:
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_lifetime_used_traffic(connection: Connection) -> None:
inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
if "lifetime_used_traffic_bytes" in columns:
return
connection.execute(
text(
"ALTER TABLE users ADD COLUMN lifetime_used_traffic_bytes BIGINT"
)
)
def _migration_0005_add_email_auth_fields(connection: Connection) -> None:
inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
if "email" not in columns:
connection.execute(text("ALTER TABLE users ADD COLUMN email VARCHAR"))
if "email_verified_at" not in columns:
connection.execute(
text("ALTER TABLE users ADD COLUMN email_verified_at TIMESTAMPTZ")
)
if "telegram_id" not in columns:
connection.execute(text("ALTER TABLE users ADD COLUMN telegram_id BIGINT"))
connection.execute(
text(
"""
UPDATE users
SET telegram_id = user_id
WHERE telegram_id IS NULL
AND user_id > 0
"""
)
)
connection.execute(
text(
"""
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_email
ON users (email)
WHERE email IS NOT NULL
"""
)
)
connection.execute(
text(
"""
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_telegram_id
ON users (telegram_id)
WHERE telegram_id IS NOT NULL
"""
)
)
connection.execute(
text(
"""
CREATE TABLE IF NOT EXISTS email_verification_codes (
code_id SERIAL PRIMARY KEY,
email VARCHAR NOT NULL,
code_hash VARCHAR NOT NULL,
purpose VARCHAR NOT NULL,
target_user_id BIGINT NULL REFERENCES users(user_id),
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ NULL,
attempts INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_email_verification_codes_lookup
ON email_verification_codes (email, purpose, target_user_id, created_at DESC)
"""
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_email_verification_codes_expires_at
ON email_verification_codes (expires_at)
"""
)
)
def _migration_0006_add_security_throttles(connection: Connection) -> None:
connection.execute(
text(
"""
CREATE TABLE IF NOT EXISTS security_throttles (
throttle_id SERIAL PRIMARY KEY,
scope VARCHAR(64) NOT NULL,
identifier VARCHAR(512) NOT NULL,
failures INTEGER NOT NULL DEFAULT 0,
window_started_at TIMESTAMPTZ NULL,
locked_until TIMESTAMPTZ NULL,
last_attempt_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NULL,
CONSTRAINT uq_security_throttles_scope_identifier UNIQUE (scope, identifier)
)
"""
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_security_throttles_scope
ON security_throttles (scope)
"""
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_security_throttles_locked_until
ON security_throttles (locked_until)
"""
)
)
def _migration_0007_add_telegram_photo_url(connection: Connection) -> None:
inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
if "telegram_photo_url" in columns:
return
connection.execute(
text("ALTER TABLE users ADD COLUMN telegram_photo_url TEXT")
)
def _migration_0008_add_email_verification_code_status(connection: Connection) -> None:
inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("email_verification_codes")}
if "status" not in columns:
connection.execute(
text(
"ALTER TABLE email_verification_codes ADD COLUMN status VARCHAR NOT NULL DEFAULT 'active'"
)
)
else:
connection.execute(
text(
"""
UPDATE email_verification_codes
SET status = 'active'
WHERE status IS NULL OR status = ''
"""
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_email_verification_codes_status
ON email_verification_codes (status)
"""
)
)
def _migration_0010_add_email_magic_token_hash(connection: Connection) -> None:
inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("email_verification_codes")}
if "magic_token_hash" not in columns:
connection.execute(
text(
"ALTER TABLE email_verification_codes ADD COLUMN magic_token_hash VARCHAR"
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_email_verification_codes_magic_token_hash
ON email_verification_codes (magic_token_hash)
"""
)
)
def _migration_0009_add_composite_indexes(connection: Connection) -> None:
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_subscriptions_is_active_end_date
ON subscriptions (is_active, end_date)
"""
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_subscriptions_user_id_is_active
ON subscriptions (user_id, is_active)
"""
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_payments_user_id_status
ON payments (user_id, status)
"""
)
)
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_lifetime_used_traffic",
description="Store lifetime traffic usage for users",
upgrade=_migration_0004_add_lifetime_used_traffic,
),
Migration(
id="0005_add_email_auth_fields",
description="Add email login identities and verification codes",
upgrade=_migration_0005_add_email_auth_fields,
),
Migration(
id="0006_add_security_throttles",
description="Add generic lockout tracking for brute-force protection",
upgrade=_migration_0006_add_security_throttles,
),
Migration(
id="0007_add_telegram_photo_url",
description="Store Telegram profile photo URLs for linked users",
upgrade=_migration_0007_add_telegram_photo_url,
),
Migration(
id="0008_add_email_verification_code_status",
description="Track superseded email verification codes explicitly",
upgrade=_migration_0008_add_email_verification_code_status,
),
Migration(
id="0009_add_composite_indexes",
description="Add composite indexes for subscription and payment lookups",
upgrade=_migration_0009_add_composite_indexes,
),
Migration(
id="0010_add_email_magic_token_hash",
description="Store hashed magic-link tokens for email login deeplinks",
upgrade=_migration_0010_add_email_magic_token_hash,
),
]
def run_database_migrations(connection: Connection) -> None:
"""
Apply pending migrations sequentially. Already applied revisions are skipped.
"""
_ensure_migrations_table(connection)
applied_revisions: Set[str] = {
row[0]
for row in connection.execute(
text("SELECT id FROM schema_migrations")
)
}
for migration in MIGRATIONS:
if migration.id in applied_revisions:
continue
logging.info(
"Migrator: applying %s %s", migration.id, migration.description
)
try:
with connection.begin_nested():
migration.upgrade(connection)
connection.execute(
text(
"INSERT INTO schema_migrations (id) VALUES (:revision)"
),
{"revision": migration.id},
)
except Exception as exc:
logging.error(
"Migrator: failed to apply %s (%s)",
migration.id,
migration.description,
exc_info=True,
)
raise exc
else:
logging.info("Migrator: migration %s applied successfully", migration.id)
+40 -56
View File
@@ -1,4 +1,4 @@
from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime, Float, ForeignKey, UniqueConstraint, Text, BigInteger, Index from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime, Float, ForeignKey, UniqueConstraint, Text, BigInteger
from sqlalchemy.orm import relationship, DeclarativeBase from sqlalchemy.orm import relationship, DeclarativeBase
from sqlalchemy.ext.asyncio import AsyncAttrs from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.sql import func from sqlalchemy.sql import func
@@ -14,10 +14,6 @@ class User(Base):
user_id = Column(BigInteger, primary_key=True, index=True) user_id = Column(BigInteger, primary_key=True, index=True)
username = Column(String, nullable=True, index=True) username = Column(String, nullable=True, index=True)
email = Column(String, nullable=True, unique=True, index=True)
email_verified_at = Column(DateTime(timezone=True), nullable=True)
telegram_id = Column(BigInteger, nullable=True, unique=True, index=True)
telegram_photo_url = Column(Text, nullable=True)
first_name = Column(String, nullable=True) first_name = Column(String, nullable=True)
last_name = Column(String, nullable=True) last_name = Column(String, nullable=True)
language_code = Column(String, default="ru") language_code = Column(String, default="ru")
@@ -29,7 +25,6 @@ class User(Base):
referred_by_id = Column(BigInteger, referred_by_id = Column(BigInteger,
ForeignKey("users.user_id"), ForeignKey("users.user_id"),
nullable=True) nullable=True)
lifetime_used_traffic_bytes = Column(BigInteger, nullable=True)
channel_subscription_verified = Column(Boolean, nullable=True) channel_subscription_verified = Column(Boolean, nullable=True)
channel_subscription_checked_at = Column(DateTime(timezone=True), channel_subscription_checked_at = Column(DateTime(timezone=True),
nullable=True) nullable=True)
@@ -61,10 +56,6 @@ class User(Base):
class Subscription(Base): class Subscription(Base):
__tablename__ = "subscriptions" __tablename__ = "subscriptions"
__table_args__ = (
Index("ix_subscriptions_is_active_end_date", "is_active", "end_date"),
Index("ix_subscriptions_user_id_is_active", "user_id", "is_active"),
)
subscription_id = Column(Integer, primary_key=True, autoincrement=True) subscription_id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(BigInteger, user_id = Column(BigInteger,
@@ -94,52 +85,8 @@ class Subscription(Base):
return f"<Subscription(id={self.subscription_id}, user_id={self.user_id}, panel_uuid='{self.panel_user_uuid}', ends='{self.end_date}')>" return f"<Subscription(id={self.subscription_id}, user_id={self.user_id}, panel_uuid='{self.panel_user_uuid}', ends='{self.end_date}')>"
class EmailVerificationCode(Base):
__tablename__ = "email_verification_codes"
code_id = Column(Integer, primary_key=True, autoincrement=True)
email = Column(String, nullable=False, index=True)
code_hash = Column(String, nullable=False)
magic_token_hash = Column(String, nullable=True, index=True)
purpose = Column(String, nullable=False, index=True)
target_user_id = Column(
BigInteger,
ForeignKey("users.user_id"),
nullable=True,
index=True,
)
expires_at = Column(DateTime(timezone=True), nullable=False, index=True)
consumed_at = Column(DateTime(timezone=True), nullable=True)
status = Column(String, nullable=False, default="active", index=True)
attempts = Column(Integer, nullable=False, default=0)
created_at = Column(DateTime(timezone=True), server_default=func.now())
target_user = relationship("User")
class SecurityThrottle(Base):
__tablename__ = "security_throttles"
throttle_id = Column(Integer, primary_key=True, autoincrement=True)
scope = Column(String(64), nullable=False, index=True)
identifier = Column(String(512), nullable=False, index=True)
failures = Column(Integer, nullable=False, default=0)
window_started_at = Column(DateTime(timezone=True), nullable=True)
locked_until = Column(DateTime(timezone=True), nullable=True, index=True)
last_attempt_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
__table_args__ = (
UniqueConstraint("scope", "identifier", name="uq_security_throttles_scope_identifier"),
)
class Payment(Base): class Payment(Base):
__tablename__ = "payments" __tablename__ = "payments"
__table_args__ = (
Index("ix_payments_user_id_status", "user_id", "status"),
)
payment_id = Column(Integer, primary_key=True, autoincrement=True) payment_id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(BigInteger, user_id = Column(BigInteger,
@@ -153,7 +100,12 @@ class Payment(Base):
provider_payment_id = Column(String, unique=True, nullable=True) provider_payment_id = Column(String, unique=True, nullable=True)
provider = Column(String, nullable=False, default="yookassa", index=True) provider = Column(String, nullable=False, default="yookassa", index=True)
idempotence_key = Column(String, unique=True, nullable=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) currency = Column(String, nullable=False)
status = Column(String, nullable=False, index=True) status = Column(String, nullable=False, index=True)
description = Column(String, nullable=True) description = Column(String, nullable=True)
@@ -207,7 +159,17 @@ class PromoCode(Base):
promo_code_id = Column(Integer, primary_key=True, autoincrement=True) promo_code_id = Column(Integer, primary_key=True, autoincrement=True)
code = Column(String, unique=True, nullable=False, index=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) max_activations = Column(Integer, nullable=False)
current_activations = Column(Integer, default=0) current_activations = Column(Integer, default=0)
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
@@ -244,6 +206,28 @@ class PromoCodeActivation(Base):
name='uq_promo_user_activation'), ) name='uq_promo_user_activation'), )
class ActiveDiscount(Base):
"""Tracks pending discount promo code reservations awaiting payment."""
__tablename__ = "active_discounts"
user_id = Column(
BigInteger,
ForeignKey("users.user_id", ondelete="CASCADE"),
primary_key=True,
)
promo_code_id = Column(
Integer,
ForeignKey("promo_codes.promo_code_id", ondelete="CASCADE"),
nullable=False,
)
discount_percentage = Column(Integer, nullable=False)
activated_at = Column(DateTime(timezone=True), server_default=func.now())
expires_at = Column(DateTime(timezone=True), nullable=False)
promo_code = relationship("PromoCode")
user = relationship("User")
class MessageLog(Base): class MessageLog(Base):
__tablename__ = "message_logs" __tablename__ = "message_logs"
-59
View File
@@ -1,59 +0,0 @@
services:
remnawave-minishop:
image: ghcr.io/3252a8/remnawave-minishop:${IMAGE_TAG:-latest}
container_name: remnawave-minishop
hostname: remnawave-minishop
env_file:
- .env
environment:
- TZ=UTC
volumes:
- ./locales:/app/locales
restart: unless-stopped
depends_on:
remnawave-minishop-db:
condition: service_healthy
remnawave-minishop-db:
image: postgres:17
container_name: remnawave-minishop-db
hostname: remnawave-minishop-db
env_file:
- .env
environment:
- TZ=UTC
volumes:
- remnawave-minishop-db-data:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 20
caddy:
image: caddy:2-alpine
container_name: remnawave-minishop-caddy
hostname: remnawave-minishop-caddy
environment:
- TZ=UTC
- WEB_SERVER_PORT=${WEB_SERVER_PORT:-8080}
- WEBAPP_SERVER_PORT=${WEBAPP_SERVER_PORT:-8081}
depends_on:
- remnawave-minishop
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
restart: unless-stopped
volumes:
remnawave-minishop-db-data:
name: remnawave-minishop-db-data
caddy_data:
name: remnawave-minishop-caddy-data
caddy_config:
name: remnawave-minishop-caddy-config
+11 -12
View File
@@ -1,13 +1,12 @@
services: services:
remnawave-minishop: remnawave-tg-shop:
image: ghcr.io/3252a8/remnawave-minishop:${IMAGE_TAG:-latest} image: kavore/remnawave-tg-shop:latest
container_name: remnawave-minishop container_name: remnawave-tg-shop
hostname: remnawave-minishop hostname: remnawave-tg-shop
env_file: env_file:
- .env - .env
ports: ports:
- '127.0.0.1:8080:8080' - '127.0.0.1:8080:8080'
- '127.0.0.1:${WEBAPP_SERVER_PORT:-8081}:${WEBAPP_SERVER_PORT:-8081}'
networks: networks:
- remnawave-network - remnawave-network
environment: environment:
@@ -16,17 +15,17 @@ services:
- ./locales:/app/locales - ./locales:/app/locales
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- remnawave-minishop-db - remnawave-tg-shop-db
remnawave-minishop-db: remnawave-tg-shop-db:
image: postgres:17 image: postgres:17
container_name: remnawave-minishop-db container_name: remnawave-tg-shop-db
hostname: remnawave-minishop-db hostname: remnawave-tg-shop-db
env_file: env_file:
- .env - .env
environment: environment:
- TZ=UTC - TZ=UTC
volumes: volumes:
- remnawave-minishop-db-data:/var/lib/postgresql/data - remnawave-tg-shop-db-data:/var/lib/postgresql/data
networks: networks:
- remnawave-network - remnawave-network
restart: unless-stopped restart: unless-stopped
@@ -38,5 +37,5 @@ services:
networks: networks:
remnawave-network: null remnawave-network: null
volumes: volumes:
remnawave-minishop-db-data: remnawave-tg-shop-db-data:
name: remnawave-minishop-db-data name: remnawave-tg-shop-db-data
+14 -20
View File
@@ -1,34 +1,28 @@
services: services:
remnawave-minishop: remnawave-tg-shop:
image: ghcr.io/3252a8/remnawave-minishop:${IMAGE_TAG:-latest} image: kavore/remnawave-tg-shop:latest
container_name: remnawave-minishop # build: .
hostname: remnawave-minishop container_name: remnawave-tg-shop
hostname: remnawave-tg-shop
env_file: env_file:
- .env - .env
networks: networks:
- remnawave-network - remnawave-network
volumes:
- ./locales:/app/locales
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
remnawave-minishop-db: remnawave-tg-shop-db:
condition: service_healthy condition: service_healthy
ports:
- 127.0.0.1:8080:8080
- 127.0.0.1:${WEBAPP_SERVER_PORT:-8081}:${WEBAPP_SERVER_PORT:-8081}
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
remnawave-minishop-db: remnawave-tg-shop-db:
image: postgres:17 image: postgres:17
container_name: remnawave-minishop-db container_name: remnawave-tg-shop-db
hostname: remnawave-minishop-db hostname: remnawave-tg-shop-db
env_file: env_file:
- .env - .env
volumes: volumes:
- remnawave-minishop-db-data:/var/lib/postgresql/data - remnawave-tg-shop-db-data:/var/lib/postgresql/data
networks: networks:
- remnawave-network - remnawave-network
restart: unless-stopped restart: unless-stopped
@@ -43,5 +37,5 @@ networks:
external: true external: true
volumes: volumes:
remnawave-minishop-db-data: remnawave-tg-shop-db-data:
name: remnawave-minishop-db-data name: remnawave-tg-shop-db-data
-150
View File
@@ -1,150 +0,0 @@
# Миграция с `remnawave-tg-shop` на `remnawave-minishop`
Начиная с этой версии контейнеры и тома названы `remnawave-minishop*` вместо `remnawave-tg-shop*`. Старый и новый стеки используют **разные имена томов**, поэтому простой `docker compose up -d` после `git pull` создаст пустую БД. Эта инструкция описывает, как перенести данные.
Есть два пути:
- [Автоматический](#автоматический-способ-через-скрипт) — один скрипт, идемпотентный, проверяет состояние на каждом шаге.
- [Ручной](#ручной-способ) — команды, которые делает скрипт, если хочется понимать происходящее или выполнить выборочно.
В обоих случаях:
- старые тома **не удаляются** автоматически — это безопасный бэкап на случай отката;
- сертификаты Caddy (если используется `docker-compose-caddy.yml`) тоже переносятся, чтобы Let's Encrypt не выписывал их заново и не упереться в rate limit.
## Автоматический способ (через скрипт)
Если helper ещё не лежит у вас локально, запускайте его прямо из `raw` из корня старого репозитория:
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh)
```
> Команда выше рассчитана на `bash` / Git Bash / WSL. Если вы запускаете из PowerShell, удобнее сначала открыть Git Bash.
Если вы уже подтянули новую версию и файл есть локально, можно запускать и так:
```bash
bash scripts/migrate_to_minishop.sh
```
По умолчанию скрипт работает с `docker-compose.yml` и переключается на ветку `main`. Можно переопределить через переменные окружения:
| Переменная | Назначение | По умолчанию |
| ----------------- | ----------------------------------------------------------------------- | ---------------------- |
| `PROJECT_ROOT` | Явный путь к корню старого репозитория, если запуск не из него | текущая директория |
| `COMPOSE_FILE` | Какой compose-файл стартовать в конце | `docker-compose.yml` |
| `TARGET_BRANCH` | На какую ветку переключаться и подтягивать обновления | `main` |
| `GIT_REMOTE` | Какой remote использовать для `fetch`/`pull` | `origin` |
| `NEW_ORIGIN_URL` | Если задано и не совпадает с URL выбранного remote — он будет обновлён | (не меняется) |
| `ASSUME_YES` | `1` — не задавать интерактивных вопросов | `0` |
Примеры:
```bash
# Caddy-вариант из raw
COMPOSE_FILE=docker-compose-caddy.yml \
bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh)
# С переключением origin на форк 3252a8
NEW_ORIGIN_URL=https://github.com/3252a8/remnawave-minishop.git \
bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh)
# Без интерактива
ASSUME_YES=1 \
bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh)
```
Что делает скрипт:
1. **Останавливает текущий стек**: проверяет известные контейнеры старой и новой схемы и останавливает их, если они запущены.
2. **Переключает `origin`**, если задана переменная `NEW_ORIGIN_URL`, иначе оставляет как есть.
3. **Подтягивает целевую ветку** (`git fetch` + `git switch` + `git pull --ff-only`). Прерывается, если в рабочем дереве есть незакоммиченные изменения.
4. **Обновляет `.env`** и правит `POSTGRES_HOST`, если он ещё указывает на старый контейнер.
5. **Подготавливает новый стек в режиме `--no-start`**, чтобы Compose сам создал тома и не ругался на уже существующий volume.
6. **Переносит тома** `remnawave-tg-shop-*``remnawave-minishop-*` через одноразовый `alpine`-контейнер. Если новый том уже непустой, копирование пропускается.
7. **Стартует новый стек** (`docker compose -f $COMPOSE_FILE up -d --remove-orphans`, а для локальной сборки ещё и `--build`) и печатает `docker compose ps`.
Скрипт идемпотентен: повторный запуск ничего не сломает, просто пропустит уже выполненные шаги.
После того как убедитесь, что бот работает и данные на месте, удалите старые тома:
```bash
docker volume rm remnawave-tg-shop-db-data
docker volume rm remnawave-tg-shop-caddy-data remnawave-tg-shop-caddy-config 2>/dev/null || true
```
> ⚠️ Если у вас есть внешний reverse proxy (Nginx и т.п.), не забудьте поправить в его конфиге `upstream`/`proxy_pass`: имя хоста контейнера изменилось с `remnawave-tg-shop` на `remnawave-minishop`. Скрипт не трогает внешние конфиги.
## Ручной способ
1. **Остановите старый стек и обновите код:**
```bash
docker compose down
git fetch origin
git checkout main
git pull --ff-only origin main
```
2. **Обновите `.env`:**
```bash
sed -i.bak 's/^POSTGRES_HOST=remnawave-tg-shop-db$/POSTGRES_HOST=remnawave-minishop-db/' .env
```
3. **Подготовьте новый стек без запуска:**
```bash
# Локальная сборка
docker compose up --no-start --build
# Или Caddy-вариант
docker compose -f docker-compose-caddy.yml up --no-start --build
# Или готовый образ
docker compose -f docker-compose-remote-server.yml up --no-start
```
4. **Перенесите том БД в новое имя:**
```bash
docker run --rm \
-v remnawave-tg-shop-db-data:/from:ro \
-v remnawave-minishop-db-data:/to \
alpine sh -c "cd /from && cp -a . /to"
```
5. **(Только для Caddy)** перенесите тома Caddy с TLS-сертификатами и состоянием ACME:
```bash
for v in caddy-data caddy-config; do
docker run --rm \
-v "remnawave-tg-shop-$v":/from:ro \
-v "remnawave-minishop-$v":/to \
alpine sh -c "cd /from && cp -a . /to"
done
```
6. **Запустите новый стек:**
```bash
docker compose up -d
# или
docker compose -f docker-compose-caddy.yml up -d --build
# или
docker compose -f docker-compose-remote-server.yml up -d
```
7. **Проверьте:**
```bash
docker compose ps
docker compose logs -f remnawave-minishop
```
8. **(Опционально) удалите старые тома**, когда убедитесь, что новый стек стабилен:
```bash
docker volume rm remnawave-tg-shop-db-data
docker volume rm remnawave-tg-shop-caddy-data remnawave-tg-shop-caddy-config 2>/dev/null || true
```

Some files were not shown because too many files have changed in this diff Show More